Fix toolbar View menu item always being checked at start.
[gpodder.git] / src / gpodder / gtkui / main.py
blob1a9d86ad0be803b11d074c8d5bfcf5be3cd6b335
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/>.
20 import collections
21 import html
22 import logging
23 import os
24 import re
25 import shutil
26 import sys
27 import tempfile
28 import threading
29 import time
30 import urllib.parse
32 import dbus.service
33 import requests.exceptions
34 import urllib3.exceptions
36 import gpodder
37 from gpodder import common, download, feedcore, my, opml, player, util, youtube
38 from gpodder.dbusproxy import DBusPodcastsProxy
39 from gpodder.model import Model, PodcastEpisode
40 from gpodder.syncui import gPodderSyncUI
42 from . import shownotes
43 from .desktop.channel import gPodderChannel
44 from .desktop.episodeselector import gPodderEpisodeSelector
45 from .desktop.exportlocal import gPodderExportToLocalFolder
46 from .desktop.podcastdirectory import gPodderPodcastDirectory
47 from .desktop.welcome import gPodderWelcome
48 from .desktopfile import UserAppsReader
49 from .download import DownloadStatusModel
50 from .draw import (cake_size_from_widget, draw_cake_pixbuf,
51 draw_iconcell_scale, draw_text_box_centered)
52 from .interface.addpodcast import gPodderAddPodcast
53 from .interface.common import (BuilderWidget, Dummy, ExtensionMenuHelper,
54 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
60 import gi # isort:skip
61 gi.require_version('Gtk', '3.0') # isort:skip
62 from gi.repository import Gdk, Gio, GLib, Gtk, Pango # isort:skip
65 logger = logging.getLogger(__name__)
67 _ = gpodder.gettext
68 N_ = gpodder.ngettext
71 class gPodder(BuilderWidget, dbus.service.Object):
73 def __init__(self, app, bus_name, gpodder_core, options):
74 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
75 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels,
76 self.on_itemUpdate_activate,
77 self.playback_episodes,
78 self.download_episode_list,
79 self.episode_object_by_uri,
80 bus_name)
81 self.application = app
82 self.core = gpodder_core
83 self.config = self.core.config
84 self.db = self.core.db
85 self.model = self.core.model
86 self.options = options
87 self.extensions_menu = None
88 self.extensions_actions = []
89 self._search_podcasts = None
90 self._search_episodes = None
91 BuilderWidget.__init__(self, None,
92 _gtk_properties={('gPodder', 'application'): app})
94 self.last_episode_date_refresh = None
95 self.refresh_episode_dates()
97 self.on_episode_list_selection_changed_id = None
99 def new(self):
100 if self.application.want_headerbar:
101 self.header_bar = Gtk.HeaderBar()
102 self.header_bar.pack_end(self.application.header_bar_menu_button)
103 self.header_bar.pack_start(self.application.header_bar_refresh_button)
104 self.header_bar.set_show_close_button(True)
105 self.header_bar.show_all()
107 # Tweaks to the UI since we moved the refresh button into the header bar
108 self.vboxChannelNavigator.set_row_spacing(0)
110 self.main_window.set_titlebar(self.header_bar)
112 gpodder.user_extensions.on_ui_object_available('gpodder-gtk', self)
113 self.toolbar.set_property('visible', self.config.ui.gtk.toolbar)
115 self.bluetooth_available = util.bluetooth_available()
117 self.config.connect_gtk_window(self.main_window, 'main_window')
119 self.config.connect_gtk_paned('ui.gtk.state.main_window.paned_position', self.channelPaned)
121 self.main_window.show()
123 self.player_receiver = player.MediaPlayerDBusReceiver(self.on_played)
125 self.gPodder.connect('key-press-event', self.on_key_press)
127 self.episode_columns_menu = None
128 self.config.add_observer(self.on_config_changed)
130 self.shownotes_pane = Gtk.Box()
131 self.shownotes_object = shownotes.get_shownotes(self.config.ui.gtk.html_shownotes, self.shownotes_pane)
133 # Vertical paned for the episode list and shownotes
134 self.vpaned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
135 paned = self.vbox_episode_list.get_parent()
136 self.vbox_episode_list.reparent(self.vpaned)
137 self.vpaned.child_set_property(self.vbox_episode_list, 'resize', True)
138 self.vpaned.child_set_property(self.vbox_episode_list, 'shrink', False)
139 self.vpaned.pack2(self.shownotes_pane, resize=False, shrink=False)
140 self.vpaned.show()
142 # Minimum height for both episode list and shownotes
143 self.vbox_episode_list.set_size_request(-1, 100)
144 self.shownotes_pane.set_size_request(-1, 100)
146 self.config.connect_gtk_paned('ui.gtk.state.main_window.episode_list_size',
147 self.vpaned)
148 paned.add2(self.vpaned)
150 self.new_episodes_window = None
152 self.download_status_model = DownloadStatusModel()
153 self.download_queue_manager = download.DownloadQueueManager(self.config, self.download_status_model)
155 self.config.connect_gtk_spinbutton('limit.downloads.concurrent', self.spinMaxDownloads,
156 self.config.limit.downloads.concurrent_max)
157 self.config.connect_gtk_togglebutton('limit.downloads.enabled', self.cbMaxDownloads)
158 self.config.connect_gtk_spinbutton('limit.bandwidth.kbps', self.spinLimitDownloads)
159 self.config.connect_gtk_togglebutton('limit.bandwidth.enabled', self.cbLimitDownloads)
161 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
162 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
164 # When the amount of maximum downloads changes, notify the queue manager
165 def changed_cb(spinbutton):
166 return self.download_queue_manager.update_max_downloads()
168 self.spinMaxDownloads.connect('value-changed', changed_cb)
169 self.cbMaxDownloads.connect('toggled', changed_cb)
171 # Keep a reference to the last add podcast dialog instance
172 self._add_podcast_dialog = None
174 self.default_title = None
175 self.set_title(_('gPodder'))
177 self.cover_downloader = CoverDownloader()
179 # Generate list models for podcasts and their episodes
180 self.podcast_list_model = PodcastListModel(self.cover_downloader)
181 self.apply_podcast_list_hide_boring()
183 self.cover_downloader.register('cover-available', self.cover_download_finished)
185 # Source IDs for timeouts for search-as-you-type
186 self._podcast_list_search_timeout = None
187 self._episode_list_search_timeout = None
189 # Subscribed channels
190 self.active_channel = None
191 self.channels = self.model.get_podcasts()
193 # For loading the list model
194 self.episode_list_model = EpisodeListModel(self.on_episode_list_filter_changed)
196 self.create_actions()
198 self.releasecell = None
200 # Init the treeviews that we use
201 self.init_podcast_list_treeview()
202 self.init_episode_list_treeview()
203 self.init_download_list_treeview()
205 self.download_tasks_seen = set()
206 self.download_list_update_timer = None
207 self.things_adding_tasks = 0
208 self.download_task_monitors = set()
210 # Set up the first instance of MygPoClient
211 self.mygpo_client = my.MygPoClient(self.config)
213 self.inject_extensions_menu()
215 gpodder.user_extensions.on_ui_initialized(self.model,
216 self.extensions_podcast_update_cb,
217 self.extensions_episode_download_cb)
219 gpodder.user_extensions.on_application_started()
221 # load list of user applications for audio playback
222 self.user_apps_reader = UserAppsReader(['audio', 'video'])
223 util.run_in_background(self.user_apps_reader.read)
225 # Now, update the feed cache, when everything's in place
226 if not self.application.want_headerbar:
227 self.btnUpdateFeeds.show()
228 self.feed_cache_update_cancelled = False
229 self.update_podcast_list_model()
231 self.partial_downloads_indicator = None
232 util.run_in_background(self.find_partial_downloads)
234 # Start the auto-update procedure
235 self._auto_update_timer_source_id = None
236 if self.config.auto.update.enabled:
237 self.restart_auto_update_timer()
239 # Find expired (old) episodes and delete them
240 old_episodes = list(common.get_expired_episodes(self.channels, self.config))
241 if len(old_episodes) > 0:
242 self.delete_episode_list(old_episodes, confirm=False)
243 updated_urls = set(e.channel.url for e in old_episodes)
244 self.update_podcast_list_model(updated_urls)
246 # Do the initial sync with the web service
247 if self.mygpo_client.can_access_webservice():
248 util.idle_add(self.mygpo_client.flush, True)
250 # First-time users should be asked if they want to see the OPML
251 if self.options.subscribe:
252 util.idle_add(self.subscribe_to_url, self.options.subscribe)
253 elif not self.channels:
254 self.on_itemUpdate_activate()
255 elif self.config.software_update.check_on_startup:
256 # Check for software updates from gpodder.org
257 diff = time.time() - self.config.software_update.last_check
258 if diff > (60 * 60 * 24) * self.config.software_update.interval:
259 self.config.software_update.last_check = int(time.time())
260 if not os.path.exists(gpodder.no_update_check_file):
261 self.check_for_updates(silent=True)
263 if self.options.close_after_startup:
264 logger.warning("Startup done, closing (--close-after-startup)")
265 self.core.db.close()
266 sys.exit()
268 def create_actions(self):
269 g = self.gPodder
271 # View
273 action = Gio.SimpleAction.new_stateful(
274 'showToolbar', None, GLib.Variant.new_boolean(self.config.ui.gtk.toolbar))
275 action.connect('activate', self.on_itemShowToolbar_activate)
276 g.add_action(action)
278 action = Gio.SimpleAction.new_stateful(
279 'searchAlwaysVisible', None, GLib.Variant.new_boolean(self.config.ui.gtk.search_always_visible))
280 action.connect('activate', self.on_item_view_search_always_visible_toggled)
281 g.add_action(action)
283 # View Podcast List
285 action = Gio.SimpleAction.new_stateful(
286 'viewHideBoringPodcasts', None, GLib.Variant.new_boolean(self.config.ui.gtk.podcast_list.hide_empty))
287 action.connect('activate', self.on_item_view_hide_boring_podcasts_toggled)
288 g.add_action(action)
290 action = Gio.SimpleAction.new_stateful(
291 'viewShowAllEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.podcast_list.all_episodes))
292 action.connect('activate', self.on_item_view_show_all_episodes_toggled)
293 g.add_action(action)
295 action = Gio.SimpleAction.new_stateful(
296 'viewShowPodcastSections', None, GLib.Variant.new_boolean(self.config.ui.gtk.podcast_list.sections))
297 action.connect('activate', self.on_item_view_show_podcast_sections_toggled)
298 g.add_action(action)
300 action = Gio.SimpleAction.new_stateful(
301 'episodeNew', None, GLib.Variant.new_boolean(False))
302 action.connect('activate', self.on_episode_new_activate)
303 g.add_action(action)
305 action = Gio.SimpleAction.new_stateful(
306 'episodeLock', None, GLib.Variant.new_boolean(False))
307 action.connect('activate', self.on_episode_lock_activate)
308 g.add_action(action)
310 action = Gio.SimpleAction.new_stateful(
311 'channelAutoArchive', None, GLib.Variant.new_boolean(False))
312 action.connect('activate', self.on_channel_toggle_lock_activate)
313 g.add_action(action)
315 # View Episode List
317 value = EpisodeListModel.VIEWS[
318 self.config.ui.gtk.episode_list.view_mode or EpisodeListModel.VIEW_ALL]
319 action = Gio.SimpleAction.new_stateful(
320 'viewEpisodes', GLib.VariantType.new('s'),
321 GLib.Variant.new_string(value))
322 action.connect('activate', self.on_item_view_episodes_changed)
323 g.add_action(action)
325 action = Gio.SimpleAction.new_stateful(
326 'viewAlwaysShowNewEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.always_show_new))
327 action.connect('activate', self.on_item_view_always_show_new_episodes_toggled)
328 g.add_action(action)
330 action = Gio.SimpleAction.new_stateful(
331 'viewTrimEpisodeTitlePrefix', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.trim_title_prefix))
332 action.connect('activate', self.on_item_view_trim_episode_title_prefix_toggled)
333 g.add_action(action)
335 action = Gio.SimpleAction.new_stateful(
336 'viewShowEpisodeDescription', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.descriptions))
337 action.connect('activate', self.on_item_view_show_episode_description_toggled)
338 g.add_action(action)
340 action = Gio.SimpleAction.new_stateful(
341 'viewShowEpisodeReleasedTime', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.show_released_time))
342 action.connect('activate', self.on_item_view_show_episode_released_time_toggled)
343 g.add_action(action)
345 action = Gio.SimpleAction.new_stateful(
346 'viewRightAlignEpisodeReleasedColumn', None,
347 GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.right_align_released_column))
348 action.connect('activate', self.on_item_view_right_align_episode_released_column_toggled)
349 g.add_action(action)
351 action = Gio.SimpleAction.new_stateful(
352 'viewCtrlClickToSortEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.ctrl_click_to_sort))
353 action.connect('activate', self.on_item_view_ctrl_click_to_sort_episodes_toggled)
354 g.add_action(action)
356 # Other Menus
358 action_defs = [
359 # gPodder
360 # Podcasts
361 ('update', self.on_itemUpdate_activate),
362 ('downloadAllNew', self.on_itemDownloadAllNew_activate),
363 ('removeOldEpisodes', self.on_itemRemoveOldEpisodes_activate),
364 ('findPodcast', self.on_find_podcast_activate),
365 # Subscriptions
366 ('discover', self.on_itemImportChannels_activate),
367 ('addChannel', self.on_itemAddChannel_activate),
368 ('removeChannel', self.on_itemRemoveChannel_activate),
369 ('massUnsubscribe', self.on_itemMassUnsubscribe_activate),
370 ('updateChannel', self.on_itemUpdateChannel_activate),
371 ('editChannel', self.on_itemEditChannel_activate),
372 ('importFromFile', self.on_item_import_from_file_activate),
373 ('exportChannels', self.on_itemExportChannels_activate),
374 ('markEpisodesAsOld', self.on_mark_episodes_as_old),
375 ('refreshImage', self.on_itemRefreshCover_activate),
376 # Episodes
377 ('play', self.on_playback_selected_episodes),
378 ('open', self.on_playback_selected_episodes),
379 ('forceDownload', self.on_force_download_selected_episodes),
380 ('download', self.on_download_selected_episodes),
381 ('pause', self.on_pause_selected_episodes),
382 ('cancel', self.on_item_cancel_download_activate),
383 ('moveUp', self.on_move_selected_items_up),
384 ('moveDown', self.on_move_selected_items_down),
385 ('remove', self.on_remove_from_download_list),
386 ('delete', self.on_btnDownloadedDelete_clicked),
387 ('toggleEpisodeNew', self.on_item_toggle_played_activate),
388 ('toggleEpisodeLock', self.on_item_toggle_lock_activate),
389 ('openEpisodeDownloadFolder', self.on_open_episode_download_folder),
390 ('openChannelDownloadFolder', self.on_open_download_folder),
391 ('selectChannel', self.on_select_channel_of_episode),
392 ('findEpisode', self.on_find_episode_activate),
393 ('toggleShownotes', self.on_shownotes_selected_episodes),
394 ('saveEpisodes', self.on_save_episodes_activate),
395 ('bluetoothEpisodes', self.on_bluetooth_episodes_activate),
396 # Extras
397 ('sync', self.on_sync_to_device_activate),
400 for name, callback in action_defs:
401 action = Gio.SimpleAction.new(name, None)
402 action.connect('activate', callback)
403 g.add_action(action)
405 # gPodder
406 # Podcasts
407 self.update_action = g.lookup_action('update')
408 # Subscriptions
409 self.update_channel_action = g.lookup_action('updateChannel')
410 self.edit_channel_action = g.lookup_action('editChannel')
411 # Episodes
412 self.play_action = g.lookup_action('play')
413 self.open_action = g.lookup_action('open')
414 self.force_download_action = g.lookup_action('forceDownload')
415 self.download_action = g.lookup_action('download')
416 self.pause_action = g.lookup_action('pause')
417 self.cancel_action = g.lookup_action('cancel')
418 self.delete_action = g.lookup_action('delete')
419 self.toggle_episode_new_action = g.lookup_action('toggleEpisodeNew')
420 self.toggle_episode_lock_action = g.lookup_action('toggleEpisodeLock')
421 self.open_episode_download_folder_action = g.lookup_action('openEpisodeDownloadFolder')
422 self.select_channel_of_episode_action = g.lookup_action('selectChannel')
423 self.auto_archive_action = g.lookup_action('channelAutoArchive')
424 self.bluetooth_episodes_action = g.lookup_action('bluetoothEpisodes')
425 self.episode_new_action = g.lookup_action('episodeNew')
426 self.episode_lock_action = g.lookup_action('episodeLock')
428 self.bluetooth_episodes_action.set_enabled(self.bluetooth_available)
430 def inject_extensions_menu(self):
432 Update Extras/Extensions menu.
433 Called at startup and when en/dis-abling extensions.
435 def gen_callback(label, callback):
436 return lambda action, param: callback()
438 for a in self.extensions_actions:
439 self.gPodder.remove_action(a.get_property('name'))
440 self.extensions_actions = []
442 if self.extensions_menu is None:
443 # insert menu section at startup (hides when empty)
444 self.extensions_menu = Gio.Menu.new()
445 self.application.menu_extras.append_section(_('Extensions'), self.extensions_menu)
446 else:
447 self.extensions_menu.remove_all()
449 extension_entries = gpodder.user_extensions.on_create_menu()
450 if extension_entries:
451 # populate menu
452 for i, (label, callback) in enumerate(extension_entries):
453 action_id = 'extensions.action_%d' % i
454 action = Gio.SimpleAction.new(action_id)
455 action.connect('activate', gen_callback(label, callback))
456 self.extensions_actions.append(action)
457 self.gPodder.add_action(action)
458 itm = Gio.MenuItem.new(label, 'win.' + action_id)
459 self.extensions_menu.append_item(itm)
461 def on_resume_all_infobar_response(self, infobar, response_id):
462 if response_id == Gtk.ResponseType.OK:
463 selection = self.treeDownloads.get_selection()
464 selection.select_all()
465 selected_tasks = self.downloads_list_get_selection()[0]
466 selection.unselect_all()
467 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
468 self.resume_all_infobar.set_revealed(False)
470 def find_partial_downloads(self):
471 def start_progress_callback(count):
472 if count:
473 self.partial_downloads_indicator = ProgressIndicator(
474 _('Loading incomplete downloads'),
475 _('Some episodes have not finished downloading in a previous session.'),
476 False, self.get_dialog_parent())
477 self.partial_downloads_indicator.on_message(N_(
478 '%(count)d partial file', '%(count)d partial files',
479 count) % {'count': count})
481 util.idle_add(self.wNotebook.set_current_page, 1)
483 def progress_callback(title, progress):
484 self.partial_downloads_indicator.on_message(title)
485 self.partial_downloads_indicator.on_progress(progress)
486 self.partial_downloads_indicator.on_tick() # not cancellable
488 def final_progress_callback():
489 self.partial_downloads_indicator.on_tick(final=_('Cleaning up...'))
491 def finish_progress_callback(resumable_episodes):
492 def offer_resuming():
493 if resumable_episodes:
494 self.download_episode_list_paused(resumable_episodes, hide_progress=True)
495 self.resume_all_infobar.set_revealed(True)
496 else:
497 util.idle_add(self.wNotebook.set_current_page, 0)
498 logger.debug("find_partial_downloads done, calling extensions")
499 gpodder.user_extensions.on_find_partial_downloads_done()
501 if self.partial_downloads_indicator:
502 util.idle_add(self.partial_downloads_indicator.on_finished)
503 self.partial_downloads_indicator = None
505 util.idle_add(offer_resuming)
507 common.find_partial_downloads(self.channels,
508 start_progress_callback,
509 progress_callback,
510 final_progress_callback,
511 finish_progress_callback)
513 def episode_object_by_uri(self, uri):
514 """Get an episode object given a local or remote URI
516 This can be used to quickly access an episode object
517 when all we have is its download filename or episode
518 URL (e.g. from external D-Bus calls / signals, etc..)
520 if uri.startswith('/'):
521 uri = 'file://' + urllib.parse.quote(uri)
523 prefix = 'file://' + urllib.parse.quote(gpodder.downloads)
525 # By default, assume we can't pre-select any channel
526 # but can match episodes simply via the download URL
528 def is_channel(c):
529 return True
531 def is_episode(e):
532 return e.url == uri
534 if uri.startswith(prefix):
535 # File is on the local filesystem in the download folder
536 # Try to reduce search space by pre-selecting the channel
537 # based on the folder name of the local file
539 filename = urllib.parse.unquote(uri[len(prefix):])
540 file_parts = [_f for _f in filename.split(os.sep) if _f]
542 if len(file_parts) != 2:
543 return None
545 foldername, filename = file_parts
547 def is_channel(c):
548 return c.download_folder == foldername
550 def is_episode(e):
551 return e.download_filename == filename
553 # Deep search through channels and episodes for a match
554 for channel in filter(is_channel, self.channels):
555 for episode in filter(is_episode, channel.get_all_episodes()):
556 return episode
558 return None
560 def on_played(self, start, end, total, file_uri):
561 """Handle the "played" signal from a media player"""
562 if start == 0 and end == 0 and total == 0:
563 # Ignore bogus play event
564 return
565 elif end < start + 5:
566 # Ignore "less than five seconds" segments,
567 # as they can happen with seeking, etc...
568 return
570 logger.debug('Received play action: %s (%d, %d, %d)', file_uri, start, end, total)
571 episode = self.episode_object_by_uri(file_uri)
573 if episode is not None:
574 file_type = episode.file_type()
576 now = time.time()
577 if total > 0:
578 episode.total_time = total
579 elif total == 0:
580 # Assume the episode's total time for the action
581 total = episode.total_time
583 assert (episode.current_position_updated is None
584 or now >= episode.current_position_updated)
586 episode.current_position = end
587 episode.current_position_updated = now
588 episode.mark(is_played=True)
589 episode.save()
590 self.episode_list_status_changed([episode])
592 # Submit this action to the webservice
593 self.mygpo_client.on_playback_full(episode, start, end, total)
595 def on_add_remove_podcasts_mygpo(self):
596 actions = self.mygpo_client.get_received_actions()
597 if not actions:
598 return False
600 existing_urls = [c.url for c in self.channels]
602 # Columns for the episode selector window - just one...
603 columns = (
604 ('description', None, None, _('Action')),
607 # A list of actions that have to be chosen from
608 changes = []
610 # Actions that are ignored (already carried out)
611 ignored = []
613 for action in actions:
614 if action.is_add and action.url not in existing_urls:
615 changes.append(my.Change(action))
616 elif action.is_remove and action.url in existing_urls:
617 podcast_object = None
618 for podcast in self.channels:
619 if podcast.url == action.url:
620 podcast_object = podcast
621 break
622 changes.append(my.Change(action, podcast_object))
623 else:
624 ignored.append(action)
626 # Confirm all ignored changes
627 self.mygpo_client.confirm_received_actions(ignored)
629 def execute_podcast_actions(selected):
630 # In the future, we might retrieve the title from gpodder.net here,
631 # but for now, we just use "None" to use the feed-provided title
632 title = None
633 add_list = [(title, c.action.url)
634 for c in selected if c.action.is_add]
635 remove_list = [c.podcast for c in selected if c.action.is_remove]
637 # Apply the accepted changes locally
638 self.add_podcast_list(add_list)
639 self.remove_podcast_list(remove_list, confirm=False)
641 # All selected items are now confirmed
642 self.mygpo_client.confirm_received_actions(c.action for c in selected)
644 # Revert the changes on the server
645 rejected = [c.action for c in changes if c not in selected]
646 self.mygpo_client.reject_received_actions(rejected)
648 def ask():
649 # We're abusing the Episode Selector again ;) -- thp
650 gPodderEpisodeSelector(self.main_window,
651 title=_('Confirm changes from gpodder.net'),
652 instructions=_('Select the actions you want to carry out.'),
653 episodes=changes,
654 columns=columns,
655 size_attribute=None,
656 ok_button=_('A_pply'),
657 callback=execute_podcast_actions,
658 _config=self.config)
660 # There are some actions that need the user's attention
661 if changes:
662 util.idle_add(ask)
663 return True
665 # We have no remaining actions - no selection happens
666 return False
668 def rewrite_urls_mygpo(self):
669 # Check if we have to rewrite URLs since the last add
670 rewritten_urls = self.mygpo_client.get_rewritten_urls()
671 changed = False
673 for rewritten_url in rewritten_urls:
674 if not rewritten_url.new_url:
675 continue
677 for channel in self.channels:
678 if channel.url == rewritten_url.old_url:
679 logger.info('Updating URL of %s to %s', channel,
680 rewritten_url.new_url)
681 channel.url = rewritten_url.new_url
682 channel.save()
683 changed = True
684 break
686 if changed:
687 util.idle_add(self.update_episode_list_model)
689 def on_send_full_subscriptions(self):
690 # Send the full subscription list to the gpodder.net client
691 # (this will overwrite the subscription list on the server)
692 indicator = ProgressIndicator(_('Uploading subscriptions'),
693 _('Your subscriptions are being uploaded to the server.'),
694 False, self.get_dialog_parent())
696 try:
697 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
698 util.idle_add(self.show_message, _('List uploaded successfully.'))
699 except Exception as e:
700 def show_error(e):
701 message = str(e)
702 if not message:
703 message = e.__class__.__name__
704 if message == 'NotFound':
705 message = _(
706 'Could not find your device.\n'
707 '\n'
708 'Check login is a username (not an email)\n'
709 'and that the device name matches one in your account.'
711 self.show_message(html.escape(message),
712 _('Error while uploading'),
713 important=True)
714 util.idle_add(show_error, e)
716 indicator.on_finished()
718 def on_button_subscribe_clicked(self, button):
719 self.on_itemImportChannels_activate(button)
721 def on_button_downloads_clicked(self, widget):
722 self.downloads_window.show()
724 def on_treeview_button_pressed(self, treeview, event):
725 if event.window != treeview.get_bin_window():
726 return False
728 role = getattr(treeview, TreeViewHelper.ROLE)
729 if role == TreeViewHelper.ROLE_EPISODES and event.button == 1:
730 # Toggle episode "new" status by clicking the icon (bug 1432)
731 result = treeview.get_path_at_pos(int(event.x), int(event.y))
732 if result is not None:
733 path, column, x, y = result
734 # The user clicked the icon if she clicked in the first column
735 # and the x position is in the area where the icon resides
736 if (x < self.EPISODE_LIST_ICON_WIDTH
737 and column == treeview.get_columns()[0]):
738 model = treeview.get_model()
739 cursor_episode = model.get_value(model.get_iter(path),
740 EpisodeListModel.C_EPISODE)
742 new_value = cursor_episode.is_new
743 selected_episodes = self.get_selected_episodes()
745 # Avoid changing anything if the clicked episode is not
746 # selected already - otherwise update all selected
747 if cursor_episode in selected_episodes:
748 for episode in selected_episodes:
749 episode.mark(is_played=new_value)
751 self.update_episode_list_icons(selected=True)
752 self.update_podcast_list_model(selected=True)
753 return True
755 return event.button == 3
757 def on_treeview_channels_button_released(self, treeview, event):
758 if event.window != treeview.get_bin_window():
759 return False
761 return self.treeview_channels_show_context_menu(event)
763 def on_treeview_channels_long_press(self, gesture, x, y, treeview):
764 ev = Dummy(x=x, y=y, button=3)
765 return self.treeview_channels_show_context_menu(ev)
767 def on_treeview_episodes_button_released(self, treeview, event):
768 if event.window != treeview.get_bin_window():
769 return False
771 return self.treeview_available_show_context_menu(event)
773 def on_treeview_episodes_long_press(self, gesture, x, y, treeview):
774 ev = Dummy(x=x, y=y, button=3)
775 return self.treeview_available_show_context_menu(ev)
777 def on_treeview_downloads_button_released(self, treeview, event):
778 if event.window != treeview.get_bin_window():
779 return False
781 return self.treeview_downloads_show_context_menu(event)
783 def on_treeview_downloads_long_press(self, gesture, x, y, treeview):
784 ev = Dummy(x=x, y=y, button=3)
785 return self.treeview_downloads_show_context_menu(ev)
787 def on_find_podcast_activate(self, *args):
788 if self._search_podcasts:
789 self._search_podcasts.show_search()
791 def init_podcast_list_treeview(self):
792 size = cake_size_from_widget(self.treeChannels) * 2
793 scale = self.treeChannels.get_scale_factor()
794 self.podcast_list_model.set_max_image_size(size, scale)
795 # Set up podcast channel tree view widget
796 column = Gtk.TreeViewColumn('')
797 iconcell = Gtk.CellRendererPixbuf()
798 iconcell.set_property('width', size + 10)
799 column.pack_start(iconcell, False)
800 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
801 column.add_attribute(iconcell, 'visible', PodcastListModel.C_COVER_VISIBLE)
802 if scale != 1:
803 column.set_cell_data_func(iconcell, draw_iconcell_scale, scale)
805 namecell = Gtk.CellRendererText()
806 namecell.set_property('ellipsize', Pango.EllipsizeMode.END)
807 column.pack_start(namecell, True)
808 column.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
810 iconcell = Gtk.CellRendererPixbuf()
811 iconcell.set_property('xalign', 1.0)
812 column.pack_start(iconcell, False)
813 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
814 column.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
815 if scale != 1:
816 column.set_cell_data_func(iconcell, draw_iconcell_scale, scale)
818 self.treeChannels.append_column(column)
820 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
821 self.podcast_list_model.widget = self.treeChannels
823 # When no podcast is selected, clear the episode list model
824 selection = self.treeChannels.get_selection()
826 # Set up channels context menu
827 menu = self.application.builder.get_object('channels-context')
828 # Extensions section, updated in signal handler
829 extmenu = Gio.Menu()
830 menu.insert_section(4, _('Extensions'), extmenu)
831 self.channel_context_menu_helper = ExtensionMenuHelper(
832 self.gPodder, extmenu, 'channel_context_action_')
833 self.channels_popover = Gtk.Popover.new_from_model(self.treeChannels, menu)
834 self.channels_popover.set_position(Gtk.PositionType.BOTTOM)
835 self.channels_popover.connect(
836 'closed', lambda popover: self.allow_tooltips(True))
838 # Long press gesture
839 lp = Gtk.GestureLongPress.new(self.treeChannels)
840 lp.set_touch_only(True)
841 lp.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
842 lp.connect("pressed", self.on_treeview_channels_long_press, self.treeChannels)
843 setattr(self.treeChannels, "long-press-gesture", lp)
845 # Set up type-ahead find for the podcast list
846 def on_key_press(treeview, event):
847 if event.keyval == Gdk.KEY_Right:
848 self.treeAvailable.grab_focus()
849 elif event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down):
850 # If section markers exist in the treeview, we want to
851 # "jump over" them when moving the cursor up and down
852 if event.keyval == Gdk.KEY_Up:
853 step = -1
854 else:
855 step = 1
857 selection = self.treeChannels.get_selection()
858 model, it = selection.get_selected()
859 if it is None:
860 it = model.get_iter_first()
861 if it is None:
862 return False
863 step = 1
865 path = model.get_path(it)
866 path = (path[0] + step,)
868 if path[0] < 0:
869 # Valid paths must have a value >= 0
870 return True
872 try:
873 it = model.get_iter(path)
874 except ValueError:
875 # Already at the end of the list
876 return True
878 self.treeChannels.set_cursor(path)
879 elif event.keyval == Gdk.KEY_Escape:
880 self._search_podcasts.hide_search()
881 elif event.get_state() & Gdk.ModifierType.CONTROL_MASK:
882 # Don't handle type-ahead when control is pressed (so shortcuts
883 # with the Ctrl key still work, e.g. Ctrl+A, ...)
884 return True
885 elif event.keyval == Gdk.KEY_Delete:
886 return False
887 elif event.keyval == Gdk.KEY_Menu:
888 self.treeview_channels_show_context_menu()
889 return True
890 else:
891 unicode_char_id = Gdk.keyval_to_unicode(event.keyval)
892 # < 32 to intercept Delete and Tab events
893 if unicode_char_id < 32:
894 return False
895 if self.config.ui.gtk.find_as_you_type:
896 input_char = chr(unicode_char_id)
897 self._search_podcasts.show_search(input_char)
898 return True
900 self.treeChannels.connect('key-press-event', on_key_press)
901 self.treeChannels.connect('popup-menu',
902 lambda _tv, *args: self.treeview_channels_show_context_menu)
904 # Enable separators to the podcast list to separate special podcasts
905 # from others (this is used for the "all episodes" view)
906 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
908 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
910 self._search_podcasts = SearchTree(self.hbox_search_podcasts,
911 self.entry_search_podcasts,
912 self.treeChannels,
913 self.podcast_list_model,
914 self.config)
915 if self.config.ui.gtk.search_always_visible:
916 self._search_podcasts.show_search(grab_focus=False)
918 def on_find_episode_activate(self, *args):
919 if self._search_episodes:
920 self._search_episodes.show_search()
922 def set_episode_list_column(self, index, new_value):
923 mask = (1 << index)
924 if new_value:
925 self.config.ui.gtk.episode_list.columns |= mask
926 else:
927 self.config.ui.gtk.episode_list.columns &= ~mask
929 def update_episode_list_columns_visibility(self):
930 columns = TreeViewHelper.get_columns(self.treeAvailable)
931 for index, column in enumerate(columns):
932 visible = bool(self.config.ui.gtk.episode_list.columns & (1 << index))
933 column.set_visible(visible)
934 self.view_column_actions[index].set_state(GLib.Variant.new_boolean(visible))
935 self.treeAvailable.columns_autosize()
937 def on_episode_list_header_reordered(self, treeview):
938 self.config.ui.gtk.state.main_window.episode_column_order = \
939 [column.get_sort_column_id() for column in treeview.get_columns()]
941 def on_episode_list_header_sorted(self, column):
942 self.config.ui.gtk.state.main_window.episode_column_sort_id = column.get_sort_column_id()
943 self.config.ui.gtk.state.main_window.episode_column_sort_order = \
944 (column.get_sort_order() is Gtk.SortType.ASCENDING)
946 def on_episode_list_header_clicked(self, button, event):
947 if event.button == 1:
948 # Require control click to sort episodes, when enabled
949 if self.config.ui.gtk.episode_list.ctrl_click_to_sort and (event.state & Gdk.ModifierType.CONTROL_MASK) == 0:
950 return True
951 elif event.button == 3:
952 if self.episode_columns_menu is not None:
953 self.episode_columns_menu.popup(None, None, None, None, event.button, event.time)
955 return False
957 def align_releasecell(self):
958 if self.config.ui.gtk.episode_list.right_align_released_column:
959 self.releasecell.set_property('xalign', 1)
960 self.releasecell.set_property('alignment', Pango.Alignment.RIGHT)
961 else:
962 self.releasecell.set_property('xalign', 0)
963 self.releasecell.set_property('alignment', Pango.Alignment.LEFT)
965 def init_episode_list_treeview(self):
966 self.episode_list_model.set_view_mode(self.config.ui.gtk.episode_list.view_mode)
968 # Set up episode context menu
969 menu = self.application.builder.get_object('episodes-context')
970 # Extensions section, updated dynamically
971 extmenu = Gio.Menu()
972 menu.insert_section(2, _('Extensions'), extmenu)
973 self.episode_context_menu_helper = ExtensionMenuHelper(
974 self.gPodder, extmenu, 'episode_context_action_')
975 # Send To submenu section, shown only for downloaded episodes
976 self.sendto_menu = Gio.Menu()
977 menu.insert_section(2, None, self.sendto_menu)
978 self.episodes_popover = Gtk.Popover.new_from_model(self.treeAvailable, menu)
979 self.episodes_popover.set_position(Gtk.PositionType.BOTTOM)
980 self.episodes_popover.connect(
981 'closed', lambda popover: self.allow_tooltips(True))
983 # Initialize progress icons
984 cake_size = cake_size_from_widget(self.treeAvailable)
985 for i in range(EpisodeListModel.PROGRESS_STEPS + 1):
986 pixbuf = draw_cake_pixbuf(
987 i / EpisodeListModel.PROGRESS_STEPS, size=cake_size)
988 icon_name = 'gpodder-progress-%d' % i
989 Gtk.IconTheme.add_builtin_icon(icon_name, cake_size, pixbuf)
991 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
993 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
995 iconcell = Gtk.CellRendererPixbuf()
996 episode_list_icon_size = Gtk.icon_size_register('episode-list',
997 cake_size, cake_size)
998 iconcell.set_property('stock-size', episode_list_icon_size)
999 iconcell.set_fixed_size(cake_size + 20, -1)
1000 self.EPISODE_LIST_ICON_WIDTH = cake_size
1002 namecell = Gtk.CellRendererText()
1003 namecell.set_property('ellipsize', Pango.EllipsizeMode.END)
1004 namecolumn = Gtk.TreeViewColumn(_('Episode'))
1005 namecolumn.pack_start(iconcell, False)
1006 namecolumn.add_attribute(iconcell, 'icon-name', EpisodeListModel.C_STATUS_ICON)
1007 namecolumn.pack_start(namecell, True)
1008 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
1009 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
1010 namecolumn.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
1011 namecolumn.set_resizable(True)
1012 namecolumn.set_expand(True)
1014 lockcell = Gtk.CellRendererPixbuf()
1015 lockcell.set_fixed_size(40, -1)
1016 lockcell.set_property('stock-size', Gtk.IconSize.MENU)
1017 lockcell.set_property('icon-name', 'emblem-readonly')
1018 namecolumn.pack_start(lockcell, False)
1019 namecolumn.add_attribute(lockcell, 'visible', EpisodeListModel.C_LOCKED)
1021 sizecell = Gtk.CellRendererText()
1022 sizecell.set_property('xalign', 1)
1023 sizecolumn = Gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
1024 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
1026 timecell = Gtk.CellRendererText()
1027 timecell.set_property('xalign', 1)
1028 timecolumn = Gtk.TreeViewColumn(_('Duration'), timecell, text=EpisodeListModel.C_TIME)
1029 timecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME)
1031 self.releasecell = Gtk.CellRendererText()
1032 self.align_releasecell()
1033 releasecolumn = Gtk.TreeViewColumn(_('Released'))
1034 releasecolumn.pack_start(self.releasecell, True)
1035 releasecolumn.add_attribute(self.releasecell, 'markup', EpisodeListModel.C_PUBLISHED_TEXT)
1036 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
1038 sizetimecell = Gtk.CellRendererText()
1039 sizetimecell.set_property('xalign', 1)
1040 sizetimecell.set_property('alignment', Pango.Alignment.RIGHT)
1041 sizetimecolumn = Gtk.TreeViewColumn(_('Size+'))
1042 sizetimecolumn.pack_start(sizetimecell, True)
1043 sizetimecolumn.add_attribute(sizetimecell, 'markup', EpisodeListModel.C_FILESIZE_AND_TIME_TEXT)
1044 sizetimecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE_AND_TIME)
1046 timesizecell = Gtk.CellRendererText()
1047 timesizecell.set_property('xalign', 1)
1048 timesizecell.set_property('alignment', Pango.Alignment.RIGHT)
1049 timesizecolumn = Gtk.TreeViewColumn(_('Duration+'))
1050 timesizecolumn.pack_start(timesizecell, True)
1051 timesizecolumn.add_attribute(timesizecell, 'markup', EpisodeListModel.C_TIME_AND_SIZE)
1052 timesizecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME_AND_SIZE)
1054 namecolumn.set_reorderable(True)
1055 self.treeAvailable.append_column(namecolumn)
1057 # EpisodeListModel.C_PUBLISHED is not available in config.py, set it here on first run
1058 if not self.config.ui.gtk.state.main_window.episode_column_sort_id:
1059 self.config.ui.gtk.state.main_window.episode_column_sort_id = EpisodeListModel.C_PUBLISHED
1061 for itemcolumn in (sizecolumn, timecolumn, releasecolumn, sizetimecolumn, timesizecolumn):
1062 itemcolumn.set_reorderable(True)
1063 self.treeAvailable.append_column(itemcolumn)
1064 TreeViewHelper.register_column(self.treeAvailable, itemcolumn)
1066 # Add context menu to all tree view column headers
1067 for column in self.treeAvailable.get_columns():
1068 label = Gtk.Label(label=column.get_title())
1069 label.show_all()
1070 column.set_widget(label)
1072 w = column.get_widget()
1073 while w is not None and not isinstance(w, Gtk.Button):
1074 w = w.get_parent()
1076 w.connect('button-release-event', self.on_episode_list_header_clicked)
1078 # Restore column sorting
1079 if column.get_sort_column_id() == self.config.ui.gtk.state.main_window.episode_column_sort_id:
1080 self.episode_list_model._sorter.set_sort_column_id(Gtk.TREE_SORTABLE_UNSORTED_SORT_COLUMN_ID,
1081 Gtk.SortType.DESCENDING)
1082 self.episode_list_model._sorter.set_sort_column_id(column.get_sort_column_id(),
1083 Gtk.SortType.ASCENDING if self.config.ui.gtk.state.main_window.episode_column_sort_order
1084 else Gtk.SortType.DESCENDING)
1085 # Save column sorting when user clicks column headers
1086 column.connect('clicked', self.on_episode_list_header_sorted)
1088 def restore_column_ordering():
1089 prev_column = None
1090 for col in self.config.ui.gtk.state.main_window.episode_column_order:
1091 for column in self.treeAvailable.get_columns():
1092 if col is column.get_sort_column_id():
1093 break
1094 else:
1095 # Column ID not found, abort
1096 # Manually re-ordering columns should fix the corrupt setting
1097 break
1098 self.treeAvailable.move_column_after(column, prev_column)
1099 prev_column = column
1100 # Save column ordering when user drags column headers
1101 self.treeAvailable.connect('columns-changed', self.on_episode_list_header_reordered)
1102 # Delay column ordering until shown to prevent "Negative content height" warnings for themes with vertical padding or borders
1103 util.idle_add(restore_column_ordering)
1105 # For each column that can be shown/hidden, add a menu item
1106 self.view_column_actions = []
1107 columns = TreeViewHelper.get_columns(self.treeAvailable)
1109 def on_visible_toggled(action, param, index):
1110 state = action.get_state()
1111 self.set_episode_list_column(index, not state)
1112 action.set_state(GLib.Variant.new_boolean(not state))
1114 for index, column in enumerate(columns):
1115 name = 'showColumn%i' % index
1116 action = Gio.SimpleAction.new_stateful(
1117 name, None, GLib.Variant.new_boolean(False))
1118 action.connect('activate', on_visible_toggled, index)
1119 self.main_window.add_action(action)
1120 self.view_column_actions.append(action)
1121 self.application.menu_view_columns.insert(index, column.get_title(), 'win.' + name)
1123 self.episode_columns_menu = Gtk.Menu.new_from_model(self.application.menu_view_columns)
1124 self.episode_columns_menu.attach_to_widget(self.main_window)
1125 # Update the visibility of the columns and the check menu items
1126 self.update_episode_list_columns_visibility()
1128 # Long press gesture
1129 lp = Gtk.GestureLongPress.new(self.treeAvailable)
1130 lp.set_touch_only(True)
1131 lp.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
1132 lp.connect("pressed", self.on_treeview_episodes_long_press, self.treeAvailable)
1133 setattr(self.treeAvailable, "long-press-gesture", lp)
1135 # Set up type-ahead find for the episode list
1136 def on_key_press(treeview, event):
1137 if event.keyval == Gdk.KEY_Left:
1138 self.treeChannels.grab_focus()
1139 elif event.keyval == Gdk.KEY_Escape:
1140 if self.hbox_search_episodes.get_property('visible'):
1141 self._search_episodes.hide_search()
1142 else:
1143 self.shownotes_object.hide_pane()
1144 elif event.keyval == Gdk.KEY_Menu:
1145 self.treeview_available_show_context_menu()
1146 elif event.get_state() & Gdk.ModifierType.CONTROL_MASK:
1147 # Don't handle type-ahead when control is pressed (so shortcuts
1148 # with the Ctrl key still work, e.g. Ctrl+A, ...)
1149 return False
1150 else:
1151 unicode_char_id = Gdk.keyval_to_unicode(event.keyval)
1152 # < 32 to intercept Delete and Tab events
1153 if unicode_char_id < 32:
1154 return False
1155 if self.config.ui.gtk.find_as_you_type:
1156 input_char = chr(unicode_char_id)
1157 self._search_episodes.show_search(input_char)
1158 return True
1160 self.treeAvailable.connect('key-press-event', on_key_press)
1161 self.treeAvailable.connect('popup-menu',
1162 lambda _tv, *args: self.treeview_available_show_context_menu)
1164 self.treeAvailable.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK,
1165 (('text/uri-list', 0, 0),), Gdk.DragAction.COPY)
1167 def drag_data_get(tree, context, selection_data, info, timestamp):
1168 uris = ['file://' + urllib.parse.quote(e.local_filename(create=False))
1169 for e in self.get_selected_episodes()
1170 if e.was_downloaded(and_exists=True)]
1171 selection_data.set_uris(uris)
1172 self.treeAvailable.connect('drag-data-get', drag_data_get)
1174 selection = self.treeAvailable.get_selection()
1175 selection.set_mode(Gtk.SelectionMode.MULTIPLE)
1176 self.episode_selection_handler_id = selection.connect('changed', self.on_episode_list_selection_changed)
1178 self._search_episodes = SearchTree(self.hbox_search_episodes,
1179 self.entry_search_episodes,
1180 self.treeAvailable,
1181 self.episode_list_model,
1182 self.config)
1183 if self.config.ui.gtk.search_always_visible:
1184 self._search_episodes.show_search(grab_focus=False)
1186 def on_episode_list_selection_changed(self, selection):
1187 # Only update the UI every 250ms to prevent lag when rapidly changing selected episode or shift-selecting episodes
1188 if self.on_episode_list_selection_changed_id is None:
1189 self.on_episode_list_selection_changed_id = util.idle_timeout_add(250, self._on_episode_list_selection_changed)
1191 def _on_episode_list_selection_changed(self):
1192 self.on_episode_list_selection_changed_id = None
1194 # Update the toolbar buttons
1195 self.play_or_download()
1196 # and the shownotes
1197 self.shownotes_object.set_episodes(self.get_selected_episodes())
1199 def on_download_list_selection_changed(self, selection):
1200 if self.wNotebook.get_current_page() > 0:
1201 # Update the toolbar buttons
1202 self.play_or_download()
1204 def init_download_list_treeview(self):
1205 # columns and renderers for "download progress" tab
1206 # First column: [ICON] Episodename
1207 column = Gtk.TreeViewColumn(_('Episode'))
1209 cell = Gtk.CellRendererPixbuf()
1210 cell.set_property('stock-size', Gtk.IconSize.BUTTON)
1211 column.pack_start(cell, False)
1212 column.add_attribute(cell, 'icon-name',
1213 DownloadStatusModel.C_ICON_NAME)
1215 cell = Gtk.CellRendererText()
1216 cell.set_property('ellipsize', Pango.EllipsizeMode.END)
1217 column.pack_start(cell, True)
1218 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
1219 column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
1220 column.set_expand(True)
1221 self.treeDownloads.append_column(column)
1223 # Second column: Progress
1224 cell = Gtk.CellRendererProgress()
1225 cell.set_property('yalign', .5)
1226 cell.set_property('ypad', 6)
1227 column = Gtk.TreeViewColumn(_('Progress'), cell,
1228 value=DownloadStatusModel.C_PROGRESS,
1229 text=DownloadStatusModel.C_PROGRESS_TEXT)
1230 column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
1231 column.set_expand(False)
1232 self.treeDownloads.append_column(column)
1233 column.set_property('min-width', 150)
1234 column.set_property('max-width', 150)
1236 self.treeDownloads.set_model(self.download_status_model)
1237 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
1239 # enable multiple selection support
1240 selection = self.treeDownloads.get_selection()
1241 selection.set_mode(Gtk.SelectionMode.MULTIPLE)
1242 self.download_selection_handler_id = selection.connect('changed', self.on_download_list_selection_changed)
1243 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
1245 # Set up downloads context menu
1246 menu = self.application.builder.get_object('downloads-context')
1247 self.downloads_popover = Gtk.Popover.new_from_model(self.treeDownloads, menu)
1248 self.downloads_popover.set_position(Gtk.PositionType.BOTTOM)
1250 # Long press gesture
1251 lp = Gtk.GestureLongPress.new(self.treeDownloads)
1252 lp.set_touch_only(True)
1253 lp.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
1254 lp.connect("pressed", self.on_treeview_downloads_long_press, self.treeDownloads)
1255 setattr(self.treeDownloads, "long-press-gesture", lp)
1257 def on_key_press(treeview, event):
1258 if event.keyval == Gdk.KEY_Menu:
1259 self.treeview_downloads_show_context_menu()
1260 return True
1261 return False
1263 self.treeDownloads.connect('key-press-event', on_key_press)
1264 self.treeDownloads.connect('popup-menu',
1265 lambda _tv, *args: self.treeview_downloads_show_context_menu)
1267 def on_treeview_expose_event(self, treeview, ctx):
1268 model = treeview.get_model()
1269 if (model is not None and model.get_iter_first() is not None):
1270 return False
1272 role = getattr(treeview, TreeViewHelper.ROLE, None)
1273 if role is None:
1274 return False
1276 width = treeview.get_allocated_width()
1277 height = treeview.get_allocated_height()
1279 if role == TreeViewHelper.ROLE_EPISODES:
1280 if self.config.ui.gtk.episode_list.view_mode != EpisodeListModel.VIEW_ALL:
1281 text = _('No episodes in current view')
1282 else:
1283 text = _('No episodes available')
1284 elif role == TreeViewHelper.ROLE_PODCASTS:
1285 if self.config.ui.gtk.episode_list.view_mode != \
1286 EpisodeListModel.VIEW_ALL and \
1287 self.config.ui.gtk.podcast_list.hide_empty and \
1288 len(self.channels) > 0:
1289 text = _('No podcasts in this view')
1290 else:
1291 text = _('No subscriptions')
1292 elif role == TreeViewHelper.ROLE_DOWNLOADS:
1293 text = _('No active tasks')
1294 else:
1295 raise Exception('on_treeview_expose_event: unknown role')
1297 draw_text_box_centered(ctx, treeview, width, height, text, None, None)
1298 return True
1300 def set_download_list_state(self, state):
1301 if state == gPodderSyncUI.DL_ADDING_TASKS:
1302 self.things_adding_tasks += 1
1303 elif state == gPodderSyncUI.DL_ADDED_TASKS:
1304 self.things_adding_tasks -= 1
1305 if self.download_list_update_timer is None:
1306 self.update_downloads_list()
1307 self.download_list_update_timer = util.IdleTimeout(1500, self.update_downloads_list).set_max_milliseconds(5000)
1309 def stop_download_list_update_timer(self):
1310 if self.download_list_update_timer is None:
1311 return False
1313 self.download_list_update_timer.cancel()
1314 self.download_list_update_timer = None
1315 return True
1317 def cleanup_downloads(self):
1318 model = self.download_status_model
1320 all_tasks = [(Gtk.TreeRowReference.new(model, row.path), row[0]) for row in model]
1321 changed_episode_urls = set()
1322 for row_reference, task in all_tasks:
1323 if task.status in (task.DONE, task.CANCELLED):
1324 model.remove(model.get_iter(row_reference.get_path()))
1325 try:
1326 # We don't "see" this task anymore - remove it;
1327 # this is needed, so update_episode_list_icons()
1328 # below gets the correct list of "seen" tasks
1329 self.download_tasks_seen.remove(task)
1330 except KeyError as key_error:
1331 pass
1332 changed_episode_urls.add(task.url)
1333 # Tell the task that it has been removed (so it can clean up)
1334 task.removed_from_list()
1336 # Tell the podcasts tab to update icons for our removed podcasts
1337 self.update_episode_list_icons(changed_episode_urls)
1339 # Update the downloads list one more time
1340 self.update_downloads_list(can_call_cleanup=False)
1342 def on_tool_downloads_toggled(self, toolbutton):
1343 if toolbutton.get_active():
1344 self.wNotebook.set_current_page(1)
1345 else:
1346 self.wNotebook.set_current_page(0)
1348 def add_download_task_monitor(self, monitor):
1349 self.download_task_monitors.add(monitor)
1350 model = self.download_status_model
1351 if model is None:
1352 model = ()
1353 for row in model.get_model():
1354 task = row[self.download_status_model.C_TASK]
1355 monitor.task_updated(task)
1357 def remove_download_task_monitor(self, monitor):
1358 self.download_task_monitors.remove(monitor)
1360 def set_download_progress(self, progress):
1361 gpodder.user_extensions.on_download_progress(progress)
1363 def update_downloads_list(self, can_call_cleanup=True):
1364 try:
1365 model = self.download_status_model
1367 downloading, synchronizing, pausing, cancelling, queued, paused, failed, finished = (0,) * 8
1368 total_speed, total_size, done_size = 0, 0, 0
1369 files_downloading = 0
1371 # Keep a list of all download tasks that we've seen
1372 download_tasks_seen = set()
1374 # Do not go through the list of the model is not (yet) available
1375 if model is None:
1376 model = ()
1378 for row in model:
1379 self.download_status_model.request_update(row.iter)
1381 task = row[self.download_status_model.C_TASK]
1382 speed, size, status, progress, activity = task.speed, task.total_size, task.status, task.progress, task.activity
1384 # Let the download task monitors know of changes
1385 for monitor in self.download_task_monitors:
1386 monitor.task_updated(task)
1388 total_size += size
1389 done_size += size * progress
1391 download_tasks_seen.add(task)
1393 if status == download.DownloadTask.DOWNLOADING:
1394 if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
1395 downloading += 1
1396 files_downloading += 1
1397 total_speed += speed
1398 elif activity == download.DownloadTask.ACTIVITY_SYNCHRONIZE:
1399 synchronizing += 1
1400 elif status == download.DownloadTask.PAUSING:
1401 pausing += 1
1402 if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
1403 files_downloading += 1
1404 elif status == download.DownloadTask.CANCELLING:
1405 cancelling += 1
1406 if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
1407 files_downloading += 1
1408 elif status == download.DownloadTask.QUEUED:
1409 queued += 1
1410 elif status == download.DownloadTask.PAUSED:
1411 paused += 1
1412 elif status == download.DownloadTask.FAILED:
1413 failed += 1
1414 elif status == download.DownloadTask.DONE:
1415 finished += 1
1417 # Remember which tasks we have seen after this run
1418 self.download_tasks_seen = download_tasks_seen
1420 text = [_('Progress')]
1421 if downloading + synchronizing + pausing + cancelling + queued + paused + failed > 0:
1422 s = []
1423 if downloading > 0:
1424 s.append(N_('%(count)d active', '%(count)d active', downloading) % {'count': downloading})
1425 if synchronizing > 0:
1426 s.append(N_('%(count)d active', '%(count)d active', synchronizing) % {'count': synchronizing})
1427 if pausing > 0:
1428 s.append(N_('%(count)d pausing', '%(count)d pausing', pausing) % {'count': pausing})
1429 if cancelling > 0:
1430 s.append(N_('%(count)d cancelling', '%(count)d cancelling', cancelling) % {'count': cancelling})
1431 if queued > 0:
1432 s.append(N_('%(count)d queued', '%(count)d queued', queued) % {'count': queued})
1433 if paused > 0:
1434 s.append(N_('%(count)d paused', '%(count)d paused', paused) % {'count': paused})
1435 if failed > 0:
1436 s.append(N_('%(count)d failed', '%(count)d failed', failed) % {'count': failed})
1437 text.append(' (' + ', '.join(s) + ')')
1438 self.labelDownloads.set_text(''.join(text))
1440 title = [self.default_title]
1442 # Accessing task.status_changed has the side effect of re-setting
1443 # the changed flag, but we only do it once here so that's okay
1444 channel_urls = [task.podcast_url for task in
1445 self.download_tasks_seen if task.status_changed]
1446 episode_urls = [task.url for task in self.download_tasks_seen]
1448 if files_downloading > 0:
1449 title.append(N_('downloading %(count)d file',
1450 'downloading %(count)d files',
1451 files_downloading) % {'count': files_downloading})
1453 if total_size > 0:
1454 percentage = 100.0 * done_size / total_size
1455 else:
1456 percentage = 0.0
1457 self.set_download_progress(percentage / 100)
1458 total_speed = util.format_filesize(total_speed)
1459 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1460 if synchronizing > 0:
1461 title.append(N_('synchronizing %(count)d file',
1462 'synchronizing %(count)d files',
1463 synchronizing) % {'count': synchronizing})
1464 if queued > 0:
1465 title.append(N_('%(queued)d task queued',
1466 '%(queued)d tasks queued',
1467 queued) % {'queued': queued})
1468 if (downloading + synchronizing + pausing + cancelling + queued) == 0 and self.things_adding_tasks == 0:
1469 self.set_download_progress(1.)
1470 self.downloads_finished(self.download_tasks_seen)
1471 gpodder.user_extensions.on_all_episodes_downloaded()
1472 logger.info('All tasks have finished.')
1474 # Remove finished episodes
1475 if self.config.ui.gtk.download_list.remove_finished and can_call_cleanup:
1476 self.cleanup_downloads()
1478 # Stop updating the download list here
1479 self.stop_download_list_update_timer()
1481 self.gPodder.set_title(' - '.join(title))
1483 self.update_episode_list_icons(episode_urls)
1484 self.play_or_download()
1485 if channel_urls:
1486 self.update_podcast_list_model(channel_urls)
1488 return (self.download_list_update_timer is not None)
1489 except Exception as e:
1490 logger.error('Exception happened while updating download list.', exc_info=True)
1491 self.show_message(
1492 '%s\n\n%s' % (_('Please report this problem and restart gPodder:'), html.escape(str(e))),
1493 _('Unhandled exception'), important=True)
1494 # We return False here, so the update loop won't be called again,
1495 # that's why we require the restart of gPodder in the message.
1496 return False
1498 def on_config_changed(self, *args):
1499 util.idle_add(self._on_config_changed, *args)
1501 def _on_config_changed(self, name, old_value, new_value):
1502 if name == 'ui.gtk.toolbar':
1503 self.toolbar.set_property('visible', new_value)
1504 elif name in ('ui.gtk.episode_list.show_released_time',
1505 'ui.gtk.episode_list.descriptions',
1506 'ui.gtk.episode_list.trim_title_prefix',
1507 'ui.gtk.episode_list.always_show_new'):
1508 self.update_episode_list_model()
1509 elif name in ('auto.update.enabled', 'auto.update.frequency'):
1510 self.restart_auto_update_timer()
1511 elif name in ('ui.gtk.podcast_list.all_episodes',
1512 'ui.gtk.podcast_list.sections'):
1513 # Force a update of the podcast list model
1514 self.update_podcast_list_model()
1515 elif name == 'ui.gtk.episode_list.columns':
1516 self.update_episode_list_columns_visibility()
1517 elif name == 'limit.downloads.concurrent_max':
1518 # Do not allow value to be set below 1
1519 if new_value < 1:
1520 self.config.limit.downloads.concurrent_max = 1
1521 return
1522 # Clamp current value to new maximum value
1523 if self.config.limit.downloads.concurrent > new_value:
1524 self.config.limit.downloads.concurrent = new_value
1525 self.spinMaxDownloads.get_adjustment().set_upper(new_value)
1526 elif name == 'limit.downloads.concurrent':
1527 if self.config.clamp_range('limit.downloads.concurrent', 1, self.config.limit.downloads.concurrent_max):
1528 return
1529 self.spinMaxDownloads.set_value(new_value)
1530 elif name == 'limit.bandwidth.kbps':
1531 adjustment = self.spinLimitDownloads.get_adjustment()
1532 if self.config.clamp_range('limit.bandwidth.kbps', adjustment.get_lower(), adjustment.get_upper()):
1533 return
1534 self.spinLimitDownloads.set_value(new_value)
1536 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1537 # With get_bin_window, we get the window that contains the rows without
1538 # the header. The Y coordinate of this window will be the height of the
1539 # treeview header. This is the amount we have to subtract from the
1540 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1541 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1542 x -= x_bin
1543 y -= y_bin
1544 (path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,) * 4
1546 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or x > 50 or (column is not None and column != treeview.get_columns()[0]):
1547 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1548 return False
1550 if path is not None:
1551 model = treeview.get_model()
1552 iter = model.get_iter(path)
1553 role = getattr(treeview, TreeViewHelper.ROLE)
1555 if role == TreeViewHelper.ROLE_EPISODES:
1556 id = model.get_value(iter, EpisodeListModel.C_URL)
1557 elif role == TreeViewHelper.ROLE_PODCASTS:
1558 id = model.get_value(iter, PodcastListModel.C_URL)
1559 if id == '-':
1560 # Section header - no tooltip here (for now at least)
1561 return False
1563 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1564 if last_tooltip is not None and last_tooltip != id:
1565 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1566 return False
1567 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1569 if role == TreeViewHelper.ROLE_EPISODES:
1570 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1571 if description:
1572 tooltip.set_text(description)
1573 else:
1574 return False
1575 elif role == TreeViewHelper.ROLE_PODCASTS:
1576 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1577 if channel is None or not hasattr(channel, 'title'):
1578 return False
1579 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1580 if error_str:
1581 error_str = _('Feedparser error: %s') % html.escape(error_str.strip())
1582 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1584 box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
1585 box.set_border_width(5)
1587 heading = Gtk.Label()
1588 heading.set_max_width_chars(60)
1589 heading.set_alignment(0, 1)
1590 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (html.escape(channel.title), html.escape(channel.url)))
1591 box.add(heading)
1593 box.add(Gtk.HSeparator())
1595 channel_description = util.remove_html_tags(channel.description)
1596 if channel._update_error is not None:
1597 description = _('ERROR: %s') % channel._update_error
1598 elif len(channel_description) < 500:
1599 description = channel_description
1600 else:
1601 pos = channel_description.find('\n\n')
1602 if pos == -1 or pos > 500:
1603 description = channel_description[:498] + '[...]'
1604 else:
1605 description = channel_description[:pos]
1607 description = Gtk.Label(label=description)
1608 description.set_max_width_chars(60)
1609 if error_str:
1610 description.set_markup(error_str)
1611 description.set_alignment(0, 0)
1612 description.set_line_wrap(True)
1613 box.add(description)
1615 box.show_all()
1616 tooltip.set_custom(box)
1618 return True
1620 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1621 return False
1623 def allow_tooltips(self, allow):
1624 setattr(self.treeChannels, TreeViewHelper.CAN_TOOLTIP, allow)
1625 setattr(self.treeAvailable, TreeViewHelper.CAN_TOOLTIP, allow)
1627 def treeview_handle_context_menu_click(self, treeview, event):
1628 if event is None:
1629 selection = treeview.get_selection()
1630 return selection.get_selected_rows()
1632 x, y = int(event.x), int(event.y)
1633 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,) * 4
1635 selection = treeview.get_selection()
1636 model, paths = selection.get_selected_rows()
1638 if path is None or (path not in paths
1639 and event.button == 3):
1640 # We have right-clicked, but not into the selection,
1641 # assume we don't want to operate on the selection
1642 paths = []
1644 if (path is not None and not paths
1645 and event.button == 3):
1646 # No selection or clicked outside selection;
1647 # select the single item where we clicked
1648 treeview.grab_focus()
1649 treeview.set_cursor(path, column, 0)
1650 paths = [path]
1652 if not paths:
1653 # Unselect any remaining items (clicked elsewhere)
1654 if not treeview.is_rubber_banding_active():
1655 selection.unselect_all()
1657 return model, paths
1659 def downloads_list_get_selection(self, model=None, paths=None):
1660 if model is None and paths is None:
1661 selection = self.treeDownloads.get_selection()
1662 model, paths = selection.get_selected_rows()
1664 can_force, can_queue, can_pause, can_cancel, can_remove = (True,) * 5
1665 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
1666 model.get_value(model.get_iter(path),
1667 DownloadStatusModel.C_TASK)) for path in paths]
1669 for row_reference, task in selected_tasks:
1670 if task.status != download.DownloadTask.QUEUED:
1671 can_force = False
1672 if not task.can_queue():
1673 can_queue = False
1674 if not task.can_pause():
1675 can_pause = False
1676 if not task.can_cancel():
1677 can_cancel = False
1678 if not task.can_remove():
1679 can_remove = False
1681 return selected_tasks, can_force, can_queue, can_pause, can_cancel, can_remove
1683 def downloads_finished(self, download_tasks_seen):
1684 # Separate tasks into downloads & syncs
1685 # Since calling notify_as_finished or notify_as_failed clears the flag,
1686 # need to iterate through downloads & syncs separately, else all sync
1687 # tasks will have their flags cleared if we do downloads first
1689 def filter_by_activity(activity, tasks):
1690 return [task for task in tasks if task.activity == activity]
1692 download_tasks = filter_by_activity(download.DownloadTask.ACTIVITY_DOWNLOAD,
1693 download_tasks_seen)
1695 finished_downloads = [str(task)
1696 for task in download_tasks if task.notify_as_finished()]
1697 failed_downloads = ['%s (%s)' % (task, task.error_message)
1698 for task in download_tasks if task.notify_as_failed()]
1700 sync_tasks = filter_by_activity(download.DownloadTask.ACTIVITY_SYNCHRONIZE,
1701 download_tasks_seen)
1703 finished_syncs = [task for task in sync_tasks if task.notify_as_finished()]
1704 failed_syncs = [task for task in sync_tasks if task.notify_as_failed()]
1706 # Note that 'finished_ / failed_downloads' is a list of strings
1707 # Whereas 'finished_ / failed_syncs' is a list of SyncTask objects
1709 if finished_downloads and failed_downloads:
1710 message = self.format_episode_list(finished_downloads, 5)
1711 message += '\n\n<i>%s</i>\n' % _('Could not download some episodes:')
1712 message += self.format_episode_list(failed_downloads, 5)
1713 self.show_message(message, _('Downloads finished'))
1714 elif finished_downloads:
1715 message = self.format_episode_list(finished_downloads)
1716 self.show_message(message, _('Downloads finished'))
1717 elif failed_downloads:
1718 message = self.format_episode_list(failed_downloads)
1719 self.show_message(message, _('Downloads failed'))
1721 if finished_syncs and failed_syncs:
1722 message = self.format_episode_list(list(map((
1723 lambda task: str(task)), finished_syncs)), 5)
1724 message += '\n\n<i>%s</i>\n' % _('Could not sync some episodes:')
1725 message += self.format_episode_list(list(map((
1726 lambda task: str(task)), failed_syncs)), 5)
1727 self.show_message(message, _('Device synchronization finished'), True)
1728 elif finished_syncs:
1729 message = self.format_episode_list(list(map((
1730 lambda task: str(task)), finished_syncs)))
1731 self.show_message(message, _('Device synchronization finished'))
1732 elif failed_syncs:
1733 message = self.format_episode_list(list(map((
1734 lambda task: str(task)), failed_syncs)))
1735 self.show_message(message, _('Device synchronization failed'), True)
1737 # Do post-sync processing if required
1738 for task in finished_syncs:
1739 if self.config.device_sync.after_sync.mark_episodes_played:
1740 logger.info('Marking as played on transfer: %s', task.episode.url)
1741 task.episode.mark(is_played=True)
1743 if self.config.device_sync.after_sync.delete_episodes:
1744 logger.info('Removing episode after transfer: %s', task.episode.url)
1745 task.episode.delete_from_disk()
1747 self.sync_ui.device.close()
1749 # Update icon list to show changes, if any
1750 self.update_episode_list_icons(all=True)
1751 self.update_podcast_list_model()
1753 def format_episode_list(self, episode_list, max_episodes=10):
1755 Format a list of episode names for notifications
1757 Will truncate long episode names and limit the amount of
1758 episodes displayed (max_episodes=10).
1760 The episode_list parameter should be a list of strings.
1762 MAX_TITLE_LENGTH = 100
1764 result = []
1765 for title in episode_list[:min(len(episode_list), max_episodes)]:
1766 # Bug 1834: make sure title is a unicode string,
1767 # so it may be cut correctly on UTF-8 char boundaries
1768 title = util.convert_bytes(title)
1769 if len(title) > MAX_TITLE_LENGTH:
1770 middle = (MAX_TITLE_LENGTH // 2) - 2
1771 title = '%s...%s' % (title[0:middle], title[-middle:])
1772 result.append(html.escape(title))
1773 result.append('\n')
1775 more_episodes = len(episode_list) - max_episodes
1776 if more_episodes > 0:
1777 result.append('(...')
1778 result.append(N_('%(count)d more episode',
1779 '%(count)d more episodes',
1780 more_episodes) % {'count': more_episodes})
1781 result.append('...)')
1783 return (''.join(result)).strip()
1785 def queue_task(self, task, force_start):
1786 if force_start:
1787 self.download_queue_manager.force_start_task(task)
1788 else:
1789 self.download_queue_manager.queue_task(task)
1791 def _for_each_task_set_status(self, tasks, status, force_start=False):
1792 count = len(tasks)
1793 if count:
1794 progress_indicator = ProgressIndicator(
1795 _('Queueing') if status == download.DownloadTask.QUEUED else
1796 _('Removing') if status is None else download.DownloadTask.STATUS_MESSAGE[status],
1797 '', True, self.get_dialog_parent(), count)
1798 else:
1799 progress_indicator = None
1801 restart_timer = self.stop_download_list_update_timer()
1802 self.download_queue_manager.disable()
1803 self.__for_each_task_set_status(tasks, status, force_start, progress_indicator, restart_timer)
1804 self.download_queue_manager.enable()
1806 if progress_indicator:
1807 progress_indicator.on_finished()
1809 def __for_each_task_set_status(self, tasks, status, force_start=False, progress_indicator=None, restart_timer=False):
1810 episode_urls = set()
1811 model = self.treeDownloads.get_model()
1812 has_queued_tasks = False
1813 for row_reference, task in tasks:
1814 with task:
1815 if status == download.DownloadTask.QUEUED:
1816 # Only queue task when it's paused/failed/cancelled (or forced)
1817 if task.can_queue() or force_start:
1818 # add the task back in if it was already cleaned up
1819 # (to trigger this cancel one downloads in the active list, cancel all
1820 # other downloads, quickly right click on the cancelled on one to get
1821 # the context menu, wait until the active list is cleared, and then
1822 # then choose download)
1823 if task not in self.download_tasks_seen:
1824 self.download_status_model.register_task(task, False)
1825 self.download_tasks_seen.add(task)
1827 self.queue_task(task, force_start)
1828 has_queued_tasks = True
1829 elif status == download.DownloadTask.CANCELLING:
1830 logger.info(("cancelling task %s" % task.status))
1831 task.cancel()
1832 elif status == download.DownloadTask.PAUSING:
1833 task.pause()
1834 elif status is None:
1835 if task.can_cancel():
1836 task.cancel()
1837 path = row_reference.get_path()
1838 # path isn't set if the item has already been removed from the list
1839 # (to trigger this cancel one downloads in the active list, cancel all
1840 # other downloads, quickly right click on the cancelled on one to get
1841 # the context menu, wait until the active list is cleared, and then
1842 # then choose remove from list)
1843 if path:
1844 model.remove(model.get_iter(path))
1845 # Remember the URL, so we can tell the UI to update
1846 try:
1847 # We don't "see" this task anymore - remove it;
1848 # this is needed, so update_episode_list_icons()
1849 # below gets the correct list of "seen" tasks
1850 self.download_tasks_seen.remove(task)
1851 except KeyError as key_error:
1852 pass
1853 episode_urls.add(task.url)
1854 # Tell the task that it has been removed (so it can clean up)
1855 task.removed_from_list()
1856 else:
1857 # We can (hopefully) simply set the task status here
1858 task.status = status
1859 if progress_indicator:
1860 if not progress_indicator.on_tick():
1861 break
1862 if progress_indicator:
1863 progress_indicator.on_tick(final=_('Updating...'))
1865 # Update the tab title and downloads list
1866 if has_queued_tasks or restart_timer:
1867 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
1868 else:
1869 self.update_downloads_list()
1870 # Tell the podcasts tab to update icons for our removed podcasts
1871 self.update_episode_list_icons(episode_urls)
1873 def treeview_downloads_show_context_menu(self, event=None):
1874 treeview = self.treeDownloads
1876 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1877 if not paths:
1878 return not treeview.is_rubber_banding_active()
1880 if event is None or event.button == 3:
1881 selected_tasks, can_force, can_queue, can_pause, can_cancel, can_remove = \
1882 self.downloads_list_get_selection(model, paths)
1884 menu = self.application.builder.get_object('downloads-context')
1885 vsec = menu.get_item_link(0, Gio.MENU_LINK_SECTION)
1886 dsec = menu.get_item_link(1, Gio.MENU_LINK_SECTION)
1888 def insert_menuitem(position, label, action, icon):
1889 dsec.insert(position, label, action)
1890 menuitem = Gio.MenuItem.new(label, action)
1891 menuitem.set_attribute_value('verb-icon', GLib.Variant.new_string(icon))
1892 vsec.insert_item(position, menuitem)
1894 vsec.remove(0)
1895 dsec.remove(0)
1896 if can_force:
1897 insert_menuitem(0, _('Start download now'), 'win.forceDownload', 'document-save-symbolic')
1898 else:
1899 insert_menuitem(0, _('Download'), 'win.download', 'document-save-symbolic')
1901 self.gPodder.lookup_action('remove').set_enabled(can_remove)
1903 area = TreeViewHelper.get_popup_rectangle(treeview, event)
1904 self.downloads_popover.set_pointing_to(area)
1905 self.downloads_popover.show()
1906 return True
1908 def on_mark_episodes_as_old(self, item, *args):
1909 assert self.active_channel is not None
1911 for episode in self.active_channel.get_all_episodes():
1912 if not episode.was_downloaded(and_exists=True):
1913 episode.mark(is_played=True)
1915 self.update_podcast_list_model(selected=True)
1916 self.update_episode_list_icons(all=True)
1918 def on_open_download_folder(self, item, *args):
1919 assert self.active_channel is not None
1920 util.gui_open(self.active_channel.save_dir, gui=self)
1922 def on_open_episode_download_folder(self, unused1=None, unused2=None):
1923 episodes = self.get_selected_episodes()
1924 assert len(episodes) == 1
1925 util.gui_open(episodes[0].parent.save_dir, gui=self)
1927 def on_select_channel_of_episode(self, unused1=None, unused2=None):
1928 episodes = self.get_selected_episodes()
1929 assert len(episodes) == 1
1930 channel = episodes[0].parent
1931 # Focus channel list
1932 self.treeChannels.grab_focus()
1933 # Select channel in list
1934 path = self.podcast_list_model.get_filter_path_from_url(channel.url)
1935 self.treeChannels.set_cursor(path)
1937 def treeview_channels_show_context_menu(self, event=None):
1938 treeview = self.treeChannels
1939 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1940 if not paths:
1941 return True
1943 # Check for valid channel id, if there's no id then
1944 # assume that it is a proxy channel or equivalent
1945 # and cannot be operated with right click
1946 if self.active_channel.id is None:
1947 return True
1949 if event is None or event.button == 3:
1950 self.auto_archive_action.change_state(
1951 GLib.Variant.new_boolean(self.active_channel.auto_archive_episodes))
1953 self.channel_context_menu_helper.replace_entries([
1954 (label,
1955 None if func is None else lambda a, b, f=func: f(self.active_channel))
1956 for label, func in list(
1957 gpodder.user_extensions.on_channel_context_menu(self.active_channel)
1958 or [])])
1960 self.allow_tooltips(False)
1962 area = TreeViewHelper.get_popup_rectangle(treeview, event)
1963 self.channels_popover.set_pointing_to(area)
1964 self.channels_popover.show()
1965 return True
1967 def cover_download_finished(self, channel, pixbuf):
1969 The Cover Downloader calls this when it has finished
1970 downloading (or registering, if already downloaded)
1971 a new channel cover, which is ready for displaying.
1973 util.idle_add(self.podcast_list_model.add_cover_by_channel,
1974 channel, pixbuf)
1976 @staticmethod
1977 def build_filename(filename, extension):
1978 filename, extension = util.sanitize_filename_ext(
1979 filename,
1980 extension,
1981 PodcastEpisode.MAX_FILENAME_LENGTH,
1982 PodcastEpisode.MAX_FILENAME_WITH_EXT_LENGTH)
1983 if not filename.endswith(extension):
1984 filename += extension
1985 return filename
1987 def on_save_episodes_activate(self, action, *args):
1988 episodes = self.get_selected_episodes()
1989 util.idle_add(self.save_episodes_as_file, episodes)
1991 def save_episodes_as_file(self, episodes):
1992 def do_save_episode(copy_from, copy_to):
1993 if os.path.exists(copy_to):
1994 logger.warning(copy_from)
1995 logger.warning(copy_to)
1996 title = _('File already exists')
1997 d = {'filename': os.path.basename(copy_to)}
1998 message = _('A file named "%(filename)s" already exists. Do you want to replace it?') % d
1999 if not self.show_confirmation(message, title):
2000 return
2001 try:
2002 shutil.copyfile(copy_from, copy_to)
2003 except (OSError, IOError) as e:
2004 logger.warning('Error copying from %s to %s: %r', copy_from, copy_to, e, exc_info=True)
2005 folder, filename = os.path.split(copy_to)
2006 # Remove characters not supported by VFAT (#282)
2007 new_filename = re.sub(r"[\"*/:<>?\\|]", "_", filename)
2008 destination = os.path.join(folder, new_filename)
2009 if (copy_to != destination):
2010 shutil.copyfile(copy_from, destination)
2011 else:
2012 raise
2014 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
2015 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
2016 allRemainingDefault = False
2017 remaining = len(episodes)
2018 dialog = gPodderExportToLocalFolder(self.main_window,
2019 _config=self.config)
2020 for episode in episodes:
2021 remaining -= 1
2022 if episode.was_downloaded(and_exists=True):
2023 copy_from = episode.local_filename(create=False)
2024 assert copy_from is not None
2026 base, extension = os.path.splitext(copy_from)
2027 filename = self.build_filename(episode.sync_filename(), extension)
2029 try:
2030 if allRemainingDefault:
2031 do_save_episode(copy_from, os.path.join(folder, filename))
2032 else:
2033 (notCancelled, folder, dest_path, allRemainingDefault) = dialog.save_as(folder, filename, remaining)
2034 if notCancelled:
2035 do_save_episode(copy_from, dest_path)
2036 else:
2037 break
2038 except (OSError, IOError) as e:
2039 if remaining:
2040 msg = _('Error saving to local folder: %(error)r.\n'
2041 'Would you like to continue?') % dict(error=e)
2042 if not self.show_confirmation(msg, _('Error saving to local folder')):
2043 logger.warning("Save to Local Folder cancelled following error")
2044 break
2045 else:
2046 self.notification(_('Error saving to local folder: %(error)r') % dict(error=e),
2047 _('Error saving to local folder'), important=True)
2049 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
2051 def on_bluetooth_episodes_activate(self, action, *args):
2052 episodes = self.get_selected_episodes()
2053 util.idle_add(self.copy_episodes_bluetooth, episodes)
2055 def copy_episodes_bluetooth(self, episodes):
2056 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
2058 def convert_and_send_thread(episode):
2059 for episode in episodes:
2060 filename = episode.local_filename(create=False)
2061 assert filename is not None
2062 (base, ext) = os.path.splitext(filename)
2063 destfile = self.build_filename(episode.sync_filename(), ext)
2064 destfile = os.path.join(tempfile.gettempdir(), destfile)
2066 try:
2067 shutil.copyfile(filename, destfile)
2068 util.bluetooth_send_file(destfile)
2069 except:
2070 logger.error('Cannot copy "%s" to "%s".', filename, destfile)
2071 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
2073 util.delete_file(destfile)
2075 util.run_in_background(lambda: convert_and_send_thread(episodes_to_copy))
2077 def treeview_available_show_context_menu(self, event=None):
2078 treeview = self.treeAvailable
2080 model, paths = self.treeview_handle_context_menu_click(treeview, event)
2081 if not paths:
2082 return not treeview.is_rubber_banding_active()
2084 if event is None or event.button == 3:
2085 episodes = self.get_selected_episodes()
2086 any_locked = any(e.archive for e in episodes)
2087 any_new = any(e.is_new and e.state != gpodder.STATE_DELETED for e in episodes)
2088 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
2089 downloading = any(e.downloading for e in episodes)
2090 (open_instead_of_play, can_play, can_preview, can_download, can_pause,
2091 can_cancel, can_delete, can_lock) = self.play_or_download()
2093 menu = self.application.builder.get_object('episodes-context')
2094 vsec = menu.get_item_link(0, Gio.MENU_LINK_SECTION)
2095 psec = menu.get_item_link(1, Gio.MENU_LINK_SECTION)
2097 def insert_menuitem(position, label, action, icon):
2098 psec.insert(position, label, action)
2099 menuitem = Gio.MenuItem.new(label, action)
2100 menuitem.set_attribute_value('verb-icon', GLib.Variant.new_string(icon))
2101 vsec.insert_item(position, menuitem)
2103 # Play / Stream / Preview / Open
2104 vsec.remove(0)
2105 psec.remove(0)
2106 if open_instead_of_play:
2107 insert_menuitem(0, _('Open'), 'win.open', 'document-open-symbolic')
2108 else:
2109 if downloaded:
2110 insert_menuitem(0, _('Play'), 'win.play', 'media-playback-start-symbolic')
2111 elif can_preview:
2112 insert_menuitem(0, _('Preview'), 'win.play', 'media-playback-start-symbolic')
2113 else:
2114 insert_menuitem(0, _('Stream'), 'win.play', 'media-playback-start-symbolic')
2116 # Download / Pause
2117 vsec.remove(1)
2118 psec.remove(1)
2119 if can_pause:
2120 insert_menuitem(1, _('Pause'), 'win.pause', 'media-playback-pause-symbolic')
2121 else:
2122 insert_menuitem(1, _('Download'), 'win.download', 'document-save-symbolic')
2124 # Cancel
2125 have_cancel = (psec.get_item_attribute_value(
2126 2, "action", GLib.VariantType("s")).get_string() == 'win.cancel')
2127 if not can_cancel and have_cancel:
2128 vsec.remove(2)
2129 psec.remove(2)
2130 elif can_cancel and not have_cancel:
2131 insert_menuitem(2, _('Cancel'), 'win.cancel', 'process-stop-symbolic')
2133 # Extensions section
2134 self.episode_context_menu_helper.replace_entries([
2135 (label, None if func is None else lambda a, b, f=func: f(episodes))
2136 for label, func in list(
2137 gpodder.user_extensions.on_episodes_context_menu(episodes) or [])])
2139 # 'Send to' submenu
2140 if downloaded:
2141 if self.sendto_menu.get_n_items() < 1:
2142 self.sendto_menu.insert_submenu(
2143 0, _('Send to'),
2144 self.application.builder.get_object('episodes-context-sendto'))
2145 else:
2146 self.sendto_menu.remove_all()
2148 # New and Archive state
2149 self.episode_new_action.change_state(GLib.Variant.new_boolean(any_new))
2150 self.episode_lock_action.change_state(GLib.Variant.new_boolean(any_locked))
2152 self.allow_tooltips(False)
2154 area = TreeViewHelper.get_popup_rectangle(treeview, event)
2155 self.episodes_popover.set_pointing_to(area)
2156 self.episodes_popover.show()
2157 return True
2159 def set_episode_actions(self, open_instead_of_play=False, can_play=False, can_force=False, can_download=False,
2160 can_pause=False, can_cancel=False, can_delete=False, can_lock=False, is_episode_selected=False):
2161 episodes = self.get_selected_episodes() if is_episode_selected else []
2163 # play icon and label
2164 if open_instead_of_play or not is_episode_selected:
2165 self.toolPlay.set_icon_name('document-open-symbolic')
2166 self.toolPlay.set_label(_('Open'))
2167 else:
2168 self.toolPlay.set_icon_name('media-playback-start-symbolic')
2170 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
2171 downloading = any(e.downloading for e in episodes)
2173 if downloaded:
2174 self.toolPlay.set_label(_('Play'))
2175 elif downloading:
2176 self.toolPlay.set_label(_('Preview'))
2177 else:
2178 self.toolPlay.set_label(_('Stream'))
2180 # toolbar
2181 self.toolPlay.set_sensitive(can_play)
2182 self.toolForceDownload.set_visible(can_force)
2183 self.toolForceDownload.set_sensitive(can_force)
2184 self.toolDownload.set_visible(not can_force)
2185 self.toolDownload.set_sensitive(can_download)
2186 self.toolPause.set_sensitive(can_pause)
2187 self.toolCancel.set_sensitive(can_cancel)
2189 # Episodes menu
2190 self.play_action.set_enabled(can_play and not open_instead_of_play)
2191 self.open_action.set_enabled(can_play and open_instead_of_play)
2192 self.download_action.set_enabled(can_force or can_download)
2193 self.pause_action.set_enabled(can_pause)
2194 self.cancel_action.set_enabled(can_cancel)
2195 self.delete_action.set_enabled(can_delete)
2196 self.toggle_episode_new_action.set_enabled(is_episode_selected)
2197 self.toggle_episode_lock_action.set_enabled(can_lock)
2198 self.open_episode_download_folder_action.set_enabled(len(episodes) == 1)
2199 self.select_channel_of_episode_action.set_enabled(len(episodes) == 1)
2201 # Episodes context menu
2202 self.episode_new_action.set_enabled(is_episode_selected)
2203 self.episode_lock_action.set_enabled(can_lock)
2205 def set_title(self, new_title):
2206 self.default_title = new_title
2207 self.gPodder.set_title(new_title)
2209 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2211 Updates the status icons in the episode list.
2213 If urls is given, it should be a list of URLs
2214 of episodes that should be updated.
2216 If urls is None, set ONE OF selected, all to
2217 True (the former updates just the selected
2218 episodes and the latter updates all episodes).
2220 self.episode_list_model.cache_config(self.config)
2222 if urls is not None:
2223 # We have a list of URLs to walk through
2224 self.episode_list_model.update_by_urls(urls)
2225 elif selected and not all:
2226 # We should update all selected episodes
2227 selection = self.treeAvailable.get_selection()
2228 model, paths = selection.get_selected_rows()
2229 for path in reversed(paths):
2230 iter = model.get_iter(path)
2231 self.episode_list_model.update_by_filter_iter(iter)
2232 elif all and not selected:
2233 # We update all (even the filter-hidden) episodes
2234 self.episode_list_model.update_all()
2235 else:
2236 # Wrong/invalid call - have to specify at least one parameter
2237 raise ValueError('Invalid call to update_episode_list_icons')
2239 def episode_list_status_changed(self, episodes):
2240 self.update_episode_list_icons(set(e.url for e in episodes))
2241 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2242 self.db.commit()
2244 def playback_episodes_for_real(self, episodes):
2245 groups = collections.defaultdict(list)
2246 for episode in episodes:
2247 episode._download_error = None
2249 if episode.download_task is not None and episode.download_task.status == episode.download_task.FAILED:
2250 if not episode.can_stream(self.config):
2251 # Do not cancel failed tasks that can not be streamed
2252 continue
2253 # Cancel failed task and remove from progress list
2254 episode.download_task.cancel()
2255 self.cleanup_downloads()
2257 player = episode.get_player(self.config)
2259 try:
2260 allow_partial = (player != 'default')
2261 filename = episode.get_playback_url(self.config, allow_partial)
2262 except Exception as e:
2263 episode._download_error = str(e)
2264 continue
2266 # Mark episode as played in the database
2267 episode.playback_mark()
2268 self.mygpo_client.on_playback([episode])
2270 # Determine the playback resume position - if the file
2271 # was played 100%, we simply start from the beginning
2272 resume_position = episode.current_position
2273 if resume_position == episode.total_time:
2274 resume_position = 0
2276 # If Panucci is configured, use D-Bus to call it
2277 if player == 'panucci':
2278 try:
2279 PANUCCI_NAME = 'org.panucci.panucciInterface'
2280 PANUCCI_PATH = '/panucciInterface'
2281 PANUCCI_INTF = 'org.panucci.panucciInterface'
2282 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2283 i = dbus.Interface(o, PANUCCI_INTF)
2285 def on_reply(*args):
2286 pass
2288 def error_handler(filename, err):
2289 logger.error('Exception in D-Bus call: %s', str(err))
2291 # Fallback: use the command line client
2292 for command in util.format_desktop_command('panucci',
2293 [filename]):
2294 logger.info('Executing: %s', repr(command))
2295 util.Popen(command, close_fds=True)
2297 def on_error(err):
2298 return error_handler(filename, err)
2300 # This method only exists in Panucci > 0.9 ('new Panucci')
2301 i.playback_from(filename, resume_position,
2302 reply_handler=on_reply, error_handler=on_error)
2304 continue # This file was handled by the D-Bus call
2305 except Exception as e:
2306 logger.error('Calling Panucci using D-Bus', exc_info=True)
2308 groups[player].append(filename)
2310 # Open episodes with system default player
2311 if 'default' in groups:
2312 for filename in groups['default']:
2313 logger.debug('Opening with system default: %s', filename)
2314 util.gui_open(filename, gui=self)
2315 del groups['default']
2317 # For each type now, go and create play commands
2318 for group in groups:
2319 for command in util.format_desktop_command(group, groups[group], resume_position):
2320 logger.debug('Executing: %s', repr(command))
2321 util.Popen(command, close_fds=True)
2323 # Persist episode status changes to the database
2324 self.db.commit()
2326 # Flush updated episode status
2327 if self.mygpo_client.can_access_webservice():
2328 self.mygpo_client.flush()
2330 def playback_episodes(self, episodes):
2331 # We need to create a list, because we run through it more than once
2332 episodes = list(Model.sort_episodes_by_pubdate(e for e in episodes if e.can_play(self.config)))
2334 try:
2335 self.playback_episodes_for_real(episodes)
2336 except Exception as e:
2337 logger.error('Error in playback!', exc_info=True)
2338 self.show_message(_('Please check your media player settings in the preferences dialog.'),
2339 _('Error opening player'))
2341 self.episode_list_status_changed(episodes)
2343 def play_or_download(self, current_page=None):
2344 if current_page is None:
2345 current_page = self.wNotebook.get_current_page()
2346 if current_page == 0:
2347 (open_instead_of_play, can_play, can_preview, can_download,
2348 can_pause, can_cancel, can_delete, can_lock) = (False,) * 8
2350 selection = self.treeAvailable.get_selection()
2351 if selection.count_selected_rows() > 0:
2352 (model, paths) = selection.get_selected_rows()
2354 for path in paths:
2355 try:
2356 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2357 if episode is None:
2358 logger.info('Invalid episode at path %s', str(path))
2359 continue
2360 except TypeError as e:
2361 logger.error('Invalid episode at path %s', str(path))
2362 continue
2364 # These values should only ever be set, never unset them once set.
2365 # Actions filter episodes using these methods.
2366 open_instead_of_play = open_instead_of_play or episode.file_type() not in ('audio', 'video')
2367 can_play = can_play or episode.can_play(self.config)
2368 can_preview = can_preview or episode.can_preview()
2369 can_download = can_download or episode.can_download()
2370 can_pause = can_pause or episode.can_pause()
2371 can_cancel = can_cancel or episode.can_cancel()
2372 can_delete = can_delete or episode.can_delete()
2373 can_lock = can_lock or episode.can_lock()
2375 self.set_episode_actions(open_instead_of_play, can_play, False, can_download, can_pause, can_cancel, can_delete, can_lock,
2376 selection.count_selected_rows() > 0)
2378 return (open_instead_of_play, can_play, can_preview, can_download,
2379 can_pause, can_cancel, can_delete, can_lock)
2380 else:
2381 (can_queue, can_pause, can_cancel, can_remove) = (False,) * 4
2382 can_force = True
2384 selection = self.treeDownloads.get_selection()
2385 if selection.count_selected_rows() > 0:
2386 (model, paths) = selection.get_selected_rows()
2388 for path in paths:
2389 try:
2390 task = model.get_value(model.get_iter(path), 0)
2391 if task is None:
2392 logger.info('Invalid task at path %s', str(path))
2393 continue
2394 except TypeError as e:
2395 logger.error('Invalid task at path %s', str(path))
2396 continue
2398 if task.status != download.DownloadTask.QUEUED:
2399 can_force = False
2401 # These values should only ever be set, never unset them once set.
2402 # Actions filter tasks using these methods.
2403 can_queue = can_queue or task.can_queue()
2404 can_pause = can_pause or task.can_pause()
2405 can_cancel = can_cancel or task.can_cancel()
2406 can_remove = can_remove or task.can_remove()
2407 else:
2408 can_force = False
2410 self.set_episode_actions(False, False, can_force, can_queue, can_pause, can_cancel, can_remove, False, False)
2412 return (False, False, False, can_queue, can_pause, can_cancel,
2413 can_remove, False)
2415 def on_cbMaxDownloads_toggled(self, widget, *args):
2416 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2418 def on_cbLimitDownloads_toggled(self, widget, *args):
2419 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2421 def episode_new_status_changed(self, urls):
2422 self.update_podcast_list_model()
2423 self.update_episode_list_icons(urls)
2425 def refresh_episode_dates(self):
2426 t = time.localtime()
2427 current_day = t[:3]
2428 if self.last_episode_date_refresh is not None and self.last_episode_date_refresh != current_day:
2429 # update all episodes in current view
2430 for row in self.episode_list_model:
2431 row[EpisodeListModel.C_PUBLISHED_TEXT] = row[EpisodeListModel.C_EPISODE].cute_pubdate()
2433 self.last_episode_date_refresh = current_day
2435 remaining_seconds = 86400 - 3600 * t.tm_hour - 60 * t.tm_min - t.tm_sec
2436 if remaining_seconds > 3600:
2437 # timeout an hour early in the event daylight savings changes the clock forward
2438 remaining_seconds = remaining_seconds - 3600
2439 util.idle_timeout_add(remaining_seconds * 1000, self.refresh_episode_dates)
2441 def update_podcast_list_model(self, urls=None, selected=False, select_url=None,
2442 sections_changed=False):
2443 """Update the podcast list treeview model
2445 If urls is given, it should list the URLs of each
2446 podcast that has to be updated in the list.
2448 If selected is True, only update the model contents
2449 for the currently-selected podcast - nothing more.
2451 The caller can optionally specify "select_url",
2452 which is the URL of the podcast that is to be
2453 selected in the list after the update is complete.
2454 This only works if the podcast list has to be
2455 reloaded; i.e. something has been added or removed
2456 since the last update of the podcast list).
2458 selection = self.treeChannels.get_selection()
2459 model, iter = selection.get_selected()
2461 def is_section(r):
2462 return r[PodcastListModel.C_URL] == '-'
2464 def is_separator(r):
2465 return r[PodcastListModel.C_SEPARATOR]
2467 sections_active = any(is_section(x) for x in self.podcast_list_model)
2469 if self.config.ui.gtk.podcast_list.all_episodes:
2470 # Update "all episodes" view in any case (if enabled)
2471 self.podcast_list_model.update_first_row()
2472 # List model length minus 1, because of "All"
2473 list_model_length = len(self.podcast_list_model) - 1
2474 else:
2475 list_model_length = len(self.podcast_list_model)
2477 force_update = (sections_active != self.config.ui.gtk.podcast_list.sections
2478 or sections_changed)
2480 # Filter items in the list model that are not podcasts, so we get the
2481 # correct podcast list count (ignore section headers and separators)
2483 def is_not_podcast(r):
2484 return is_section(r) or is_separator(r)
2486 list_model_length -= len(list(filter(is_not_podcast, self.podcast_list_model)))
2488 if selected and not force_update:
2489 # very cheap! only update selected channel
2490 if iter is not None:
2491 # If we have selected the "all episodes" view, we have
2492 # to update all channels for selected episodes:
2493 if self.config.ui.gtk.podcast_list.all_episodes and \
2494 self.podcast_list_model.iter_is_first_row(iter):
2495 urls = self.get_podcast_urls_from_selected_episodes()
2496 self.podcast_list_model.update_by_urls(urls)
2497 else:
2498 # Otherwise just update the selected row (a podcast)
2499 self.podcast_list_model.update_by_filter_iter(iter)
2501 if self.config.ui.gtk.podcast_list.sections:
2502 self.podcast_list_model.update_sections()
2503 elif list_model_length == len(self.channels) and not force_update:
2504 # we can keep the model, but have to update some
2505 if urls is None:
2506 # still cheaper than reloading the whole list
2507 self.podcast_list_model.update_all()
2508 else:
2509 # ok, we got a bunch of urls to update
2510 self.podcast_list_model.update_by_urls(urls)
2511 if self.config.ui.gtk.podcast_list.sections:
2512 self.podcast_list_model.update_sections()
2513 else:
2514 if model and iter and select_url is None:
2515 # Get the URL of the currently-selected podcast
2516 select_url = model.get_value(iter, PodcastListModel.C_URL)
2518 # Update the podcast list model with new channels
2519 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2521 try:
2522 selected_iter = model.get_iter_first()
2523 # Find the previously-selected URL in the new
2524 # model if we have an URL (else select first)
2525 if select_url is not None:
2526 pos = model.get_iter_first()
2527 while pos is not None:
2528 url = model.get_value(pos, PodcastListModel.C_URL)
2529 if url == select_url:
2530 selected_iter = pos
2531 break
2532 pos = model.iter_next(pos)
2534 if selected_iter is not None:
2535 selection.select_iter(selected_iter)
2536 self.on_treeChannels_cursor_changed(self.treeChannels)
2537 except:
2538 logger.error('Cannot select podcast in list', exc_info=True)
2540 def on_episode_list_filter_changed(self, has_episodes):
2541 self.play_or_download()
2543 def update_episode_list_model(self):
2544 if self.channels and self.active_channel is not None:
2545 self.treeAvailable.get_selection().unselect_all()
2546 self.treeAvailable.scroll_to_point(0, 0)
2548 self.episode_list_model.cache_config(self.config)
2550 with self.treeAvailable.get_selection().handler_block(self.episode_selection_handler_id):
2551 # have to block the on_episode_list_selection_changed handler because
2552 # when selecting any channel from All Episodes, on_episode_list_selection_changed
2553 # is called once per episode (4k time in my case), causing episode shownotes
2554 # to be updated as many time, resulting in UI freeze for 10 seconds.
2555 self.episode_list_model.replace_from_channel(self.active_channel)
2556 else:
2557 self.episode_list_model.clear()
2559 @dbus.service.method(gpodder.dbus_interface)
2560 def offer_new_episodes(self, channels=None):
2561 new_episodes = self.get_new_episodes(channels)
2562 if new_episodes:
2563 self.new_episodes_show(new_episodes)
2564 return True
2565 return False
2567 def add_podcast_list(self, podcasts, auth_tokens=None):
2568 """Subscribe to a list of podcast given (title, url) pairs
2570 If auth_tokens is given, it should be a dictionary
2571 mapping URLs to (username, password) tuples."""
2573 if auth_tokens is None:
2574 auth_tokens = {}
2576 existing_urls = set(podcast.url for podcast in self.channels)
2578 # For a given URL, the desired title (or None)
2579 title_for_url = {}
2581 # Sort and split the URL list into five buckets
2582 queued, failed, existing, worked, authreq = [], [], [], [], []
2583 for input_title, input_url in podcasts:
2584 url = util.normalize_feed_url(input_url)
2586 # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
2587 url = youtube.parse_youtube_url(url)
2589 if url is None:
2590 # Fail this one because the URL is not valid
2591 failed.append(input_url)
2592 elif url in existing_urls:
2593 # A podcast already exists in the list for this URL
2594 existing.append(url)
2595 # XXX: Should we try to update the title of the existing
2596 # subscription from input_title here if it is different?
2597 else:
2598 # This URL has survived the first round - queue for add
2599 title_for_url[url] = input_title
2600 queued.append(url)
2601 if url != input_url and input_url in auth_tokens:
2602 auth_tokens[url] = auth_tokens[input_url]
2604 error_messages = {}
2605 redirections = {}
2607 progress = ProgressIndicator(_('Adding podcasts'),
2608 _('Please wait while episode information is downloaded.'),
2609 parent=self.get_dialog_parent())
2611 def on_after_update():
2612 progress.on_finished()
2614 # Report already-existing subscriptions to the user
2615 if existing:
2616 title = _('Existing subscriptions skipped')
2617 message = _('You are already subscribed to these podcasts:') \
2618 + '\n\n' + '\n'.join(html.escape(url) for url in existing)
2619 self.show_message(message, title, widget=self.treeChannels)
2621 # Report subscriptions that require authentication
2622 retry_podcasts = {}
2623 if authreq:
2624 for url in authreq:
2625 title = _('Podcast requires authentication')
2626 message = _('Please login to %s:') % (html.escape(url),)
2627 success, auth_tokens = self.show_login_dialog(title, message)
2628 if success:
2629 retry_podcasts[url] = auth_tokens
2630 else:
2631 # Stop asking the user for more login data
2632 retry_podcasts = {}
2633 for url in authreq:
2634 error_messages[url] = _('Authentication failed')
2635 failed.append(url)
2636 break
2638 # Report website redirections
2639 for url in redirections:
2640 title = _('Website redirection detected')
2641 message = _('The URL %(url)s redirects to %(target)s.') \
2642 + '\n\n' + _('Do you want to visit the website now?')
2643 message = message % {'url': url, 'target': redirections[url]}
2644 if self.show_confirmation(message, title):
2645 util.open_website(url)
2646 else:
2647 break
2649 # Report failed subscriptions to the user
2650 if failed:
2651 title = _('Could not add some podcasts')
2652 message = _('Some podcasts could not be added to your list:')
2653 details = '\n\n'.join('<b>{}</b>:\n{}'.format(html.escape(url),
2654 html.escape(error_messages.get(url, _('Unknown')))) for url in failed)
2655 self.show_message_details(title, message, details)
2657 # Upload subscription changes to gpodder.net
2658 self.mygpo_client.on_subscribe(worked)
2660 # Fix URLs if mygpo has rewritten them
2661 self.rewrite_urls_mygpo()
2663 # If only one podcast was added, select it after the update
2664 if len(worked) == 1:
2665 url = worked[0]
2666 else:
2667 url = None
2669 # Update the list of subscribed podcasts
2670 self.update_podcast_list_model(select_url=url)
2672 # If we have authentication data to retry, do so here
2673 if retry_podcasts:
2674 podcasts = [(title_for_url.get(url), url)
2675 for url in list(retry_podcasts.keys())]
2676 self.add_podcast_list(podcasts, retry_podcasts)
2677 # This will NOT show new episodes for podcasts that have
2678 # been added ("worked"), but it will prevent problems with
2679 # multiple dialogs being open at the same time ;)
2680 return
2682 # Offer to download new episodes
2683 episodes = []
2684 for podcast in self.channels:
2685 if podcast.url in worked:
2686 episodes.extend(podcast.get_all_episodes())
2688 if episodes:
2689 episodes = list(Model.sort_episodes_by_pubdate(episodes,
2690 reverse=True))
2691 self.new_episodes_show(episodes,
2692 selected=[e.check_is_new() for e in episodes])
2694 @util.run_in_background
2695 def thread_proc():
2696 # After the initial sorting and splitting, try all queued podcasts
2697 length = len(queued)
2698 for index, url in enumerate(queued):
2699 title = title_for_url.get(url)
2700 progress.on_progress(float(index) / float(length))
2701 progress.on_message(title or url)
2702 try:
2703 # The URL is valid and does not exist already - subscribe!
2704 channel = self.model.load_podcast(url=url, create=True,
2705 authentication_tokens=auth_tokens.get(url, None),
2706 max_episodes=self.config.limit.episodes)
2708 try:
2709 username, password = util.username_password_from_url(url)
2710 except ValueError as ve:
2711 username, password = (None, None)
2713 if title is not None:
2714 # Prefer title from subscription source (bug 1711)
2715 channel.title = title
2717 if username is not None and channel.auth_username is None and \
2718 password is not None and channel.auth_password is None:
2719 channel.auth_username = username
2720 channel.auth_password = password
2722 channel.save()
2724 self._update_cover(channel)
2725 except feedcore.AuthenticationRequired as e:
2726 # use e.url because there might have been a redirection (#571)
2727 if e.url in auth_tokens:
2728 # Fail for wrong authentication data
2729 error_messages[e.url] = _('Authentication failed')
2730 failed.append(e.url)
2731 else:
2732 # Queue for login dialog later
2733 authreq.append(e.url)
2734 continue
2735 except feedcore.WifiLogin as error:
2736 redirections[url] = error.data
2737 failed.append(url)
2738 error_messages[url] = _('Redirection detected')
2739 continue
2740 except Exception as e:
2741 logger.error('Subscription error: %s', e, exc_info=True)
2742 error_messages[url] = str(e)
2743 failed.append(url)
2744 continue
2746 assert channel is not None
2747 worked.append(channel.url)
2749 util.idle_add(on_after_update)
2751 def find_episode(self, podcast_url, episode_url):
2752 """Find an episode given its podcast and episode URL
2754 The function will return a PodcastEpisode object if
2755 the episode is found, or None if it's not found.
2757 for podcast in self.channels:
2758 if podcast_url == podcast.url:
2759 for episode in podcast.get_all_episodes():
2760 if episode_url == episode.url:
2761 return episode
2763 return None
2765 def process_received_episode_actions(self):
2766 """Process/merge episode actions from gpodder.net
2768 This function will merge all changes received from
2769 the server to the local database and update the
2770 status of the affected episodes as necessary.
2772 indicator = ProgressIndicator(_('Merging episode actions'),
2773 _('Episode actions from gpodder.net are merged.'),
2774 False, self.get_dialog_parent())
2776 Gtk.main_iteration()
2778 self.mygpo_client.process_episode_actions(self.find_episode)
2780 self.db.commit()
2782 indicator.on_finished()
2784 def _update_cover(self, channel):
2785 if channel is not None:
2786 self.cover_downloader.request_cover(channel)
2788 def show_update_feeds_buttons(self):
2789 # Make sure that the buttons for updating feeds
2790 # appear - this should happen after a feed update
2791 self.hboxUpdateFeeds.hide()
2792 if not self.application.want_headerbar:
2793 self.btnUpdateFeeds.show()
2794 self.update_action.set_enabled(True)
2795 self.update_channel_action.set_enabled(True)
2797 def on_btnCancelFeedUpdate_clicked(self, widget):
2798 if not self.feed_cache_update_cancelled:
2799 self.pbFeedUpdate.set_text(_('Cancelling...'))
2800 self.feed_cache_update_cancelled = True
2801 self.btnCancelFeedUpdate.set_sensitive(False)
2802 else:
2803 self.show_update_feeds_buttons()
2805 def update_feed_cache(self, channels=None,
2806 show_new_episodes_dialog=True):
2807 if self.config.check_connection and not util.connection_available():
2808 self.show_message(_('Please connect to a network, then try again.'),
2809 _('No network connection'), important=True)
2810 return
2812 # Fix URLs if mygpo has rewritten them
2813 self.rewrite_urls_mygpo()
2815 if channels is None:
2816 # Only update podcasts for which updates are enabled
2817 channels = [c for c in self.channels if not c.pause_subscription]
2819 self.update_action.set_enabled(False)
2820 self.update_channel_action.set_enabled(False)
2822 self.feed_cache_update_cancelled = False
2823 self.btnCancelFeedUpdate.show()
2824 self.btnCancelFeedUpdate.set_sensitive(True)
2825 self.btnCancelFeedUpdate.set_image(Gtk.Image.new_from_icon_name('process-stop', Gtk.IconSize.BUTTON))
2826 self.hboxUpdateFeeds.show_all()
2827 self.btnUpdateFeeds.hide()
2829 count = len(channels)
2830 text = N_('Updating %(count)d feed...', 'Updating %(count)d feeds...',
2831 count) % {'count': count}
2833 self.pbFeedUpdate.set_text(text)
2834 self.pbFeedUpdate.set_fraction(0)
2836 @util.run_in_background
2837 def update_feed_cache_proc():
2838 updated_channels = []
2839 nr_update_errors = 0
2840 new_episodes = []
2841 for updated, channel in enumerate(channels):
2842 if self.feed_cache_update_cancelled:
2843 break
2845 def indicate_updating_podcast(channel):
2846 d = {'podcast': channel.title, 'position': updated + 1, 'total': count}
2847 progression = _('Updating %(podcast)s (%(position)d/%(total)d)') % d
2848 logger.info(progression)
2849 self.pbFeedUpdate.set_text(progression)
2851 try:
2852 channel._update_error = None
2853 util.idle_add(indicate_updating_podcast, channel)
2854 new_episodes.extend(channel.update(max_episodes=self.config.limit.episodes))
2855 self._update_cover(channel)
2856 except Exception as e:
2857 message = str(e)
2858 if message:
2859 channel._update_error = message
2860 else:
2861 channel._update_error = '?'
2862 nr_update_errors += 1
2863 logger.error('Error updating feed: %s: %s', channel.title, message, exc_info=(e.__class__ not in [
2864 gpodder.feedcore.BadRequest,
2865 gpodder.feedcore.AuthenticationRequired,
2866 gpodder.feedcore.Unsubscribe,
2867 gpodder.feedcore.NotFound,
2868 gpodder.feedcore.InternalServerError,
2869 gpodder.feedcore.UnknownStatusCode,
2870 requests.exceptions.ConnectionError,
2871 requests.exceptions.RetryError,
2872 urllib3.exceptions.MaxRetryError,
2873 urllib3.exceptions.ReadTimeoutError,
2876 updated_channels.append(channel)
2878 def update_progress(channel):
2879 self.update_podcast_list_model([channel.url])
2881 # If the currently-viewed podcast is updated, reload episodes
2882 if self.active_channel is not None and \
2883 self.active_channel == channel:
2884 logger.debug('Updated channel is active, updating UI')
2885 self.update_episode_list_model()
2887 self.pbFeedUpdate.set_fraction(float(updated + 1) / float(count))
2889 util.idle_add(update_progress, channel)
2891 if nr_update_errors > 0:
2892 self.notification(
2893 N_('%(count)d channel failed to update',
2894 '%(count)d channels failed to update',
2895 nr_update_errors) % {'count': nr_update_errors},
2896 _('Error while updating feeds'), widget=self.treeChannels)
2898 def update_feed_cache_finish_callback(new_episodes):
2899 # Process received episode actions for all updated URLs
2900 self.process_received_episode_actions()
2902 # If we are currently viewing "All episodes" or a section, update its episode list now
2903 if self.active_channel is not None and \
2904 isinstance(self.active_channel, PodcastChannelProxy):
2905 self.update_episode_list_model()
2907 if self.feed_cache_update_cancelled:
2908 # The user decided to abort the feed update
2909 self.show_update_feeds_buttons()
2911 # The filter extension can mark newly added episodes as old,
2912 # so take only episodes marked as new.
2913 episodes = ((e for e in new_episodes if e.check_is_new())
2914 if self.config.ui.gtk.only_added_are_new
2915 else self.get_new_episodes([c for c in updated_channels]))
2917 if self.config.downloads.chronological_order:
2918 # download older episodes first
2919 episodes = list(Model.sort_episodes_by_pubdate(episodes))
2921 # Remove episodes without downloadable content
2922 downloadable_episodes = [e for e in episodes if e.url]
2924 if not downloadable_episodes:
2925 # Nothing new here - but inform the user
2926 self.pbFeedUpdate.set_fraction(1.0)
2927 self.pbFeedUpdate.set_text(
2928 _('No new episodes with downloadable content') if episodes else _('No new episodes'))
2929 self.feed_cache_update_cancelled = True
2930 self.btnCancelFeedUpdate.show()
2931 self.btnCancelFeedUpdate.set_sensitive(True)
2932 self.update_action.set_enabled(True)
2933 self.btnCancelFeedUpdate.set_image(Gtk.Image.new_from_icon_name('edit-clear', Gtk.IconSize.BUTTON))
2934 else:
2935 episodes = downloadable_episodes
2937 count = len(episodes)
2938 # New episodes are available
2939 self.pbFeedUpdate.set_fraction(1.0)
2941 if self.config.ui.gtk.new_episodes == 'download':
2942 self.download_episode_list(episodes)
2943 title = N_('Downloading %(count)d new episode.',
2944 'Downloading %(count)d new episodes.',
2945 count) % {'count': count}
2946 self.show_message(title, _('New episodes available'))
2947 elif self.config.ui.gtk.new_episodes == 'queue':
2948 self.download_episode_list_paused(episodes)
2949 title = N_(
2950 '%(count)d new episode added to download list.',
2951 '%(count)d new episodes added to download list.',
2952 count) % {'count': count}
2953 self.show_message(title, _('New episodes available'))
2954 else:
2955 if (show_new_episodes_dialog
2956 and self.config.ui.gtk.new_episodes == 'show'):
2957 self.new_episodes_show(episodes, notification=True)
2958 else: # !show_new_episodes_dialog or ui.gtk.new_episodes == 'ignore'
2959 message = N_('%(count)d new episode available',
2960 '%(count)d new episodes available',
2961 count) % {'count': count}
2962 self.pbFeedUpdate.set_text(message)
2964 self.show_update_feeds_buttons()
2966 util.idle_add(update_feed_cache_finish_callback, new_episodes)
2968 def on_gPodder_delete_event(self, *args):
2969 """Called when the GUI wants to close the window
2970 Displays a confirmation dialog (and closes/hides gPodder)
2973 if self.confirm_quit():
2974 self.close_gpodder()
2976 return True
2978 def confirm_quit(self):
2979 """Called when the GUI wants to close the window
2980 Displays a confirmation dialog
2983 downloading = self.download_status_model.are_downloads_in_progress()
2985 if downloading:
2986 dialog = Gtk.MessageDialog(self.gPodder, Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE)
2987 dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
2988 quit_button = dialog.add_button(_('_Quit'), Gtk.ResponseType.CLOSE)
2990 title = _('Quit gPodder')
2991 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
2993 dialog.set_title(title)
2994 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
2996 quit_button.grab_focus()
2997 result = dialog.run()
2998 dialog.destroy()
3000 return result == Gtk.ResponseType.CLOSE
3001 else:
3002 return True
3004 def close_gpodder(self):
3005 """ clean everything and exit properly
3007 # Cancel any running background updates of the episode list model
3008 self.episode_list_model.background_update = None
3010 self.gPodder.hide()
3012 # Notify all tasks to to carry out any clean-up actions
3013 self.download_status_model.tell_all_tasks_to_quit()
3015 while Gtk.events_pending() or self.download_queue_manager.has_workers():
3016 Gtk.main_iteration()
3018 self.core.shutdown()
3020 self.application.remove_window(self.gPodder)
3022 def format_delete_message(self, message, things, max_things, max_length):
3023 titles = []
3024 for index, thing in zip(range(max_things), things):
3025 titles.append('• ' + (html.escape(thing.title if len(thing.title) <= max_length else thing.title[:max_length] + '…')))
3026 if len(things) > max_things:
3027 titles.append('+%(count)d more…' % {'count': len(things) - max_things})
3028 return '\n'.join(titles) + '\n\n' + message
3030 def delete_episode_list(self, episodes, confirm=True, callback=None):
3031 if self.wNotebook.get_current_page() > 0:
3032 selection = self.treeDownloads.get_selection()
3033 (model, paths) = selection.get_selected_rows()
3034 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
3035 model.get_value(model.get_iter(path),
3036 DownloadStatusModel.C_TASK)) for path in paths]
3037 self._for_each_task_set_status(selected_tasks, status=None)
3038 return
3040 if not episodes:
3041 return False
3043 episodes = [e for e in episodes if not e.archive]
3045 if not episodes:
3046 title = _('Episodes are locked')
3047 message = _(
3048 'The selected episodes are locked. Please unlock the '
3049 'episodes that you want to delete before trying '
3050 'to delete them.')
3051 self.notification(message, title, widget=self.treeAvailable)
3052 return False
3054 count = len(episodes)
3055 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
3056 count) % {'count': count}
3057 message = _('Deleting episodes removes downloaded files.')
3059 message = self.format_delete_message(message, episodes, 5, 60)
3061 if confirm and not self.show_confirmation(message, title):
3062 return False
3064 self.on_item_cancel_download_activate(force=True)
3066 progress = ProgressIndicator(_('Deleting episodes'),
3067 _('Please wait while episodes are deleted'),
3068 parent=self.get_dialog_parent())
3070 def finish_deletion(episode_urls, channel_urls):
3071 # Episodes have been deleted - persist the database
3072 self.db.commit()
3074 self.update_episode_list_icons(episode_urls)
3075 self.update_podcast_list_model(channel_urls)
3076 self.play_or_download()
3078 progress.on_finished()
3080 @util.run_in_background
3081 def thread_proc():
3082 episode_urls = set()
3083 channel_urls = set()
3085 episodes_status_update = []
3086 for idx, episode in enumerate(episodes):
3087 progress.on_progress(idx / len(episodes))
3088 if not episode.archive:
3089 progress.on_message(episode.title)
3090 episode.delete_from_disk()
3091 episode_urls.add(episode.url)
3092 channel_urls.add(episode.channel.url)
3093 episodes_status_update.append(episode)
3095 # Notify the web service about the status update + upload
3096 if self.mygpo_client.can_access_webservice():
3097 self.mygpo_client.on_delete(episodes_status_update)
3098 self.mygpo_client.flush()
3100 if callback is None:
3101 util.idle_add(finish_deletion, episode_urls, channel_urls)
3102 else:
3103 util.idle_add(callback, episode_urls, channel_urls, progress)
3105 return True
3107 def on_itemRemoveOldEpisodes_activate(self, action, param):
3108 self.show_delete_episodes_window()
3110 def show_delete_episodes_window(self, channel=None):
3111 """Offer deletion of episodes
3113 If channel is None, offer deletion of all episodes.
3114 Otherwise only offer deletion of episodes in the channel.
3116 columns = (
3117 ('markup_delete_episodes', None, None, _('Episode')),
3120 msg_older_than = N_('Select older than %(count)d day', 'Select older than %(count)d days', self.config.auto.cleanup.days)
3121 selection_buttons = {
3122 _('Select played'): lambda episode: not episode.is_new,
3123 _('Select finished'): lambda episode: episode.is_finished(),
3124 msg_older_than % {'count': self.config.auto.cleanup.days}:
3125 lambda episode: episode.age_in_days() > self.config.auto.cleanup.days,
3128 instructions = _('Select the episodes you want to delete:')
3130 if channel is None:
3131 channels = self.channels
3132 else:
3133 channels = [channel]
3135 episodes = []
3136 for channel in channels:
3137 for episode in channel.get_episodes(gpodder.STATE_DOWNLOADED):
3138 # Disallow deletion of locked episodes that still exist
3139 if not episode.archive or not episode.file_exists():
3140 episodes.append(episode)
3142 selected = [not e.is_new or not e.file_exists() for e in episodes]
3144 gPodderEpisodeSelector(
3145 self.main_window, title=_('Delete episodes'),
3146 instructions=instructions,
3147 episodes=episodes, selected=selected, columns=columns,
3148 ok_button=_('_Delete'), callback=self.delete_episode_list,
3149 selection_buttons=selection_buttons, _config=self.config)
3151 def on_selected_episodes_status_changed(self):
3152 # The order of the updates here is important! When "All episodes" is
3153 # selected, the update of the podcast list model depends on the episode
3154 # list selection to determine which podcasts are affected. Updating
3155 # the episode list could remove the selection if a filter is active.
3156 self.update_podcast_list_model(selected=True)
3157 self.update_episode_list_icons(selected=True)
3158 self.db.commit()
3160 self.play_or_download()
3162 def mark_selected_episodes_new(self):
3163 for episode in self.get_selected_episodes():
3164 episode.mark(is_played=False)
3165 self.on_selected_episodes_status_changed()
3167 def mark_selected_episodes_old(self):
3168 for episode in self.get_selected_episodes():
3169 episode.mark(is_played=True)
3170 self.on_selected_episodes_status_changed()
3172 def on_item_toggle_played_activate(self, action, param):
3173 for episode in self.get_selected_episodes():
3174 episode.mark(is_played=episode.is_new and episode.state != gpodder.STATE_DELETED)
3175 self.on_selected_episodes_status_changed()
3177 def on_item_toggle_lock_activate(self, unused, toggle=True, new_value=False):
3178 for episode in self.get_selected_episodes():
3179 if episode.state == gpodder.STATE_DELETED:
3180 # Always unlock deleted episodes
3181 episode.mark(is_locked=False)
3182 elif toggle or toggle is None:
3183 # Gio.SimpleAction activate signal passes None (see #681)
3184 episode.mark(is_locked=not episode.archive)
3185 else:
3186 episode.mark(is_locked=new_value)
3187 self.on_selected_episodes_status_changed()
3188 self.play_or_download()
3190 def on_episode_lock_activate(self, action, *params):
3191 new_value = not action.get_state().get_boolean()
3192 self.on_item_toggle_lock_activate(None, toggle=False, new_value=new_value)
3193 action.change_state(GLib.Variant.new_boolean(new_value))
3194 self.episodes_popover.popdown()
3195 return True
3197 def on_channel_toggle_lock_activate(self, action, *params):
3198 if self.active_channel is None:
3199 return
3201 self.active_channel.auto_archive_episodes = not self.active_channel.auto_archive_episodes
3202 self.active_channel.save()
3204 for episode in self.active_channel.get_all_episodes():
3205 episode.mark(is_locked=self.active_channel.auto_archive_episodes)
3207 self.update_podcast_list_model(selected=True)
3208 self.update_episode_list_icons(all=True)
3209 action.change_state(
3210 GLib.Variant.new_boolean(self.active_channel.auto_archive_episodes))
3211 self.channels_popover.popdown()
3213 def on_itemUpdateChannel_activate(self, *params):
3214 if self.active_channel is None:
3215 title = _('No podcast selected')
3216 message = _('Please select a podcast in the podcasts list to update.')
3217 self.show_message(message, title, widget=self.treeChannels)
3218 return
3220 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3221 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3222 self.update_feed_cache()
3223 else:
3224 self.update_feed_cache(channels=[self.active_channel])
3226 def on_itemUpdate_activate(self, action=None, param=None):
3227 # Check if we have outstanding subscribe/unsubscribe actions
3228 self.on_add_remove_podcasts_mygpo()
3230 if self.channels:
3231 self.update_feed_cache()
3232 else:
3233 def show_welcome_window():
3234 def on_show_example_podcasts(widget):
3235 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3236 self.on_itemImportChannels_activate(None)
3238 def on_add_podcast_via_url(widget):
3239 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3240 self.on_itemAddChannel_activate(None)
3242 def on_setup_my_gpodder(widget):
3243 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3244 self.on_download_subscriptions_from_mygpo(None)
3246 welcome_window = gPodderWelcome(self.main_window,
3247 center_on_widget=self.main_window,
3248 on_show_example_podcasts=on_show_example_podcasts,
3249 on_add_podcast_via_url=on_add_podcast_via_url,
3250 on_setup_my_gpodder=on_setup_my_gpodder)
3252 welcome_window.main_window.run()
3253 welcome_window.main_window.destroy()
3255 util.idle_add(show_welcome_window)
3257 def download_episode_list_paused(self, episodes, hide_progress=False):
3258 self.download_episode_list(episodes, True, hide_progress=hide_progress)
3260 def download_episode_list(self, episodes, add_paused=False, force_start=False, downloader=None, hide_progress=False):
3261 # Start progress indicator to queue existing tasks
3262 count = len(episodes)
3263 if count and not hide_progress:
3264 progress_indicator = ProgressIndicator(
3265 _('Queueing'),
3266 '', True, self.get_dialog_parent(), count)
3267 else:
3268 progress_indicator = None
3270 restart_timer = self.stop_download_list_update_timer()
3271 self.download_queue_manager.disable()
3273 def queue_tasks(tasks, queued_existing_task):
3274 if progress_indicator is None or not progress_indicator.cancelled:
3275 if progress_indicator:
3276 count = len(tasks)
3277 if count:
3278 # Restart progress indicator to queue new tasks
3279 progress_indicator.set_max_ticks(count)
3280 progress_indicator.on_progress(0.0)
3282 for task in tasks:
3283 with task:
3284 if add_paused:
3285 task.status = task.PAUSED
3286 else:
3287 self.mygpo_client.on_download([task.episode])
3288 self.queue_task(task, force_start)
3289 if progress_indicator:
3290 if not progress_indicator.on_tick():
3291 break
3293 if progress_indicator:
3294 progress_indicator.on_tick(final=_('Updating...'))
3295 self.download_queue_manager.enable()
3297 # Update the tab title and downloads list
3298 if tasks or queued_existing_task or restart_timer:
3299 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3300 # Flush updated episode status
3301 if self.mygpo_client.can_access_webservice():
3302 self.mygpo_client.flush()
3304 if progress_indicator:
3305 progress_indicator.on_finished()
3307 queued_existing_task = False
3308 new_tasks = []
3310 if self.config.downloads.chronological_order:
3311 # Download episodes in chronological order (older episodes first)
3312 episodes = list(Model.sort_episodes_by_pubdate(episodes))
3314 for episode in episodes:
3315 if progress_indicator:
3316 # The continues require ticking before doing the work
3317 if not progress_indicator.on_tick():
3318 break
3320 logger.debug('Downloading episode: %s', episode.title)
3321 if not episode.was_downloaded(and_exists=True):
3322 episode._download_error = None
3323 if episode.state == gpodder.STATE_DELETED:
3324 episode.state = gpodder.STATE_NORMAL
3325 episode.save()
3326 task_exists = False
3327 for task in self.download_tasks_seen:
3328 if episode.url == task.url:
3329 task_exists = True
3330 task.unpause()
3331 task.reuse()
3332 if task.status not in (task.DOWNLOADING, task.QUEUED):
3333 if downloader:
3334 # replace existing task's download with forced one
3335 task.downloader = downloader
3336 self.queue_task(task, force_start)
3337 queued_existing_task = True
3338 continue
3340 if task_exists:
3341 continue
3343 try:
3344 task = download.DownloadTask(episode, self.config, downloader=downloader)
3345 except Exception as e:
3346 episode._download_error = str(e)
3347 d = {'episode': html.escape(episode.title), 'message': html.escape(str(e))}
3348 message = _('Download error while downloading %(episode)s: %(message)s')
3349 self.show_message(message % d, _('Download error'), important=True)
3350 logger.error('While downloading %s', episode.title, exc_info=True)
3351 continue
3353 # New Task, we must wait on the GTK Loop
3354 self.download_status_model.register_task(task)
3355 new_tasks.append(task)
3357 # Executes after tasks have been registered
3358 util.idle_add(queue_tasks, new_tasks, queued_existing_task)
3360 def cancel_task_list(self, tasks, force=False):
3361 if not tasks:
3362 return
3364 progress_indicator = ProgressIndicator(
3365 download.DownloadTask.STATUS_MESSAGE[download.DownloadTask.CANCELLING],
3366 '', True, self.get_dialog_parent(), len(tasks))
3368 restart_timer = self.stop_download_list_update_timer()
3369 self.download_queue_manager.disable()
3370 for task in tasks:
3371 task.cancel()
3373 if not progress_indicator.on_tick():
3374 break
3375 progress_indicator.on_tick(final=_('Updating...'))
3376 self.download_queue_manager.enable()
3378 self.update_episode_list_icons([task.url for task in tasks])
3379 self.play_or_download()
3381 # Update the tab title and downloads list
3382 if restart_timer:
3383 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3384 else:
3385 self.update_downloads_list()
3387 progress_indicator.on_finished()
3389 def new_episodes_show(self, episodes, notification=False, selected=None):
3390 columns = (
3391 ('markup_new_episodes', None, None, _('Episode')),
3394 instructions = _('Select the episodes you want to download:')
3396 if self.new_episodes_window is not None:
3397 self.new_episodes_window.main_window.destroy()
3398 self.new_episodes_window = None
3400 def download_episodes_callback(episodes):
3401 self.new_episodes_window = None
3402 self.download_episode_list(episodes)
3404 # Remove episodes without downloadable content
3405 episodes = [e for e in episodes if e.url]
3406 if len(episodes) == 0:
3407 return
3409 if selected is None:
3410 # Select all by default
3411 selected = [True] * len(episodes)
3413 self.new_episodes_window = gPodderEpisodeSelector(self.main_window,
3414 title=_('New episodes available'),
3415 instructions=instructions,
3416 episodes=episodes,
3417 columns=columns,
3418 selected=selected,
3419 ok_button='gpodder-download',
3420 callback=download_episodes_callback,
3421 remove_callback=lambda e: e.mark_old(),
3422 remove_action=_('_Mark as old'),
3423 remove_finished=self.episode_new_status_changed,
3424 _config=self.config,
3425 show_notification=False)
3427 def on_itemDownloadAllNew_activate(self, action, param):
3428 if not self.offer_new_episodes():
3429 self.show_message(_('Please check for new episodes later.'),
3430 _('No new episodes available'))
3432 def get_new_episodes(self, channels=None):
3433 return [e for c in channels or self.channels for e in
3434 [e for e in c.get_all_episodes() if e.check_is_new()]]
3436 def commit_changes_to_database(self):
3437 """This will be called after the sync process is finished"""
3438 self.db.commit()
3440 def on_itemShowToolbar_activate(self, action, param):
3441 state = action.get_state()
3442 self.config.ui.gtk.toolbar = not state
3443 action.set_state(GLib.Variant.new_boolean(not state))
3445 def on_item_view_search_always_visible_toggled(self, action, param):
3446 state = action.get_state()
3447 self.config.ui.gtk.search_always_visible = not state
3448 action.set_state(GLib.Variant.new_boolean(not state))
3449 for search in (self._search_episodes, self._search_podcasts):
3450 if search:
3451 if self.config.ui.gtk.search_always_visible:
3452 search.show_search(grab_focus=False)
3453 else:
3454 search.hide_search()
3456 def on_item_view_hide_boring_podcasts_toggled(self, action, param):
3457 state = action.get_state()
3458 self.config.ui.gtk.podcast_list.hide_empty = not state
3459 action.set_state(GLib.Variant.new_boolean(not state))
3460 self.apply_podcast_list_hide_boring()
3462 def on_item_view_show_all_episodes_toggled(self, action, param):
3463 state = action.get_state()
3464 self.config.ui.gtk.podcast_list.all_episodes = not state
3465 action.set_state(GLib.Variant.new_boolean(not state))
3467 def on_item_view_show_podcast_sections_toggled(self, action, param):
3468 state = action.get_state()
3469 self.config.ui.gtk.podcast_list.sections = not state
3470 action.set_state(GLib.Variant.new_boolean(not state))
3472 def on_item_view_episodes_changed(self, action, param):
3473 self.config.ui.gtk.episode_list.view_mode = getattr(EpisodeListModel, param.get_string()) or EpisodeListModel.VIEW_ALL
3474 action.set_state(param)
3476 self.episode_list_model.set_view_mode(self.config.ui.gtk.episode_list.view_mode)
3477 self.apply_podcast_list_hide_boring()
3479 def on_item_view_always_show_new_episodes_toggled(self, action, param):
3480 state = action.get_state()
3481 self.config.ui.gtk.episode_list.always_show_new = not state
3482 action.set_state(GLib.Variant.new_boolean(not state))
3484 def on_item_view_trim_episode_title_prefix_toggled(self, action, param):
3485 state = action.get_state()
3486 self.config.ui.gtk.episode_list.trim_title_prefix = not state
3487 action.set_state(GLib.Variant.new_boolean(not state))
3489 def on_item_view_show_episode_description_toggled(self, action, param):
3490 state = action.get_state()
3491 self.config.ui.gtk.episode_list.descriptions = not state
3492 action.set_state(GLib.Variant.new_boolean(not state))
3494 def on_item_view_show_episode_released_time_toggled(self, action, param):
3495 state = action.get_state()
3496 self.config.ui.gtk.episode_list.show_released_time = not state
3497 action.set_state(GLib.Variant.new_boolean(not state))
3499 def on_item_view_right_align_episode_released_column_toggled(self, action, param):
3500 state = action.get_state()
3501 self.config.ui.gtk.episode_list.right_align_released_column = not state
3502 action.set_state(GLib.Variant.new_boolean(not state))
3503 self.align_releasecell()
3504 self.treeAvailable.queue_draw()
3506 def on_item_view_ctrl_click_to_sort_episodes_toggled(self, action, param):
3507 state = action.get_state()
3508 self.config.ui.gtk.episode_list.ctrl_click_to_sort = not state
3509 action.set_state(GLib.Variant.new_boolean(not state))
3511 def apply_podcast_list_hide_boring(self):
3512 if self.config.ui.gtk.podcast_list.hide_empty:
3513 self.podcast_list_model.set_view_mode(self.config.ui.gtk.episode_list.view_mode)
3514 else:
3515 self.podcast_list_model.set_view_mode(-1)
3517 def on_download_subscriptions_from_mygpo(self, action=None):
3518 def after_login():
3519 title = _('Subscriptions on %(server)s') \
3520 % {'server': self.config.mygpo.server}
3521 dir = gPodderPodcastDirectory(self.gPodder,
3522 _config=self.config,
3523 custom_title=title,
3524 add_podcast_list=self.add_podcast_list,
3525 hide_url_entry=True)
3527 url = self.mygpo_client.get_download_user_subscriptions_url()
3528 dir.download_opml_file(url)
3530 title = _('Login to gpodder.net')
3531 message = _('Please login to download your subscriptions.')
3533 def on_register_button_clicked():
3534 util.open_website('http://gpodder.net/register/')
3536 success, (root_url, username, password) = self.show_login_dialog(title, message,
3537 self.config.mygpo.server,
3538 self.config.mygpo.username, self.config.mygpo.password,
3539 register_callback=on_register_button_clicked,
3540 ask_server=True)
3541 if not success:
3542 return
3544 self.config.mygpo.server = root_url
3545 self.config.mygpo.username = username
3546 self.config.mygpo.password = password
3548 util.idle_add(after_login)
3550 def on_itemAddChannel_activate(self, action=None, param=None):
3551 self._add_podcast_dialog = gPodderAddPodcast(self.gPodder,
3552 add_podcast_list=self.add_podcast_list)
3554 def on_itemEditChannel_activate(self, action, param=None):
3555 if self.active_channel is None:
3556 title = _('No podcast selected')
3557 message = _('Please select a podcast in the podcasts list to edit.')
3558 self.show_message(message, title, widget=self.treeChannels)
3559 return
3561 gPodderChannel(self.main_window,
3562 channel=self.active_channel,
3563 update_podcast_list_model=self.update_podcast_list_model,
3564 cover_downloader=self.cover_downloader,
3565 sections=set(c.section for c in self.channels),
3566 clear_cover_cache=self.podcast_list_model.clear_cover_cache,
3567 _config=self.config)
3569 def on_itemMassUnsubscribe_activate(self, action, param):
3570 columns = (
3571 ('title_markup', None, None, _('Podcast')),
3574 # We're abusing the Episode Selector for selecting Podcasts here,
3575 # but it works and looks good, so why not? -- thp
3576 gPodderEpisodeSelector(self.main_window,
3577 title=_('Delete podcasts'),
3578 instructions=_('Select the podcast you want to delete.'),
3579 episodes=self.channels,
3580 columns=columns,
3581 size_attribute=None,
3582 ok_button=_('_Delete'),
3583 callback=self.remove_podcast_list,
3584 _config=self.config)
3586 def remove_podcast_list(self, channels, confirm=True):
3587 if not channels:
3588 return
3590 if len(channels) == 1:
3591 title = _('Deleting podcast')
3592 info = _('Please wait while the podcast is deleted')
3593 message = _('This podcast and all its episodes will be PERMANENTLY DELETED.\nAre you sure you want to continue?')
3594 else:
3595 title = _('Deleting podcasts')
3596 info = _('Please wait while the podcasts are deleted')
3597 message = _('These podcasts and all their episodes will be PERMANENTLY DELETED.\nAre you sure you want to continue?')
3599 message = self.format_delete_message(message, channels, 5, 60)
3601 if confirm and not self.show_confirmation(message, title):
3602 return
3604 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3606 def finish_deletion(select_url):
3607 # Upload subscription list changes to the web service
3608 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3610 # Re-load the channels and select the desired new channel
3611 self.update_podcast_list_model(select_url=select_url)
3613 progress.on_finished()
3615 @util.run_in_background
3616 def thread_proc():
3617 select_url = None
3619 for idx, channel in enumerate(channels):
3620 # Update the UI for correct status messages
3621 progress.on_progress(idx / len(channels))
3622 progress.on_message(channel.title)
3624 # Delete downloaded episodes
3625 channel.remove_downloaded()
3627 # cancel any active downloads from this channel
3628 for episode in channel.get_all_episodes():
3629 if episode.downloading:
3630 episode.download_task.cancel()
3632 if len(channels) == 1:
3633 # get the URL of the podcast we want to select next
3634 if channel in self.channels:
3635 position = self.channels.index(channel)
3636 else:
3637 position = -1
3639 if position == len(self.channels) - 1:
3640 # this is the last podcast, so select the URL
3641 # of the item before this one (i.e. the "new last")
3642 select_url = self.channels[position - 1].url
3643 else:
3644 # there is a podcast after the deleted one, so
3645 # we simply select the one that comes after it
3646 select_url = self.channels[position + 1].url
3648 # Remove the channel and clean the database entries
3649 channel.delete()
3651 # Clean up downloads and download directories
3652 common.clean_up_downloads()
3654 # The remaining stuff is to be done in the GTK main thread
3655 util.idle_add(finish_deletion, select_url)
3657 def on_itemRefreshCover_activate(self, widget, *args):
3658 assert self.active_channel is not None
3660 self.podcast_list_model.clear_cover_cache(self.active_channel.url)
3661 self.cover_downloader.replace_cover(self.active_channel, custom_url=False)
3663 def on_itemRemoveChannel_activate(self, widget, *args):
3664 if self.active_channel is None:
3665 title = _('No podcast selected')
3666 message = _('Please select a podcast in the podcasts list to remove.')
3667 self.show_message(message, title, widget=self.treeChannels)
3668 return
3670 self.remove_podcast_list([self.active_channel])
3672 def get_opml_filter(self):
3673 filter = Gtk.FileFilter()
3674 filter.add_pattern('*.opml')
3675 filter.add_pattern('*.xml')
3676 filter.set_name(_('OPML files') + ' (*.opml, *.xml)')
3677 return filter
3679 def on_item_import_from_file_activate(self, action, filename=None):
3680 if filename is None:
3681 dlg = Gtk.FileChooserDialog(title=_('Import from OPML'),
3682 parent=self.main_window,
3683 action=Gtk.FileChooserAction.OPEN)
3684 dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
3685 dlg.add_button(_('_Open'), Gtk.ResponseType.OK)
3686 dlg.set_filter(self.get_opml_filter())
3687 response = dlg.run()
3688 filename = None
3689 if response == Gtk.ResponseType.OK:
3690 filename = dlg.get_filename()
3691 dlg.destroy()
3693 if filename is not None:
3694 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config,
3695 custom_title=_('Import podcasts from OPML file'),
3696 add_podcast_list=self.add_podcast_list,
3697 hide_url_entry=True)
3698 dir.download_opml_file(filename)
3700 def on_itemExportChannels_activate(self, widget, *args):
3701 if not self.channels:
3702 title = _('Nothing to export')
3703 message = _('Your list of podcast subscriptions is empty. '
3704 'Please subscribe to some podcasts first before '
3705 'trying to export your subscription list.')
3706 self.show_message(message, title, widget=self.treeChannels)
3707 return
3709 dlg = Gtk.FileChooserDialog(title=_('Export to OPML'),
3710 parent=self.gPodder,
3711 action=Gtk.FileChooserAction.SAVE)
3712 dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
3713 dlg.add_button(_('_Save'), Gtk.ResponseType.OK)
3714 dlg.set_filter(self.get_opml_filter())
3715 response = dlg.run()
3716 if response == Gtk.ResponseType.OK:
3717 filename = dlg.get_filename()
3718 dlg.destroy()
3719 exporter = opml.Exporter(filename)
3720 if filename is not None and exporter.write(self.channels):
3721 count = len(self.channels)
3722 title = N_('%(count)d subscription exported',
3723 '%(count)d subscriptions exported',
3724 count) % {'count': count}
3725 self.show_message(_('Your podcast list has been successfully '
3726 'exported.'),
3727 title, widget=self.treeChannels)
3728 else:
3729 self.show_message(_('Could not export OPML to file. '
3730 'Please check your permissions.'),
3731 _('OPML export failed'), important=True)
3732 else:
3733 dlg.destroy()
3735 def on_itemImportChannels_activate(self, widget, *args):
3736 self._podcast_directory = gPodderPodcastDirectory(self.main_window,
3737 _config=self.config,
3738 add_podcast_list=self.add_podcast_list)
3740 def on_homepage_activate(self, widget, *args):
3741 util.open_website(gpodder.__url__)
3743 def check_for_distro_updates(self):
3744 title = _('Managed by distribution')
3745 message = _('Please check your distribution for gPodder updates.')
3746 self.show_message(message, title, important=True)
3748 def check_for_updates(self, silent):
3749 """Check for updates and (optionally) show a message
3751 If silent=False, a message will be shown even if no updates are
3752 available (set silent=False when the check is manually triggered).
3754 try:
3755 up_to_date, version, released, days = util.get_update_info()
3756 except Exception as e:
3757 if silent:
3758 logger.warning('Could not check for updates.', exc_info=True)
3759 else:
3760 title = _('Could not check for updates')
3761 message = _('Please try again later.')
3762 self.show_message(message, title, important=True)
3763 return
3765 if up_to_date and not silent:
3766 title = _('No updates available')
3767 message = _('You have the latest version of gPodder.')
3768 self.show_message(message, title, important=True)
3770 if not up_to_date:
3771 title = _('New version available')
3772 message = '\n'.join([
3773 _('Installed version: %s') % gpodder.__version__,
3774 _('Newest version: %s') % version,
3775 _('Release date: %s') % released,
3777 _('Download the latest version from gpodder.org?'),
3780 if self.show_confirmation(message, title):
3781 util.open_website('http://gpodder.org/downloads')
3783 def on_wNotebook_switch_page(self, notebook, page, page_num):
3784 self.play_or_download(current_page=page_num)
3786 def on_treeChannels_row_activated(self, widget, path, *args):
3787 # double-click action of the podcast list or enter
3788 self.treeChannels.set_cursor(path)
3790 # open channel settings
3791 channel = self.get_selected_channels()[0]
3792 if channel and not isinstance(channel, PodcastChannelProxy):
3793 self.on_itemEditChannel_activate(None)
3795 def get_selected_channels(self):
3796 """Get a list of selected channels from treeChannels"""
3797 selection = self.treeChannels.get_selection()
3798 model, paths = selection.get_selected_rows()
3800 channels = [model.get_value(model.get_iter(path), PodcastListModel.C_CHANNEL) for path in paths]
3801 channels = [c for c in channels if c is not None]
3802 return channels
3804 def on_treeChannels_cursor_changed(self, widget, *args):
3805 (model, iter) = self.treeChannels.get_selection().get_selected()
3807 if model is not None and iter is not None:
3808 old_active_channel = self.active_channel
3809 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3811 if self.active_channel == old_active_channel:
3812 return
3814 # Dirty hack to check for "All episodes" or a section (see gpodder.gtkui.model)
3815 if isinstance(self.active_channel, PodcastChannelProxy):
3816 self.edit_channel_action.set_enabled(False)
3817 else:
3818 self.edit_channel_action.set_enabled(True)
3819 else:
3820 self.active_channel = None
3821 self.edit_channel_action.set_enabled(False)
3823 self.update_episode_list_model()
3825 def on_btnEditChannel_clicked(self, widget, *args):
3826 self.on_itemEditChannel_activate(widget, args)
3828 def get_podcast_urls_from_selected_episodes(self):
3829 """Get a set of podcast URLs based on the selected episodes"""
3830 return set(episode.channel.url for episode in
3831 self.get_selected_episodes())
3833 def get_selected_episodes(self):
3834 """Get a list of selected episodes from treeAvailable"""
3835 selection = self.treeAvailable.get_selection()
3836 model, paths = selection.get_selected_rows()
3838 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3839 episodes = [e for e in episodes if e is not None]
3840 return episodes
3842 def on_playback_selected_episodes(self, *params):
3843 self.playback_episodes(self.get_selected_episodes())
3845 def on_episode_new_activate(self, action, *params):
3846 state = not action.get_state().get_boolean()
3847 if state:
3848 self.mark_selected_episodes_new()
3849 else:
3850 self.mark_selected_episodes_old()
3851 action.change_state(GLib.Variant.new_boolean(state))
3852 self.episodes_popover.popdown()
3853 return True
3855 def on_shownotes_selected_episodes(self, *params):
3856 episodes = self.get_selected_episodes()
3857 self.shownotes_object.toggle_pane_visibility(episodes)
3859 def on_download_selected_episodes(self, action_or_widget, param=None):
3860 if self.wNotebook.get_current_page() == 0:
3861 episodes = [e for e in self.get_selected_episodes() if e.can_download()]
3862 self.download_episode_list(episodes)
3863 else:
3864 selection = self.treeDownloads.get_selection()
3865 (model, paths) = selection.get_selected_rows()
3866 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
3867 model.get_value(model.get_iter(path),
3868 DownloadStatusModel.C_TASK)) for path in paths]
3869 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
3871 def on_force_download_selected_episodes(self, action_or_widget, param=None):
3872 if self.wNotebook.get_current_page() == 1:
3873 selection = self.treeDownloads.get_selection()
3874 (model, paths) = selection.get_selected_rows()
3875 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
3876 model.get_value(model.get_iter(path),
3877 DownloadStatusModel.C_TASK)) for path in paths]
3878 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED, True)
3880 def on_pause_selected_episodes(self, action_or_widget, param=None):
3881 if self.wNotebook.get_current_page() == 0:
3882 selection = self.get_selected_episodes()
3883 selected_tasks = [(None, e.download_task) for e in selection if e.download_task is not None and e.can_pause()]
3884 self._for_each_task_set_status(selected_tasks, download.DownloadTask.PAUSING)
3885 else:
3886 selection = self.treeDownloads.get_selection()
3887 (model, paths) = selection.get_selected_rows()
3888 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
3889 model.get_value(model.get_iter(path),
3890 DownloadStatusModel.C_TASK)) for path in paths]
3891 self._for_each_task_set_status(selected_tasks, download.DownloadTask.PAUSING)
3893 def on_move_selected_items_up(self, action, *args):
3894 selection = self.treeDownloads.get_selection()
3895 model, selected_paths = selection.get_selected_rows()
3896 for path in selected_paths:
3897 index_above = path[0] - 1
3898 if index_above < 0:
3899 return
3900 task = model.get_value(
3901 model.get_iter(path),
3902 DownloadStatusModel.C_TASK)
3903 model.move_before(
3904 model.get_iter(path),
3905 model.get_iter((index_above,)))
3907 def on_move_selected_items_down(self, action, *args):
3908 selection = self.treeDownloads.get_selection()
3909 model, selected_paths = selection.get_selected_rows()
3910 for path in reversed(selected_paths):
3911 index_below = path[0] + 1
3912 if index_below >= len(model):
3913 return
3914 task = model.get_value(
3915 model.get_iter(path),
3916 DownloadStatusModel.C_TASK)
3917 model.move_after(
3918 model.get_iter(path),
3919 model.get_iter((index_below,)))
3921 def on_remove_from_download_list(self, action, *args):
3922 selected_tasks, x, x, x, x, x = self.downloads_list_get_selection()
3923 self._for_each_task_set_status(selected_tasks, None, False)
3925 def on_treeAvailable_row_activated(self, widget, path, view_column):
3926 """Double-click/enter action handler for treeAvailable"""
3927 self.on_shownotes_selected_episodes(widget)
3929 def restart_auto_update_timer(self):
3930 if self._auto_update_timer_source_id is not None:
3931 logger.debug('Removing existing auto update timer.')
3932 GLib.source_remove(self._auto_update_timer_source_id)
3933 self._auto_update_timer_source_id = None
3935 if (self.config.auto.update.enabled
3936 and self.config.auto.update.frequency):
3937 interval = 60 * 1000 * self.config.auto.update.frequency
3938 logger.debug('Setting up auto update timer with interval %d.',
3939 self.config.auto.update.frequency)
3940 self._auto_update_timer_source_id = util.idle_timeout_add(interval, self._on_auto_update_timer)
3942 def _on_auto_update_timer(self):
3943 if self.config.check_connection and not util.connection_available():
3944 logger.debug('Skipping auto update (no connection available)')
3945 return True
3947 logger.debug('Auto update timer fired.')
3948 self.update_feed_cache()
3950 # Ask web service for sub changes (if enabled)
3951 if self.mygpo_client.can_access_webservice():
3952 self.mygpo_client.flush()
3954 return True
3956 def on_treeDownloads_row_activated(self, widget, *args):
3957 # Use the standard way of working on the treeview
3958 selection = self.treeDownloads.get_selection()
3959 (model, paths) = selection.get_selected_rows()
3960 selected_tasks = [(Gtk.TreeRowReference.new(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3962 has_queued_tasks = False
3963 for tree_row_reference, task in selected_tasks:
3964 with task:
3965 if task.status in (task.DOWNLOADING, task.QUEUED):
3966 task.pause()
3967 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3968 self.download_queue_manager.queue_task(task)
3969 has_queued_tasks = True
3970 elif task.status == task.DONE:
3971 model.remove(model.get_iter(tree_row_reference.get_path()))
3972 if has_queued_tasks:
3973 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3975 self.play_or_download()
3977 # Update the tab title and downloads list
3978 self.update_downloads_list()
3980 def on_item_cancel_download_activate(self, *params, force=False):
3981 if self.wNotebook.get_current_page() == 0:
3982 selection = self.treeAvailable.get_selection()
3983 (model, paths) = selection.get_selected_rows()
3984 urls = [model.get_value(model.get_iter(path),
3985 self.episode_list_model.C_URL) for path in paths]
3986 selected_tasks = [task for task in self.download_tasks_seen
3987 if task.url in urls]
3988 else:
3989 selection = self.treeDownloads.get_selection()
3990 (model, paths) = selection.get_selected_rows()
3991 selected_tasks = [model.get_value(model.get_iter(path),
3992 self.download_status_model.C_TASK) for path in paths]
3993 self.cancel_task_list(selected_tasks, force=force)
3995 def on_btnCancelAll_clicked(self, widget, *args):
3996 self.cancel_task_list(self.download_tasks_seen)
3998 def on_btnDownloadedDelete_clicked(self, widget, *args):
3999 episodes = self.get_selected_episodes()
4000 self.delete_episode_list(episodes)
4002 def on_key_press(self, widget, event):
4003 # Allow tab switching with Ctrl + PgUp/PgDown/Tab
4004 if event.get_state() & Gdk.ModifierType.CONTROL_MASK:
4005 current_page = self.wNotebook.get_current_page()
4006 if event.keyval in (Gdk.KEY_Page_Up, Gdk.KEY_ISO_Left_Tab):
4007 if current_page == 0:
4008 current_page = self.wNotebook.get_n_pages()
4009 self.wNotebook.set_current_page(current_page - 1)
4010 return True
4011 elif event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Tab):
4012 if current_page == self.wNotebook.get_n_pages() - 1:
4013 current_page = -1
4014 self.wNotebook.set_current_page(current_page + 1)
4015 return True
4016 elif event.keyval == Gdk.KEY_Delete:
4017 if isinstance(widget.get_focus(), Gtk.Entry):
4018 logger.debug("Entry has focus, ignoring Delete")
4019 else:
4020 self.main_window.activate_action('delete')
4021 return True
4023 return False
4025 def uniconify_main_window(self):
4026 if self.is_iconified():
4027 # We need to hide and then show the window in WMs like Metacity
4028 # or KWin4 to move the window to the active workspace
4029 # (see http://gpodder.org/bug/1125)
4030 self.gPodder.hide()
4031 self.gPodder.show()
4032 self.gPodder.present()
4034 def iconify_main_window(self):
4035 if not self.is_iconified():
4036 self.gPodder.iconify()
4038 @dbus.service.method(gpodder.dbus_interface)
4039 def show_gui_window(self):
4040 parent = self.get_dialog_parent()
4041 parent.present()
4043 @dbus.service.method(gpodder.dbus_interface)
4044 def subscribe_to_url(self, url):
4045 # Strip leading application protocol, so these URLs work:
4046 # gpodder://example.com/episodes.rss
4047 # gpodder:https://example.org/podcast.xml
4048 if url.startswith('gpodder:'):
4049 url = url[len('gpodder:'):]
4050 while url.startswith('/'):
4051 url = url[1:]
4053 self._add_podcast_dialog = gPodderAddPodcast(self.gPodder,
4054 add_podcast_list=self.add_podcast_list,
4055 preset_url=url)
4057 @dbus.service.method(gpodder.dbus_interface)
4058 def mark_episode_played(self, filename):
4059 if filename is None:
4060 return False
4062 for channel in self.channels:
4063 for episode in channel.get_all_episodes():
4064 fn = episode.local_filename(create=False, check_only=True)
4065 if fn == filename:
4066 episode.mark(is_played=True)
4067 self.db.commit()
4068 self.update_episode_list_icons([episode.url])
4069 self.update_podcast_list_model([episode.channel.url])
4070 return True
4072 return False
4074 def extensions_podcast_update_cb(self, podcast):
4075 logger.debug('extensions_podcast_update_cb(%s)', podcast)
4076 self.update_feed_cache(channels=[podcast],
4077 show_new_episodes_dialog=False)
4079 def extensions_episode_download_cb(self, episode):
4080 logger.debug('extension_episode_download_cb(%s)', episode)
4081 self.download_episode_list(episodes=[episode])
4083 def mount_volume_cb(self, file, res, mount_result):
4084 result = True
4085 try:
4086 file.mount_enclosing_volume_finish(res)
4087 except GLib.Error as err:
4088 if (not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_SUPPORTED)
4089 and not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED)):
4090 logger.error('mounting volume %s failed: %s' % (file.get_uri(), err.message))
4091 result = False
4092 finally:
4093 mount_result["result"] = result
4094 Gtk.main_quit()
4096 def mount_volume_for_file(self, file):
4097 op = Gtk.MountOperation.new(self.main_window)
4098 result, message = util.mount_volume_for_file(file, op)
4099 if not result:
4100 logger.error('mounting volume %s failed: %s' % (file.get_uri(), message))
4101 return result
4103 def on_sync_to_device_activate(self, widget, episodes=None, force_played=True):
4104 self.sync_ui = gPodderSyncUI(self.config, self.notification,
4105 self.main_window,
4106 self.show_confirmation,
4107 self.application.on_itemPreferences_activate,
4108 self.channels,
4109 self.download_status_model,
4110 self.download_queue_manager,
4111 self.set_download_list_state,
4112 self.commit_changes_to_database,
4113 self.delete_episode_list,
4114 gPodderEpisodeSelector,
4115 self.mount_volume_for_file)
4117 self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played)
4119 def on_extension_enabled(self, extension):
4120 if getattr(extension, 'on_ui_object_available', None) is not None:
4121 extension.on_ui_object_available('gpodder-gtk', self)
4122 if getattr(extension, 'on_ui_initialized', None) is not None:
4123 extension.on_ui_initialized(self.model,
4124 self.extensions_podcast_update_cb,
4125 self.extensions_episode_download_cb)
4126 self.inject_extensions_menu()
4128 def on_extension_disabled(self, extension):
4129 self.inject_extensions_menu()