Display feed name when logging update errors.
[gpodder.git] / src / gpodder / gtkui / main.py
blob9a7dfb3fde2767d9282d2009223220ff3bb80d9d
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, TreeViewHelper
54 from .interface.progress import ProgressIndicator
55 from .interface.searchtree import SearchTree
56 from .model import EpisodeListModel, PodcastChannelProxy, PodcastListModel
57 from .services import CoverDownloader
59 import gi # isort:skip
60 gi.require_version('Gtk', '3.0') # isort:skip
61 from gi.repository import Gdk, Gio, GLib, Gtk, Pango # isort:skip
64 logger = logging.getLogger(__name__)
66 _ = gpodder.gettext
67 N_ = gpodder.ngettext
70 class gPodder(BuilderWidget, dbus.service.Object):
72 def __init__(self, app, bus_name, gpodder_core, options):
73 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
74 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels,
75 self.on_itemUpdate_activate,
76 self.playback_episodes,
77 self.download_episode_list,
78 self.episode_object_by_uri,
79 bus_name)
80 self.application = app
81 self.core = gpodder_core
82 self.config = self.core.config
83 self.db = self.core.db
84 self.model = self.core.model
85 self.options = options
86 self.extensions_menu = None
87 self.extensions_actions = []
88 self._search_podcasts = None
89 self._search_episodes = None
90 BuilderWidget.__init__(self, None,
91 _gtk_properties={('gPodder', 'application'): app})
93 self.last_episode_date_refresh = None
94 self.refresh_episode_dates()
96 self.on_episode_list_selection_changed_id = None
98 def new(self):
99 if self.application.want_headerbar:
100 self.header_bar = Gtk.HeaderBar()
101 self.header_bar.pack_end(self.application.header_bar_menu_button)
102 self.header_bar.pack_start(self.application.header_bar_refresh_button)
103 self.header_bar.set_show_close_button(True)
104 self.header_bar.show_all()
106 # Tweaks to the UI since we moved the refresh button into the header bar
107 self.vboxChannelNavigator.set_row_spacing(0)
109 self.main_window.set_titlebar(self.header_bar)
111 gpodder.user_extensions.on_ui_object_available('gpodder-gtk', self)
112 self.toolbar.set_property('visible', self.config.ui.gtk.toolbar)
114 self.bluetooth_available = util.bluetooth_available()
116 self.config.connect_gtk_window(self.main_window, 'main_window')
118 self.config.connect_gtk_paned('ui.gtk.state.main_window.paned_position', self.channelPaned)
120 self.main_window.show()
122 self.player_receiver = player.MediaPlayerDBusReceiver(self.on_played)
124 self.gPodder.connect('key-press-event', self.on_key_press)
126 self.episode_columns_menu = None
127 self.config.add_observer(self.on_config_changed)
129 self.shownotes_pane = Gtk.Box()
130 self.shownotes_object = shownotes.get_shownotes(self.config.ui.gtk.html_shownotes, self.shownotes_pane)
132 # Vertical paned for the episode list and shownotes
133 self.vpaned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
134 paned = self.vbox_episode_list.get_parent()
135 self.vbox_episode_list.reparent(self.vpaned)
136 self.vpaned.child_set_property(self.vbox_episode_list, 'resize', True)
137 self.vpaned.child_set_property(self.vbox_episode_list, 'shrink', False)
138 self.vpaned.pack2(self.shownotes_pane, resize=False, shrink=False)
139 self.vpaned.show()
141 # Minimum height for both episode list and shownotes
142 self.vbox_episode_list.set_size_request(-1, 100)
143 self.shownotes_pane.set_size_request(-1, 100)
145 self.config.connect_gtk_paned('ui.gtk.state.main_window.episode_list_size',
146 self.vpaned)
147 paned.add2(self.vpaned)
149 self.new_episodes_window = None
151 self.download_status_model = DownloadStatusModel()
152 self.download_queue_manager = download.DownloadQueueManager(self.config, self.download_status_model)
154 self.config.connect_gtk_spinbutton('limit.downloads.concurrent', self.spinMaxDownloads,
155 self.config.limit.downloads.concurrent_max)
156 self.config.connect_gtk_togglebutton('limit.downloads.enabled', self.cbMaxDownloads)
157 self.config.connect_gtk_spinbutton('limit.bandwidth.kbps', self.spinLimitDownloads)
158 self.config.connect_gtk_togglebutton('limit.bandwidth.enabled', self.cbLimitDownloads)
160 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
161 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
163 # When the amount of maximum downloads changes, notify the queue manager
164 def changed_cb(spinbutton):
165 return self.download_queue_manager.update_max_downloads()
167 self.spinMaxDownloads.connect('value-changed', changed_cb)
168 self.cbMaxDownloads.connect('toggled', changed_cb)
170 # Keep a reference to the last add podcast dialog instance
171 self._add_podcast_dialog = None
173 self.default_title = None
174 self.set_title(_('gPodder'))
176 self.cover_downloader = CoverDownloader()
178 # Generate list models for podcasts and their episodes
179 self.podcast_list_model = PodcastListModel(self.cover_downloader)
180 self.apply_podcast_list_hide_boring()
182 self.cover_downloader.register('cover-available', self.cover_download_finished)
184 # Source IDs for timeouts for search-as-you-type
185 self._podcast_list_search_timeout = None
186 self._episode_list_search_timeout = None
188 # Subscribed channels
189 self.active_channel = None
190 self.channels = self.model.get_podcasts()
192 # For loading the list model
193 self.episode_list_model = EpisodeListModel(self.on_episode_list_filter_changed)
195 self.create_actions()
197 # Init the treeviews that we use
198 self.init_podcast_list_treeview()
199 self.init_episode_list_treeview()
200 self.init_download_list_treeview()
202 self.download_tasks_seen = set()
203 self.download_list_update_timer = None
204 self.things_adding_tasks = 0
205 self.download_task_monitors = set()
207 # Set up the first instance of MygPoClient
208 self.mygpo_client = my.MygPoClient(self.config)
210 self.inject_extensions_menu()
212 gpodder.user_extensions.on_ui_initialized(self.model,
213 self.extensions_podcast_update_cb,
214 self.extensions_episode_download_cb)
216 gpodder.user_extensions.on_application_started()
218 # load list of user applications for audio playback
219 self.user_apps_reader = UserAppsReader(['audio', 'video'])
220 util.run_in_background(self.user_apps_reader.read)
222 # Now, update the feed cache, when everything's in place
223 if not self.application.want_headerbar:
224 self.btnUpdateFeeds.show()
225 self.feed_cache_update_cancelled = False
226 self.update_podcast_list_model()
228 self.partial_downloads_indicator = None
229 util.run_in_background(self.find_partial_downloads)
231 # Start the auto-update procedure
232 self._auto_update_timer_source_id = None
233 if self.config.auto.update.enabled:
234 self.restart_auto_update_timer()
236 # Find expired (old) episodes and delete them
237 old_episodes = list(common.get_expired_episodes(self.channels, self.config))
238 if len(old_episodes) > 0:
239 self.delete_episode_list(old_episodes, confirm=False)
240 updated_urls = set(e.channel.url for e in old_episodes)
241 self.update_podcast_list_model(updated_urls)
243 # Do the initial sync with the web service
244 if self.mygpo_client.can_access_webservice():
245 util.idle_add(self.mygpo_client.flush, True)
247 # First-time users should be asked if they want to see the OPML
248 if self.options.subscribe:
249 util.idle_add(self.subscribe_to_url, self.options.subscribe)
250 elif not self.channels:
251 self.on_itemUpdate_activate()
252 elif self.config.software_update.check_on_startup:
253 # Check for software updates from gpodder.org
254 diff = time.time() - self.config.software_update.last_check
255 if diff > (60 * 60 * 24) * self.config.software_update.interval:
256 self.config.software_update.last_check = int(time.time())
257 if not os.path.exists(gpodder.no_update_check_file):
258 self.check_for_updates(silent=True)
260 if self.options.close_after_startup:
261 logger.warning("Startup done, closing (--close-after-startup)")
262 self.core.db.close()
263 sys.exit()
265 def create_actions(self):
266 g = self.gPodder
268 # View
270 action = Gio.SimpleAction.new_stateful(
271 'showToolbar', None, GLib.Variant.new_boolean(self.config.ui.gtk.toolbar))
272 action.connect('activate', self.on_itemShowToolbar_activate)
273 g.add_action(action)
275 action = Gio.SimpleAction.new_stateful(
276 'searchAlwaysVisible', None, GLib.Variant.new_boolean(self.config.ui.gtk.search_always_visible))
277 action.connect('activate', self.on_item_view_search_always_visible_toggled)
278 g.add_action(action)
280 # View Podcast List
282 action = Gio.SimpleAction.new_stateful(
283 'viewHideBoringPodcasts', None, GLib.Variant.new_boolean(self.config.ui.gtk.podcast_list.hide_empty))
284 action.connect('activate', self.on_item_view_hide_boring_podcasts_toggled)
285 g.add_action(action)
287 action = Gio.SimpleAction.new_stateful(
288 'viewShowAllEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.podcast_list.all_episodes))
289 action.connect('activate', self.on_item_view_show_all_episodes_toggled)
290 g.add_action(action)
292 action = Gio.SimpleAction.new_stateful(
293 'viewShowPodcastSections', None, GLib.Variant.new_boolean(self.config.ui.gtk.podcast_list.sections))
294 action.connect('activate', self.on_item_view_show_podcast_sections_toggled)
295 g.add_action(action)
297 # View Episode List
299 value = EpisodeListModel.VIEWS[
300 self.config.ui.gtk.episode_list.view_mode or EpisodeListModel.VIEW_ALL]
301 action = Gio.SimpleAction.new_stateful(
302 'viewEpisodes', GLib.VariantType.new('s'),
303 GLib.Variant.new_string(value))
304 action.connect('activate', self.on_item_view_episodes_changed)
305 g.add_action(action)
307 action = Gio.SimpleAction.new_stateful(
308 'viewAlwaysShowNewEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.always_show_new))
309 action.connect('activate', self.on_item_view_always_show_new_episodes_toggled)
310 g.add_action(action)
312 action = Gio.SimpleAction.new_stateful(
313 'viewTrimEpisodeTitlePrefix', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.trim_title_prefix))
314 action.connect('activate', self.on_item_view_trim_episode_title_prefix_toggled)
315 g.add_action(action)
317 action = Gio.SimpleAction.new_stateful(
318 'viewShowEpisodeDescription', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.descriptions))
319 action.connect('activate', self.on_item_view_show_episode_description_toggled)
320 g.add_action(action)
322 action = Gio.SimpleAction.new_stateful(
323 'viewCtrlClickToSortEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.ctrl_click_to_sort))
324 action.connect('activate', self.on_item_view_ctrl_click_to_sort_episodes_toggled)
325 g.add_action(action)
327 # Other Menus
329 action_defs = [
330 # gPodder
331 # Podcasts
332 ('update', self.on_itemUpdate_activate),
333 ('downloadAllNew', self.on_itemDownloadAllNew_activate),
334 ('removeOldEpisodes', self.on_itemRemoveOldEpisodes_activate),
335 ('findPodcast', self.on_find_podcast_activate),
336 # Subscriptions
337 ('discover', self.on_itemImportChannels_activate),
338 ('addChannel', self.on_itemAddChannel_activate),
339 ('massUnsubscribe', self.on_itemMassUnsubscribe_activate),
340 ('updateChannel', self.on_itemUpdateChannel_activate),
341 ('editChannel', self.on_itemEditChannel_activate),
342 ('importFromFile', self.on_item_import_from_file_activate),
343 ('exportChannels', self.on_itemExportChannels_activate),
344 # Episodes
345 ('play', self.on_playback_selected_episodes),
346 ('open', self.on_playback_selected_episodes),
347 ('download', self.on_download_selected_episodes),
348 ('pause', self.on_pause_selected_episodes),
349 ('cancel', self.on_item_cancel_download_activate),
350 ('delete', self.on_btnDownloadedDelete_clicked),
351 ('toggleEpisodeNew', self.on_item_toggle_played_activate),
352 ('toggleEpisodeLock', self.on_item_toggle_lock_activate),
353 ('openEpisodeDownloadFolder', self.on_open_episode_download_folder),
354 ('selectChannel', self.on_select_channel_of_episode),
355 ('findEpisode', self.on_find_episode_activate),
356 ('toggleShownotes', self.on_shownotes_selected_episodes),
357 # Extras
358 ('sync', self.on_sync_to_device_activate),
361 for name, callback in action_defs:
362 action = Gio.SimpleAction.new(name, None)
363 action.connect('activate', callback)
364 g.add_action(action)
366 # gPodder
367 # Podcasts
368 self.update_action = g.lookup_action('update')
369 # Subscriptions
370 self.update_channel_action = g.lookup_action('updateChannel')
371 self.edit_channel_action = g.lookup_action('editChannel')
372 # Episodes
373 self.play_action = g.lookup_action('play')
374 self.open_action = g.lookup_action('open')
375 self.download_action = g.lookup_action('download')
376 self.pause_action = g.lookup_action('pause')
377 self.cancel_action = g.lookup_action('cancel')
378 self.delete_action = g.lookup_action('delete')
379 self.toggle_episode_new_action = g.lookup_action('toggleEpisodeNew')
380 self.toggle_episode_lock_action = g.lookup_action('toggleEpisodeLock')
381 self.open_episode_download_folder_action = g.lookup_action('openEpisodeDownloadFolder')
382 self.select_channel_of_episode_action = g.lookup_action('selectChannel')
383 # Extras
385 def inject_extensions_menu(self):
387 Update Extras/Extensions menu.
388 Called at startup and when en/dis-abling extenstions.
390 def gen_callback(label, callback):
391 return lambda action, param: callback()
393 for a in self.extensions_actions:
394 self.gPodder.remove_action(a.get_property('name'))
395 self.extensions_actions = []
397 if self.extensions_menu is None:
398 # insert menu section at startup (hides when empty)
399 self.extensions_menu = Gio.Menu.new()
400 self.application.menu_extras.append_section(_('Extensions'), self.extensions_menu)
401 else:
402 self.extensions_menu.remove_all()
404 extension_entries = gpodder.user_extensions.on_create_menu()
405 if extension_entries:
406 # populate menu
407 for i, (label, callback) in enumerate(extension_entries):
408 action_id = 'extensions.action_%d' % i
409 action = Gio.SimpleAction.new(action_id)
410 action.connect('activate', gen_callback(label, callback))
411 self.extensions_actions.append(action)
412 self.gPodder.add_action(action)
413 itm = Gio.MenuItem.new(label, 'win.' + action_id)
414 self.extensions_menu.append_item(itm)
416 def on_resume_all_infobar_response(self, infobar, response_id):
417 if response_id == Gtk.ResponseType.OK:
418 selection = self.treeDownloads.get_selection()
419 selection.select_all()
420 selected_tasks = self.downloads_list_get_selection()[0]
421 selection.unselect_all()
422 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
423 self.resume_all_infobar.set_revealed(False)
425 def find_partial_downloads(self):
426 def start_progress_callback(count):
427 if count:
428 self.partial_downloads_indicator = ProgressIndicator(
429 _('Loading incomplete downloads'),
430 _('Some episodes have not finished downloading in a previous session.'),
431 False, self.get_dialog_parent())
432 self.partial_downloads_indicator.on_message(N_(
433 '%(count)d partial file', '%(count)d partial files',
434 count) % {'count': count})
436 util.idle_add(self.wNotebook.set_current_page, 1)
438 def progress_callback(title, progress):
439 self.partial_downloads_indicator.on_message(title)
440 self.partial_downloads_indicator.on_progress(progress)
441 self.partial_downloads_indicator.on_tick() # not cancellable
443 def final_progress_callback():
444 self.partial_downloads_indicator.on_tick(final=_('Cleaning up...'))
446 def finish_progress_callback(resumable_episodes):
447 def offer_resuming():
448 if resumable_episodes:
449 self.download_episode_list_paused(resumable_episodes, hide_progress=True)
450 self.resume_all_infobar.set_revealed(True)
451 else:
452 util.idle_add(self.wNotebook.set_current_page, 0)
453 logger.debug("find_partial_downloads done, calling extensions")
454 gpodder.user_extensions.on_find_partial_downloads_done()
456 if self.partial_downloads_indicator:
457 util.idle_add(self.partial_downloads_indicator.on_finished)
458 self.partial_downloads_indicator = None
460 util.idle_add(offer_resuming)
462 common.find_partial_downloads(self.channels,
463 start_progress_callback,
464 progress_callback,
465 final_progress_callback,
466 finish_progress_callback)
468 def episode_object_by_uri(self, uri):
469 """Get an episode object given a local or remote URI
471 This can be used to quickly access an episode object
472 when all we have is its download filename or episode
473 URL (e.g. from external D-Bus calls / signals, etc..)
475 if uri.startswith('/'):
476 uri = 'file://' + urllib.parse.quote(uri)
478 prefix = 'file://' + urllib.parse.quote(gpodder.downloads)
480 # By default, assume we can't pre-select any channel
481 # but can match episodes simply via the download URL
483 def is_channel(c):
484 return True
486 def is_episode(e):
487 return e.url == uri
489 if uri.startswith(prefix):
490 # File is on the local filesystem in the download folder
491 # Try to reduce search space by pre-selecting the channel
492 # based on the folder name of the local file
494 filename = urllib.parse.unquote(uri[len(prefix):])
495 file_parts = [_f for _f in filename.split(os.sep) if _f]
497 if len(file_parts) != 2:
498 return None
500 foldername, filename = file_parts
502 def is_channel(c):
503 return c.download_folder == foldername
505 def is_episode(e):
506 return e.download_filename == filename
508 # Deep search through channels and episodes for a match
509 for channel in filter(is_channel, self.channels):
510 for episode in filter(is_episode, channel.get_all_episodes()):
511 return episode
513 return None
515 def on_played(self, start, end, total, file_uri):
516 """Handle the "played" signal from a media player"""
517 if start == 0 and end == 0 and total == 0:
518 # Ignore bogus play event
519 return
520 elif end < start + 5:
521 # Ignore "less than five seconds" segments,
522 # as they can happen with seeking, etc...
523 return
525 logger.debug('Received play action: %s (%d, %d, %d)', file_uri, start, end, total)
526 episode = self.episode_object_by_uri(file_uri)
528 if episode is not None:
529 file_type = episode.file_type()
531 now = time.time()
532 if total > 0:
533 episode.total_time = total
534 elif total == 0:
535 # Assume the episode's total time for the action
536 total = episode.total_time
538 assert (episode.current_position_updated is None
539 or now >= episode.current_position_updated)
541 episode.current_position = end
542 episode.current_position_updated = now
543 episode.mark(is_played=True)
544 episode.save()
545 self.episode_list_status_changed([episode])
547 # Submit this action to the webservice
548 self.mygpo_client.on_playback_full(episode, start, end, total)
550 def on_add_remove_podcasts_mygpo(self):
551 actions = self.mygpo_client.get_received_actions()
552 if not actions:
553 return False
555 existing_urls = [c.url for c in self.channels]
557 # Columns for the episode selector window - just one...
558 columns = (
559 ('description', None, None, _('Action')),
562 # A list of actions that have to be chosen from
563 changes = []
565 # Actions that are ignored (already carried out)
566 ignored = []
568 for action in actions:
569 if action.is_add and action.url not in existing_urls:
570 changes.append(my.Change(action))
571 elif action.is_remove and action.url in existing_urls:
572 podcast_object = None
573 for podcast in self.channels:
574 if podcast.url == action.url:
575 podcast_object = podcast
576 break
577 changes.append(my.Change(action, podcast_object))
578 else:
579 ignored.append(action)
581 # Confirm all ignored changes
582 self.mygpo_client.confirm_received_actions(ignored)
584 def execute_podcast_actions(selected):
585 # In the future, we might retrieve the title from gpodder.net here,
586 # but for now, we just use "None" to use the feed-provided title
587 title = None
588 add_list = [(title, c.action.url)
589 for c in selected if c.action.is_add]
590 remove_list = [c.podcast for c in selected if c.action.is_remove]
592 # Apply the accepted changes locally
593 self.add_podcast_list(add_list)
594 self.remove_podcast_list(remove_list, confirm=False)
596 # All selected items are now confirmed
597 self.mygpo_client.confirm_received_actions(c.action for c in selected)
599 # Revert the changes on the server
600 rejected = [c.action for c in changes if c not in selected]
601 self.mygpo_client.reject_received_actions(rejected)
603 def ask():
604 # We're abusing the Episode Selector again ;) -- thp
605 gPodderEpisodeSelector(self.main_window,
606 title=_('Confirm changes from gpodder.net'),
607 instructions=_('Select the actions you want to carry out.'),
608 episodes=changes,
609 columns=columns,
610 size_attribute=None,
611 ok_button=_('A_pply'),
612 callback=execute_podcast_actions,
613 _config=self.config)
615 # There are some actions that need the user's attention
616 if changes:
617 util.idle_add(ask)
618 return True
620 # We have no remaining actions - no selection happens
621 return False
623 def rewrite_urls_mygpo(self):
624 # Check if we have to rewrite URLs since the last add
625 rewritten_urls = self.mygpo_client.get_rewritten_urls()
626 changed = False
628 for rewritten_url in rewritten_urls:
629 if not rewritten_url.new_url:
630 continue
632 for channel in self.channels:
633 if channel.url == rewritten_url.old_url:
634 logger.info('Updating URL of %s to %s', channel,
635 rewritten_url.new_url)
636 channel.url = rewritten_url.new_url
637 channel.save()
638 changed = True
639 break
641 if changed:
642 util.idle_add(self.update_episode_list_model)
644 def on_send_full_subscriptions(self):
645 # Send the full subscription list to the gpodder.net client
646 # (this will overwrite the subscription list on the server)
647 indicator = ProgressIndicator(_('Uploading subscriptions'),
648 _('Your subscriptions are being uploaded to the server.'),
649 False, self.get_dialog_parent())
651 try:
652 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
653 util.idle_add(self.show_message, _('List uploaded successfully.'))
654 except Exception as e:
655 def show_error(e):
656 message = str(e)
657 if not message:
658 message = e.__class__.__name__
659 if message == 'NotFound':
660 message = _(
661 'Could not find your device.\n'
662 '\n'
663 'Check login is a username (not an email)\n'
664 'and that the device name matches one in your account.'
666 self.show_message(html.escape(message),
667 _('Error while uploading'),
668 important=True)
669 util.idle_add(show_error, e)
671 indicator.on_finished()
673 def on_button_subscribe_clicked(self, button):
674 self.on_itemImportChannels_activate(button)
676 def on_button_downloads_clicked(self, widget):
677 self.downloads_window.show()
679 def on_treeview_button_pressed(self, treeview, event):
680 if event.window != treeview.get_bin_window():
681 return False
683 role = getattr(treeview, TreeViewHelper.ROLE)
684 if role == TreeViewHelper.ROLE_EPISODES and event.button == 1:
685 # Toggle episode "new" status by clicking the icon (bug 1432)
686 result = treeview.get_path_at_pos(int(event.x), int(event.y))
687 if result is not None:
688 path, column, x, y = result
689 # The user clicked the icon if she clicked in the first column
690 # and the x position is in the area where the icon resides
691 if (x < self.EPISODE_LIST_ICON_WIDTH
692 and column == treeview.get_columns()[0]):
693 model = treeview.get_model()
694 cursor_episode = model.get_value(model.get_iter(path),
695 EpisodeListModel.C_EPISODE)
697 new_value = cursor_episode.is_new
698 selected_episodes = self.get_selected_episodes()
700 # Avoid changing anything if the clicked episode is not
701 # selected already - otherwise update all selected
702 if cursor_episode in selected_episodes:
703 for episode in selected_episodes:
704 episode.mark(is_played=new_value)
706 self.update_episode_list_icons(selected=True)
707 self.update_podcast_list_model(selected=True)
708 return True
710 return event.button == 3
712 def on_treeview_podcasts_button_released(self, treeview, event):
713 if event.window != treeview.get_bin_window():
714 return False
716 return self.treeview_channels_show_context_menu(treeview, event)
718 def on_treeview_episodes_button_released(self, treeview, event):
719 if event.window != treeview.get_bin_window():
720 return False
722 return self.treeview_available_show_context_menu(treeview, event)
724 def on_treeview_downloads_button_released(self, treeview, event):
725 if event.window != treeview.get_bin_window():
726 return False
728 return self.treeview_downloads_show_context_menu(treeview, event)
730 def on_find_podcast_activate(self, *args):
731 if self._search_podcasts:
732 self._search_podcasts.show_search()
734 def init_podcast_list_treeview(self):
735 size = cake_size_from_widget(self.treeChannels) * 2
736 scale = self.treeChannels.get_scale_factor()
737 self.podcast_list_model.set_max_image_size(size, scale)
738 # Set up podcast channel tree view widget
739 column = Gtk.TreeViewColumn('')
740 iconcell = Gtk.CellRendererPixbuf()
741 iconcell.set_property('width', size + 10)
742 column.pack_start(iconcell, False)
743 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
744 column.add_attribute(iconcell, 'visible', PodcastListModel.C_COVER_VISIBLE)
745 if scale != 1:
746 column.set_cell_data_func(iconcell, draw_iconcell_scale, scale)
748 namecell = Gtk.CellRendererText()
749 namecell.set_property('ellipsize', Pango.EllipsizeMode.END)
750 column.pack_start(namecell, True)
751 column.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
753 iconcell = Gtk.CellRendererPixbuf()
754 iconcell.set_property('xalign', 1.0)
755 column.pack_start(iconcell, False)
756 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
757 column.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
758 if scale != 1:
759 column.set_cell_data_func(iconcell, draw_iconcell_scale, scale)
761 self.treeChannels.append_column(column)
763 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
764 self.podcast_list_model.widget = self.treeChannels
766 # When no podcast is selected, clear the episode list model
767 selection = self.treeChannels.get_selection()
769 # Set up type-ahead find for the podcast list
770 def on_key_press(treeview, event):
771 if event.keyval == Gdk.KEY_Right:
772 self.treeAvailable.grab_focus()
773 elif event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down):
774 # If section markers exist in the treeview, we want to
775 # "jump over" them when moving the cursor up and down
776 if event.keyval == Gdk.KEY_Up:
777 step = -1
778 else:
779 step = 1
781 selection = self.treeChannels.get_selection()
782 model, it = selection.get_selected()
783 if it is None:
784 it = model.get_iter_first()
785 if it is None:
786 return False
787 step = 1
789 path = model.get_path(it)
790 path = (path[0] + step,)
792 if path[0] < 0:
793 # Valid paths must have a value >= 0
794 return True
796 try:
797 it = model.get_iter(path)
798 except ValueError:
799 # Already at the end of the list
800 return True
802 self.treeChannels.set_cursor(path)
803 elif event.keyval == Gdk.KEY_Escape:
804 self._search_podcasts.hide_search()
805 elif event.get_state() & Gdk.ModifierType.CONTROL_MASK:
806 # Don't handle type-ahead when control is pressed (so shortcuts
807 # with the Ctrl key still work, e.g. Ctrl+A, ...)
808 return True
809 elif event.keyval == Gdk.KEY_Delete:
810 return False
811 else:
812 unicode_char_id = Gdk.keyval_to_unicode(event.keyval)
813 # < 32 to intercept Delete and Tab events
814 if unicode_char_id < 32:
815 return False
816 if self.config.ui.gtk.find_as_you_type:
817 input_char = chr(unicode_char_id)
818 self._search_podcasts.show_search(input_char)
819 return True
820 self.treeChannels.connect('key-press-event', on_key_press)
822 self.treeChannels.connect('popup-menu', self.treeview_channels_show_context_menu)
824 # Enable separators to the podcast list to separate special podcasts
825 # from others (this is used for the "all episodes" view)
826 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
828 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
830 self._search_podcasts = SearchTree(self.hbox_search_podcasts,
831 self.entry_search_podcasts,
832 self.treeChannels,
833 self.podcast_list_model,
834 self.config)
835 if self.config.ui.gtk.search_always_visible:
836 self._search_podcasts.show_search(grab_focus=False)
838 def on_find_episode_activate(self, *args):
839 if self._search_episodes:
840 self._search_episodes.show_search()
842 def set_episode_list_column(self, index, new_value):
843 mask = (1 << index)
844 if new_value:
845 self.config.ui.gtk.episode_list.columns |= mask
846 else:
847 self.config.ui.gtk.episode_list.columns &= ~mask
849 def update_episode_list_columns_visibility(self):
850 columns = TreeViewHelper.get_columns(self.treeAvailable)
851 for index, column in enumerate(columns):
852 visible = bool(self.config.ui.gtk.episode_list.columns & (1 << index))
853 column.set_visible(visible)
854 self.view_column_actions[index].set_state(GLib.Variant.new_boolean(visible))
855 self.treeAvailable.columns_autosize()
857 def on_episode_list_header_reordered(self, treeview):
858 self.config.ui.gtk.state.main_window.episode_column_order = \
859 [column.get_sort_column_id() for column in treeview.get_columns()]
861 def on_episode_list_header_sorted(self, column):
862 self.config.ui.gtk.state.main_window.episode_column_sort_id = column.get_sort_column_id()
863 self.config.ui.gtk.state.main_window.episode_column_sort_order = \
864 (column.get_sort_order() is Gtk.SortType.ASCENDING)
866 def on_episode_list_header_clicked(self, button, event):
867 if event.button == 1:
868 # Require control click to sort episodes, when enabled
869 if self.config.ui.gtk.episode_list.ctrl_click_to_sort and (event.state & Gdk.ModifierType.CONTROL_MASK) == 0:
870 return True
871 elif event.button == 3:
872 if self.episode_columns_menu is not None:
873 self.episode_columns_menu.popup(None, None, None, None, event.button, event.time)
875 return False
877 def init_episode_list_treeview(self):
878 self.episode_list_model.set_view_mode(self.config.ui.gtk.episode_list.view_mode)
880 # Initialize progress icons
881 cake_size = cake_size_from_widget(self.treeAvailable)
882 for i in range(EpisodeListModel.PROGRESS_STEPS + 1):
883 pixbuf = draw_cake_pixbuf(
884 i / EpisodeListModel.PROGRESS_STEPS, size=cake_size)
885 icon_name = 'gpodder-progress-%d' % i
886 Gtk.IconTheme.add_builtin_icon(icon_name, cake_size, pixbuf)
888 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
890 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
892 iconcell = Gtk.CellRendererPixbuf()
893 episode_list_icon_size = Gtk.icon_size_register('episode-list',
894 cake_size, cake_size)
895 iconcell.set_property('stock-size', episode_list_icon_size)
896 iconcell.set_fixed_size(cake_size + 20, -1)
897 self.EPISODE_LIST_ICON_WIDTH = cake_size
899 namecell = Gtk.CellRendererText()
900 namecell.set_property('ellipsize', Pango.EllipsizeMode.END)
901 namecolumn = Gtk.TreeViewColumn(_('Episode'))
902 namecolumn.pack_start(iconcell, False)
903 namecolumn.add_attribute(iconcell, 'icon-name', EpisodeListModel.C_STATUS_ICON)
904 namecolumn.pack_start(namecell, True)
905 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
906 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
907 namecolumn.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
908 namecolumn.set_resizable(True)
909 namecolumn.set_expand(True)
911 lockcell = Gtk.CellRendererPixbuf()
912 lockcell.set_fixed_size(40, -1)
913 lockcell.set_property('stock-size', Gtk.IconSize.MENU)
914 lockcell.set_property('icon-name', 'emblem-readonly')
915 namecolumn.pack_start(lockcell, False)
916 namecolumn.add_attribute(lockcell, 'visible', EpisodeListModel.C_LOCKED)
918 sizecell = Gtk.CellRendererText()
919 sizecell.set_property('xalign', 1)
920 sizecolumn = Gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
921 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
923 timecell = Gtk.CellRendererText()
924 timecell.set_property('xalign', 1)
925 timecolumn = Gtk.TreeViewColumn(_('Duration'), timecell, text=EpisodeListModel.C_TIME)
926 timecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME)
928 releasecell = Gtk.CellRendererText()
929 releasecolumn = Gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
930 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
932 sizetimecell = Gtk.CellRendererText()
933 sizetimecell.set_property('xalign', 1)
934 sizetimecell.set_property('alignment', Pango.Alignment.RIGHT)
935 sizetimecolumn = Gtk.TreeViewColumn(_('Size+'))
936 sizetimecolumn.pack_start(sizetimecell, True)
937 sizetimecolumn.add_attribute(sizetimecell, 'markup', EpisodeListModel.C_FILESIZE_AND_TIME_TEXT)
938 sizetimecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE_AND_TIME)
940 timesizecell = Gtk.CellRendererText()
941 timesizecell.set_property('xalign', 1)
942 timesizecell.set_property('alignment', Pango.Alignment.RIGHT)
943 timesizecolumn = Gtk.TreeViewColumn(_('Duration+'))
944 timesizecolumn.pack_start(timesizecell, True)
945 timesizecolumn.add_attribute(timesizecell, 'markup', EpisodeListModel.C_TIME_AND_SIZE)
946 timesizecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME_AND_SIZE)
948 namecolumn.set_reorderable(True)
949 self.treeAvailable.append_column(namecolumn)
951 # EpisodeListModel.C_PUBLISHED is not available in config.py, set it here on first run
952 if not self.config.ui.gtk.state.main_window.episode_column_sort_id:
953 self.config.ui.gtk.state.main_window.episode_column_sort_id = EpisodeListModel.C_PUBLISHED
955 for itemcolumn in (sizecolumn, timecolumn, releasecolumn, sizetimecolumn, timesizecolumn):
956 itemcolumn.set_reorderable(True)
957 self.treeAvailable.append_column(itemcolumn)
958 TreeViewHelper.register_column(self.treeAvailable, itemcolumn)
960 # Add context menu to all tree view column headers
961 for column in self.treeAvailable.get_columns():
962 label = Gtk.Label(label=column.get_title())
963 label.show_all()
964 column.set_widget(label)
966 w = column.get_widget()
967 while w is not None and not isinstance(w, Gtk.Button):
968 w = w.get_parent()
970 w.connect('button-release-event', self.on_episode_list_header_clicked)
972 # Restore column sorting
973 if column.get_sort_column_id() == self.config.ui.gtk.state.main_window.episode_column_sort_id:
974 self.episode_list_model._sorter.set_sort_column_id(Gtk.TREE_SORTABLE_UNSORTED_SORT_COLUMN_ID,
975 Gtk.SortType.DESCENDING)
976 self.episode_list_model._sorter.set_sort_column_id(column.get_sort_column_id(),
977 Gtk.SortType.ASCENDING if self.config.ui.gtk.state.main_window.episode_column_sort_order
978 else Gtk.SortType.DESCENDING)
979 # Save column sorting when user clicks column headers
980 column.connect('clicked', self.on_episode_list_header_sorted)
982 def restore_column_ordering():
983 prev_column = None
984 for col in self.config.ui.gtk.state.main_window.episode_column_order:
985 for column in self.treeAvailable.get_columns():
986 if col is column.get_sort_column_id():
987 break
988 else:
989 # Column ID not found, abort
990 # Manually re-ordering columns should fix the corrupt setting
991 break
992 self.treeAvailable.move_column_after(column, prev_column)
993 prev_column = column
994 # Save column ordering when user drags column headers
995 self.treeAvailable.connect('columns-changed', self.on_episode_list_header_reordered)
996 # Delay column ordering until shown to prevent "Negative content height" warnings for themes with vertical padding or borders
997 util.idle_add(restore_column_ordering)
999 # For each column that can be shown/hidden, add a menu item
1000 self.view_column_actions = []
1001 columns = TreeViewHelper.get_columns(self.treeAvailable)
1003 def on_visible_toggled(action, param, index):
1004 state = action.get_state()
1005 self.set_episode_list_column(index, not state)
1006 action.set_state(GLib.Variant.new_boolean(not state))
1008 for index, column in enumerate(columns):
1009 name = 'showColumn%i' % index
1010 action = Gio.SimpleAction.new_stateful(
1011 name, None, GLib.Variant.new_boolean(False))
1012 action.connect('activate', on_visible_toggled, index)
1013 self.main_window.add_action(action)
1014 self.view_column_actions.append(action)
1015 self.application.menu_view_columns.insert(index, column.get_title(), 'win.' + name)
1017 self.episode_columns_menu = Gtk.Menu.new_from_model(self.application.menu_view_columns)
1018 self.episode_columns_menu.attach_to_widget(self.main_window)
1019 # Update the visibility of the columns and the check menu items
1020 self.update_episode_list_columns_visibility()
1022 # Set up type-ahead find for the episode list
1023 def on_key_press(treeview, event):
1024 if event.keyval == Gdk.KEY_Left:
1025 self.treeChannels.grab_focus()
1026 elif event.keyval == Gdk.KEY_Escape:
1027 if self.hbox_search_episodes.get_property('visible'):
1028 self._search_episodes.hide_search()
1029 else:
1030 self.shownotes_object.hide_pane()
1031 elif event.get_state() & Gdk.ModifierType.CONTROL_MASK:
1032 # Don't handle type-ahead when control is pressed (so shortcuts
1033 # with the Ctrl key still work, e.g. Ctrl+A, ...)
1034 return False
1035 else:
1036 unicode_char_id = Gdk.keyval_to_unicode(event.keyval)
1037 # < 32 to intercept Delete and Tab events
1038 if unicode_char_id < 32:
1039 return False
1040 if self.config.ui.gtk.find_as_you_type:
1041 input_char = chr(unicode_char_id)
1042 self._search_episodes.show_search(input_char)
1043 return True
1044 self.treeAvailable.connect('key-press-event', on_key_press)
1046 self.treeAvailable.connect('popup-menu', self.treeview_available_show_context_menu)
1048 self.treeAvailable.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK,
1049 (('text/uri-list', 0, 0),), Gdk.DragAction.COPY)
1051 def drag_data_get(tree, context, selection_data, info, timestamp):
1052 uris = ['file://' + urllib.parse.quote(e.local_filename(create=False))
1053 for e in self.get_selected_episodes()
1054 if e.was_downloaded(and_exists=True)]
1055 selection_data.set_uris(uris)
1056 self.treeAvailable.connect('drag-data-get', drag_data_get)
1058 selection = self.treeAvailable.get_selection()
1059 selection.set_mode(Gtk.SelectionMode.MULTIPLE)
1060 self.episode_selection_handler_id = selection.connect('changed', self.on_episode_list_selection_changed)
1062 self._search_episodes = SearchTree(self.hbox_search_episodes,
1063 self.entry_search_episodes,
1064 self.treeAvailable,
1065 self.episode_list_model,
1066 self.config)
1067 if self.config.ui.gtk.search_always_visible:
1068 self._search_episodes.show_search(grab_focus=False)
1070 def on_episode_list_selection_changed(self, selection):
1071 # Only update the UI every 250ms to prevent lag when rapidly changing selected episode or shift-selecting episodes
1072 if self.on_episode_list_selection_changed_id is None:
1073 self.on_episode_list_selection_changed_id = util.idle_timeout_add(250, self._on_episode_list_selection_changed)
1075 def _on_episode_list_selection_changed(self):
1076 self.on_episode_list_selection_changed_id = None
1078 # Update the toolbar buttons
1079 self.play_or_download()
1080 # and the shownotes
1081 self.shownotes_object.set_episodes(self.get_selected_episodes())
1083 def on_download_list_selection_changed(self, selection):
1084 if self.wNotebook.get_current_page() > 0:
1085 # Update the toolbar buttons
1086 self.play_or_download()
1088 def init_download_list_treeview(self):
1089 # columns and renderers for "download progress" tab
1090 # First column: [ICON] Episodename
1091 column = Gtk.TreeViewColumn(_('Episode'))
1093 cell = Gtk.CellRendererPixbuf()
1094 cell.set_property('stock-size', Gtk.IconSize.BUTTON)
1095 column.pack_start(cell, False)
1096 column.add_attribute(cell, 'icon-name',
1097 DownloadStatusModel.C_ICON_NAME)
1099 cell = Gtk.CellRendererText()
1100 cell.set_property('ellipsize', Pango.EllipsizeMode.END)
1101 column.pack_start(cell, True)
1102 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
1103 column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
1104 column.set_expand(True)
1105 self.treeDownloads.append_column(column)
1107 # Second column: Progress
1108 cell = Gtk.CellRendererProgress()
1109 cell.set_property('yalign', .5)
1110 cell.set_property('ypad', 6)
1111 column = Gtk.TreeViewColumn(_('Progress'), cell,
1112 value=DownloadStatusModel.C_PROGRESS,
1113 text=DownloadStatusModel.C_PROGRESS_TEXT)
1114 column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
1115 column.set_expand(False)
1116 self.treeDownloads.append_column(column)
1117 column.set_property('min-width', 150)
1118 column.set_property('max-width', 150)
1120 self.treeDownloads.set_model(self.download_status_model)
1121 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
1123 self.treeDownloads.connect('popup-menu', self.treeview_downloads_show_context_menu)
1125 # enable multiple selection support
1126 selection = self.treeDownloads.get_selection()
1127 selection.set_mode(Gtk.SelectionMode.MULTIPLE)
1128 self.download_selection_handler_id = selection.connect('changed', self.on_download_list_selection_changed)
1129 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
1131 def on_treeview_expose_event(self, treeview, ctx):
1132 model = treeview.get_model()
1133 if (model is not None and model.get_iter_first() is not None):
1134 return False
1136 role = getattr(treeview, TreeViewHelper.ROLE, None)
1137 if role is None:
1138 return False
1140 width = treeview.get_allocated_width()
1141 height = treeview.get_allocated_height()
1143 if role == TreeViewHelper.ROLE_EPISODES:
1144 if self.config.ui.gtk.episode_list.view_mode != EpisodeListModel.VIEW_ALL:
1145 text = _('No episodes in current view')
1146 else:
1147 text = _('No episodes available')
1148 elif role == TreeViewHelper.ROLE_PODCASTS:
1149 if self.config.ui.gtk.episode_list.view_mode != \
1150 EpisodeListModel.VIEW_ALL and \
1151 self.config.ui.gtk.podcast_list.hide_empty and \
1152 len(self.channels) > 0:
1153 text = _('No podcasts in this view')
1154 else:
1155 text = _('No subscriptions')
1156 elif role == TreeViewHelper.ROLE_DOWNLOADS:
1157 text = _('No active tasks')
1158 else:
1159 raise Exception('on_treeview_expose_event: unknown role')
1161 draw_text_box_centered(ctx, treeview, width, height, text, None, None)
1162 return True
1164 def set_download_list_state(self, state):
1165 if state == gPodderSyncUI.DL_ADDING_TASKS:
1166 self.things_adding_tasks += 1
1167 elif state == gPodderSyncUI.DL_ADDED_TASKS:
1168 self.things_adding_tasks -= 1
1169 if self.download_list_update_timer is None:
1170 self.update_downloads_list()
1171 self.download_list_update_timer = util.IdleTimeout(1500, self.update_downloads_list).set_max_milliseconds(5000)
1173 def stop_download_list_update_timer(self):
1174 if self.download_list_update_timer is None:
1175 return False
1177 self.download_list_update_timer.cancel()
1178 self.download_list_update_timer = None
1179 return True
1181 def cleanup_downloads(self):
1182 model = self.download_status_model
1184 all_tasks = [(Gtk.TreeRowReference.new(model, row.path), row[0]) for row in model]
1185 changed_episode_urls = set()
1186 for row_reference, task in all_tasks:
1187 if task.status in (task.DONE, task.CANCELLED):
1188 model.remove(model.get_iter(row_reference.get_path()))
1189 try:
1190 # We don't "see" this task anymore - remove it;
1191 # this is needed, so update_episode_list_icons()
1192 # below gets the correct list of "seen" tasks
1193 self.download_tasks_seen.remove(task)
1194 except KeyError as key_error:
1195 pass
1196 changed_episode_urls.add(task.url)
1197 # Tell the task that it has been removed (so it can clean up)
1198 task.removed_from_list()
1200 # Tell the podcasts tab to update icons for our removed podcasts
1201 self.update_episode_list_icons(changed_episode_urls)
1203 # Update the downloads list one more time
1204 self.update_downloads_list(can_call_cleanup=False)
1206 def on_tool_downloads_toggled(self, toolbutton):
1207 if toolbutton.get_active():
1208 self.wNotebook.set_current_page(1)
1209 else:
1210 self.wNotebook.set_current_page(0)
1212 def add_download_task_monitor(self, monitor):
1213 self.download_task_monitors.add(monitor)
1214 model = self.download_status_model
1215 if model is None:
1216 model = ()
1217 for row in model.get_model():
1218 task = row[self.download_status_model.C_TASK]
1219 monitor.task_updated(task)
1221 def remove_download_task_monitor(self, monitor):
1222 self.download_task_monitors.remove(monitor)
1224 def set_download_progress(self, progress):
1225 gpodder.user_extensions.on_download_progress(progress)
1227 def update_downloads_list(self, can_call_cleanup=True):
1228 try:
1229 model = self.download_status_model
1231 downloading, synchronizing, pausing, cancelling, queued, paused, failed, finished = (0,) * 8
1232 total_speed, total_size, done_size = 0, 0, 0
1233 files_downloading = 0
1235 # Keep a list of all download tasks that we've seen
1236 download_tasks_seen = set()
1238 # Do not go through the list of the model is not (yet) available
1239 if model is None:
1240 model = ()
1242 for row in model:
1243 self.download_status_model.request_update(row.iter)
1245 task = row[self.download_status_model.C_TASK]
1246 speed, size, status, progress, activity = task.speed, task.total_size, task.status, task.progress, task.activity
1248 # Let the download task monitors know of changes
1249 for monitor in self.download_task_monitors:
1250 monitor.task_updated(task)
1252 total_size += size
1253 done_size += size * progress
1255 download_tasks_seen.add(task)
1257 if status == download.DownloadTask.DOWNLOADING:
1258 if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
1259 downloading += 1
1260 files_downloading += 1
1261 total_speed += speed
1262 elif activity == download.DownloadTask.ACTIVITY_SYNCHRONIZE:
1263 synchronizing += 1
1264 elif status == download.DownloadTask.PAUSING:
1265 pausing += 1
1266 if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
1267 files_downloading += 1
1268 elif status == download.DownloadTask.CANCELLING:
1269 cancelling += 1
1270 if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
1271 files_downloading += 1
1272 elif status == download.DownloadTask.QUEUED:
1273 queued += 1
1274 elif status == download.DownloadTask.PAUSED:
1275 paused += 1
1276 elif status == download.DownloadTask.FAILED:
1277 failed += 1
1278 elif status == download.DownloadTask.DONE:
1279 finished += 1
1281 # Remember which tasks we have seen after this run
1282 self.download_tasks_seen = download_tasks_seen
1284 text = [_('Progress')]
1285 if downloading + synchronizing + pausing + cancelling + queued + paused + failed > 0:
1286 s = []
1287 if downloading > 0:
1288 s.append(N_('%(count)d active', '%(count)d active', downloading) % {'count': downloading})
1289 if synchronizing > 0:
1290 s.append(N_('%(count)d active', '%(count)d active', synchronizing) % {'count': synchronizing})
1291 if pausing > 0:
1292 s.append(N_('%(count)d pausing', '%(count)d pausing', pausing) % {'count': pausing})
1293 if cancelling > 0:
1294 s.append(N_('%(count)d cancelling', '%(count)d cancelling', cancelling) % {'count': cancelling})
1295 if queued > 0:
1296 s.append(N_('%(count)d queued', '%(count)d queued', queued) % {'count': queued})
1297 if paused > 0:
1298 s.append(N_('%(count)d paused', '%(count)d paused', paused) % {'count': paused})
1299 if failed > 0:
1300 s.append(N_('%(count)d failed', '%(count)d failed', failed) % {'count': failed})
1301 text.append(' (' + ', '.join(s) + ')')
1302 self.labelDownloads.set_text(''.join(text))
1304 title = [self.default_title]
1306 # Accessing task.status_changed has the side effect of re-setting
1307 # the changed flag, but we only do it once here so that's okay
1308 channel_urls = [task.podcast_url for task in
1309 self.download_tasks_seen if task.status_changed]
1310 episode_urls = [task.url for task in self.download_tasks_seen]
1312 if files_downloading > 0:
1313 title.append(N_('downloading %(count)d file',
1314 'downloading %(count)d files',
1315 files_downloading) % {'count': files_downloading})
1317 if total_size > 0:
1318 percentage = 100.0 * done_size / total_size
1319 else:
1320 percentage = 0.0
1321 self.set_download_progress(percentage / 100)
1322 total_speed = util.format_filesize(total_speed)
1323 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1324 if synchronizing > 0:
1325 title.append(N_('synchronizing %(count)d file',
1326 'synchronizing %(count)d files',
1327 synchronizing) % {'count': synchronizing})
1328 if queued > 0:
1329 title.append(N_('%(queued)d task queued',
1330 '%(queued)d tasks queued',
1331 queued) % {'queued': queued})
1332 if (downloading + synchronizing + pausing + cancelling + queued) == 0 and self.things_adding_tasks == 0:
1333 self.set_download_progress(1.)
1334 self.downloads_finished(self.download_tasks_seen)
1335 gpodder.user_extensions.on_all_episodes_downloaded()
1336 logger.info('All tasks have finished.')
1338 # Remove finished episodes
1339 if self.config.ui.gtk.download_list.remove_finished and can_call_cleanup:
1340 self.cleanup_downloads()
1342 # Stop updating the download list here
1343 self.stop_download_list_update_timer()
1345 self.gPodder.set_title(' - '.join(title))
1347 self.update_episode_list_icons(episode_urls)
1348 self.play_or_download()
1349 if channel_urls:
1350 self.update_podcast_list_model(channel_urls)
1352 return (self.download_list_update_timer is not None)
1353 except Exception as e:
1354 logger.error('Exception happened while updating download list.', exc_info=True)
1355 self.show_message(
1356 '%s\n\n%s' % (_('Please report this problem and restart gPodder:'), html.escape(str(e))),
1357 _('Unhandled exception'), important=True)
1358 # We return False here, so the update loop won't be called again,
1359 # that's why we require the restart of gPodder in the message.
1360 return False
1362 def on_config_changed(self, *args):
1363 util.idle_add(self._on_config_changed, *args)
1365 def _on_config_changed(self, name, old_value, new_value):
1366 if name == 'ui.gtk.toolbar':
1367 self.toolbar.set_property('visible', new_value)
1368 elif name in ('ui.gtk.episode_list.descriptions',
1369 'ui.gtk.episode_list.trim_title_prefix',
1370 'ui.gtk.episode_list.always_show_new'):
1371 self.update_episode_list_model()
1372 elif name in ('auto.update.enabled', 'auto.update.frequency'):
1373 self.restart_auto_update_timer()
1374 elif name in ('ui.gtk.podcast_list.all_episodes',
1375 'ui.gtk.podcast_list.sections'):
1376 # Force a update of the podcast list model
1377 self.update_podcast_list_model()
1378 elif name == 'ui.gtk.episode_list.columns':
1379 self.update_episode_list_columns_visibility()
1380 elif name == 'limit.downloads.concurrent_max':
1381 # Do not allow value to be set below 1
1382 if new_value < 1:
1383 self.config.limit.downloads.concurrent_max = 1
1384 return
1385 # Clamp current value to new maximum value
1386 if self.config.limit.downloads.concurrent > new_value:
1387 self.config.limit.downloads.concurrent = new_value
1388 self.spinMaxDownloads.get_adjustment().set_upper(new_value)
1389 elif name == 'limit.downloads.concurrent':
1390 if self.config.clamp_range('limit.downloads.concurrent', 1, self.config.limit.downloads.concurrent_max):
1391 return
1392 self.spinMaxDownloads.set_value(new_value)
1393 elif name == 'limit.bandwidth.kbps':
1394 adjustment = self.spinLimitDownloads.get_adjustment()
1395 if self.config.clamp_range('limit.bandwidth.kbps', adjustment.get_lower(), adjustment.get_upper()):
1396 return
1397 self.spinLimitDownloads.set_value(new_value)
1399 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1400 # With get_bin_window, we get the window that contains the rows without
1401 # the header. The Y coordinate of this window will be the height of the
1402 # treeview header. This is the amount we have to subtract from the
1403 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1404 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1405 x -= x_bin
1406 y -= y_bin
1407 (path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,) * 4
1409 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or x > 50 or (column is not None and column != treeview.get_columns()[0]):
1410 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1411 return False
1413 if path is not None:
1414 model = treeview.get_model()
1415 iter = model.get_iter(path)
1416 role = getattr(treeview, TreeViewHelper.ROLE)
1418 if role == TreeViewHelper.ROLE_EPISODES:
1419 id = model.get_value(iter, EpisodeListModel.C_URL)
1420 elif role == TreeViewHelper.ROLE_PODCASTS:
1421 id = model.get_value(iter, PodcastListModel.C_URL)
1422 if id == '-':
1423 # Section header - no tooltip here (for now at least)
1424 return False
1426 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1427 if last_tooltip is not None and last_tooltip != id:
1428 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1429 return False
1430 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1432 if role == TreeViewHelper.ROLE_EPISODES:
1433 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1434 if description:
1435 tooltip.set_text(description)
1436 else:
1437 return False
1438 elif role == TreeViewHelper.ROLE_PODCASTS:
1439 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1440 if channel is None or not hasattr(channel, 'title'):
1441 return False
1442 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1443 if error_str:
1444 error_str = _('Feedparser error: %s') % html.escape(error_str.strip())
1445 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1447 box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
1448 box.set_border_width(5)
1450 heading = Gtk.Label()
1451 heading.set_max_width_chars(60)
1452 heading.set_alignment(0, 1)
1453 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (html.escape(channel.title), html.escape(channel.url)))
1454 box.add(heading)
1456 box.add(Gtk.HSeparator())
1458 channel_description = util.remove_html_tags(channel.description)
1459 if channel._update_error is not None:
1460 description = _('ERROR: %s') % channel._update_error
1461 elif len(channel_description) < 500:
1462 description = channel_description
1463 else:
1464 pos = channel_description.find('\n\n')
1465 if pos == -1 or pos > 500:
1466 description = channel_description[:498] + '[...]'
1467 else:
1468 description = channel_description[:pos]
1470 description = Gtk.Label(label=description)
1471 description.set_max_width_chars(60)
1472 if error_str:
1473 description.set_markup(error_str)
1474 description.set_alignment(0, 0)
1475 description.set_line_wrap(True)
1476 box.add(description)
1478 box.show_all()
1479 tooltip.set_custom(box)
1481 return True
1483 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1484 return False
1486 def treeview_allow_tooltips(self, treeview, allow):
1487 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1489 def treeview_handle_context_menu_click(self, treeview, event):
1490 if event is None:
1491 selection = treeview.get_selection()
1492 return selection.get_selected_rows()
1494 x, y = int(event.x), int(event.y)
1495 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,) * 4
1497 selection = treeview.get_selection()
1498 model, paths = selection.get_selected_rows()
1500 if path is None or (path not in paths
1501 and event.button == 3):
1502 # We have right-clicked, but not into the selection,
1503 # assume we don't want to operate on the selection
1504 paths = []
1506 if (path is not None and not paths
1507 and event.button == 3):
1508 # No selection or clicked outside selection;
1509 # select the single item where we clicked
1510 treeview.grab_focus()
1511 treeview.set_cursor(path, column, 0)
1512 paths = [path]
1514 if not paths:
1515 # Unselect any remaining items (clicked elsewhere)
1516 if not treeview.is_rubber_banding_active():
1517 selection.unselect_all()
1519 return model, paths
1521 def downloads_list_get_selection(self, model=None, paths=None):
1522 if model is None and paths is None:
1523 selection = self.treeDownloads.get_selection()
1524 model, paths = selection.get_selected_rows()
1526 can_force, can_queue, can_pause, can_cancel, can_remove = (True,) * 5
1527 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
1528 model.get_value(model.get_iter(path),
1529 DownloadStatusModel.C_TASK)) for path in paths]
1531 for row_reference, task in selected_tasks:
1532 if task.status != download.DownloadTask.QUEUED:
1533 can_force = False
1534 if not task.can_queue():
1535 can_queue = False
1536 if not task.can_pause():
1537 can_pause = False
1538 if not task.can_cancel():
1539 can_cancel = False
1540 if not task.can_remove():
1541 can_remove = False
1543 return selected_tasks, can_force, can_queue, can_pause, can_cancel, can_remove
1545 def downloads_finished(self, download_tasks_seen):
1546 # Separate tasks into downloads & syncs
1547 # Since calling notify_as_finished or notify_as_failed clears the flag,
1548 # need to iterate through downloads & syncs separately, else all sync
1549 # tasks will have their flags cleared if we do downloads first
1551 def filter_by_activity(activity, tasks):
1552 return [task for task in tasks if task.activity == activity]
1554 download_tasks = filter_by_activity(download.DownloadTask.ACTIVITY_DOWNLOAD,
1555 download_tasks_seen)
1557 finished_downloads = [str(task)
1558 for task in download_tasks if task.notify_as_finished()]
1559 failed_downloads = ['%s (%s)' % (task, task.error_message)
1560 for task in download_tasks if task.notify_as_failed()]
1562 sync_tasks = filter_by_activity(download.DownloadTask.ACTIVITY_SYNCHRONIZE,
1563 download_tasks_seen)
1565 finished_syncs = [task for task in sync_tasks if task.notify_as_finished()]
1566 failed_syncs = [task for task in sync_tasks if task.notify_as_failed()]
1568 # Note that 'finished_ / failed_downloads' is a list of strings
1569 # Whereas 'finished_ / failed_syncs' is a list of SyncTask objects
1571 if finished_downloads and failed_downloads:
1572 message = self.format_episode_list(finished_downloads, 5)
1573 message += '\n\n<i>%s</i>\n' % _('Could not download some episodes:')
1574 message += self.format_episode_list(failed_downloads, 5)
1575 self.show_message(message, _('Downloads finished'))
1576 elif finished_downloads:
1577 message = self.format_episode_list(finished_downloads)
1578 self.show_message(message, _('Downloads finished'))
1579 elif failed_downloads:
1580 message = self.format_episode_list(failed_downloads)
1581 self.show_message(message, _('Downloads failed'))
1583 if finished_syncs and failed_syncs:
1584 message = self.format_episode_list(list(map((
1585 lambda task: str(task)), finished_syncs)), 5)
1586 message += '\n\n<i>%s</i>\n' % _('Could not sync some episodes:')
1587 message += self.format_episode_list(list(map((
1588 lambda task: str(task)), failed_syncs)), 5)
1589 self.show_message(message, _('Device synchronization finished'), True)
1590 elif finished_syncs:
1591 message = self.format_episode_list(list(map((
1592 lambda task: str(task)), finished_syncs)))
1593 self.show_message(message, _('Device synchronization finished'))
1594 elif failed_syncs:
1595 message = self.format_episode_list(list(map((
1596 lambda task: str(task)), failed_syncs)))
1597 self.show_message(message, _('Device synchronization failed'), True)
1599 # Do post-sync processing if required
1600 for task in finished_syncs:
1601 if self.config.device_sync.after_sync.mark_episodes_played:
1602 logger.info('Marking as played on transfer: %s', task.episode.url)
1603 task.episode.mark(is_played=True)
1605 if self.config.device_sync.after_sync.delete_episodes:
1606 logger.info('Removing episode after transfer: %s', task.episode.url)
1607 task.episode.delete_from_disk()
1609 self.sync_ui.device.close()
1611 # Update icon list to show changes, if any
1612 self.update_episode_list_icons(all=True)
1613 self.update_podcast_list_model()
1615 def format_episode_list(self, episode_list, max_episodes=10):
1617 Format a list of episode names for notifications
1619 Will truncate long episode names and limit the amount of
1620 episodes displayed (max_episodes=10).
1622 The episode_list parameter should be a list of strings.
1624 MAX_TITLE_LENGTH = 100
1626 result = []
1627 for title in episode_list[:min(len(episode_list), max_episodes)]:
1628 # Bug 1834: make sure title is a unicode string,
1629 # so it may be cut correctly on UTF-8 char boundaries
1630 title = util.convert_bytes(title)
1631 if len(title) > MAX_TITLE_LENGTH:
1632 middle = (MAX_TITLE_LENGTH // 2) - 2
1633 title = '%s...%s' % (title[0:middle], title[-middle:])
1634 result.append(html.escape(title))
1635 result.append('\n')
1637 more_episodes = len(episode_list) - max_episodes
1638 if more_episodes > 0:
1639 result.append('(...')
1640 result.append(N_('%(count)d more episode',
1641 '%(count)d more episodes',
1642 more_episodes) % {'count': more_episodes})
1643 result.append('...)')
1645 return (''.join(result)).strip()
1647 def queue_task(self, task, force_start):
1648 if force_start:
1649 self.download_queue_manager.force_start_task(task)
1650 else:
1651 self.download_queue_manager.queue_task(task)
1653 def _for_each_task_set_status(self, tasks, status, force_start=False):
1654 count = len(tasks)
1655 if count:
1656 progress_indicator = ProgressIndicator(
1657 _('Queueing') if status == download.DownloadTask.QUEUED else
1658 _('Removing') if status is None else download.DownloadTask.STATUS_MESSAGE[status],
1659 '', True, self.get_dialog_parent(), count)
1660 else:
1661 progress_indicator = None
1663 restart_timer = self.stop_download_list_update_timer()
1664 self.download_queue_manager.disable()
1665 self.__for_each_task_set_status(tasks, status, force_start, progress_indicator, restart_timer)
1666 self.download_queue_manager.enable()
1668 if progress_indicator:
1669 progress_indicator.on_finished()
1671 def __for_each_task_set_status(self, tasks, status, force_start=False, progress_indicator=None, restart_timer=False):
1672 episode_urls = set()
1673 model = self.treeDownloads.get_model()
1674 has_queued_tasks = False
1675 for row_reference, task in tasks:
1676 with task:
1677 if status == download.DownloadTask.QUEUED:
1678 # Only queue task when it's paused/failed/cancelled (or forced)
1679 if task.can_queue() or force_start:
1680 # add the task back in if it was already cleaned up
1681 # (to trigger this cancel one downloads in the active list, cancel all
1682 # other downloads, quickly right click on the cancelled on one to get
1683 # the context menu, wait until the active list is cleared, and then
1684 # then choose download)
1685 if task not in self.download_tasks_seen:
1686 self.download_status_model.register_task(task, False)
1687 self.download_tasks_seen.add(task)
1689 self.queue_task(task, force_start)
1690 has_queued_tasks = True
1691 elif status == download.DownloadTask.CANCELLING:
1692 logger.info(("cancelling task %s" % task.status))
1693 task.cancel()
1694 elif status == download.DownloadTask.PAUSING:
1695 task.pause()
1696 elif status is None:
1697 if task.can_cancel():
1698 task.cancel()
1699 path = row_reference.get_path()
1700 # path isn't set if the item has already been removed from the list
1701 # (to trigger this cancel one downloads in the active list, cancel all
1702 # other downloads, quickly right click on the cancelled on one to get
1703 # the context menu, wait until the active list is cleared, and then
1704 # then choose remove from list)
1705 if path:
1706 model.remove(model.get_iter(path))
1707 # Remember the URL, so we can tell the UI to update
1708 try:
1709 # We don't "see" this task anymore - remove it;
1710 # this is needed, so update_episode_list_icons()
1711 # below gets the correct list of "seen" tasks
1712 self.download_tasks_seen.remove(task)
1713 except KeyError as key_error:
1714 pass
1715 episode_urls.add(task.url)
1716 # Tell the task that it has been removed (so it can clean up)
1717 task.removed_from_list()
1718 else:
1719 # We can (hopefully) simply set the task status here
1720 task.status = status
1721 if progress_indicator:
1722 if not progress_indicator.on_tick():
1723 break
1724 if progress_indicator:
1725 progress_indicator.on_tick(final=_('Updating...'))
1727 # Update the tab title and downloads list
1728 if has_queued_tasks or restart_timer:
1729 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
1730 else:
1731 self.update_downloads_list()
1732 # Tell the podcasts tab to update icons for our removed podcasts
1733 self.update_episode_list_icons(episode_urls)
1735 def treeview_downloads_show_context_menu(self, treeview, event=None):
1736 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1737 if not paths:
1738 return not treeview.is_rubber_banding_active()
1740 if event is None or event.button == 3:
1741 selected_tasks, can_force, can_queue, can_pause, can_cancel, can_remove = \
1742 self.downloads_list_get_selection(model, paths)
1744 def make_menu_item(label, icon_name, tasks=None, status=None, sensitive=True, force_start=False, action=None):
1745 # This creates a menu item for selection-wide actions
1746 item = Gtk.ImageMenuItem.new_with_mnemonic(label)
1747 if icon_name is not None:
1748 item.set_image(Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU))
1749 if action is not None:
1750 item.connect('activate', action)
1751 else:
1752 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status, force_start))
1753 item.set_sensitive(sensitive)
1754 return item
1756 def move_selected_items_up(menu_item):
1757 selection = self.treeDownloads.get_selection()
1758 model, selected_paths = selection.get_selected_rows()
1759 for path in selected_paths:
1760 index_above = path[0] - 1
1761 if index_above < 0:
1762 return
1763 task = model.get_value(
1764 model.get_iter(path),
1765 DownloadStatusModel.C_TASK)
1766 model.move_before(
1767 model.get_iter(path),
1768 model.get_iter((index_above,)))
1770 def move_selected_items_down(menu_item):
1771 selection = self.treeDownloads.get_selection()
1772 model, selected_paths = selection.get_selected_rows()
1773 for path in reversed(selected_paths):
1774 index_below = path[0] + 1
1775 if index_below >= len(model):
1776 return
1777 task = model.get_value(
1778 model.get_iter(path),
1779 DownloadStatusModel.C_TASK)
1780 model.move_after(
1781 model.get_iter(path),
1782 model.get_iter((index_below,)))
1784 menu = Gtk.Menu()
1786 if can_force:
1787 menu.append(make_menu_item(_('Start download now'), 'document-save',
1788 selected_tasks,
1789 download.DownloadTask.QUEUED,
1790 force_start=True))
1791 else:
1792 menu.append(make_menu_item(_('Download'), 'document-save',
1793 selected_tasks,
1794 download.DownloadTask.QUEUED,
1795 can_queue))
1797 menu.append(make_menu_item(_('Pause'), 'media-playback-pause',
1798 selected_tasks,
1799 download.DownloadTask.PAUSING, can_pause))
1800 menu.append(make_menu_item(_('Cancel'), 'media-playback-stop',
1801 selected_tasks,
1802 download.DownloadTask.CANCELLING,
1803 can_cancel))
1804 menu.append(Gtk.SeparatorMenuItem())
1805 menu.append(make_menu_item(_('Move up'), 'go-up',
1806 action=move_selected_items_up))
1807 menu.append(make_menu_item(_('Move down'), 'go-down',
1808 action=move_selected_items_down))
1809 menu.append(Gtk.SeparatorMenuItem())
1810 menu.append(make_menu_item(_('Remove from list'), 'list-remove',
1811 selected_tasks, sensitive=can_remove))
1813 menu.attach_to_widget(treeview)
1814 menu.show_all()
1816 if event is None:
1817 func = TreeViewHelper.make_popup_position_func(treeview)
1818 menu.popup(None, None, func, None, 3, Gtk.get_current_event_time())
1819 else:
1820 menu.popup(None, None, None, None, event.button, event.time)
1821 return True
1823 def on_mark_episodes_as_old(self, item):
1824 assert self.active_channel is not None
1826 for episode in self.active_channel.get_all_episodes():
1827 if not episode.was_downloaded(and_exists=True):
1828 episode.mark(is_played=True)
1830 self.update_podcast_list_model(selected=True)
1831 self.update_episode_list_icons(all=True)
1833 def on_open_download_folder(self, item):
1834 assert self.active_channel is not None
1835 util.gui_open(self.active_channel.save_dir, gui=self)
1837 def on_open_episode_download_folder(self, unused1=None, unused2=None):
1838 episodes = self.get_selected_episodes()
1839 assert len(episodes) == 1
1840 util.gui_open(episodes[0].parent.save_dir, gui=self)
1842 def on_select_channel_of_episode(self, unused1=None, unused2=None):
1843 episodes = self.get_selected_episodes()
1844 assert len(episodes) == 1
1845 channel = episodes[0].parent
1846 # Focus channel list
1847 self.treeChannels.grab_focus()
1848 # Select channel in list
1849 path = self.podcast_list_model.get_filter_path_from_url(channel.url)
1850 self.treeChannels.set_cursor(path)
1852 def treeview_channels_show_context_menu(self, treeview, event=None):
1853 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1854 if not paths:
1855 return True
1857 # Check for valid channel id, if there's no id then
1858 # assume that it is a proxy channel or equivalent
1859 # and cannot be operated with right click
1860 if self.active_channel.id is None:
1861 return True
1863 if event is None or event.button == 3:
1864 menu = Gtk.Menu()
1866 item = Gtk.ImageMenuItem(_('Update podcast'))
1867 item.set_image(Gtk.Image.new_from_icon_name('view-refresh', Gtk.IconSize.MENU))
1868 item.set_action_name('win.updateChannel')
1869 menu.append(item)
1871 menu.append(Gtk.SeparatorMenuItem())
1873 item = Gtk.MenuItem(_('Open download folder'))
1874 item.connect('activate', self.on_open_download_folder)
1875 menu.append(item)
1877 menu.append(Gtk.SeparatorMenuItem())
1879 item = Gtk.MenuItem(_('Mark episodes as old'))
1880 item.connect('activate', self.on_mark_episodes_as_old)
1881 menu.append(item)
1883 item = Gtk.CheckMenuItem(_('Archive'))
1884 item.set_active(self.active_channel.auto_archive_episodes)
1885 item.connect('activate', self.on_channel_toggle_lock_activate)
1886 menu.append(item)
1888 item = Gtk.ImageMenuItem(_('Refresh image'))
1889 item.connect('activate', self.on_itemRefreshCover_activate)
1890 menu.append(item)
1892 item = Gtk.ImageMenuItem(_('Delete podcast'))
1893 item.set_image(Gtk.Image.new_from_icon_name('edit-delete', Gtk.IconSize.MENU))
1894 item.connect('activate', self.on_itemRemoveChannel_activate)
1895 menu.append(item)
1897 result = gpodder.user_extensions.on_channel_context_menu(self.active_channel)
1898 if result:
1899 menu.append(Gtk.SeparatorMenuItem())
1900 for label, callback in result:
1901 item = Gtk.MenuItem(label)
1902 if callback:
1903 item.connect('activate', lambda item, callback: callback(self.active_channel), callback)
1904 else:
1905 item.set_sensitive(False)
1906 menu.append(item)
1908 menu.append(Gtk.SeparatorMenuItem())
1910 item = Gtk.ImageMenuItem(_('Podcast settings'))
1911 item.set_image(Gtk.Image.new_from_icon_name('document-properties', Gtk.IconSize.MENU))
1912 item.set_action_name('win.editChannel')
1913 menu.append(item)
1915 menu.attach_to_widget(treeview)
1916 menu.show_all()
1917 # Disable tooltips while we are showing the menu, so
1918 # the tooltip will not appear over the menu
1919 self.treeview_allow_tooltips(self.treeChannels, False)
1920 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1922 if event is None:
1923 func = TreeViewHelper.make_popup_position_func(treeview)
1924 menu.popup(None, None, func, None, 3, Gtk.get_current_event_time())
1925 else:
1926 menu.popup(None, None, None, None, event.button, event.time)
1928 return True
1930 def cover_download_finished(self, channel, pixbuf):
1932 The Cover Downloader calls this when it has finished
1933 downloading (or registering, if already downloaded)
1934 a new channel cover, which is ready for displaying.
1936 util.idle_add(self.podcast_list_model.add_cover_by_channel,
1937 channel, pixbuf)
1939 @staticmethod
1940 def build_filename(filename, extension):
1941 filename, extension = util.sanitize_filename_ext(
1942 filename,
1943 extension,
1944 PodcastEpisode.MAX_FILENAME_LENGTH,
1945 PodcastEpisode.MAX_FILENAME_WITH_EXT_LENGTH)
1946 if not filename.endswith(extension):
1947 filename += extension
1948 return filename
1950 def save_episodes_as_file(self, episodes):
1951 def do_save_episode(copy_from, copy_to):
1952 if os.path.exists(copy_to):
1953 logger.warning(copy_from)
1954 logger.warning(copy_to)
1955 title = _('File already exists')
1956 d = {'filename': os.path.basename(copy_to)}
1957 message = _('A file named "%(filename)s" already exists. Do you want to replace it?') % d
1958 if not self.show_confirmation(message, title):
1959 return
1960 try:
1961 shutil.copyfile(copy_from, copy_to)
1962 except (OSError, IOError) as e:
1963 logger.warning('Error copying from %s to %s: %r', copy_from, copy_to, e, exc_info=True)
1964 folder, filename = os.path.split(copy_to)
1965 # Remove characters not supported by VFAT (#282)
1966 new_filename = re.sub(r"[\"*/:<>?\\|]", "_", filename)
1967 destination = os.path.join(folder, new_filename)
1968 if (copy_to != destination):
1969 shutil.copyfile(copy_from, destination)
1970 else:
1971 raise
1973 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1974 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1975 allRemainingDefault = False
1976 remaining = len(episodes)
1977 dialog = gPodderExportToLocalFolder(self.main_window,
1978 _config=self.config)
1979 for episode in episodes:
1980 remaining -= 1
1981 if episode.was_downloaded(and_exists=True):
1982 copy_from = episode.local_filename(create=False)
1983 assert copy_from is not None
1985 base, extension = os.path.splitext(copy_from)
1986 filename = self.build_filename(episode.sync_filename(), extension)
1988 try:
1989 if allRemainingDefault:
1990 do_save_episode(copy_from, os.path.join(folder, filename))
1991 else:
1992 (notCancelled, folder, dest_path, allRemainingDefault) = dialog.save_as(folder, filename, remaining)
1993 if notCancelled:
1994 do_save_episode(copy_from, dest_path)
1995 else:
1996 break
1997 except (OSError, IOError) as e:
1998 if remaining:
1999 msg = _('Error saving to local folder: %(error)r.\n'
2000 'Would you like to continue?') % dict(error=e)
2001 if not self.show_confirmation(msg, _('Error saving to local folder')):
2002 logger.warning("Save to Local Folder cancelled following error")
2003 break
2004 else:
2005 self.notification(_('Error saving to local folder: %(error)r') % dict(error=e),
2006 _('Error saving to local folder'), important=True)
2008 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
2010 def copy_episodes_bluetooth(self, episodes):
2011 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
2013 def convert_and_send_thread(episode):
2014 for episode in episodes:
2015 filename = episode.local_filename(create=False)
2016 assert filename is not None
2017 (base, ext) = os.path.splitext(filename)
2018 destfile = self.build_filename(episode.sync_filename(), ext)
2019 destfile = os.path.join(tempfile.gettempdir(), destfile)
2021 try:
2022 shutil.copyfile(filename, destfile)
2023 util.bluetooth_send_file(destfile)
2024 except:
2025 logger.error('Cannot copy "%s" to "%s".', filename, destfile)
2026 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
2028 util.delete_file(destfile)
2030 util.run_in_background(lambda: convert_and_send_thread(episodes_to_copy))
2032 def _add_sub_menu(self, menu, label):
2033 root_item = Gtk.MenuItem(label)
2034 menu.append(root_item)
2035 sub_menu = Gtk.Menu()
2036 root_item.set_submenu(sub_menu)
2037 return sub_menu
2039 def _submenu_item_activate_hack(self, item, callback, *args):
2040 # See http://stackoverflow.com/questions/5221326/submenu-item-does-not-call-function-with-working-solution
2041 # Note that we can't just call the callback on button-press-event, as
2042 # it might be blocking (see http://gpodder.org/bug/1778), so we run
2043 # this in the GUI thread at a later point in time (util.idle_add).
2044 # Also, we also have to connect to the activate signal, as this is the
2045 # only signal that is fired when keyboard navigation is used.
2047 # It can happen that both (button-release-event and activate) signals
2048 # are fired, and we must avoid calling the callback twice. We do this
2049 # using a semaphore and only acquiring (but never releasing) it, making
2050 # sure that the util.idle_add() call below is only ever called once.
2051 only_once = threading.Semaphore(1)
2053 def handle_event(item, event=None):
2054 if only_once.acquire(False):
2055 util.idle_add(callback, *args)
2057 item.connect('button-press-event', handle_event)
2058 item.connect('activate', handle_event)
2060 def treeview_available_show_context_menu(self, treeview, event=None):
2061 model, paths = self.treeview_handle_context_menu_click(treeview, event)
2062 if not paths:
2063 return not treeview.is_rubber_banding_active()
2065 if event is None or event.button == 3:
2066 episodes = self.get_selected_episodes()
2067 any_locked = any(e.archive for e in episodes)
2068 any_new = any(e.is_new and e.state != gpodder.STATE_DELETED for e in episodes)
2069 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
2070 downloading = any(e.downloading for e in episodes)
2072 menu = Gtk.Menu()
2074 (open_instead_of_play, can_play, can_preview, can_download, can_pause,
2075 can_cancel, can_delete, can_lock) = self.play_or_download()
2077 if open_instead_of_play:
2078 item = Gtk.ImageMenuItem(_('Open'))
2079 item.set_image(Gtk.Image.new_from_icon_name('document-open', Gtk.IconSize.MENU))
2080 else:
2081 if downloaded:
2082 item = Gtk.ImageMenuItem(_('Play'))
2083 elif can_preview:
2084 item = Gtk.ImageMenuItem(_('Preview'))
2085 else:
2086 item = Gtk.ImageMenuItem(_('Stream'))
2087 item.set_image(Gtk.Image.new_from_icon_name('media-playback-start', Gtk.IconSize.MENU))
2089 item.set_sensitive(can_play)
2090 item.connect('activate', self.on_playback_selected_episodes)
2091 menu.append(item)
2093 if can_download:
2094 item = Gtk.ImageMenuItem(_('Download'))
2095 item.set_image(Gtk.Image.new_from_icon_name('document-save', Gtk.IconSize.MENU))
2096 item.set_action_name('win.download')
2097 menu.append(item)
2098 if can_pause:
2099 item = Gtk.ImageMenuItem(_('Pause'))
2100 item.set_image(Gtk.Image.new_from_icon_name('media-playback-pause', Gtk.IconSize.MENU))
2101 item.set_action_name('win.pause')
2102 menu.append(item)
2103 if can_cancel:
2104 item = Gtk.ImageMenuItem.new_with_mnemonic(_('_Cancel'))
2105 item.set_action_name('win.cancel')
2106 menu.append(item)
2108 item = Gtk.ImageMenuItem.new_with_mnemonic(_('_Delete'))
2109 item.set_image(Gtk.Image.new_from_icon_name('edit-delete', Gtk.IconSize.MENU))
2110 item.set_action_name('win.delete')
2111 menu.append(item)
2113 result = gpodder.user_extensions.on_episodes_context_menu(episodes)
2114 if result:
2115 menu.append(Gtk.SeparatorMenuItem())
2116 submenus = {}
2117 for label, callback in result:
2118 key, sep, title = label.rpartition('/')
2119 item = Gtk.ImageMenuItem(title)
2120 if callback:
2121 self._submenu_item_activate_hack(item, callback, episodes)
2122 else:
2123 item.set_sensitive(False)
2124 if key:
2125 if key not in submenus:
2126 sub_menu = self._add_sub_menu(menu, key)
2127 submenus[key] = sub_menu
2128 else:
2129 sub_menu = submenus[key]
2130 sub_menu.append(item)
2131 else:
2132 menu.append(item)
2134 # Ok, this probably makes sense to only display for downloaded files
2135 if downloaded:
2136 menu.append(Gtk.SeparatorMenuItem())
2137 share_menu = self._add_sub_menu(menu, _('Send to'))
2139 item = Gtk.ImageMenuItem(_('Local folder'))
2140 item.set_image(Gtk.Image.new_from_icon_name('folder', Gtk.IconSize.MENU))
2141 self._submenu_item_activate_hack(item, self.save_episodes_as_file, episodes)
2142 share_menu.append(item)
2143 if self.bluetooth_available:
2144 item = Gtk.ImageMenuItem(_('Bluetooth device'))
2145 item.set_image(Gtk.Image.new_from_icon_name('bluetooth', Gtk.IconSize.MENU))
2146 self._submenu_item_activate_hack(item, self.copy_episodes_bluetooth, episodes)
2147 share_menu.append(item)
2149 menu.append(Gtk.SeparatorMenuItem())
2151 item = Gtk.CheckMenuItem(_('New'))
2152 item.set_active(any_new)
2153 if any_new:
2154 item.connect('activate', lambda w: self.mark_selected_episodes_old())
2155 else:
2156 item.connect('activate', lambda w: self.mark_selected_episodes_new())
2157 menu.append(item)
2159 if can_lock:
2160 item = Gtk.CheckMenuItem(_('Archive'))
2161 item.set_active(any_locked)
2162 item.connect('activate',
2163 lambda w: self.on_item_toggle_lock_activate(
2164 w, False, not any_locked))
2165 menu.append(item)
2167 menu.append(Gtk.SeparatorMenuItem())
2168 # Single item, add episode information menu item
2169 item = Gtk.ImageMenuItem(_('Episode details'))
2170 item.set_image(Gtk.Image.new_from_icon_name('dialog-information',
2171 Gtk.IconSize.MENU))
2172 item.set_action_name('win.toggleShownotes')
2173 menu.append(item)
2175 if len(self.get_selected_episodes()) == 1:
2176 item = Gtk.MenuItem(_('Open download folder'))
2177 item.connect('activate', self.on_open_episode_download_folder)
2178 menu.append(item)
2180 item = Gtk.MenuItem(_('Select channel'))
2181 item.connect('activate', self.on_select_channel_of_episode)
2182 menu.append(item)
2184 menu.attach_to_widget(treeview)
2185 menu.show_all()
2186 # Disable tooltips while we are showing the menu, so
2187 # the tooltip will not appear over the menu
2188 self.treeview_allow_tooltips(self.treeAvailable, False)
2189 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
2190 if event is None:
2191 func = TreeViewHelper.make_popup_position_func(treeview)
2192 menu.popup(None, None, func, None, 3, Gtk.get_current_event_time())
2193 else:
2194 menu.popup(None, None, None, None, event.button, event.time)
2196 return True
2198 def set_episode_actions(self, open_instead_of_play=False, can_play=False, can_download=False, can_pause=False, can_cancel=False,
2199 can_delete=False, can_lock=False, is_episode_selected=False):
2200 episodes = self.get_selected_episodes() if is_episode_selected else []
2202 # play icon and label
2203 if open_instead_of_play or not is_episode_selected:
2204 self.toolPlay.set_icon_name('document-open-symbolic')
2205 self.toolPlay.set_label(_('Open'))
2206 else:
2207 self.toolPlay.set_icon_name('media-playback-start-symbolic')
2209 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
2210 downloading = any(e.downloading for e in episodes)
2212 if downloaded:
2213 self.toolPlay.set_label(_('Play'))
2214 elif downloading:
2215 self.toolPlay.set_label(_('Preview'))
2216 else:
2217 self.toolPlay.set_label(_('Stream'))
2219 # toolbar
2220 self.toolPlay.set_sensitive(can_play)
2221 self.toolDownload.set_sensitive(can_download)
2222 self.toolPause.set_sensitive(can_pause)
2223 self.toolCancel.set_sensitive(can_cancel)
2225 # Episodes menu
2226 self.play_action.set_enabled(can_play and not open_instead_of_play)
2227 self.open_action.set_enabled(can_play and open_instead_of_play)
2228 self.download_action.set_enabled(can_download)
2229 self.pause_action.set_enabled(can_pause)
2230 self.cancel_action.set_enabled(can_cancel)
2231 self.delete_action.set_enabled(can_delete)
2232 self.toggle_episode_new_action.set_enabled(is_episode_selected)
2233 self.toggle_episode_lock_action.set_enabled(can_lock)
2234 self.open_episode_download_folder_action.set_enabled(len(episodes) == 1)
2235 self.select_channel_of_episode_action.set_enabled(len(episodes) == 1)
2237 def set_title(self, new_title):
2238 self.default_title = new_title
2239 self.gPodder.set_title(new_title)
2241 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2243 Updates the status icons in the episode list.
2245 If urls is given, it should be a list of URLs
2246 of episodes that should be updated.
2248 If urls is None, set ONE OF selected, all to
2249 True (the former updates just the selected
2250 episodes and the latter updates all episodes).
2252 self.episode_list_model.cache_config(self.config)
2254 if urls is not None:
2255 # We have a list of URLs to walk through
2256 self.episode_list_model.update_by_urls(urls)
2257 elif selected and not all:
2258 # We should update all selected episodes
2259 selection = self.treeAvailable.get_selection()
2260 model, paths = selection.get_selected_rows()
2261 for path in reversed(paths):
2262 iter = model.get_iter(path)
2263 self.episode_list_model.update_by_filter_iter(iter)
2264 elif all and not selected:
2265 # We update all (even the filter-hidden) episodes
2266 self.episode_list_model.update_all()
2267 else:
2268 # Wrong/invalid call - have to specify at least one parameter
2269 raise ValueError('Invalid call to update_episode_list_icons')
2271 def episode_list_status_changed(self, episodes):
2272 self.update_episode_list_icons(set(e.url for e in episodes))
2273 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2274 self.db.commit()
2276 def playback_episodes_for_real(self, episodes):
2277 groups = collections.defaultdict(list)
2278 for episode in episodes:
2279 episode._download_error = None
2281 if episode.download_task is not None and episode.download_task.status == episode.download_task.FAILED:
2282 if not episode.can_stream(self.config):
2283 # Do not cancel failed tasks that can not be streamed
2284 continue
2285 # Cancel failed task and remove from progress list
2286 episode.download_task.cancel()
2287 self.cleanup_downloads()
2289 player = episode.get_player(self.config)
2291 try:
2292 allow_partial = (player != 'default')
2293 filename = episode.get_playback_url(self.config, allow_partial)
2294 except Exception as e:
2295 episode._download_error = str(e)
2296 continue
2298 # Mark episode as played in the database
2299 episode.playback_mark()
2300 self.mygpo_client.on_playback([episode])
2302 # Determine the playback resume position - if the file
2303 # was played 100%, we simply start from the beginning
2304 resume_position = episode.current_position
2305 if resume_position == episode.total_time:
2306 resume_position = 0
2308 # If Panucci is configured, use D-Bus to call it
2309 if player == 'panucci':
2310 try:
2311 PANUCCI_NAME = 'org.panucci.panucciInterface'
2312 PANUCCI_PATH = '/panucciInterface'
2313 PANUCCI_INTF = 'org.panucci.panucciInterface'
2314 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2315 i = dbus.Interface(o, PANUCCI_INTF)
2317 def on_reply(*args):
2318 pass
2320 def error_handler(filename, err):
2321 logger.error('Exception in D-Bus call: %s', str(err))
2323 # Fallback: use the command line client
2324 for command in util.format_desktop_command('panucci',
2325 [filename]):
2326 logger.info('Executing: %s', repr(command))
2327 util.Popen(command, close_fds=True)
2329 def on_error(err):
2330 return error_handler(filename, err)
2332 # This method only exists in Panucci > 0.9 ('new Panucci')
2333 i.playback_from(filename, resume_position,
2334 reply_handler=on_reply, error_handler=on_error)
2336 continue # This file was handled by the D-Bus call
2337 except Exception as e:
2338 logger.error('Calling Panucci using D-Bus', exc_info=True)
2340 groups[player].append(filename)
2342 # Open episodes with system default player
2343 if 'default' in groups:
2344 for filename in groups['default']:
2345 logger.debug('Opening with system default: %s', filename)
2346 util.gui_open(filename, gui=self)
2347 del groups['default']
2349 # For each type now, go and create play commands
2350 for group in groups:
2351 for command in util.format_desktop_command(group, groups[group], resume_position):
2352 logger.debug('Executing: %s', repr(command))
2353 util.Popen(command, close_fds=True)
2355 # Persist episode status changes to the database
2356 self.db.commit()
2358 # Flush updated episode status
2359 if self.mygpo_client.can_access_webservice():
2360 self.mygpo_client.flush()
2362 def playback_episodes(self, episodes):
2363 # We need to create a list, because we run through it more than once
2364 episodes = list(Model.sort_episodes_by_pubdate(e for e in episodes if e.can_play(self.config)))
2366 try:
2367 self.playback_episodes_for_real(episodes)
2368 except Exception as e:
2369 logger.error('Error in playback!', exc_info=True)
2370 self.show_message(_('Please check your media player settings in the preferences dialog.'),
2371 _('Error opening player'))
2373 self.episode_list_status_changed(episodes)
2375 def play_or_download(self, current_page=None):
2376 if current_page is None:
2377 current_page = self.wNotebook.get_current_page()
2378 if current_page == 0:
2379 (open_instead_of_play, can_play, can_preview, can_download,
2380 can_pause, can_cancel, can_delete, can_lock) = (False,) * 8
2382 selection = self.treeAvailable.get_selection()
2383 if selection.count_selected_rows() > 0:
2384 (model, paths) = selection.get_selected_rows()
2386 for path in paths:
2387 try:
2388 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2389 if episode is None:
2390 logger.info('Invalid episode at path %s', str(path))
2391 continue
2392 except TypeError as te:
2393 logger.error('Invalid episode at path %s', str(path))
2394 continue
2396 # These values should only ever be set, never unset them once set.
2397 # Actions filter episodes using these methods.
2398 open_instead_of_play = open_instead_of_play or episode.file_type() not in ('audio', 'video')
2399 can_play = can_play or episode.can_play(self.config)
2400 can_preview = can_preview or episode.can_preview()
2401 can_download = can_download or episode.can_download()
2402 can_pause = can_pause or episode.can_pause()
2403 can_cancel = can_cancel or episode.can_cancel()
2404 can_delete = can_delete or episode.can_delete()
2405 can_lock = can_lock or episode.can_lock()
2407 self.set_episode_actions(open_instead_of_play, can_play, can_download, can_pause, can_cancel, can_delete, can_lock,
2408 selection.count_selected_rows() > 0)
2410 return (open_instead_of_play, can_play, can_preview, can_download,
2411 can_pause, can_cancel, can_delete, can_lock)
2412 else:
2413 (can_queue, can_pause, can_cancel, can_remove) = (False,) * 4
2415 selection = self.treeDownloads.get_selection()
2416 if selection.count_selected_rows() > 0:
2417 (model, paths) = selection.get_selected_rows()
2419 for path in paths:
2420 try:
2421 task = model.get_value(model.get_iter(path), 0)
2422 if task is None:
2423 logger.info('Invalid task at path %s', str(path))
2424 continue
2425 except TypeError as te:
2426 logger.error('Invalid task at path %s', str(path))
2427 continue
2429 # These values should only ever be set, never unset them once set.
2430 # Actions filter tasks using these methods.
2431 can_queue = can_queue or task.can_queue()
2432 can_pause = can_pause or task.can_pause()
2433 can_cancel = can_cancel or task.can_cancel()
2434 can_remove = can_remove or task.can_remove()
2436 self.set_episode_actions(False, False, can_queue, can_pause, can_cancel, can_remove, False, False)
2438 return (False, False, False, can_queue, can_pause, can_cancel,
2439 can_remove, False)
2441 def on_cbMaxDownloads_toggled(self, widget, *args):
2442 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2444 def on_cbLimitDownloads_toggled(self, widget, *args):
2445 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2447 def episode_new_status_changed(self, urls):
2448 self.update_podcast_list_model()
2449 self.update_episode_list_icons(urls)
2451 def refresh_episode_dates(self):
2452 t = time.localtime()
2453 current_day = t[:3]
2454 if self.last_episode_date_refresh is not None and self.last_episode_date_refresh != current_day:
2455 # update all episodes in current view
2456 for row in self.episode_list_model:
2457 row[EpisodeListModel.C_PUBLISHED_TEXT] = row[EpisodeListModel.C_EPISODE].cute_pubdate()
2459 self.last_episode_date_refresh = current_day
2461 remaining_seconds = 86400 - 3600 * t.tm_hour - 60 * t.tm_min - t.tm_sec
2462 if remaining_seconds > 3600:
2463 # timeout an hour early in the event daylight savings changes the clock forward
2464 remaining_seconds = remaining_seconds - 3600
2465 util.idle_timeout_add(remaining_seconds * 1000, self.refresh_episode_dates)
2467 def update_podcast_list_model(self, urls=None, selected=False, select_url=None,
2468 sections_changed=False):
2469 """Update the podcast list treeview model
2471 If urls is given, it should list the URLs of each
2472 podcast that has to be updated in the list.
2474 If selected is True, only update the model contents
2475 for the currently-selected podcast - nothing more.
2477 The caller can optionally specify "select_url",
2478 which is the URL of the podcast that is to be
2479 selected in the list after the update is complete.
2480 This only works if the podcast list has to be
2481 reloaded; i.e. something has been added or removed
2482 since the last update of the podcast list).
2484 selection = self.treeChannels.get_selection()
2485 model, iter = selection.get_selected()
2487 def is_section(r):
2488 return r[PodcastListModel.C_URL] == '-'
2490 def is_separator(r):
2491 return r[PodcastListModel.C_SEPARATOR]
2493 sections_active = any(is_section(x) for x in self.podcast_list_model)
2495 if self.config.ui.gtk.podcast_list.all_episodes:
2496 # Update "all episodes" view in any case (if enabled)
2497 self.podcast_list_model.update_first_row()
2498 # List model length minus 1, because of "All"
2499 list_model_length = len(self.podcast_list_model) - 1
2500 else:
2501 list_model_length = len(self.podcast_list_model)
2503 force_update = (sections_active != self.config.ui.gtk.podcast_list.sections
2504 or sections_changed)
2506 # Filter items in the list model that are not podcasts, so we get the
2507 # correct podcast list count (ignore section headers and separators)
2509 def is_not_podcast(r):
2510 return is_section(r) or is_separator(r)
2512 list_model_length -= len(list(filter(is_not_podcast, self.podcast_list_model)))
2514 if selected and not force_update:
2515 # very cheap! only update selected channel
2516 if iter is not None:
2517 # If we have selected the "all episodes" view, we have
2518 # to update all channels for selected episodes:
2519 if self.config.ui.gtk.podcast_list.all_episodes and \
2520 self.podcast_list_model.iter_is_first_row(iter):
2521 urls = self.get_podcast_urls_from_selected_episodes()
2522 self.podcast_list_model.update_by_urls(urls)
2523 else:
2524 # Otherwise just update the selected row (a podcast)
2525 self.podcast_list_model.update_by_filter_iter(iter)
2527 if self.config.ui.gtk.podcast_list.sections:
2528 self.podcast_list_model.update_sections()
2529 elif list_model_length == len(self.channels) and not force_update:
2530 # we can keep the model, but have to update some
2531 if urls is None:
2532 # still cheaper than reloading the whole list
2533 self.podcast_list_model.update_all()
2534 else:
2535 # ok, we got a bunch of urls to update
2536 self.podcast_list_model.update_by_urls(urls)
2537 if self.config.ui.gtk.podcast_list.sections:
2538 self.podcast_list_model.update_sections()
2539 else:
2540 if model and iter and select_url is None:
2541 # Get the URL of the currently-selected podcast
2542 select_url = model.get_value(iter, PodcastListModel.C_URL)
2544 # Update the podcast list model with new channels
2545 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2547 try:
2548 selected_iter = model.get_iter_first()
2549 # Find the previously-selected URL in the new
2550 # model if we have an URL (else select first)
2551 if select_url is not None:
2552 pos = model.get_iter_first()
2553 while pos is not None:
2554 url = model.get_value(pos, PodcastListModel.C_URL)
2555 if url == select_url:
2556 selected_iter = pos
2557 break
2558 pos = model.iter_next(pos)
2560 if selected_iter is not None:
2561 selection.select_iter(selected_iter)
2562 self.on_treeChannels_cursor_changed(self.treeChannels)
2563 except:
2564 logger.error('Cannot select podcast in list', exc_info=True)
2566 def on_episode_list_filter_changed(self, has_episodes):
2567 self.play_or_download()
2569 def update_episode_list_model(self):
2570 if self.channels and self.active_channel is not None:
2571 self.treeAvailable.get_selection().unselect_all()
2572 self.treeAvailable.scroll_to_point(0, 0)
2574 self.episode_list_model.cache_config(self.config)
2576 with self.treeAvailable.get_selection().handler_block(self.episode_selection_handler_id):
2577 # have to block the on_episode_list_selection_changed handler because
2578 # when selecting any channel from All Episodes, on_episode_list_selection_changed
2579 # is called once per episode (4k time in my case), causing episode shownotes
2580 # to be updated as many time, resulting in UI freeze for 10 seconds.
2581 self.episode_list_model.replace_from_channel(self.active_channel)
2582 else:
2583 self.episode_list_model.clear()
2585 @dbus.service.method(gpodder.dbus_interface)
2586 def offer_new_episodes(self, channels=None):
2587 new_episodes = self.get_new_episodes(channels)
2588 if new_episodes:
2589 self.new_episodes_show(new_episodes)
2590 return True
2591 return False
2593 def add_podcast_list(self, podcasts, auth_tokens=None):
2594 """Subscribe to a list of podcast given (title, url) pairs
2596 If auth_tokens is given, it should be a dictionary
2597 mapping URLs to (username, password) tuples."""
2599 if auth_tokens is None:
2600 auth_tokens = {}
2602 existing_urls = set(podcast.url for podcast in self.channels)
2604 # For a given URL, the desired title (or None)
2605 title_for_url = {}
2607 # Sort and split the URL list into five buckets
2608 queued, failed, existing, worked, authreq = [], [], [], [], []
2609 for input_title, input_url in podcasts:
2610 url = util.normalize_feed_url(input_url)
2612 # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
2613 url = youtube.parse_youtube_url(url)
2615 if url is None:
2616 # Fail this one because the URL is not valid
2617 failed.append(input_url)
2618 elif url in existing_urls:
2619 # A podcast already exists in the list for this URL
2620 existing.append(url)
2621 # XXX: Should we try to update the title of the existing
2622 # subscription from input_title here if it is different?
2623 else:
2624 # This URL has survived the first round - queue for add
2625 title_for_url[url] = input_title
2626 queued.append(url)
2627 if url != input_url and input_url in auth_tokens:
2628 auth_tokens[url] = auth_tokens[input_url]
2630 error_messages = {}
2631 redirections = {}
2633 progress = ProgressIndicator(_('Adding podcasts'),
2634 _('Please wait while episode information is downloaded.'),
2635 parent=self.get_dialog_parent())
2637 def on_after_update():
2638 progress.on_finished()
2640 # Report already-existing subscriptions to the user
2641 if existing:
2642 title = _('Existing subscriptions skipped')
2643 message = _('You are already subscribed to these podcasts:') \
2644 + '\n\n' + '\n'.join(html.escape(url) for url in existing)
2645 self.show_message(message, title, widget=self.treeChannels)
2647 # Report subscriptions that require authentication
2648 retry_podcasts = {}
2649 if authreq:
2650 for url in authreq:
2651 title = _('Podcast requires authentication')
2652 message = _('Please login to %s:') % (html.escape(url),)
2653 success, auth_tokens = self.show_login_dialog(title, message)
2654 if success:
2655 retry_podcasts[url] = auth_tokens
2656 else:
2657 # Stop asking the user for more login data
2658 retry_podcasts = {}
2659 for url in authreq:
2660 error_messages[url] = _('Authentication failed')
2661 failed.append(url)
2662 break
2664 # Report website redirections
2665 for url in redirections:
2666 title = _('Website redirection detected')
2667 message = _('The URL %(url)s redirects to %(target)s.') \
2668 + '\n\n' + _('Do you want to visit the website now?')
2669 message = message % {'url': url, 'target': redirections[url]}
2670 if self.show_confirmation(message, title):
2671 util.open_website(url)
2672 else:
2673 break
2675 # Report failed subscriptions to the user
2676 if failed:
2677 title = _('Could not add some podcasts')
2678 message = _('Some podcasts could not be added to your list:')
2679 details = '\n\n'.join('<b>{}</b>:\n{}'.format(html.escape(url),
2680 html.escape(error_messages.get(url, _('Unknown')))) for url in failed)
2681 self.show_message_details(title, message, details)
2683 # Upload subscription changes to gpodder.net
2684 self.mygpo_client.on_subscribe(worked)
2686 # Fix URLs if mygpo has rewritten them
2687 self.rewrite_urls_mygpo()
2689 # If only one podcast was added, select it after the update
2690 if len(worked) == 1:
2691 url = worked[0]
2692 else:
2693 url = None
2695 # Update the list of subscribed podcasts
2696 self.update_podcast_list_model(select_url=url)
2698 # If we have authentication data to retry, do so here
2699 if retry_podcasts:
2700 podcasts = [(title_for_url.get(url), url)
2701 for url in list(retry_podcasts.keys())]
2702 self.add_podcast_list(podcasts, retry_podcasts)
2703 # This will NOT show new episodes for podcasts that have
2704 # been added ("worked"), but it will prevent problems with
2705 # multiple dialogs being open at the same time ;)
2706 return
2708 # Offer to download new episodes
2709 episodes = []
2710 for podcast in self.channels:
2711 if podcast.url in worked:
2712 episodes.extend(podcast.get_all_episodes())
2714 if episodes:
2715 episodes = list(Model.sort_episodes_by_pubdate(episodes,
2716 reverse=True))
2717 self.new_episodes_show(episodes,
2718 selected=[e.check_is_new() for e in episodes])
2720 @util.run_in_background
2721 def thread_proc():
2722 # After the initial sorting and splitting, try all queued podcasts
2723 length = len(queued)
2724 for index, url in enumerate(queued):
2725 title = title_for_url.get(url)
2726 progress.on_progress(float(index) / float(length))
2727 progress.on_message(title or url)
2728 try:
2729 # The URL is valid and does not exist already - subscribe!
2730 channel = self.model.load_podcast(url=url, create=True,
2731 authentication_tokens=auth_tokens.get(url, None),
2732 max_episodes=self.config.limit.episodes)
2734 try:
2735 username, password = util.username_password_from_url(url)
2736 except ValueError as ve:
2737 username, password = (None, None)
2739 if title is not None:
2740 # Prefer title from subscription source (bug 1711)
2741 channel.title = title
2743 if username is not None and channel.auth_username is None and \
2744 password is not None and channel.auth_password is None:
2745 channel.auth_username = username
2746 channel.auth_password = password
2748 channel.save()
2750 self._update_cover(channel)
2751 except feedcore.AuthenticationRequired as e:
2752 # use e.url because there might have been a redirection (#571)
2753 if e.url in auth_tokens:
2754 # Fail for wrong authentication data
2755 error_messages[e.url] = _('Authentication failed')
2756 failed.append(e.url)
2757 else:
2758 # Queue for login dialog later
2759 authreq.append(e.url)
2760 continue
2761 except feedcore.WifiLogin as error:
2762 redirections[url] = error.data
2763 failed.append(url)
2764 error_messages[url] = _('Redirection detected')
2765 continue
2766 except Exception as e:
2767 logger.error('Subscription error: %s', e, exc_info=True)
2768 error_messages[url] = str(e)
2769 failed.append(url)
2770 continue
2772 assert channel is not None
2773 worked.append(channel.url)
2775 util.idle_add(on_after_update)
2777 def find_episode(self, podcast_url, episode_url):
2778 """Find an episode given its podcast and episode URL
2780 The function will return a PodcastEpisode object if
2781 the episode is found, or None if it's not found.
2783 for podcast in self.channels:
2784 if podcast_url == podcast.url:
2785 for episode in podcast.get_all_episodes():
2786 if episode_url == episode.url:
2787 return episode
2789 return None
2791 def process_received_episode_actions(self):
2792 """Process/merge episode actions from gpodder.net
2794 This function will merge all changes received from
2795 the server to the local database and update the
2796 status of the affected episodes as necessary.
2798 indicator = ProgressIndicator(_('Merging episode actions'),
2799 _('Episode actions from gpodder.net are merged.'),
2800 False, self.get_dialog_parent())
2802 Gtk.main_iteration()
2804 self.mygpo_client.process_episode_actions(self.find_episode)
2806 self.db.commit()
2808 indicator.on_finished()
2810 def _update_cover(self, channel):
2811 if channel is not None:
2812 self.cover_downloader.request_cover(channel)
2814 def show_update_feeds_buttons(self):
2815 # Make sure that the buttons for updating feeds
2816 # appear - this should happen after a feed update
2817 self.hboxUpdateFeeds.hide()
2818 if not self.application.want_headerbar:
2819 self.btnUpdateFeeds.show()
2820 self.update_action.set_enabled(True)
2821 self.update_channel_action.set_enabled(True)
2823 def on_btnCancelFeedUpdate_clicked(self, widget):
2824 if not self.feed_cache_update_cancelled:
2825 self.pbFeedUpdate.set_text(_('Cancelling...'))
2826 self.feed_cache_update_cancelled = True
2827 self.btnCancelFeedUpdate.set_sensitive(False)
2828 else:
2829 self.show_update_feeds_buttons()
2831 def update_feed_cache(self, channels=None,
2832 show_new_episodes_dialog=True):
2833 if self.config.check_connection and not util.connection_available():
2834 self.show_message(_('Please connect to a network, then try again.'),
2835 _('No network connection'), important=True)
2836 return
2838 # Fix URLs if mygpo has rewritten them
2839 self.rewrite_urls_mygpo()
2841 if channels is None:
2842 # Only update podcasts for which updates are enabled
2843 channels = [c for c in self.channels if not c.pause_subscription]
2845 self.update_action.set_enabled(False)
2846 self.update_channel_action.set_enabled(False)
2848 self.feed_cache_update_cancelled = False
2849 self.btnCancelFeedUpdate.show()
2850 self.btnCancelFeedUpdate.set_sensitive(True)
2851 self.btnCancelFeedUpdate.set_image(Gtk.Image.new_from_icon_name('process-stop', Gtk.IconSize.BUTTON))
2852 self.hboxUpdateFeeds.show_all()
2853 self.btnUpdateFeeds.hide()
2855 count = len(channels)
2856 text = N_('Updating %(count)d feed...', 'Updating %(count)d feeds...',
2857 count) % {'count': count}
2859 self.pbFeedUpdate.set_text(text)
2860 self.pbFeedUpdate.set_fraction(0)
2862 @util.run_in_background
2863 def update_feed_cache_proc():
2864 updated_channels = []
2865 nr_update_errors = 0
2866 new_episodes = []
2867 for updated, channel in enumerate(channels):
2868 if self.feed_cache_update_cancelled:
2869 break
2871 def indicate_updating_podcast(channel):
2872 d = {'podcast': channel.title, 'position': updated + 1, 'total': count}
2873 progression = _('Updating %(podcast)s (%(position)d/%(total)d)') % d
2874 logger.info(progression)
2875 self.pbFeedUpdate.set_text(progression)
2877 try:
2878 channel._update_error = None
2879 util.idle_add(indicate_updating_podcast, channel)
2880 new_episodes.extend(channel.update(max_episodes=self.config.limit.episodes))
2881 self._update_cover(channel)
2882 except Exception as e:
2883 message = str(e)
2884 if message:
2885 channel._update_error = message
2886 else:
2887 channel._update_error = '?'
2888 nr_update_errors += 1
2889 logger.error('Error updating feed: %s: %s', channel.title, message, exc_info=(e.__class__ not in [
2890 gpodder.feedcore.BadRequest,
2891 gpodder.feedcore.AuthenticationRequired,
2892 gpodder.feedcore.Unsubscribe,
2893 gpodder.feedcore.NotFound,
2894 gpodder.feedcore.InternalServerError,
2895 gpodder.feedcore.UnknownStatusCode,
2896 requests.exceptions.ConnectionError,
2897 requests.exceptions.RetryError,
2898 urllib3.exceptions.MaxRetryError,
2899 urllib3.exceptions.ReadTimeoutError,
2902 updated_channels.append(channel)
2904 def update_progress(channel):
2905 self.update_podcast_list_model([channel.url])
2907 # If the currently-viewed podcast is updated, reload episodes
2908 if self.active_channel is not None and \
2909 self.active_channel == channel:
2910 logger.debug('Updated channel is active, updating UI')
2911 self.update_episode_list_model()
2913 self.pbFeedUpdate.set_fraction(float(updated + 1) / float(count))
2915 util.idle_add(update_progress, channel)
2917 if nr_update_errors > 0:
2918 self.notification(
2919 N_('%(count)d channel failed to update',
2920 '%(count)d channels failed to update',
2921 nr_update_errors) % {'count': nr_update_errors},
2922 _('Error while updating feeds'), widget=self.treeChannels)
2924 def update_feed_cache_finish_callback(new_episodes):
2925 # Process received episode actions for all updated URLs
2926 self.process_received_episode_actions()
2928 # If we are currently viewing "All episodes" or a section, update its episode list now
2929 if self.active_channel is not None and \
2930 isinstance(self.active_channel, PodcastChannelProxy):
2931 self.update_episode_list_model()
2933 if self.feed_cache_update_cancelled:
2934 # The user decided to abort the feed update
2935 self.show_update_feeds_buttons()
2937 # The filter extension can mark newly added episodes as old,
2938 # so take only episodes marked as new.
2939 episodes = ((e for e in new_episodes if e.check_is_new())
2940 if self.config.ui.gtk.only_added_are_new
2941 else self.get_new_episodes([c for c in updated_channels]))
2943 if self.config.downloads.chronological_order:
2944 # download older episodes first
2945 episodes = list(Model.sort_episodes_by_pubdate(episodes))
2947 # Remove episodes without downloadable content
2948 downloadable_episodes = [e for e in episodes if e.url]
2950 if not downloadable_episodes:
2951 # Nothing new here - but inform the user
2952 self.pbFeedUpdate.set_fraction(1.0)
2953 self.pbFeedUpdate.set_text(
2954 _('No new episodes with downloadable content') if episodes else _('No new episodes'))
2955 self.feed_cache_update_cancelled = True
2956 self.btnCancelFeedUpdate.show()
2957 self.btnCancelFeedUpdate.set_sensitive(True)
2958 self.update_action.set_enabled(True)
2959 self.btnCancelFeedUpdate.set_image(Gtk.Image.new_from_icon_name('edit-clear', Gtk.IconSize.BUTTON))
2960 else:
2961 episodes = downloadable_episodes
2963 count = len(episodes)
2964 # New episodes are available
2965 self.pbFeedUpdate.set_fraction(1.0)
2967 if self.config.ui.gtk.new_episodes == 'download':
2968 self.download_episode_list(episodes)
2969 title = N_('Downloading %(count)d new episode.',
2970 'Downloading %(count)d new episodes.',
2971 count) % {'count': count}
2972 self.show_message(title, _('New episodes available'))
2973 elif self.config.ui.gtk.new_episodes == 'queue':
2974 self.download_episode_list_paused(episodes)
2975 title = N_(
2976 '%(count)d new episode added to download list.',
2977 '%(count)d new episodes added to download list.',
2978 count) % {'count': count}
2979 self.show_message(title, _('New episodes available'))
2980 else:
2981 if (show_new_episodes_dialog
2982 and self.config.ui.gtk.new_episodes == 'show'):
2983 self.new_episodes_show(episodes, notification=True)
2984 else: # !show_new_episodes_dialog or ui.gtk.new_episodes == 'ignore'
2985 message = N_('%(count)d new episode available',
2986 '%(count)d new episodes available',
2987 count) % {'count': count}
2988 self.pbFeedUpdate.set_text(message)
2990 self.show_update_feeds_buttons()
2992 util.idle_add(update_feed_cache_finish_callback, new_episodes)
2994 def on_gPodder_delete_event(self, *args):
2995 """Called when the GUI wants to close the window
2996 Displays a confirmation dialog (and closes/hides gPodder)
2999 if self.confirm_quit():
3000 self.close_gpodder()
3002 return True
3004 def confirm_quit(self):
3005 """Called when the GUI wants to close the window
3006 Displays a confirmation dialog
3009 downloading = self.download_status_model.are_downloads_in_progress()
3011 if downloading:
3012 dialog = Gtk.MessageDialog(self.gPodder, Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE)
3013 dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
3014 quit_button = dialog.add_button(_('_Quit'), Gtk.ResponseType.CLOSE)
3016 title = _('Quit gPodder')
3017 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
3019 dialog.set_title(title)
3020 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
3022 quit_button.grab_focus()
3023 result = dialog.run()
3024 dialog.destroy()
3026 return result == Gtk.ResponseType.CLOSE
3027 else:
3028 return True
3030 def close_gpodder(self):
3031 """ clean everything and exit properly
3033 # Cancel any running background updates of the episode list model
3034 self.episode_list_model.background_update = None
3036 self.gPodder.hide()
3038 # Notify all tasks to to carry out any clean-up actions
3039 self.download_status_model.tell_all_tasks_to_quit()
3041 while Gtk.events_pending() or self.download_queue_manager.has_workers():
3042 Gtk.main_iteration()
3044 self.core.shutdown()
3046 self.application.remove_window(self.gPodder)
3048 def format_delete_message(self, message, things, max_things, max_length):
3049 titles = []
3050 for index, thing in zip(range(max_things), things):
3051 titles.append('• ' + (html.escape(thing.title if len(thing.title) <= max_length else thing.title[:max_length] + '…')))
3052 if len(things) > max_things:
3053 titles.append('+%(count)d more…' % {'count': len(things) - max_things})
3054 return '\n'.join(titles) + '\n\n' + message
3056 def delete_episode_list(self, episodes, confirm=True, callback=None):
3057 if self.wNotebook.get_current_page() > 0:
3058 selection = self.treeDownloads.get_selection()
3059 (model, paths) = selection.get_selected_rows()
3060 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
3061 model.get_value(model.get_iter(path),
3062 DownloadStatusModel.C_TASK)) for path in paths]
3063 self._for_each_task_set_status(selected_tasks, status=None)
3064 return
3066 if not episodes:
3067 return False
3069 episodes = [e for e in episodes if not e.archive]
3071 if not episodes:
3072 title = _('Episodes are locked')
3073 message = _(
3074 'The selected episodes are locked. Please unlock the '
3075 'episodes that you want to delete before trying '
3076 'to delete them.')
3077 self.notification(message, title, widget=self.treeAvailable)
3078 return False
3080 count = len(episodes)
3081 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
3082 count) % {'count': count}
3083 message = _('Deleting episodes removes downloaded files.')
3085 message = self.format_delete_message(message, episodes, 5, 60)
3087 if confirm and not self.show_confirmation(message, title):
3088 return False
3090 self.on_item_cancel_download_activate(force=True)
3092 progress = ProgressIndicator(_('Deleting episodes'),
3093 _('Please wait while episodes are deleted'),
3094 parent=self.get_dialog_parent())
3096 def finish_deletion(episode_urls, channel_urls):
3097 # Episodes have been deleted - persist the database
3098 self.db.commit()
3100 self.update_episode_list_icons(episode_urls)
3101 self.update_podcast_list_model(channel_urls)
3102 self.play_or_download()
3104 progress.on_finished()
3106 @util.run_in_background
3107 def thread_proc():
3108 episode_urls = set()
3109 channel_urls = set()
3111 episodes_status_update = []
3112 for idx, episode in enumerate(episodes):
3113 progress.on_progress(idx / len(episodes))
3114 if not episode.archive:
3115 progress.on_message(episode.title)
3116 episode.delete_from_disk()
3117 episode_urls.add(episode.url)
3118 channel_urls.add(episode.channel.url)
3119 episodes_status_update.append(episode)
3121 # Notify the web service about the status update + upload
3122 if self.mygpo_client.can_access_webservice():
3123 self.mygpo_client.on_delete(episodes_status_update)
3124 self.mygpo_client.flush()
3126 if callback is None:
3127 util.idle_add(finish_deletion, episode_urls, channel_urls)
3128 else:
3129 util.idle_add(callback, episode_urls, channel_urls, progress)
3131 return True
3133 def on_itemRemoveOldEpisodes_activate(self, action, param):
3134 self.show_delete_episodes_window()
3136 def show_delete_episodes_window(self, channel=None):
3137 """Offer deletion of episodes
3139 If channel is None, offer deletion of all episodes.
3140 Otherwise only offer deletion of episodes in the channel.
3142 columns = (
3143 ('markup_delete_episodes', None, None, _('Episode')),
3146 msg_older_than = N_('Select older than %(count)d day', 'Select older than %(count)d days', self.config.auto.cleanup.days)
3147 selection_buttons = {
3148 _('Select played'): lambda episode: not episode.is_new,
3149 _('Select finished'): lambda episode: episode.is_finished(),
3150 msg_older_than % {'count': self.config.auto.cleanup.days}:
3151 lambda episode: episode.age_in_days() > self.config.auto.cleanup.days,
3154 instructions = _('Select the episodes you want to delete:')
3156 if channel is None:
3157 channels = self.channels
3158 else:
3159 channels = [channel]
3161 episodes = []
3162 for channel in channels:
3163 for episode in channel.get_episodes(gpodder.STATE_DOWNLOADED):
3164 # Disallow deletion of locked episodes that still exist
3165 if not episode.archive or not episode.file_exists():
3166 episodes.append(episode)
3168 selected = [not e.is_new or not e.file_exists() for e in episodes]
3170 gPodderEpisodeSelector(
3171 self.main_window, title=_('Delete episodes'),
3172 instructions=instructions,
3173 episodes=episodes, selected=selected, columns=columns,
3174 ok_button=_('_Delete'), callback=self.delete_episode_list,
3175 selection_buttons=selection_buttons, _config=self.config)
3177 def on_selected_episodes_status_changed(self):
3178 # The order of the updates here is important! When "All episodes" is
3179 # selected, the update of the podcast list model depends on the episode
3180 # list selection to determine which podcasts are affected. Updating
3181 # the episode list could remove the selection if a filter is active.
3182 self.update_podcast_list_model(selected=True)
3183 self.update_episode_list_icons(selected=True)
3184 self.db.commit()
3186 self.play_or_download()
3188 def mark_selected_episodes_new(self):
3189 for episode in self.get_selected_episodes():
3190 episode.mark(is_played=False)
3191 self.on_selected_episodes_status_changed()
3193 def mark_selected_episodes_old(self):
3194 for episode in self.get_selected_episodes():
3195 episode.mark(is_played=True)
3196 self.on_selected_episodes_status_changed()
3198 def on_item_toggle_played_activate(self, action, param):
3199 for episode in self.get_selected_episodes():
3200 episode.mark(is_played=episode.is_new and episode.state != gpodder.STATE_DELETED)
3201 self.on_selected_episodes_status_changed()
3203 def on_item_toggle_lock_activate(self, unused, toggle=True, new_value=False):
3204 for episode in self.get_selected_episodes():
3205 if episode.state == gpodder.STATE_DELETED:
3206 # Always unlock deleted episodes
3207 episode.mark(is_locked=False)
3208 elif toggle or toggle is None:
3209 # Gio.SimpleAction activate signal passes None (see #681)
3210 episode.mark(is_locked=not episode.archive)
3211 else:
3212 episode.mark(is_locked=new_value)
3213 self.on_selected_episodes_status_changed()
3214 self.play_or_download()
3216 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3217 if self.active_channel is None:
3218 return
3220 self.active_channel.auto_archive_episodes = not self.active_channel.auto_archive_episodes
3221 self.active_channel.save()
3223 for episode in self.active_channel.get_all_episodes():
3224 episode.mark(is_locked=self.active_channel.auto_archive_episodes)
3226 self.update_podcast_list_model(selected=True)
3227 self.update_episode_list_icons(all=True)
3229 def on_itemUpdateChannel_activate(self, *params):
3230 if self.active_channel is None:
3231 title = _('No podcast selected')
3232 message = _('Please select a podcast in the podcasts list to update.')
3233 self.show_message(message, title, widget=self.treeChannels)
3234 return
3236 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3237 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3238 self.update_feed_cache()
3239 else:
3240 self.update_feed_cache(channels=[self.active_channel])
3242 def on_itemUpdate_activate(self, action=None, param=None):
3243 # Check if we have outstanding subscribe/unsubscribe actions
3244 self.on_add_remove_podcasts_mygpo()
3246 if self.channels:
3247 self.update_feed_cache()
3248 else:
3249 def show_welcome_window():
3250 def on_show_example_podcasts(widget):
3251 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3252 self.on_itemImportChannels_activate(None)
3254 def on_add_podcast_via_url(widget):
3255 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3256 self.on_itemAddChannel_activate(None)
3258 def on_setup_my_gpodder(widget):
3259 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3260 self.on_download_subscriptions_from_mygpo(None)
3262 welcome_window = gPodderWelcome(self.main_window,
3263 center_on_widget=self.main_window,
3264 on_show_example_podcasts=on_show_example_podcasts,
3265 on_add_podcast_via_url=on_add_podcast_via_url,
3266 on_setup_my_gpodder=on_setup_my_gpodder)
3268 welcome_window.main_window.run()
3269 welcome_window.main_window.destroy()
3271 util.idle_add(show_welcome_window)
3273 def download_episode_list_paused(self, episodes, hide_progress=False):
3274 self.download_episode_list(episodes, True, hide_progress=hide_progress)
3276 def download_episode_list(self, episodes, add_paused=False, force_start=False, downloader=None, hide_progress=False):
3277 # Start progress indicator to queue existing tasks
3278 count = len(episodes)
3279 if count and not hide_progress:
3280 progress_indicator = ProgressIndicator(
3281 _('Queueing'),
3282 '', True, self.get_dialog_parent(), count)
3283 else:
3284 progress_indicator = None
3286 restart_timer = self.stop_download_list_update_timer()
3287 self.download_queue_manager.disable()
3289 def queue_tasks(tasks, queued_existing_task):
3290 if progress_indicator is None or not progress_indicator.cancelled:
3291 if progress_indicator:
3292 count = len(tasks)
3293 if count:
3294 # Restart progress indicator to queue new tasks
3295 progress_indicator.set_max_ticks(count)
3296 progress_indicator.on_progress(0.0)
3298 for task in tasks:
3299 with task:
3300 if add_paused:
3301 task.status = task.PAUSED
3302 else:
3303 self.mygpo_client.on_download([task.episode])
3304 self.queue_task(task, force_start)
3305 if progress_indicator:
3306 if not progress_indicator.on_tick():
3307 break
3309 if progress_indicator:
3310 progress_indicator.on_tick(final=_('Updating...'))
3311 self.download_queue_manager.enable()
3313 # Update the tab title and downloads list
3314 if tasks or queued_existing_task or restart_timer:
3315 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3316 # Flush updated episode status
3317 if self.mygpo_client.can_access_webservice():
3318 self.mygpo_client.flush()
3320 if progress_indicator:
3321 progress_indicator.on_finished()
3323 queued_existing_task = False
3324 new_tasks = []
3326 if self.config.downloads.chronological_order:
3327 # Download episodes in chronological order (older episodes first)
3328 episodes = list(Model.sort_episodes_by_pubdate(episodes))
3330 for episode in episodes:
3331 if progress_indicator:
3332 # The continues require ticking before doing the work
3333 if not progress_indicator.on_tick():
3334 break
3336 logger.debug('Downloading episode: %s', episode.title)
3337 if not episode.was_downloaded(and_exists=True):
3338 episode._download_error = None
3339 if episode.state == gpodder.STATE_DELETED:
3340 episode.state = gpodder.STATE_NORMAL
3341 episode.save()
3342 task_exists = False
3343 for task in self.download_tasks_seen:
3344 if episode.url == task.url:
3345 task_exists = True
3346 task.unpause()
3347 task.reuse()
3348 if task.status not in (task.DOWNLOADING, task.QUEUED):
3349 if downloader:
3350 # replace existing task's download with forced one
3351 task.downloader = downloader
3352 self.queue_task(task, force_start)
3353 queued_existing_task = True
3354 continue
3356 if task_exists:
3357 continue
3359 try:
3360 task = download.DownloadTask(episode, self.config, downloader=downloader)
3361 except Exception as e:
3362 episode._download_error = str(e)
3363 d = {'episode': html.escape(episode.title), 'message': html.escape(str(e))}
3364 message = _('Download error while downloading %(episode)s: %(message)s')
3365 self.show_message(message % d, _('Download error'), important=True)
3366 logger.error('While downloading %s', episode.title, exc_info=True)
3367 continue
3369 # New Task, we must wait on the GTK Loop
3370 self.download_status_model.register_task(task)
3371 new_tasks.append(task)
3373 # Executes after tasks have been registered
3374 util.idle_add(queue_tasks, new_tasks, queued_existing_task)
3376 def cancel_task_list(self, tasks, force=False):
3377 if not tasks:
3378 return
3380 progress_indicator = ProgressIndicator(
3381 download.DownloadTask.STATUS_MESSAGE[download.DownloadTask.CANCELLING],
3382 '', True, self.get_dialog_parent(), len(tasks))
3384 restart_timer = self.stop_download_list_update_timer()
3385 self.download_queue_manager.disable()
3386 for task in tasks:
3387 task.cancel()
3389 if not progress_indicator.on_tick():
3390 break
3391 progress_indicator.on_tick(final=_('Updating...'))
3392 self.download_queue_manager.enable()
3394 self.update_episode_list_icons([task.url for task in tasks])
3395 self.play_or_download()
3397 # Update the tab title and downloads list
3398 if restart_timer:
3399 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3400 else:
3401 self.update_downloads_list()
3403 progress_indicator.on_finished()
3405 def new_episodes_show(self, episodes, notification=False, selected=None):
3406 columns = (
3407 ('markup_new_episodes', None, None, _('Episode')),
3410 instructions = _('Select the episodes you want to download:')
3412 if self.new_episodes_window is not None:
3413 self.new_episodes_window.main_window.destroy()
3414 self.new_episodes_window = None
3416 def download_episodes_callback(episodes):
3417 self.new_episodes_window = None
3418 self.download_episode_list(episodes)
3420 # Remove episodes without downloadable content
3421 episodes = [e for e in episodes if e.url]
3422 if len(episodes) == 0:
3423 return
3425 if selected is None:
3426 # Select all by default
3427 selected = [True] * len(episodes)
3429 self.new_episodes_window = gPodderEpisodeSelector(self.main_window,
3430 title=_('New episodes available'),
3431 instructions=instructions,
3432 episodes=episodes,
3433 columns=columns,
3434 selected=selected,
3435 ok_button='gpodder-download',
3436 callback=download_episodes_callback,
3437 remove_callback=lambda e: e.mark_old(),
3438 remove_action=_('_Mark as old'),
3439 remove_finished=self.episode_new_status_changed,
3440 _config=self.config,
3441 show_notification=False)
3443 def on_itemDownloadAllNew_activate(self, action, param):
3444 if not self.offer_new_episodes():
3445 self.show_message(_('Please check for new episodes later.'),
3446 _('No new episodes available'))
3448 def get_new_episodes(self, channels=None):
3449 return [e for c in channels or self.channels for e in
3450 [e for e in c.get_all_episodes() if e.check_is_new()]]
3452 def commit_changes_to_database(self):
3453 """This will be called after the sync process is finished"""
3454 self.db.commit()
3456 def on_itemShowToolbar_activate(self, action, param):
3457 state = action.get_state()
3458 self.config.ui.gtk.toolbar = not state
3459 action.set_state(GLib.Variant.new_boolean(not state))
3461 def on_item_view_search_always_visible_toggled(self, action, param):
3462 state = action.get_state()
3463 self.config.ui.gtk.search_always_visible = not state
3464 action.set_state(GLib.Variant.new_boolean(not state))
3465 for search in (self._search_episodes, self._search_podcasts):
3466 if search:
3467 if self.config.ui.gtk.search_always_visible:
3468 search.show_search(grab_focus=False)
3469 else:
3470 search.hide_search()
3472 def on_item_view_hide_boring_podcasts_toggled(self, action, param):
3473 state = action.get_state()
3474 self.config.ui.gtk.podcast_list.hide_empty = not state
3475 action.set_state(GLib.Variant.new_boolean(not state))
3476 self.apply_podcast_list_hide_boring()
3478 def on_item_view_show_all_episodes_toggled(self, action, param):
3479 state = action.get_state()
3480 self.config.ui.gtk.podcast_list.all_episodes = not state
3481 action.set_state(GLib.Variant.new_boolean(not state))
3483 def on_item_view_show_podcast_sections_toggled(self, action, param):
3484 state = action.get_state()
3485 self.config.ui.gtk.podcast_list.sections = not state
3486 action.set_state(GLib.Variant.new_boolean(not state))
3488 def on_item_view_episodes_changed(self, action, param):
3489 self.config.ui.gtk.episode_list.view_mode = getattr(EpisodeListModel, param.get_string()) or EpisodeListModel.VIEW_ALL
3490 action.set_state(param)
3492 self.episode_list_model.set_view_mode(self.config.ui.gtk.episode_list.view_mode)
3493 self.apply_podcast_list_hide_boring()
3495 def on_item_view_always_show_new_episodes_toggled(self, action, param):
3496 state = action.get_state()
3497 self.config.ui.gtk.episode_list.always_show_new = not state
3498 action.set_state(GLib.Variant.new_boolean(not state))
3500 def on_item_view_trim_episode_title_prefix_toggled(self, action, param):
3501 state = action.get_state()
3502 self.config.ui.gtk.episode_list.trim_title_prefix = not state
3503 action.set_state(GLib.Variant.new_boolean(not state))
3505 def on_item_view_show_episode_description_toggled(self, action, param):
3506 state = action.get_state()
3507 self.config.ui.gtk.episode_list.descriptions = not state
3508 action.set_state(GLib.Variant.new_boolean(not state))
3510 def on_item_view_ctrl_click_to_sort_episodes_toggled(self, action, param):
3511 state = action.get_state()
3512 self.config.ui.gtk.episode_list.ctrl_click_to_sort = not state
3513 action.set_state(GLib.Variant.new_boolean(not state))
3515 def apply_podcast_list_hide_boring(self):
3516 if self.config.ui.gtk.podcast_list.hide_empty:
3517 self.podcast_list_model.set_view_mode(self.config.ui.gtk.episode_list.view_mode)
3518 else:
3519 self.podcast_list_model.set_view_mode(-1)
3521 def on_download_subscriptions_from_mygpo(self, action=None):
3522 def after_login():
3523 title = _('Subscriptions on %(server)s') \
3524 % {'server': self.config.mygpo.server}
3525 dir = gPodderPodcastDirectory(self.gPodder,
3526 _config=self.config,
3527 custom_title=title,
3528 add_podcast_list=self.add_podcast_list,
3529 hide_url_entry=True)
3531 url = self.mygpo_client.get_download_user_subscriptions_url()
3532 dir.download_opml_file(url)
3534 title = _('Login to gpodder.net')
3535 message = _('Please login to download your subscriptions.')
3537 def on_register_button_clicked():
3538 util.open_website('http://gpodder.net/register/')
3540 success, (root_url, username, password) = self.show_login_dialog(title, message,
3541 self.config.mygpo.server,
3542 self.config.mygpo.username, self.config.mygpo.password,
3543 register_callback=on_register_button_clicked,
3544 ask_server=True)
3545 if not success:
3546 return
3548 self.config.mygpo.server = root_url
3549 self.config.mygpo.username = username
3550 self.config.mygpo.password = password
3552 util.idle_add(after_login)
3554 def on_itemAddChannel_activate(self, action=None, param=None):
3555 self._add_podcast_dialog = gPodderAddPodcast(self.gPodder,
3556 add_podcast_list=self.add_podcast_list)
3558 def on_itemEditChannel_activate(self, action, param=None):
3559 if self.active_channel is None:
3560 title = _('No podcast selected')
3561 message = _('Please select a podcast in the podcasts list to edit.')
3562 self.show_message(message, title, widget=self.treeChannels)
3563 return
3565 gPodderChannel(self.main_window,
3566 channel=self.active_channel,
3567 update_podcast_list_model=self.update_podcast_list_model,
3568 cover_downloader=self.cover_downloader,
3569 sections=set(c.section for c in self.channels),
3570 clear_cover_cache=self.podcast_list_model.clear_cover_cache,
3571 _config=self.config)
3573 def on_itemMassUnsubscribe_activate(self, action, param):
3574 columns = (
3575 ('title_markup', None, None, _('Podcast')),
3578 # We're abusing the Episode Selector for selecting Podcasts here,
3579 # but it works and looks good, so why not? -- thp
3580 gPodderEpisodeSelector(self.main_window,
3581 title=_('Delete podcasts'),
3582 instructions=_('Select the podcast you want to delete.'),
3583 episodes=self.channels,
3584 columns=columns,
3585 size_attribute=None,
3586 ok_button=_('_Delete'),
3587 callback=self.remove_podcast_list,
3588 _config=self.config)
3590 def remove_podcast_list(self, channels, confirm=True):
3591 if not channels:
3592 return
3594 if len(channels) == 1:
3595 title = _('Deleting podcast')
3596 info = _('Please wait while the podcast is deleted')
3597 message = _('This podcast and all its episodes will be PERMANENTLY DELETED.\nAre you sure you want to continue?')
3598 else:
3599 title = _('Deleting podcasts')
3600 info = _('Please wait while the podcasts are deleted')
3601 message = _('These podcasts and all their episodes will be PERMANENTLY DELETED.\nAre you sure you want to continue?')
3603 message = self.format_delete_message(message, channels, 5, 60)
3605 if confirm and not self.show_confirmation(message, title):
3606 return
3608 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3610 def finish_deletion(select_url):
3611 # Upload subscription list changes to the web service
3612 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3614 # Re-load the channels and select the desired new channel
3615 self.update_podcast_list_model(select_url=select_url)
3617 progress.on_finished()
3619 @util.run_in_background
3620 def thread_proc():
3621 select_url = None
3623 for idx, channel in enumerate(channels):
3624 # Update the UI for correct status messages
3625 progress.on_progress(idx / len(channels))
3626 progress.on_message(channel.title)
3628 # Delete downloaded episodes
3629 channel.remove_downloaded()
3631 # cancel any active downloads from this channel
3632 for episode in channel.get_all_episodes():
3633 if episode.downloading:
3634 episode.download_task.cancel()
3636 if len(channels) == 1:
3637 # get the URL of the podcast we want to select next
3638 if channel in self.channels:
3639 position = self.channels.index(channel)
3640 else:
3641 position = -1
3643 if position == len(self.channels) - 1:
3644 # this is the last podcast, so select the URL
3645 # of the item before this one (i.e. the "new last")
3646 select_url = self.channels[position - 1].url
3647 else:
3648 # there is a podcast after the deleted one, so
3649 # we simply select the one that comes after it
3650 select_url = self.channels[position + 1].url
3652 # Remove the channel and clean the database entries
3653 channel.delete()
3655 # Clean up downloads and download directories
3656 common.clean_up_downloads()
3658 # The remaining stuff is to be done in the GTK main thread
3659 util.idle_add(finish_deletion, select_url)
3661 def on_itemRefreshCover_activate(self, widget, *args):
3662 assert self.active_channel is not None
3664 self.podcast_list_model.clear_cover_cache(self.active_channel.url)
3665 self.cover_downloader.replace_cover(self.active_channel, custom_url=False)
3667 def on_itemRemoveChannel_activate(self, widget, *args):
3668 if self.active_channel is None:
3669 title = _('No podcast selected')
3670 message = _('Please select a podcast in the podcasts list to remove.')
3671 self.show_message(message, title, widget=self.treeChannels)
3672 return
3674 self.remove_podcast_list([self.active_channel])
3676 def get_opml_filter(self):
3677 filter = Gtk.FileFilter()
3678 filter.add_pattern('*.opml')
3679 filter.add_pattern('*.xml')
3680 filter.set_name(_('OPML files') + ' (*.opml, *.xml)')
3681 return filter
3683 def on_item_import_from_file_activate(self, action, filename=None):
3684 if filename is None:
3685 dlg = Gtk.FileChooserDialog(title=_('Import from OPML'),
3686 parent=self.main_window,
3687 action=Gtk.FileChooserAction.OPEN)
3688 dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
3689 dlg.add_button(_('_Open'), Gtk.ResponseType.OK)
3690 dlg.set_filter(self.get_opml_filter())
3691 response = dlg.run()
3692 filename = None
3693 if response == Gtk.ResponseType.OK:
3694 filename = dlg.get_filename()
3695 dlg.destroy()
3697 if filename is not None:
3698 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config,
3699 custom_title=_('Import podcasts from OPML file'),
3700 add_podcast_list=self.add_podcast_list,
3701 hide_url_entry=True)
3702 dir.download_opml_file(filename)
3704 def on_itemExportChannels_activate(self, widget, *args):
3705 if not self.channels:
3706 title = _('Nothing to export')
3707 message = _('Your list of podcast subscriptions is empty. '
3708 'Please subscribe to some podcasts first before '
3709 'trying to export your subscription list.')
3710 self.show_message(message, title, widget=self.treeChannels)
3711 return
3713 dlg = Gtk.FileChooserDialog(title=_('Export to OPML'),
3714 parent=self.gPodder,
3715 action=Gtk.FileChooserAction.SAVE)
3716 dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
3717 dlg.add_button(_('_Save'), Gtk.ResponseType.OK)
3718 dlg.set_filter(self.get_opml_filter())
3719 response = dlg.run()
3720 if response == Gtk.ResponseType.OK:
3721 filename = dlg.get_filename()
3722 dlg.destroy()
3723 exporter = opml.Exporter(filename)
3724 if filename is not None and exporter.write(self.channels):
3725 count = len(self.channels)
3726 title = N_('%(count)d subscription exported',
3727 '%(count)d subscriptions exported',
3728 count) % {'count': count}
3729 self.show_message(_('Your podcast list has been successfully '
3730 'exported.'),
3731 title, widget=self.treeChannels)
3732 else:
3733 self.show_message(_('Could not export OPML to file. '
3734 'Please check your permissions.'),
3735 _('OPML export failed'), important=True)
3736 else:
3737 dlg.destroy()
3739 def on_itemImportChannels_activate(self, widget, *args):
3740 self._podcast_directory = gPodderPodcastDirectory(self.main_window,
3741 _config=self.config,
3742 add_podcast_list=self.add_podcast_list)
3744 def on_homepage_activate(self, widget, *args):
3745 util.open_website(gpodder.__url__)
3747 def check_for_distro_updates(self):
3748 title = _('Managed by distribution')
3749 message = _('Please check your distribution for gPodder updates.')
3750 self.show_message(message, title, important=True)
3752 def check_for_updates(self, silent):
3753 """Check for updates and (optionally) show a message
3755 If silent=False, a message will be shown even if no updates are
3756 available (set silent=False when the check is manually triggered).
3758 try:
3759 up_to_date, version, released, days = util.get_update_info()
3760 except Exception as e:
3761 if silent:
3762 logger.warning('Could not check for updates.', exc_info=True)
3763 else:
3764 title = _('Could not check for updates')
3765 message = _('Please try again later.')
3766 self.show_message(message, title, important=True)
3767 return
3769 if up_to_date and not silent:
3770 title = _('No updates available')
3771 message = _('You have the latest version of gPodder.')
3772 self.show_message(message, title, important=True)
3774 if not up_to_date:
3775 title = _('New version available')
3776 message = '\n'.join([
3777 _('Installed version: %s') % gpodder.__version__,
3778 _('Newest version: %s') % version,
3779 _('Release date: %s') % released,
3781 _('Download the latest version from gpodder.org?'),
3784 if self.show_confirmation(message, title):
3785 util.open_website('http://gpodder.org/downloads')
3787 def on_wNotebook_switch_page(self, notebook, page, page_num):
3788 self.play_or_download(current_page=page_num)
3790 def on_treeChannels_row_activated(self, widget, path, *args):
3791 # double-click action of the podcast list or enter
3792 self.treeChannels.set_cursor(path)
3794 # open channel settings
3795 channel = self.get_selected_channels()[0]
3796 if channel and not isinstance(channel, PodcastChannelProxy):
3797 self.on_itemEditChannel_activate(None)
3799 def get_selected_channels(self):
3800 """Get a list of selected channels from treeChannels"""
3801 selection = self.treeChannels.get_selection()
3802 model, paths = selection.get_selected_rows()
3804 channels = [model.get_value(model.get_iter(path), PodcastListModel.C_CHANNEL) for path in paths]
3805 channels = [c for c in channels if c is not None]
3806 return channels
3808 def on_treeChannels_cursor_changed(self, widget, *args):
3809 (model, iter) = self.treeChannels.get_selection().get_selected()
3811 if model is not None and iter is not None:
3812 old_active_channel = self.active_channel
3813 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3815 if self.active_channel == old_active_channel:
3816 return
3818 # Dirty hack to check for "All episodes" or a section (see gpodder.gtkui.model)
3819 if isinstance(self.active_channel, PodcastChannelProxy):
3820 self.edit_channel_action.set_enabled(False)
3821 else:
3822 self.edit_channel_action.set_enabled(True)
3823 else:
3824 self.active_channel = None
3825 self.edit_channel_action.set_enabled(False)
3827 self.update_episode_list_model()
3829 def on_btnEditChannel_clicked(self, widget, *args):
3830 self.on_itemEditChannel_activate(widget, args)
3832 def get_podcast_urls_from_selected_episodes(self):
3833 """Get a set of podcast URLs based on the selected episodes"""
3834 return set(episode.channel.url for episode in
3835 self.get_selected_episodes())
3837 def get_selected_episodes(self):
3838 """Get a list of selected episodes from treeAvailable"""
3839 selection = self.treeAvailable.get_selection()
3840 model, paths = selection.get_selected_rows()
3842 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3843 episodes = [e for e in episodes if e is not None]
3844 return episodes
3846 def on_playback_selected_episodes(self, *params):
3847 self.playback_episodes(self.get_selected_episodes())
3849 def on_shownotes_selected_episodes(self, *params):
3850 episodes = self.get_selected_episodes()
3851 self.shownotes_object.toggle_pane_visibility(episodes)
3853 def on_download_selected_episodes(self, action_or_widget, param=None):
3854 if self.wNotebook.get_current_page() == 0:
3855 episodes = [e for e in self.get_selected_episodes() if e.can_download()]
3856 self.download_episode_list(episodes)
3857 else:
3858 selection = self.treeDownloads.get_selection()
3859 (model, paths) = selection.get_selected_rows()
3860 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
3861 model.get_value(model.get_iter(path),
3862 DownloadStatusModel.C_TASK)) for path in paths]
3863 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
3865 def on_pause_selected_episodes(self, action_or_widget, param=None):
3866 if self.wNotebook.get_current_page() == 0:
3867 selection = self.get_selected_episodes()
3868 selected_tasks = [(None, e.download_task) for e in selection if e.download_task is not None and e.can_pause()]
3869 self._for_each_task_set_status(selected_tasks, download.DownloadTask.PAUSING)
3870 else:
3871 selection = self.treeDownloads.get_selection()
3872 (model, paths) = selection.get_selected_rows()
3873 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
3874 model.get_value(model.get_iter(path),
3875 DownloadStatusModel.C_TASK)) for path in paths]
3876 self._for_each_task_set_status(selected_tasks, download.DownloadTask.PAUSING)
3878 def on_treeAvailable_row_activated(self, widget, path, view_column):
3879 """Double-click/enter action handler for treeAvailable"""
3880 self.on_shownotes_selected_episodes(widget)
3882 def restart_auto_update_timer(self):
3883 if self._auto_update_timer_source_id is not None:
3884 logger.debug('Removing existing auto update timer.')
3885 GLib.source_remove(self._auto_update_timer_source_id)
3886 self._auto_update_timer_source_id = None
3888 if (self.config.auto.update.enabled
3889 and self.config.auto.update.frequency):
3890 interval = 60 * 1000 * self.config.auto.update.frequency
3891 logger.debug('Setting up auto update timer with interval %d.',
3892 self.config.auto.update.frequency)
3893 self._auto_update_timer_source_id = util.idle_timeout_add(interval, self._on_auto_update_timer)
3895 def _on_auto_update_timer(self):
3896 if self.config.check_connection and not util.connection_available():
3897 logger.debug('Skipping auto update (no connection available)')
3898 return True
3900 logger.debug('Auto update timer fired.')
3901 self.update_feed_cache()
3903 # Ask web service for sub changes (if enabled)
3904 if self.mygpo_client.can_access_webservice():
3905 self.mygpo_client.flush()
3907 return True
3909 def on_treeDownloads_row_activated(self, widget, *args):
3910 # Use the standard way of working on the treeview
3911 selection = self.treeDownloads.get_selection()
3912 (model, paths) = selection.get_selected_rows()
3913 selected_tasks = [(Gtk.TreeRowReference.new(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3915 has_queued_tasks = False
3916 for tree_row_reference, task in selected_tasks:
3917 with task:
3918 if task.status in (task.DOWNLOADING, task.QUEUED):
3919 task.pause()
3920 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3921 self.download_queue_manager.queue_task(task)
3922 has_queued_tasks = True
3923 elif task.status == task.DONE:
3924 model.remove(model.get_iter(tree_row_reference.get_path()))
3925 if has_queued_tasks:
3926 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3928 self.play_or_download()
3930 # Update the tab title and downloads list
3931 self.update_downloads_list()
3933 def on_item_cancel_download_activate(self, *params, force=False):
3934 if self.wNotebook.get_current_page() == 0:
3935 selection = self.treeAvailable.get_selection()
3936 (model, paths) = selection.get_selected_rows()
3937 urls = [model.get_value(model.get_iter(path),
3938 self.episode_list_model.C_URL) for path in paths]
3939 selected_tasks = [task for task in self.download_tasks_seen
3940 if task.url in urls]
3941 else:
3942 selection = self.treeDownloads.get_selection()
3943 (model, paths) = selection.get_selected_rows()
3944 selected_tasks = [model.get_value(model.get_iter(path),
3945 self.download_status_model.C_TASK) for path in paths]
3946 self.cancel_task_list(selected_tasks, force=force)
3948 def on_btnCancelAll_clicked(self, widget, *args):
3949 self.cancel_task_list(self.download_tasks_seen)
3951 def on_btnDownloadedDelete_clicked(self, widget, *args):
3952 episodes = self.get_selected_episodes()
3953 self.delete_episode_list(episodes)
3955 def on_key_press(self, widget, event):
3956 # Allow tab switching with Ctrl + PgUp/PgDown/Tab
3957 if event.get_state() & Gdk.ModifierType.CONTROL_MASK:
3958 current_page = self.wNotebook.get_current_page()
3959 if event.keyval in (Gdk.KEY_Page_Up, Gdk.KEY_ISO_Left_Tab):
3960 if current_page == 0:
3961 current_page = self.wNotebook.get_n_pages()
3962 self.wNotebook.set_current_page(current_page - 1)
3963 return True
3964 elif event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Tab):
3965 if current_page == self.wNotebook.get_n_pages() - 1:
3966 current_page = -1
3967 self.wNotebook.set_current_page(current_page + 1)
3968 return True
3969 elif event.keyval == Gdk.KEY_Delete:
3970 if isinstance(widget.get_focus(), Gtk.Entry):
3971 logger.debug("Entry has focus, ignoring Delete")
3972 else:
3973 self.main_window.activate_action('delete')
3974 return True
3976 return False
3978 def uniconify_main_window(self):
3979 if self.is_iconified():
3980 # We need to hide and then show the window in WMs like Metacity
3981 # or KWin4 to move the window to the active workspace
3982 # (see http://gpodder.org/bug/1125)
3983 self.gPodder.hide()
3984 self.gPodder.show()
3985 self.gPodder.present()
3987 def iconify_main_window(self):
3988 if not self.is_iconified():
3989 self.gPodder.iconify()
3991 @dbus.service.method(gpodder.dbus_interface)
3992 def show_gui_window(self):
3993 parent = self.get_dialog_parent()
3994 parent.present()
3996 @dbus.service.method(gpodder.dbus_interface)
3997 def subscribe_to_url(self, url):
3998 # Strip leading application protocol, so these URLs work:
3999 # gpodder://example.com/episodes.rss
4000 # gpodder:https://example.org/podcast.xml
4001 if url.startswith('gpodder:'):
4002 url = url[len('gpodder:'):]
4003 while url.startswith('/'):
4004 url = url[1:]
4006 self._add_podcast_dialog = gPodderAddPodcast(self.gPodder,
4007 add_podcast_list=self.add_podcast_list,
4008 preset_url=url)
4010 @dbus.service.method(gpodder.dbus_interface)
4011 def mark_episode_played(self, filename):
4012 if filename is None:
4013 return False
4015 for channel in self.channels:
4016 for episode in channel.get_all_episodes():
4017 fn = episode.local_filename(create=False, check_only=True)
4018 if fn == filename:
4019 episode.mark(is_played=True)
4020 self.db.commit()
4021 self.update_episode_list_icons([episode.url])
4022 self.update_podcast_list_model([episode.channel.url])
4023 return True
4025 return False
4027 def extensions_podcast_update_cb(self, podcast):
4028 logger.debug('extensions_podcast_update_cb(%s)', podcast)
4029 self.update_feed_cache(channels=[podcast],
4030 show_new_episodes_dialog=False)
4032 def extensions_episode_download_cb(self, episode):
4033 logger.debug('extension_episode_download_cb(%s)', episode)
4034 self.download_episode_list(episodes=[episode])
4036 def mount_volume_cb(self, file, res, mount_result):
4037 result = True
4038 try:
4039 file.mount_enclosing_volume_finish(res)
4040 except GLib.Error as err:
4041 if (not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_SUPPORTED)
4042 and not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED)):
4043 logger.error('mounting volume %s failed: %s' % (file.get_uri(), err.message))
4044 result = False
4045 finally:
4046 mount_result["result"] = result
4047 Gtk.main_quit()
4049 def mount_volume_for_file(self, file):
4050 op = Gtk.MountOperation.new(self.main_window)
4051 result, message = util.mount_volume_for_file(file, op)
4052 if not result:
4053 logger.error('mounting volume %s failed: %s' % (file.get_uri(), message))
4054 return result
4056 def on_sync_to_device_activate(self, widget, episodes=None, force_played=True):
4057 self.sync_ui = gPodderSyncUI(self.config, self.notification,
4058 self.main_window,
4059 self.show_confirmation,
4060 self.application.on_itemPreferences_activate,
4061 self.channels,
4062 self.download_status_model,
4063 self.download_queue_manager,
4064 self.set_download_list_state,
4065 self.commit_changes_to_database,
4066 self.delete_episode_list,
4067 gPodderEpisodeSelector,
4068 self.mount_volume_for_file)
4070 self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played)
4072 def on_extension_enabled(self, extension):
4073 if getattr(extension, 'on_ui_object_available', None) is not None:
4074 extension.on_ui_object_available('gpodder-gtk', self)
4075 if getattr(extension, 'on_ui_initialized', None) is not None:
4076 extension.on_ui_initialized(self.model,
4077 self.extensions_podcast_update_cb,
4078 self.extensions_episode_download_cb)
4079 self.inject_extensions_menu()
4081 def on_extension_disabled(self, extension):
4082 self.inject_extensions_menu()