Use ellipsis character instead of '...' for deleted titles.
[gpodder.git] / src / gpodder / gtkui / main.py
blob369732c3c6bb8555c401124bdbc26f513c8627ef
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, extensions, feedcore, my, opml, player,
38 util, youtube)
39 from gpodder.dbusproxy import DBusPodcastsProxy
40 from gpodder.model import Model, PodcastEpisode
41 from gpodder.syncui import gPodderSyncUI
43 from . import shownotes
44 from .desktop.channel import gPodderChannel
45 from .desktop.episodeselector import gPodderEpisodeSelector
46 from .desktop.exportlocal import gPodderExportToLocalFolder
47 from .desktop.podcastdirectory import gPodderPodcastDirectory
48 from .desktop.welcome import gPodderWelcome
49 from .desktopfile import UserAppsReader
50 from .download import DownloadStatusModel
51 from .draw import (cake_size_from_widget, draw_cake_pixbuf,
52 draw_iconcell_scale, draw_text_box_centered)
53 from .interface.addpodcast import gPodderAddPodcast
54 from .interface.common import BuilderWidget, TreeViewHelper
55 from .interface.progress import ProgressIndicator
56 from .interface.searchtree import SearchTree
57 from .model import EpisodeListModel, PodcastChannelProxy, PodcastListModel
58 from .services import CoverDownloader
59 from .widgets import SimpleMessageArea
61 import gi # isort:skip
62 gi.require_version('Gtk', '3.0') # isort:skip
63 from gi.repository import Gdk, GdkPixbuf, Gio, GLib, GObject, Gtk, Pango # isort:skip
66 logger = logging.getLogger(__name__)
68 _ = gpodder.gettext
69 N_ = gpodder.ngettext
72 class gPodder(BuilderWidget, dbus.service.Object):
74 def __init__(self, app, bus_name, gpodder_core, options):
75 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
76 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels,
77 self.on_itemUpdate_activate,
78 self.playback_episodes,
79 self.download_episode_list,
80 self.episode_object_by_uri,
81 bus_name)
82 self.application = app
83 self.core = gpodder_core
84 self.config = self.core.config
85 self.db = self.core.db
86 self.model = self.core.model
87 self.options = options
88 self.extensions_menu = None
89 self.extensions_actions = []
90 self._search_podcasts = None
91 self._search_episodes = None
92 BuilderWidget.__init__(self, None,
93 _gtk_properties={('gPodder', 'application'): app})
95 self.last_episode_date_refresh = None
96 self.refresh_episode_dates()
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.show_toolbar)
114 self.bluetooth_available = util.bluetooth_available()
116 self.config.connect_gtk_window(self.main_window, 'main_window')
118 self.config.connect_gtk_paned('ui.gtk.state.main_window.paned_position', self.channelPaned)
120 self.main_window.show()
122 self.player_receiver = player.MediaPlayerDBusReceiver(self.on_played)
124 self.gPodder.connect('key-press-event', self.on_key_press)
126 self.episode_columns_menu = None
127 self.config.add_observer(self.on_config_changed)
129 self.shownotes_pane = Gtk.Box()
130 self.shownotes_object = shownotes.get_shownotes(self.config.ui.gtk.html_shownotes, self.shownotes_pane)
132 # Vertical paned for the episode list and shownotes
133 self.vpaned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
134 paned = self.vbox_episode_list.get_parent()
135 self.vbox_episode_list.reparent(self.vpaned)
136 self.vpaned.child_set_property(self.vbox_episode_list, 'resize', True)
137 self.vpaned.child_set_property(self.vbox_episode_list, 'shrink', False)
138 self.vpaned.pack2(self.shownotes_pane, resize=False, shrink=False)
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('max_downloads_enabled', self.cbMaxDownloads)
157 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
158 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
160 # When the amount of maximum downloads changes, notify the queue manager
161 def changed_cb(spinbutton):
162 return self.download_queue_manager.update_max_downloads()
164 self.spinMaxDownloads.connect('value-changed', changed_cb)
165 self.cbMaxDownloads.connect('toggled', changed_cb)
167 # Keep a reference to the last add podcast dialog instance
168 self._add_podcast_dialog = None
170 self.default_title = None
171 self.set_title(_('gPodder'))
173 self.cover_downloader = CoverDownloader()
175 # Generate list models for podcasts and their episodes
176 self.podcast_list_model = PodcastListModel(self.cover_downloader)
177 self.apply_podcast_list_hide_boring()
179 self.cover_downloader.register('cover-available', self.cover_download_finished)
181 # Source IDs for timeouts for search-as-you-type
182 self._podcast_list_search_timeout = None
183 self._episode_list_search_timeout = None
185 # Subscribed channels
186 self.active_channel = None
187 self.channels = self.model.get_podcasts()
189 # For loading the list model
190 self.episode_list_model = EpisodeListModel(self.config, self.on_episode_list_filter_changed)
192 self.create_actions()
194 # Init the treeviews that we use
195 self.init_podcast_list_treeview()
196 self.init_episode_list_treeview()
197 self.init_download_list_treeview()
199 self.download_tasks_seen = set()
200 self.download_list_update_enabled = False
201 self.things_adding_tasks = 0
202 self.download_task_monitors = set()
204 # Set up the first instance of MygPoClient
205 self.mygpo_client = my.MygPoClient(self.config)
207 self.inject_extensions_menu()
209 gpodder.user_extensions.on_ui_initialized(self.model,
210 self.extensions_podcast_update_cb,
211 self.extensions_episode_download_cb)
213 gpodder.user_extensions.on_application_started()
215 # load list of user applications for audio playback
216 self.user_apps_reader = UserAppsReader(['audio', 'video'])
217 util.run_in_background(self.user_apps_reader.read)
219 # Now, update the feed cache, when everything's in place
220 if not self.application.want_headerbar:
221 self.btnUpdateFeeds.show()
222 self.feed_cache_update_cancelled = False
223 self.update_podcast_list_model()
225 self.message_area = None
227 self.partial_downloads_indicator = None
228 util.run_in_background(self.find_partial_downloads)
230 # Start the auto-update procedure
231 self._auto_update_timer_source_id = None
232 if self.config.auto_update_feeds:
233 self.restart_auto_update_timer()
235 # Find expired (old) episodes and delete them
236 old_episodes = list(common.get_expired_episodes(self.channels, self.config))
237 if len(old_episodes) > 0:
238 self.delete_episode_list(old_episodes, confirm=False)
239 updated_urls = set(e.channel.url for e in old_episodes)
240 self.update_podcast_list_model(updated_urls)
242 # Do the initial sync with the web service
243 if self.mygpo_client.can_access_webservice():
244 util.idle_add(self.mygpo_client.flush, True)
246 # First-time users should be asked if they want to see the OPML
247 if self.options.subscribe:
248 util.idle_add(self.subscribe_to_url, self.options.subscribe)
249 elif not self.channels:
250 self.on_itemUpdate_activate()
251 elif self.config.software_update.check_on_startup:
252 # Check for software updates from gpodder.org
253 diff = time.time() - self.config.software_update.last_check
254 if diff > (60 * 60 * 24) * self.config.software_update.interval:
255 self.config.software_update.last_check = int(time.time())
256 if not os.path.exists(gpodder.no_update_check_file):
257 self.check_for_updates(silent=True)
259 if self.options.close_after_startup:
260 logger.warning("Startup done, closing (--close-after-startup)")
261 self.core.db.close()
262 sys.exit()
264 def create_actions(self):
265 g = self.gPodder
267 action = Gio.SimpleAction.new_stateful(
268 'showEpisodeDescription', None, GLib.Variant.new_boolean(self.config.episode_list_descriptions))
269 action.connect('activate', self.on_itemShowDescription_activate)
270 g.add_action(action)
272 action = Gio.SimpleAction.new_stateful(
273 'viewHideBoringPodcasts', None, GLib.Variant.new_boolean(self.config.podcast_list_hide_boring))
274 action.connect('activate', self.on_item_view_hide_boring_podcasts_toggled)
275 g.add_action(action)
277 action = Gio.SimpleAction.new_stateful(
278 'viewAlwaysShowNewEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.always_show_new))
279 action.connect('activate', self.on_item_view_always_show_new_episodes_toggled)
280 g.add_action(action)
282 action = Gio.SimpleAction.new_stateful(
283 'viewCtrlClickToSortEpisodes', None, GLib.Variant.new_boolean(self.config.ui.gtk.episode_list.ctrl_click_to_sort))
284 action.connect('activate', self.on_item_view_ctrl_click_to_sort_episodes_toggled)
285 g.add_action(action)
287 action = Gio.SimpleAction.new_stateful(
288 'searchAlwaysVisible', None, GLib.Variant.new_boolean(self.config.ui.gtk.search_always_visible))
289 action.connect('activate', self.on_item_view_search_always_visible_toggled)
290 g.add_action(action)
292 value = EpisodeListModel.VIEWS[
293 self.config.episode_list_view_mode or EpisodeListModel.VIEW_ALL]
294 action = Gio.SimpleAction.new_stateful(
295 'viewEpisodes', GLib.VariantType.new('s'),
296 GLib.Variant.new_string(value))
297 action.connect('activate', self.on_item_view_episodes_changed)
298 g.add_action(action)
300 action_defs = [
301 ('update', self.on_itemUpdate_activate),
302 ('downloadAllNew', self.on_itemDownloadAllNew_activate),
303 ('removeOldEpisodes', self.on_itemRemoveOldEpisodes_activate),
304 ('discover', self.on_itemImportChannels_activate),
305 ('addChannel', self.on_itemAddChannel_activate),
306 ('massUnsubscribe', self.on_itemMassUnsubscribe_activate),
307 ('updateChannel', self.on_itemUpdateChannel_activate),
308 ('editChannel', self.on_itemEditChannel_activate),
309 ('importFromFile', self.on_item_import_from_file_activate),
310 ('exportChannels', self.on_itemExportChannels_activate),
311 ('play', self.on_playback_selected_episodes),
312 ('open', self.on_playback_selected_episodes),
313 ('download', self.on_download_selected_episodes),
314 ('pause', self.on_pause_selected_episodes),
315 ('cancel', self.on_item_cancel_download_activate),
316 ('delete', self.on_btnDownloadedDelete_clicked),
317 ('toggleEpisodeNew', self.on_item_toggle_played_activate),
318 ('toggleEpisodeLock', self.on_item_toggle_lock_activate),
319 ('openEpisodeDownloadFolder', self.on_open_episode_download_folder),
320 ('toggleShownotes', self.on_shownotes_selected_episodes),
321 ('sync', self.on_sync_to_device_activate),
322 ('findPodcast', self.on_find_podcast_activate),
323 ('findEpisode', self.on_find_episode_activate),
326 for name, callback in action_defs:
327 action = Gio.SimpleAction.new(name, None)
328 action.connect('activate', callback)
329 g.add_action(action)
331 self.update_action = g.lookup_action('update')
332 self.update_channel_action = g.lookup_action('updateChannel')
333 self.edit_channel_action = g.lookup_action('editChannel')
334 self.play_action = g.lookup_action('play')
335 self.open_action = g.lookup_action('open')
336 self.download_action = g.lookup_action('download')
337 self.pause_action = g.lookup_action('pause')
338 self.cancel_action = g.lookup_action('cancel')
339 self.delete_action = g.lookup_action('delete')
340 self.toggle_episode_new_action = g.lookup_action('toggleEpisodeNew')
341 self.toggle_episode_lock_action = g.lookup_action('toggleEpisodeLock')
342 self.open_episode_download_folder_action = g.lookup_action('openEpisodeDownloadFolder')
344 action = Gio.SimpleAction.new_stateful(
345 'showToolbar', None, GLib.Variant.new_boolean(self.config.show_toolbar))
346 action.connect('activate', self.on_itemShowToolbar_activate)
347 g.add_action(action)
349 def inject_extensions_menu(self):
351 Update Extras/Extensions menu.
352 Called at startup and when en/dis-abling extenstions.
354 def gen_callback(label, callback):
355 return lambda action, param: callback()
357 for a in self.extensions_actions:
358 self.gPodder.remove_action(a.get_property('name'))
359 self.extensions_actions = []
361 if self.extensions_menu is None:
362 # insert menu section at startup (hides when empty)
363 self.extensions_menu = Gio.Menu.new()
364 self.application.menu_extras.append_section(_('Extensions'), self.extensions_menu)
365 else:
366 self.extensions_menu.remove_all()
368 extension_entries = gpodder.user_extensions.on_create_menu()
369 if extension_entries:
370 # populate menu
371 for i, (label, callback) in enumerate(extension_entries):
372 action_id = 'extensions.action_%d' % i
373 action = Gio.SimpleAction.new(action_id)
374 action.connect('activate', gen_callback(label, callback))
375 self.extensions_actions.append(action)
376 self.gPodder.add_action(action)
377 itm = Gio.MenuItem.new(label, 'win.' + action_id)
378 self.extensions_menu.append_item(itm)
380 def find_partial_downloads(self):
381 def start_progress_callback(count):
382 if count:
383 self.partial_downloads_indicator = ProgressIndicator(
384 _('Loading incomplete downloads'),
385 _('Some episodes have not finished downloading in a previous session.'),
386 False, self.get_dialog_parent())
387 self.partial_downloads_indicator.on_message(N_(
388 '%(count)d partial file', '%(count)d partial files',
389 count) % {'count': count})
391 util.idle_add(self.wNotebook.set_current_page, 1)
393 def progress_callback(title, progress):
394 self.partial_downloads_indicator.on_message(title)
395 self.partial_downloads_indicator.on_progress(progress)
397 def finish_progress_callback(resumable_episodes):
398 def offer_resuming():
399 if resumable_episodes:
400 self.download_episode_list_paused(resumable_episodes)
401 resume_all = Gtk.Button(_('Resume all'))
403 def on_resume_all(button):
404 selection = self.treeDownloads.get_selection()
405 selection.select_all()
406 selected_tasks, _, _, _, _, _ = self.downloads_list_get_selection()
407 selection.unselect_all()
408 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
409 self.message_area.hide()
410 resume_all.connect('clicked', on_resume_all)
412 self.message_area = SimpleMessageArea(
413 _('Incomplete downloads from a previous session were found.'),
414 (resume_all,))
415 self.vboxDownloadStatusWidgets.attach(self.message_area, 0, -1, 1, 1)
416 self.message_area.show_all()
417 else:
418 util.idle_add(self.wNotebook.set_current_page, 0)
419 logger.debug("find_partial_downloads done, calling extensions")
420 gpodder.user_extensions.on_find_partial_downloads_done()
422 if self.partial_downloads_indicator:
423 util.idle_add(self.partial_downloads_indicator.on_finished)
424 self.partial_downloads_indicator = None
425 util.idle_add(offer_resuming)
427 common.find_partial_downloads(self.channels,
428 start_progress_callback,
429 progress_callback,
430 finish_progress_callback)
432 def episode_object_by_uri(self, uri):
433 """Get an episode object given a local or remote URI
435 This can be used to quickly access an episode object
436 when all we have is its download filename or episode
437 URL (e.g. from external D-Bus calls / signals, etc..)
439 if uri.startswith('/'):
440 uri = 'file://' + urllib.parse.quote(uri)
442 prefix = 'file://' + urllib.parse.quote(gpodder.downloads)
444 # By default, assume we can't pre-select any channel
445 # but can match episodes simply via the download URL
447 def is_channel(c):
448 return True
450 def is_episode(e):
451 return e.url == uri
453 if uri.startswith(prefix):
454 # File is on the local filesystem in the download folder
455 # Try to reduce search space by pre-selecting the channel
456 # based on the folder name of the local file
458 filename = urllib.parse.unquote(uri[len(prefix):])
459 file_parts = [_f for _f in filename.split(os.sep) if _f]
461 if len(file_parts) != 2:
462 return None
464 foldername, filename = file_parts
466 def is_channel(c):
467 return c.download_folder == foldername
469 def is_episode(e):
470 return e.download_filename == filename
472 # Deep search through channels and episodes for a match
473 for channel in filter(is_channel, self.channels):
474 for episode in filter(is_episode, channel.get_all_episodes()):
475 return episode
477 return None
479 def on_played(self, start, end, total, file_uri):
480 """Handle the "played" signal from a media player"""
481 if start == 0 and end == 0 and total == 0:
482 # Ignore bogus play event
483 return
484 elif end < start + 5:
485 # Ignore "less than five seconds" segments,
486 # as they can happen with seeking, etc...
487 return
489 logger.debug('Received play action: %s (%d, %d, %d)', file_uri, start, end, total)
490 episode = self.episode_object_by_uri(file_uri)
492 if episode is not None:
493 file_type = episode.file_type()
495 now = time.time()
496 if total > 0:
497 episode.total_time = total
498 elif total == 0:
499 # Assume the episode's total time for the action
500 total = episode.total_time
502 assert (episode.current_position_updated is None or
503 now >= episode.current_position_updated)
505 episode.current_position = end
506 episode.current_position_updated = now
507 episode.mark(is_played=True)
508 episode.save()
509 self.episode_list_status_changed([episode])
511 # Submit this action to the webservice
512 self.mygpo_client.on_playback_full(episode, start, end, total)
514 def on_add_remove_podcasts_mygpo(self):
515 actions = self.mygpo_client.get_received_actions()
516 if not actions:
517 return False
519 existing_urls = [c.url for c in self.channels]
521 # Columns for the episode selector window - just one...
522 columns = (
523 ('description', None, None, _('Action')),
526 # A list of actions that have to be chosen from
527 changes = []
529 # Actions that are ignored (already carried out)
530 ignored = []
532 for action in actions:
533 if action.is_add and action.url not in existing_urls:
534 changes.append(my.Change(action))
535 elif action.is_remove and action.url in existing_urls:
536 podcast_object = None
537 for podcast in self.channels:
538 if podcast.url == action.url:
539 podcast_object = podcast
540 break
541 changes.append(my.Change(action, podcast_object))
542 else:
543 ignored.append(action)
545 # Confirm all ignored changes
546 self.mygpo_client.confirm_received_actions(ignored)
548 def execute_podcast_actions(selected):
549 # In the future, we might retrieve the title from gpodder.net here,
550 # but for now, we just use "None" to use the feed-provided title
551 title = None
552 add_list = [(title, c.action.url)
553 for c in selected if c.action.is_add]
554 remove_list = [c.podcast for c in selected if c.action.is_remove]
556 # Apply the accepted changes locally
557 self.add_podcast_list(add_list)
558 self.remove_podcast_list(remove_list, confirm=False)
560 # All selected items are now confirmed
561 self.mygpo_client.confirm_received_actions(c.action for c in selected)
563 # Revert the changes on the server
564 rejected = [c.action for c in changes if c not in selected]
565 self.mygpo_client.reject_received_actions(rejected)
567 def ask():
568 # We're abusing the Episode Selector again ;) -- thp
569 gPodderEpisodeSelector(self.main_window,
570 title=_('Confirm changes from gpodder.net'),
571 instructions=_('Select the actions you want to carry out.'),
572 episodes=changes,
573 columns=columns,
574 size_attribute=None,
575 ok_button=_('A_pply'),
576 callback=execute_podcast_actions,
577 _config=self.config)
579 # There are some actions that need the user's attention
580 if changes:
581 util.idle_add(ask)
582 return True
584 # We have no remaining actions - no selection happens
585 return False
587 def rewrite_urls_mygpo(self):
588 # Check if we have to rewrite URLs since the last add
589 rewritten_urls = self.mygpo_client.get_rewritten_urls()
590 changed = False
592 for rewritten_url in rewritten_urls:
593 if not rewritten_url.new_url:
594 continue
596 for channel in self.channels:
597 if channel.url == rewritten_url.old_url:
598 logger.info('Updating URL of %s to %s', channel,
599 rewritten_url.new_url)
600 channel.url = rewritten_url.new_url
601 channel.save()
602 changed = True
603 break
605 if changed:
606 util.idle_add(self.update_episode_list_model)
608 def on_send_full_subscriptions(self):
609 # Send the full subscription list to the gpodder.net client
610 # (this will overwrite the subscription list on the server)
611 indicator = ProgressIndicator(_('Uploading subscriptions'),
612 _('Your subscriptions are being uploaded to the server.'),
613 False, self.get_dialog_parent())
615 try:
616 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
617 util.idle_add(self.show_message, _('List uploaded successfully.'))
618 except Exception as e:
619 def show_error(e):
620 message = str(e)
621 if not message:
622 message = e.__class__.__name__
623 if message == 'NotFound':
624 message = _(
625 'Could not find your device.\n'
626 '\n'
627 'Check login is a username (not an email)\n'
628 'and that the device name matches one in your account.'
630 self.show_message(html.escape(message),
631 _('Error while uploading'),
632 important=True)
633 util.idle_add(show_error, e)
635 util.idle_add(indicator.on_finished)
637 def on_button_subscribe_clicked(self, button):
638 self.on_itemImportChannels_activate(button)
640 def on_button_downloads_clicked(self, widget):
641 self.downloads_window.show()
643 def on_treeview_button_pressed(self, treeview, event):
644 if event.window != treeview.get_bin_window():
645 return False
647 role = getattr(treeview, TreeViewHelper.ROLE)
648 if role == TreeViewHelper.ROLE_EPISODES and event.button == 1:
649 # Toggle episode "new" status by clicking the icon (bug 1432)
650 result = treeview.get_path_at_pos(int(event.x), int(event.y))
651 if result is not None:
652 path, column, x, y = result
653 # The user clicked the icon if she clicked in the first column
654 # and the x position is in the area where the icon resides
655 if (x < self.EPISODE_LIST_ICON_WIDTH and
656 column == treeview.get_columns()[0]):
657 model = treeview.get_model()
658 cursor_episode = model.get_value(model.get_iter(path),
659 EpisodeListModel.C_EPISODE)
661 new_value = cursor_episode.is_new
662 selected_episodes = self.get_selected_episodes()
664 # Avoid changing anything if the clicked episode is not
665 # selected already - otherwise update all selected
666 if cursor_episode in selected_episodes:
667 for episode in selected_episodes:
668 episode.mark(is_played=new_value)
670 self.update_episode_list_icons(selected=True)
671 self.update_podcast_list_model(selected=True)
672 return True
674 return event.button == 3
676 def on_treeview_podcasts_button_released(self, treeview, event):
677 if event.window != treeview.get_bin_window():
678 return False
680 return self.treeview_channels_show_context_menu(treeview, event)
682 def on_treeview_episodes_button_released(self, treeview, event):
683 if event.window != treeview.get_bin_window():
684 return False
686 return self.treeview_available_show_context_menu(treeview, event)
688 def on_treeview_downloads_button_released(self, treeview, event):
689 if event.window != treeview.get_bin_window():
690 return False
692 return self.treeview_downloads_show_context_menu(treeview, event)
694 def on_find_podcast_activate(self, *args):
695 if self._search_podcasts:
696 self._search_podcasts.show_search()
698 def init_podcast_list_treeview(self):
699 size = cake_size_from_widget(self.treeChannels) * 2
700 scale = self.treeChannels.get_scale_factor()
701 self.podcast_list_model.set_max_image_size(size, scale)
702 # Set up podcast channel tree view widget
703 column = Gtk.TreeViewColumn('')
704 iconcell = Gtk.CellRendererPixbuf()
705 iconcell.set_property('width', size + 10)
706 column.pack_start(iconcell, False)
707 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
708 column.add_attribute(iconcell, 'visible', PodcastListModel.C_COVER_VISIBLE)
709 if scale != 1:
710 column.set_cell_data_func(iconcell, draw_iconcell_scale, scale)
712 namecell = Gtk.CellRendererText()
713 namecell.set_property('ellipsize', Pango.EllipsizeMode.END)
714 column.pack_start(namecell, True)
715 column.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
717 iconcell = Gtk.CellRendererPixbuf()
718 iconcell.set_property('xalign', 1.0)
719 column.pack_start(iconcell, False)
720 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
721 column.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
722 if scale != 1:
723 column.set_cell_data_func(iconcell, draw_iconcell_scale, scale)
725 self.treeChannels.append_column(column)
727 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
728 self.podcast_list_model.widget = self.treeChannels
730 # When no podcast is selected, clear the episode list model
731 selection = self.treeChannels.get_selection()
733 # Set up type-ahead find for the podcast list
734 def on_key_press(treeview, event):
735 if event.keyval == Gdk.KEY_Right:
736 self.treeAvailable.grab_focus()
737 elif event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down):
738 # If section markers exist in the treeview, we want to
739 # "jump over" them when moving the cursor up and down
740 if event.keyval == Gdk.KEY_Up:
741 step = -1
742 else:
743 step = 1
745 selection = self.treeChannels.get_selection()
746 model, it = selection.get_selected()
747 if it is None:
748 it = model.get_iter_first()
749 if it is None:
750 return False
751 step = 1
753 path = model.get_path(it)
754 path = (path[0] + step,)
756 if path[0] < 0:
757 # Valid paths must have a value >= 0
758 return True
760 try:
761 it = model.get_iter(path)
762 except ValueError:
763 # Already at the end of the list
764 return True
766 self.treeChannels.set_cursor(path)
767 elif event.keyval == Gdk.KEY_Escape:
768 self._search_podcasts.hide_search()
769 elif event.get_state() & Gdk.ModifierType.CONTROL_MASK:
770 # Don't handle type-ahead when control is pressed (so shortcuts
771 # with the Ctrl key still work, e.g. Ctrl+A, ...)
772 return True
773 elif event.keyval == Gdk.KEY_Delete:
774 return False
775 else:
776 unicode_char_id = Gdk.keyval_to_unicode(event.keyval)
777 # < 32 to intercept Delete and Tab events
778 if unicode_char_id < 32:
779 return False
780 input_char = chr(unicode_char_id)
781 self._search_podcasts.show_search(input_char)
782 return True
783 self.treeChannels.connect('key-press-event', on_key_press)
785 self.treeChannels.connect('popup-menu', self.treeview_channels_show_context_menu)
787 # Enable separators to the podcast list to separate special podcasts
788 # from others (this is used for the "all episodes" view)
789 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
791 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
793 self._search_podcasts = SearchTree(self.hbox_search_podcasts,
794 self.entry_search_podcasts,
795 self.treeChannels,
796 self.podcast_list_model,
797 self.config)
798 if self.config.ui.gtk.search_always_visible:
799 self._search_podcasts.show_search(grab_focus=False)
801 def on_find_episode_activate(self, *args):
802 if self._search_episodes:
803 self._search_episodes.show_search()
805 def set_episode_list_column(self, index, new_value):
806 mask = (1 << index)
807 if new_value:
808 self.config.episode_list_columns |= mask
809 else:
810 self.config.episode_list_columns &= ~mask
812 def update_episode_list_columns_visibility(self):
813 columns = TreeViewHelper.get_columns(self.treeAvailable)
814 for index, column in enumerate(columns):
815 visible = bool(self.config.episode_list_columns & (1 << index))
816 column.set_visible(visible)
817 self.view_column_actions[index].set_state(GLib.Variant.new_boolean(visible))
818 self.treeAvailable.columns_autosize()
820 def on_episode_list_header_reordered(self, treeview):
821 self.config.ui.gtk.state.main_window.episode_column_order = \
822 [column.get_sort_column_id() for column in treeview.get_columns()]
824 def on_episode_list_header_sorted(self, column):
825 self.config.ui.gtk.state.main_window.episode_column_sort_id = column.get_sort_column_id()
826 self.config.ui.gtk.state.main_window.episode_column_sort_order = \
827 (column.get_sort_order() is Gtk.SortType.ASCENDING)
829 def on_episode_list_header_clicked(self, button, event):
830 if event.button == 1:
831 # Require control click to sort episodes, when enabled
832 if self.config.ui.gtk.episode_list.ctrl_click_to_sort and (event.state & Gdk.ModifierType.CONTROL_MASK) == 0:
833 return True
834 elif event.button == 3:
835 if self.episode_columns_menu is not None:
836 self.episode_columns_menu.popup(None, None, None, None, event.button, event.time)
838 return False
840 def init_episode_list_treeview(self):
841 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
843 # Initialize progress icons
844 cake_size = cake_size_from_widget(self.treeAvailable)
845 for i in range(EpisodeListModel.PROGRESS_STEPS + 1):
846 pixbuf = draw_cake_pixbuf(i /
847 EpisodeListModel.PROGRESS_STEPS, size=cake_size)
848 icon_name = 'gpodder-progress-%d' % i
849 Gtk.IconTheme.add_builtin_icon(icon_name, cake_size, pixbuf)
851 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
853 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
855 iconcell = Gtk.CellRendererPixbuf()
856 episode_list_icon_size = Gtk.icon_size_register('episode-list',
857 cake_size, cake_size)
858 iconcell.set_property('stock-size', episode_list_icon_size)
859 iconcell.set_fixed_size(cake_size + 20, -1)
860 self.EPISODE_LIST_ICON_WIDTH = cake_size
862 namecell = Gtk.CellRendererText()
863 namecell.set_property('ellipsize', Pango.EllipsizeMode.END)
864 namecolumn = Gtk.TreeViewColumn(_('Episode'))
865 namecolumn.pack_start(iconcell, False)
866 namecolumn.add_attribute(iconcell, 'icon-name', EpisodeListModel.C_STATUS_ICON)
867 namecolumn.pack_start(namecell, True)
868 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
869 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
870 namecolumn.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
871 namecolumn.set_resizable(True)
872 namecolumn.set_expand(True)
874 lockcell = Gtk.CellRendererPixbuf()
875 lockcell.set_fixed_size(40, -1)
876 lockcell.set_property('stock-size', Gtk.IconSize.MENU)
877 lockcell.set_property('icon-name', 'emblem-readonly')
878 namecolumn.pack_start(lockcell, False)
879 namecolumn.add_attribute(lockcell, 'visible', EpisodeListModel.C_LOCKED)
881 sizecell = Gtk.CellRendererText()
882 sizecell.set_property('xalign', 1)
883 sizecolumn = Gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
884 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
886 timecell = Gtk.CellRendererText()
887 timecell.set_property('xalign', 1)
888 timecolumn = Gtk.TreeViewColumn(_('Duration'), timecell, text=EpisodeListModel.C_TIME)
889 timecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME)
891 releasecell = Gtk.CellRendererText()
892 releasecolumn = Gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
893 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
895 sizetimecell = Gtk.CellRendererText()
896 sizetimecell.set_property('xalign', 1)
897 sizetimecell.set_property('alignment', Pango.Alignment.RIGHT)
898 sizetimecolumn = Gtk.TreeViewColumn(_('Size+'))
899 sizetimecolumn.pack_start(sizetimecell, True)
900 sizetimecolumn.add_attribute(sizetimecell, 'markup', EpisodeListModel.C_FILESIZE_AND_TIME_TEXT)
901 sizetimecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE_AND_TIME)
903 timesizecell = Gtk.CellRendererText()
904 timesizecell.set_property('xalign', 1)
905 timesizecell.set_property('alignment', Pango.Alignment.RIGHT)
906 timesizecolumn = Gtk.TreeViewColumn(_('Duration+'))
907 timesizecolumn.pack_start(timesizecell, True)
908 timesizecolumn.add_attribute(timesizecell, 'markup', EpisodeListModel.C_TIME_AND_SIZE)
909 timesizecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME_AND_SIZE)
911 namecolumn.set_reorderable(True)
912 self.treeAvailable.append_column(namecolumn)
914 # EpisodeListModel.C_PUBLISHED is not available in config.py, set it here on first run
915 if not self.config.ui.gtk.state.main_window.episode_column_sort_id:
916 self.config.ui.gtk.state.main_window.episode_column_sort_id = EpisodeListModel.C_PUBLISHED
918 for itemcolumn in (sizecolumn, timecolumn, releasecolumn, sizetimecolumn, timesizecolumn):
919 itemcolumn.set_reorderable(True)
920 self.treeAvailable.append_column(itemcolumn)
921 TreeViewHelper.register_column(self.treeAvailable, itemcolumn)
923 # Add context menu to all tree view column headers
924 for column in self.treeAvailable.get_columns():
925 label = Gtk.Label(label=column.get_title())
926 label.show_all()
927 column.set_widget(label)
929 w = column.get_widget()
930 while w is not None and not isinstance(w, Gtk.Button):
931 w = w.get_parent()
933 w.connect('button-release-event', self.on_episode_list_header_clicked)
935 # Restore column sorting
936 if column.get_sort_column_id() == self.config.ui.gtk.state.main_window.episode_column_sort_id:
937 self.episode_list_model._sorter.set_sort_column_id(Gtk.TREE_SORTABLE_UNSORTED_SORT_COLUMN_ID,
938 Gtk.SortType.DESCENDING)
939 self.episode_list_model._sorter.set_sort_column_id(column.get_sort_column_id(),
940 Gtk.SortType.ASCENDING if self.config.ui.gtk.state.main_window.episode_column_sort_order
941 else Gtk.SortType.DESCENDING)
942 # Save column sorting when user clicks column headers
943 column.connect('clicked', self.on_episode_list_header_sorted)
945 def restore_column_ordering():
946 prev_column = None
947 for col in self.config.ui.gtk.state.main_window.episode_column_order:
948 for column in self.treeAvailable.get_columns():
949 if col is column.get_sort_column_id():
950 break
951 else:
952 # Column ID not found, abort
953 # Manually re-ordering columns should fix the corrupt setting
954 break
955 self.treeAvailable.move_column_after(column, prev_column)
956 prev_column = column
957 # Save column ordering when user drags column headers
958 self.treeAvailable.connect('columns-changed', self.on_episode_list_header_reordered)
959 # Delay column ordering until shown to prevent "Negative content height" warnings for themes with vertical padding or borders
960 util.idle_add(restore_column_ordering)
962 # For each column that can be shown/hidden, add a menu item
963 self.view_column_actions = []
964 columns = TreeViewHelper.get_columns(self.treeAvailable)
966 def on_visible_toggled(action, param, index):
967 state = action.get_state()
968 self.set_episode_list_column(index, not state)
969 action.set_state(GLib.Variant.new_boolean(not state))
971 for index, column in enumerate(columns):
972 name = 'showColumn%i' % index
973 action = Gio.SimpleAction.new_stateful(
974 name, None, GLib.Variant.new_boolean(False))
975 action.connect('activate', on_visible_toggled, index)
976 self.main_window.add_action(action)
977 self.view_column_actions.append(action)
978 self.application.menu_view_columns.insert(index, column.get_title(), 'win.' + name)
980 self.episode_columns_menu = Gtk.Menu.new_from_model(self.application.menu_view_columns)
981 self.episode_columns_menu.attach_to_widget(self.main_window)
982 # Update the visibility of the columns and the check menu items
983 self.update_episode_list_columns_visibility()
985 # Set up type-ahead find for the episode list
986 def on_key_press(treeview, event):
987 if event.keyval == Gdk.KEY_Left:
988 self.treeChannels.grab_focus()
989 elif event.keyval == Gdk.KEY_Escape:
990 if self.hbox_search_episodes.get_property('visible'):
991 self._search_episodes.hide_search()
992 else:
993 self.shownotes_object.hide_pane()
994 elif event.get_state() & Gdk.ModifierType.CONTROL_MASK:
995 # Don't handle type-ahead when control is pressed (so shortcuts
996 # with the Ctrl key still work, e.g. Ctrl+A, ...)
997 return False
998 else:
999 unicode_char_id = Gdk.keyval_to_unicode(event.keyval)
1000 # < 32 to intercept Delete and Tab events
1001 if unicode_char_id < 32:
1002 return False
1003 input_char = chr(unicode_char_id)
1004 self._search_episodes.show_search(input_char)
1005 return True
1006 self.treeAvailable.connect('key-press-event', on_key_press)
1008 self.treeAvailable.connect('popup-menu', self.treeview_available_show_context_menu)
1010 self.treeAvailable.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK,
1011 (('text/uri-list', 0, 0),), Gdk.DragAction.COPY)
1013 def drag_data_get(tree, context, selection_data, info, timestamp):
1014 uris = ['file://' + e.local_filename(create=False)
1015 for e in self.get_selected_episodes()
1016 if e.was_downloaded(and_exists=True)]
1017 selection_data.set_uris(uris)
1018 self.treeAvailable.connect('drag-data-get', drag_data_get)
1020 selection = self.treeAvailable.get_selection()
1021 selection.set_mode(Gtk.SelectionMode.MULTIPLE)
1022 self.episode_selection_handler_id = selection.connect('changed', self.on_episode_list_selection_changed)
1024 self._search_episodes = SearchTree(self.hbox_search_episodes,
1025 self.entry_search_episodes,
1026 self.treeAvailable,
1027 self.episode_list_model,
1028 self.config)
1029 if self.config.ui.gtk.search_always_visible:
1030 self._search_episodes.show_search(grab_focus=False)
1032 def on_episode_list_selection_changed(self, selection):
1033 # Update the toolbar buttons
1034 self.play_or_download()
1035 # and the shownotes
1036 self.shownotes_object.set_episodes(self.get_selected_episodes())
1038 def on_download_list_selection_changed(self, selection):
1039 if self.wNotebook.get_current_page() > 0:
1040 # Update the toolbar buttons
1041 self.play_or_download()
1043 def init_download_list_treeview(self):
1044 # columns and renderers for "download progress" tab
1045 # First column: [ICON] Episodename
1046 column = Gtk.TreeViewColumn(_('Episode'))
1048 cell = Gtk.CellRendererPixbuf()
1049 cell.set_property('stock-size', Gtk.IconSize.BUTTON)
1050 column.pack_start(cell, False)
1051 column.add_attribute(cell, 'icon-name',
1052 DownloadStatusModel.C_ICON_NAME)
1054 cell = Gtk.CellRendererText()
1055 cell.set_property('ellipsize', Pango.EllipsizeMode.END)
1056 column.pack_start(cell, True)
1057 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
1058 column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
1059 column.set_expand(True)
1060 self.treeDownloads.append_column(column)
1062 # Second column: Progress
1063 cell = Gtk.CellRendererProgress()
1064 cell.set_property('yalign', .5)
1065 cell.set_property('ypad', 6)
1066 column = Gtk.TreeViewColumn(_('Progress'), cell,
1067 value=DownloadStatusModel.C_PROGRESS,
1068 text=DownloadStatusModel.C_PROGRESS_TEXT)
1069 column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
1070 column.set_expand(False)
1071 self.treeDownloads.append_column(column)
1072 column.set_property('min-width', 150)
1073 column.set_property('max-width', 150)
1075 self.treeDownloads.set_model(self.download_status_model)
1076 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
1078 self.treeDownloads.connect('popup-menu', self.treeview_downloads_show_context_menu)
1080 # enable multiple selection support
1081 selection = self.treeDownloads.get_selection()
1082 selection.set_mode(Gtk.SelectionMode.MULTIPLE)
1083 self.download_selection_handler_id = selection.connect('changed', self.on_download_list_selection_changed)
1084 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
1086 def on_treeview_expose_event(self, treeview, ctx):
1087 model = treeview.get_model()
1088 if (model is not None and model.get_iter_first() is not None):
1089 return False
1091 role = getattr(treeview, TreeViewHelper.ROLE, None)
1092 if role is None:
1093 return False
1095 width = treeview.get_allocated_width()
1096 height = treeview.get_allocated_height()
1098 if role == TreeViewHelper.ROLE_EPISODES:
1099 if self.config.episode_list_view_mode != EpisodeListModel.VIEW_ALL:
1100 text = _('No episodes in current view')
1101 else:
1102 text = _('No episodes available')
1103 elif role == TreeViewHelper.ROLE_PODCASTS:
1104 if self.config.episode_list_view_mode != \
1105 EpisodeListModel.VIEW_ALL and \
1106 self.config.podcast_list_hide_boring and \
1107 len(self.channels) > 0:
1108 text = _('No podcasts in this view')
1109 else:
1110 text = _('No subscriptions')
1111 elif role == TreeViewHelper.ROLE_DOWNLOADS:
1112 text = _('No active tasks')
1113 else:
1114 raise Exception('on_treeview_expose_event: unknown role')
1116 draw_text_box_centered(ctx, treeview, width, height, text, None, None)
1117 return True
1119 def set_download_list_state(self, state):
1120 if state == gPodderSyncUI.DL_ADDING_TASKS:
1121 self.things_adding_tasks += 1
1122 elif state == gPodderSyncUI.DL_ADDED_TASKS:
1123 self.things_adding_tasks -= 1
1124 if not self.download_list_update_enabled:
1125 self.update_downloads_list()
1126 GObject.timeout_add(1500, self.update_downloads_list)
1127 self.download_list_update_enabled = True
1129 def cleanup_downloads(self):
1130 model = self.download_status_model
1132 all_tasks = [(Gtk.TreeRowReference.new(model, row.path), row[0]) for row in model]
1133 changed_episode_urls = set()
1134 for row_reference, task in all_tasks:
1135 if task.status in (task.DONE, task.CANCELLED):
1136 model.remove(model.get_iter(row_reference.get_path()))
1137 try:
1138 # We don't "see" this task anymore - remove it;
1139 # this is needed, so update_episode_list_icons()
1140 # below gets the correct list of "seen" tasks
1141 self.download_tasks_seen.remove(task)
1142 except KeyError as key_error:
1143 pass
1144 changed_episode_urls.add(task.url)
1145 # Tell the task that it has been removed (so it can clean up)
1146 task.removed_from_list()
1148 # Tell the podcasts tab to update icons for our removed podcasts
1149 self.update_episode_list_icons(changed_episode_urls)
1151 # Update the downloads list one more time
1152 self.update_downloads_list(can_call_cleanup=False)
1154 def on_tool_downloads_toggled(self, toolbutton):
1155 if toolbutton.get_active():
1156 self.wNotebook.set_current_page(1)
1157 else:
1158 self.wNotebook.set_current_page(0)
1160 def add_download_task_monitor(self, monitor):
1161 self.download_task_monitors.add(monitor)
1162 model = self.download_status_model
1163 if model is None:
1164 model = ()
1165 for row in model.get_model():
1166 task = row[self.download_status_model.C_TASK]
1167 monitor.task_updated(task)
1169 def remove_download_task_monitor(self, monitor):
1170 self.download_task_monitors.remove(monitor)
1172 def set_download_progress(self, progress):
1173 gpodder.user_extensions.on_download_progress(progress)
1175 def update_downloads_list(self, can_call_cleanup=True):
1176 try:
1177 model = self.download_status_model
1179 downloading, synchronizing, pausing, cancelling, queued, paused, failed, finished, others = (0,) * 9
1180 total_speed, total_size, done_size = 0, 0, 0
1181 files_downloading = 0
1183 # Keep a list of all download tasks that we've seen
1184 download_tasks_seen = set()
1186 # Do not go through the list of the model is not (yet) available
1187 if model is None:
1188 model = ()
1190 for row in model:
1191 self.download_status_model.request_update(row.iter)
1193 task = row[self.download_status_model.C_TASK]
1194 speed, size, status, progress, activity = task.speed, task.total_size, task.status, task.progress, task.activity
1196 # Let the download task monitors know of changes
1197 for monitor in self.download_task_monitors:
1198 monitor.task_updated(task)
1200 total_size += size
1201 done_size += size * progress
1203 download_tasks_seen.add(task)
1205 if status == download.DownloadTask.DOWNLOADING:
1206 if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
1207 downloading += 1
1208 files_downloading += 1
1209 total_speed += speed
1210 elif activity == download.DownloadTask.ACTIVITY_SYNCHRONIZE:
1211 synchronizing += 1
1212 else:
1213 others += 1
1214 elif status == download.DownloadTask.PAUSING:
1215 pausing += 1
1216 if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
1217 files_downloading += 1
1218 elif status == download.DownloadTask.CANCELLING:
1219 cancelling += 1
1220 if activity == download.DownloadTask.ACTIVITY_DOWNLOAD:
1221 files_downloading += 1
1222 elif status == download.DownloadTask.QUEUED:
1223 queued += 1
1224 elif status == download.DownloadTask.PAUSED:
1225 paused += 1
1226 elif status == download.DownloadTask.FAILED:
1227 failed += 1
1228 elif status == download.DownloadTask.DONE:
1229 finished += 1
1230 else:
1231 others += 1
1233 # TODO: 'others' is not used
1235 # Remember which tasks we have seen after this run
1236 self.download_tasks_seen = download_tasks_seen
1238 text = [_('Progress')]
1239 if downloading + synchronizing + pausing + cancelling + queued + paused + failed > 0:
1240 s = []
1241 if downloading > 0:
1242 s.append(N_('%(count)d active', '%(count)d active', downloading) % {'count': downloading})
1243 if synchronizing > 0:
1244 s.append(N_('%(count)d active', '%(count)d active', synchronizing) % {'count': synchronizing})
1245 if pausing > 0:
1246 s.append(N_('%(count)d pausing', '%(count)d pausing', pausing) % {'count': pausing})
1247 if cancelling > 0:
1248 s.append(N_('%(count)d cancelling', '%(count)d cancelling', cancelling) % {'count': cancelling})
1249 if queued > 0:
1250 s.append(N_('%(count)d queued', '%(count)d queued', queued) % {'count': queued})
1251 if paused > 0:
1252 s.append(N_('%(count)d paused', '%(count)d paused', paused) % {'count': paused})
1253 if failed > 0:
1254 s.append(N_('%(count)d failed', '%(count)d failed', failed) % {'count': failed})
1255 text.append(' (' + ', '.join(s) + ')')
1256 self.labelDownloads.set_text(''.join(text))
1258 title = [self.default_title]
1260 # Accessing task.status_changed has the side effect of re-setting
1261 # the changed flag, but we only do it once here so that's okay
1262 channel_urls = [task.podcast_url for task in
1263 self.download_tasks_seen if task.status_changed]
1264 episode_urls = [task.url for task in self.download_tasks_seen]
1266 if files_downloading > 0:
1267 title.append(N_('downloading %(count)d file',
1268 'downloading %(count)d files',
1269 files_downloading) % {'count': files_downloading})
1271 if total_size > 0:
1272 percentage = 100.0 * done_size / total_size
1273 else:
1274 percentage = 0.0
1275 self.set_download_progress(percentage / 100)
1276 total_speed = util.format_filesize(total_speed)
1277 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1278 if synchronizing > 0:
1279 title.append(N_('synchronizing %(count)d file',
1280 'synchronizing %(count)d files',
1281 synchronizing) % {'count': synchronizing})
1282 if queued > 0:
1283 title.append(N_('%(queued)d task queued',
1284 '%(queued)d tasks queued',
1285 queued) % {'queued': queued})
1286 if (downloading + synchronizing + pausing + cancelling + queued) == 0 and self.things_adding_tasks == 0:
1287 self.set_download_progress(1.)
1288 self.downloads_finished(self.download_tasks_seen)
1289 gpodder.user_extensions.on_all_episodes_downloaded()
1290 logger.info('All tasks have finished.')
1292 # Remove finished episodes
1293 if self.config.ui.gtk.download_list.remove_finished and can_call_cleanup:
1294 self.cleanup_downloads()
1296 # Stop updating the download list here
1297 self.download_list_update_enabled = False
1299 self.gPodder.set_title(' - '.join(title))
1301 self.update_episode_list_icons(episode_urls)
1302 self.play_or_download()
1303 if channel_urls:
1304 self.update_podcast_list_model(channel_urls)
1306 return self.download_list_update_enabled
1307 except Exception as e:
1308 logger.error('Exception happened while updating download list.', exc_info=True)
1309 self.show_message(
1310 '%s\n\n%s' % (_('Please report this problem and restart gPodder:'), html.escape(str(e))),
1311 _('Unhandled exception'), important=True)
1312 # We return False here, so the update loop won't be called again,
1313 # that's why we require the restart of gPodder in the message.
1314 return False
1316 def on_config_changed(self, *args):
1317 util.idle_add(self._on_config_changed, *args)
1319 def _on_config_changed(self, name, old_value, new_value):
1320 if name == 'ui.gtk.toolbar':
1321 self.toolbar.set_property('visible', new_value)
1322 elif name in ('ui.gtk.episode_list.descriptions',
1323 'ui.gtk.episode_list.always_show_new'):
1324 self.update_episode_list_model()
1325 elif name in ('auto.update.enabled', 'auto.update.frequency'):
1326 self.restart_auto_update_timer()
1327 elif name in ('ui.gtk.podcast_list.all_episodes',
1328 'ui.gtk.podcast_list.sections'):
1329 # Force a update of the podcast list model
1330 self.update_podcast_list_model()
1331 elif name == 'ui.gtk.episode_list.columns':
1332 self.update_episode_list_columns_visibility()
1334 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1335 # With get_bin_window, we get the window that contains the rows without
1336 # the header. The Y coordinate of this window will be the height of the
1337 # treeview header. This is the amount we have to subtract from the
1338 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1339 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1340 x -= x_bin
1341 y -= y_bin
1342 (path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,) * 4
1344 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or x > 50 or (column is not None and column != treeview.get_columns()[0]):
1345 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1346 return False
1348 if path is not None:
1349 model = treeview.get_model()
1350 iter = model.get_iter(path)
1351 role = getattr(treeview, TreeViewHelper.ROLE)
1353 if role == TreeViewHelper.ROLE_EPISODES:
1354 id = model.get_value(iter, EpisodeListModel.C_URL)
1355 elif role == TreeViewHelper.ROLE_PODCASTS:
1356 id = model.get_value(iter, PodcastListModel.C_URL)
1357 if id == '-':
1358 # Section header - no tooltip here (for now at least)
1359 return False
1361 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1362 if last_tooltip is not None and last_tooltip != id:
1363 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1364 return False
1365 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1367 if role == TreeViewHelper.ROLE_EPISODES:
1368 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1369 if description:
1370 tooltip.set_text(description)
1371 else:
1372 return False
1373 elif role == TreeViewHelper.ROLE_PODCASTS:
1374 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1375 if channel is None or not hasattr(channel, 'title'):
1376 return False
1377 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1378 if error_str:
1379 error_str = _('Feedparser error: %s') % html.escape(error_str.strip())
1380 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1382 box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
1383 box.set_border_width(5)
1385 heading = Gtk.Label()
1386 heading.set_max_width_chars(60)
1387 heading.set_alignment(0, 1)
1388 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (html.escape(channel.title), html.escape(channel.url)))
1389 box.add(heading)
1391 box.add(Gtk.HSeparator())
1393 channel_description = util.remove_html_tags(channel.description)
1394 if channel._update_error is not None:
1395 description = _('ERROR: %s') % channel._update_error
1396 elif len(channel_description) < 500:
1397 description = channel_description
1398 else:
1399 pos = channel_description.find('\n\n')
1400 if pos == -1 or pos > 500:
1401 description = channel_description[:498] + '[...]'
1402 else:
1403 description = channel_description[:pos]
1405 description = Gtk.Label(label=description)
1406 description.set_max_width_chars(60)
1407 if error_str:
1408 description.set_markup(error_str)
1409 description.set_alignment(0, 0)
1410 description.set_line_wrap(True)
1411 box.add(description)
1413 box.show_all()
1414 tooltip.set_custom(box)
1416 return True
1418 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1419 return False
1421 def treeview_allow_tooltips(self, treeview, allow):
1422 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1424 def treeview_handle_context_menu_click(self, treeview, event):
1425 if event is None:
1426 selection = treeview.get_selection()
1427 return selection.get_selected_rows()
1429 x, y = int(event.x), int(event.y)
1430 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,) * 4
1432 selection = treeview.get_selection()
1433 model, paths = selection.get_selected_rows()
1435 if path is None or (path not in paths and
1436 event.button == 3):
1437 # We have right-clicked, but not into the selection,
1438 # assume we don't want to operate on the selection
1439 paths = []
1441 if (path is not None and not paths and
1442 event.button == 3):
1443 # No selection or clicked outside selection;
1444 # select the single item where we clicked
1445 treeview.grab_focus()
1446 treeview.set_cursor(path, column, 0)
1447 paths = [path]
1449 if not paths:
1450 # Unselect any remaining items (clicked elsewhere)
1451 if not treeview.is_rubber_banding_active():
1452 selection.unselect_all()
1454 return model, paths
1456 def downloads_list_get_selection(self, model=None, paths=None):
1457 if model is None and paths is None:
1458 selection = self.treeDownloads.get_selection()
1459 model, paths = selection.get_selected_rows()
1461 can_force, can_queue, can_pause, can_cancel, can_remove = (True,) * 5
1462 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
1463 model.get_value(model.get_iter(path),
1464 DownloadStatusModel.C_TASK)) for path in paths]
1466 for row_reference, task in selected_tasks:
1467 if task.status != download.DownloadTask.QUEUED:
1468 can_force = False
1469 if not task.can_queue():
1470 can_queue = False
1471 if not task.can_pause():
1472 can_pause = False
1473 if not task.can_cancel():
1474 can_cancel = False
1475 if not task.can_remove():
1476 can_remove = False
1478 return selected_tasks, can_force, can_queue, can_pause, can_cancel, can_remove
1480 def downloads_finished(self, download_tasks_seen):
1481 # Separate tasks into downloads & syncs
1482 # Since calling notify_as_finished or notify_as_failed clears the flag,
1483 # need to iterate through downloads & syncs separately, else all sync
1484 # tasks will have their flags cleared if we do downloads first
1486 def filter_by_activity(activity, tasks):
1487 return [task for task in tasks if task.activity == activity]
1489 download_tasks = filter_by_activity(download.DownloadTask.ACTIVITY_DOWNLOAD,
1490 download_tasks_seen)
1492 finished_downloads = [str(task)
1493 for task in download_tasks if task.notify_as_finished()]
1494 failed_downloads = ['%s (%s)' % (task, task.error_message)
1495 for task in download_tasks if task.notify_as_failed()]
1497 sync_tasks = filter_by_activity(download.DownloadTask.ACTIVITY_SYNCHRONIZE,
1498 download_tasks_seen)
1500 finished_syncs = [task for task in sync_tasks if task.notify_as_finished()]
1501 failed_syncs = [task for task in sync_tasks if task.notify_as_failed()]
1503 # Note that 'finished_ / failed_downloads' is a list of strings
1504 # Whereas 'finished_ / failed_syncs' is a list of SyncTask objects
1506 if finished_downloads and failed_downloads:
1507 message = self.format_episode_list(finished_downloads, 5)
1508 message += '\n\n<i>%s</i>\n' % _('Could not download some episodes:')
1509 message += self.format_episode_list(failed_downloads, 5)
1510 self.show_message(message, _('Downloads finished'))
1511 elif finished_downloads:
1512 message = self.format_episode_list(finished_downloads)
1513 self.show_message(message, _('Downloads finished'))
1514 elif failed_downloads:
1515 message = self.format_episode_list(failed_downloads)
1516 self.show_message(message, _('Downloads failed'))
1518 if finished_syncs and failed_syncs:
1519 message = self.format_episode_list(list(map((
1520 lambda task: str(task)), finished_syncs)), 5)
1521 message += '\n\n<i>%s</i>\n' % _('Could not sync some episodes:')
1522 message += self.format_episode_list(list(map((
1523 lambda task: str(task)), failed_syncs)), 5)
1524 self.show_message(message, _('Device synchronization finished'), True)
1525 elif finished_syncs:
1526 message = self.format_episode_list(list(map((
1527 lambda task: str(task)), finished_syncs)))
1528 self.show_message(message, _('Device synchronization finished'))
1529 elif failed_syncs:
1530 message = self.format_episode_list(list(map((
1531 lambda task: str(task)), failed_syncs)))
1532 self.show_message(message, _('Device synchronization failed'), True)
1534 # Do post-sync processing if required
1535 for task in finished_syncs:
1536 if self.config.device_sync.after_sync.mark_episodes_played:
1537 logger.info('Marking as played on transfer: %s', task.episode.url)
1538 task.episode.mark(is_played=True)
1540 if self.config.device_sync.after_sync.delete_episodes:
1541 logger.info('Removing episode after transfer: %s', task.episode.url)
1542 task.episode.delete_from_disk()
1544 self.sync_ui.device.close()
1546 # Update icon list to show changes, if any
1547 self.update_episode_list_icons(all=True)
1548 self.update_podcast_list_model()
1550 def format_episode_list(self, episode_list, max_episodes=10):
1552 Format a list of episode names for notifications
1554 Will truncate long episode names and limit the amount of
1555 episodes displayed (max_episodes=10).
1557 The episode_list parameter should be a list of strings.
1559 MAX_TITLE_LENGTH = 100
1561 result = []
1562 for title in episode_list[:min(len(episode_list), max_episodes)]:
1563 # Bug 1834: make sure title is a unicode string,
1564 # so it may be cut correctly on UTF-8 char boundaries
1565 title = util.convert_bytes(title)
1566 if len(title) > MAX_TITLE_LENGTH:
1567 middle = (MAX_TITLE_LENGTH // 2) - 2
1568 title = '%s...%s' % (title[0:middle], title[-middle:])
1569 result.append(html.escape(title))
1570 result.append('\n')
1572 more_episodes = len(episode_list) - max_episodes
1573 if more_episodes > 0:
1574 result.append('(...')
1575 result.append(N_('%(count)d more episode',
1576 '%(count)d more episodes',
1577 more_episodes) % {'count': more_episodes})
1578 result.append('...)')
1580 return (''.join(result)).strip()
1582 def queue_task(self, task, force_start):
1583 if force_start:
1584 self.download_queue_manager.force_start_task(task)
1585 else:
1586 self.download_queue_manager.queue_task(task)
1588 def _for_each_task_set_status(self, tasks, status, force_start=False):
1589 episode_urls = set()
1590 model = self.treeDownloads.get_model()
1591 for row_reference, task in tasks:
1592 with task:
1593 if status == download.DownloadTask.QUEUED:
1594 # Only queue task when it's paused/failed/cancelled (or forced)
1595 if task.can_queue() or force_start:
1596 # add the task back in if it was already cleaned up
1597 # (to trigger this cancel one downloads in the active list, cancel all
1598 # other downloads, quickly right click on the cancelled on one to get
1599 # the context menu, wait until the active list is cleared, and then
1600 # then choose download)
1601 if task not in self.download_tasks_seen:
1602 self.download_status_model.register_task(task, False)
1603 self.download_tasks_seen.add(task)
1605 self.queue_task(task, force_start)
1606 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
1607 elif status == download.DownloadTask.CANCELLING:
1608 logger.info(("cancelling task %s" % task.status))
1609 task.cancel()
1610 elif status == download.DownloadTask.PAUSING:
1611 task.pause()
1612 elif status is None:
1613 if task.can_cancel():
1614 task.cancel()
1615 path = row_reference.get_path()
1616 # path isn't set if the item has already been removed from the list
1617 # (to trigger this cancel one downloads in the active list, cancel all
1618 # other downloads, quickly right click on the cancelled on one to get
1619 # the context menu, wait until the active list is cleared, and then
1620 # then choose remove from list)
1621 if path:
1622 model.remove(model.get_iter(path))
1623 # Remember the URL, so we can tell the UI to update
1624 try:
1625 # We don't "see" this task anymore - remove it;
1626 # this is needed, so update_episode_list_icons()
1627 # below gets the correct list of "seen" tasks
1628 self.download_tasks_seen.remove(task)
1629 except KeyError as key_error:
1630 pass
1631 episode_urls.add(task.url)
1632 # Tell the task that it has been removed (so it can clean up)
1633 task.removed_from_list()
1634 else:
1635 # We can (hopefully) simply set the task status here
1636 task.status = status
1637 # Tell the podcasts tab to update icons for our removed podcasts
1638 self.update_episode_list_icons(episode_urls)
1639 # Update the tab title and downloads list
1640 self.update_downloads_list()
1642 def treeview_downloads_show_context_menu(self, treeview, event=None):
1643 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1644 if not paths:
1645 return not treeview.is_rubber_banding_active()
1647 if event is None or event.button == 3:
1648 selected_tasks, can_force, can_queue, can_pause, can_cancel, can_remove = \
1649 self.downloads_list_get_selection(model, paths)
1651 def make_menu_item(label, icon_name, tasks=None, status=None, sensitive=True, force_start=False, action=None):
1652 # This creates a menu item for selection-wide actions
1653 item = Gtk.ImageMenuItem.new_with_mnemonic(label)
1654 if icon_name is not None:
1655 item.set_image(Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU))
1656 if action is not None:
1657 item.connect('activate', action)
1658 else:
1659 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status, force_start))
1660 item.set_sensitive(sensitive)
1661 return item
1663 def move_selected_items_up(menu_item):
1664 selection = self.treeDownloads.get_selection()
1665 model, selected_paths = selection.get_selected_rows()
1666 for path in selected_paths:
1667 index_above = path[0] - 1
1668 if index_above < 0:
1669 return
1670 task = model.get_value(
1671 model.get_iter(path),
1672 DownloadStatusModel.C_TASK)
1673 model.move_before(
1674 model.get_iter(path),
1675 model.get_iter((index_above,)))
1677 def move_selected_items_down(menu_item):
1678 selection = self.treeDownloads.get_selection()
1679 model, selected_paths = selection.get_selected_rows()
1680 for path in reversed(selected_paths):
1681 index_below = path[0] + 1
1682 if index_below >= len(model):
1683 return
1684 task = model.get_value(
1685 model.get_iter(path),
1686 DownloadStatusModel.C_TASK)
1687 model.move_after(
1688 model.get_iter(path),
1689 model.get_iter((index_below,)))
1691 menu = Gtk.Menu()
1693 if can_force:
1694 menu.append(make_menu_item(_('Start download now'), 'document-save',
1695 selected_tasks,
1696 download.DownloadTask.QUEUED,
1697 force_start=True))
1698 else:
1699 menu.append(make_menu_item(_('Download'), 'document-save',
1700 selected_tasks,
1701 download.DownloadTask.QUEUED,
1702 can_queue))
1704 menu.append(make_menu_item(_('Pause'), 'media-playback-pause',
1705 selected_tasks,
1706 download.DownloadTask.PAUSING, can_pause))
1707 menu.append(make_menu_item(_('Cancel'), 'media-playback-stop',
1708 selected_tasks,
1709 download.DownloadTask.CANCELLING,
1710 can_cancel))
1711 menu.append(Gtk.SeparatorMenuItem())
1712 menu.append(make_menu_item(_('Move up'), 'go-up',
1713 action=move_selected_items_up))
1714 menu.append(make_menu_item(_('Move down'), 'go-down',
1715 action=move_selected_items_down))
1716 menu.append(Gtk.SeparatorMenuItem())
1717 menu.append(make_menu_item(_('Remove from list'), 'list-remove',
1718 selected_tasks, sensitive=can_remove))
1720 menu.attach_to_widget(treeview)
1721 menu.show_all()
1723 if event is None:
1724 func = TreeViewHelper.make_popup_position_func(treeview)
1725 menu.popup(None, None, func, None, 3, Gtk.get_current_event_time())
1726 else:
1727 menu.popup(None, None, None, None, event.button, event.time)
1728 return True
1730 def on_mark_episodes_as_old(self, item):
1731 assert self.active_channel is not None
1733 for episode in self.active_channel.get_all_episodes():
1734 if not episode.was_downloaded(and_exists=True):
1735 episode.mark(is_played=True)
1737 self.update_podcast_list_model(selected=True)
1738 self.update_episode_list_icons(all=True)
1740 def on_open_download_folder(self, item):
1741 assert self.active_channel is not None
1742 util.gui_open(self.active_channel.save_dir, gui=self)
1744 def on_open_episode_download_folder(self, unused1=None, unused2=None):
1745 episodes = self.get_selected_episodes()
1746 assert len(episodes) == 1
1747 util.gui_open(episodes[0].parent.save_dir, gui=self)
1749 def treeview_channels_show_context_menu(self, treeview, event=None):
1750 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1751 if not paths:
1752 return True
1754 # Check for valid channel id, if there's no id then
1755 # assume that it is a proxy channel or equivalent
1756 # and cannot be operated with right click
1757 if self.active_channel.id is None:
1758 return True
1760 if event is None or event.button == 3:
1761 menu = Gtk.Menu()
1763 item = Gtk.ImageMenuItem(_('Update podcast'))
1764 item.set_image(Gtk.Image.new_from_icon_name('view-refresh', Gtk.IconSize.MENU))
1765 item.set_action_name('win.updateChannel')
1766 menu.append(item)
1768 menu.append(Gtk.SeparatorMenuItem())
1770 item = Gtk.MenuItem(_('Open download folder'))
1771 item.connect('activate', self.on_open_download_folder)
1772 menu.append(item)
1774 menu.append(Gtk.SeparatorMenuItem())
1776 item = Gtk.MenuItem(_('Mark episodes as old'))
1777 item.connect('activate', self.on_mark_episodes_as_old)
1778 menu.append(item)
1780 item = Gtk.CheckMenuItem(_('Archive'))
1781 item.set_active(self.active_channel.auto_archive_episodes)
1782 item.connect('activate', self.on_channel_toggle_lock_activate)
1783 menu.append(item)
1785 item = Gtk.ImageMenuItem(_('Refresh image'))
1786 item.connect('activate', self.on_itemRefreshCover_activate)
1787 menu.append(item)
1789 item = Gtk.ImageMenuItem(_('Delete podcast'))
1790 item.set_image(Gtk.Image.new_from_icon_name('edit-delete', Gtk.IconSize.MENU))
1791 item.connect('activate', self.on_itemRemoveChannel_activate)
1792 menu.append(item)
1794 result = gpodder.user_extensions.on_channel_context_menu(self.active_channel)
1795 if result:
1796 menu.append(Gtk.SeparatorMenuItem())
1797 for label, callback in result:
1798 item = Gtk.MenuItem(label)
1799 if callback:
1800 item.connect('activate', lambda item, callback: callback(self.active_channel), callback)
1801 else:
1802 item.set_sensitive(False)
1803 menu.append(item)
1805 menu.append(Gtk.SeparatorMenuItem())
1807 item = Gtk.ImageMenuItem(_('Podcast settings'))
1808 item.set_image(Gtk.Image.new_from_icon_name('document-properties', Gtk.IconSize.MENU))
1809 item.set_action_name('win.editChannel')
1810 menu.append(item)
1812 menu.attach_to_widget(treeview)
1813 menu.show_all()
1814 # Disable tooltips while we are showing the menu, so
1815 # the tooltip will not appear over the menu
1816 self.treeview_allow_tooltips(self.treeChannels, False)
1817 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1819 if event is None:
1820 func = TreeViewHelper.make_popup_position_func(treeview)
1821 menu.popup(None, None, func, None, 3, Gtk.get_current_event_time())
1822 else:
1823 menu.popup(None, None, None, None, event.button, event.time)
1825 return True
1827 def cover_download_finished(self, channel, pixbuf):
1829 The Cover Downloader calls this when it has finished
1830 downloading (or registering, if already downloaded)
1831 a new channel cover, which is ready for displaying.
1833 util.idle_add(self.podcast_list_model.add_cover_by_channel,
1834 channel, pixbuf)
1836 @staticmethod
1837 def build_filename(filename, extension):
1838 filename, extension = util.sanitize_filename_ext(
1839 filename,
1840 extension,
1841 PodcastEpisode.MAX_FILENAME_LENGTH,
1842 PodcastEpisode.MAX_FILENAME_WITH_EXT_LENGTH)
1843 if not filename.endswith(extension):
1844 filename += extension
1845 return filename
1847 def save_episodes_as_file(self, episodes):
1848 def do_save_episode(copy_from, copy_to):
1849 if os.path.exists(copy_to):
1850 logger.warning(copy_from)
1851 logger.warning(copy_to)
1852 title = _('File already exists')
1853 d = {'filename': os.path.basename(copy_to)}
1854 message = _('A file named "%(filename)s" already exists. Do you want to replace it?') % d
1855 if not self.show_confirmation(message, title):
1856 return
1857 try:
1858 shutil.copyfile(copy_from, copy_to)
1859 except (OSError, IOError) as e:
1860 logger.warning('Error copying from %s to %s: %r', copy_from, copy_to, e, exc_info=True)
1861 folder, filename = os.path.split(copy_to)
1862 # Remove characters not supported by VFAT (#282)
1863 new_filename = re.sub(r"[\"*/:<>?\\|]", "_", filename)
1864 destination = os.path.join(folder, new_filename)
1865 if (copy_to != destination):
1866 shutil.copyfile(copy_from, destination)
1867 else:
1868 raise
1870 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1871 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1872 allRemainingDefault = False
1873 remaining = len(episodes)
1874 dialog = gPodderExportToLocalFolder(self.main_window,
1875 _config=self.config)
1876 for episode in episodes:
1877 remaining -= 1
1878 if episode.was_downloaded(and_exists=True):
1879 copy_from = episode.local_filename(create=False)
1880 assert copy_from is not None
1882 base, extension = os.path.splitext(copy_from)
1883 filename = self.build_filename(episode.sync_filename(), extension)
1885 try:
1886 if allRemainingDefault:
1887 do_save_episode(copy_from, os.path.join(folder, filename))
1888 else:
1889 (notCancelled, folder, dest_path, allRemainingDefault) = dialog.save_as(folder, filename, remaining)
1890 if notCancelled:
1891 do_save_episode(copy_from, dest_path)
1892 else:
1893 break
1894 except (OSError, IOError) as e:
1895 if remaining:
1896 msg = _('Error saving to local folder: %(error)r.\n'
1897 'Would you like to continue?') % dict(error=e)
1898 if not self.show_confirmation(msg, _('Error saving to local folder')):
1899 logger.warning("Save to Local Folder cancelled following error")
1900 break
1901 else:
1902 self.notification(_('Error saving to local folder: %(error)r') % dict(error=e),
1903 _('Error saving to local folder'), important=True)
1905 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1907 def copy_episodes_bluetooth(self, episodes):
1908 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1910 def convert_and_send_thread(episode):
1911 for episode in episodes:
1912 filename = episode.local_filename(create=False)
1913 assert filename is not None
1914 (base, ext) = os.path.splitext(filename)
1915 destfile = self.build_filename(episode.sync_filename(), ext)
1916 destfile = os.path.join(tempfile.gettempdir(), destfile)
1918 try:
1919 shutil.copyfile(filename, destfile)
1920 util.bluetooth_send_file(destfile)
1921 except:
1922 logger.error('Cannot copy "%s" to "%s".', filename, destfile)
1923 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1925 util.delete_file(destfile)
1927 util.run_in_background(lambda: convert_and_send_thread(episodes_to_copy))
1929 def _add_sub_menu(self, menu, label):
1930 root_item = Gtk.MenuItem(label)
1931 menu.append(root_item)
1932 sub_menu = Gtk.Menu()
1933 root_item.set_submenu(sub_menu)
1934 return sub_menu
1936 def _submenu_item_activate_hack(self, item, callback, *args):
1937 # See http://stackoverflow.com/questions/5221326/submenu-item-does-not-call-function-with-working-solution
1938 # Note that we can't just call the callback on button-press-event, as
1939 # it might be blocking (see http://gpodder.org/bug/1778), so we run
1940 # this in the GUI thread at a later point in time (util.idle_add).
1941 # Also, we also have to connect to the activate signal, as this is the
1942 # only signal that is fired when keyboard navigation is used.
1944 # It can happen that both (button-release-event and activate) signals
1945 # are fired, and we must avoid calling the callback twice. We do this
1946 # using a semaphore and only acquiring (but never releasing) it, making
1947 # sure that the util.idle_add() call below is only ever called once.
1948 only_once = threading.Semaphore(1)
1950 def handle_event(item, event=None):
1951 if only_once.acquire(False):
1952 util.idle_add(callback, *args)
1954 item.connect('button-press-event', handle_event)
1955 item.connect('activate', handle_event)
1957 def treeview_available_show_context_menu(self, treeview, event=None):
1958 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1959 if not paths:
1960 return not treeview.is_rubber_banding_active()
1962 if event is None or event.button == 3:
1963 episodes = self.get_selected_episodes()
1964 any_locked = any(e.archive for e in episodes)
1965 any_new = any(e.is_new and e.state != gpodder.STATE_DELETED for e in episodes)
1966 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1967 downloading = any(e.downloading for e in episodes)
1969 menu = Gtk.Menu()
1971 (open_instead_of_play, can_play, can_download, can_pause, can_cancel, can_delete, can_lock) = self.play_or_download()
1973 if open_instead_of_play:
1974 item = Gtk.ImageMenuItem(_('Open'))
1975 item.set_image(Gtk.Image.new_from_icon_name('document-open', Gtk.IconSize.MENU))
1976 else:
1977 if downloaded:
1978 item = Gtk.ImageMenuItem(_('Play'))
1979 elif downloading:
1980 item = Gtk.ImageMenuItem(_('Preview'))
1981 else:
1982 item = Gtk.ImageMenuItem(_('Stream'))
1983 item.set_image(Gtk.Image.new_from_icon_name('media-playback-start', Gtk.IconSize.MENU))
1985 item.set_sensitive(can_play)
1986 item.connect('activate', self.on_playback_selected_episodes)
1987 menu.append(item)
1989 if can_download:
1990 item = Gtk.ImageMenuItem(_('Download'))
1991 item.set_image(Gtk.Image.new_from_icon_name('document-save', Gtk.IconSize.MENU))
1992 item.set_action_name('win.download')
1993 menu.append(item)
1994 if can_pause:
1995 item = Gtk.ImageMenuItem(_('Pause'))
1996 item.set_image(Gtk.Image.new_from_icon_name('media-playback-pause', Gtk.IconSize.MENU))
1997 item.set_action_name('win.pause')
1998 menu.append(item)
1999 if can_cancel:
2000 item = Gtk.ImageMenuItem.new_with_mnemonic(_('_Cancel'))
2001 item.set_action_name('win.cancel')
2002 menu.append(item)
2004 item = Gtk.ImageMenuItem.new_with_mnemonic(_('_Delete'))
2005 item.set_image(Gtk.Image.new_from_icon_name('edit-delete', Gtk.IconSize.MENU))
2006 item.set_action_name('win.delete')
2007 menu.append(item)
2009 result = gpodder.user_extensions.on_episodes_context_menu(episodes)
2010 if result:
2011 menu.append(Gtk.SeparatorMenuItem())
2012 submenus = {}
2013 for label, callback in result:
2014 key, sep, title = label.rpartition('/')
2015 item = Gtk.ImageMenuItem(title)
2016 if callback:
2017 self._submenu_item_activate_hack(item, callback, episodes)
2018 else:
2019 item.set_sensitive(False)
2020 if key:
2021 if key not in submenus:
2022 sub_menu = self._add_sub_menu(menu, key)
2023 submenus[key] = sub_menu
2024 else:
2025 sub_menu = submenus[key]
2026 sub_menu.append(item)
2027 else:
2028 menu.append(item)
2030 # Ok, this probably makes sense to only display for downloaded files
2031 if downloaded:
2032 menu.append(Gtk.SeparatorMenuItem())
2033 share_menu = self._add_sub_menu(menu, _('Send to'))
2035 item = Gtk.ImageMenuItem(_('Local folder'))
2036 item.set_image(Gtk.Image.new_from_icon_name('folder', Gtk.IconSize.MENU))
2037 self._submenu_item_activate_hack(item, self.save_episodes_as_file, episodes)
2038 share_menu.append(item)
2039 if self.bluetooth_available:
2040 item = Gtk.ImageMenuItem(_('Bluetooth device'))
2041 item.set_image(Gtk.Image.new_from_icon_name('bluetooth', Gtk.IconSize.MENU))
2042 self._submenu_item_activate_hack(item, self.copy_episodes_bluetooth, episodes)
2043 share_menu.append(item)
2045 menu.append(Gtk.SeparatorMenuItem())
2047 item = Gtk.CheckMenuItem(_('New'))
2048 item.set_active(any_new)
2049 if any_new:
2050 item.connect('activate', lambda w: self.mark_selected_episodes_old())
2051 else:
2052 item.connect('activate', lambda w: self.mark_selected_episodes_new())
2053 menu.append(item)
2055 if can_lock:
2056 item = Gtk.CheckMenuItem(_('Archive'))
2057 item.set_active(any_locked)
2058 item.connect('activate',
2059 lambda w: self.on_item_toggle_lock_activate(
2060 w, False, not any_locked))
2061 menu.append(item)
2063 menu.append(Gtk.SeparatorMenuItem())
2064 # Single item, add episode information menu item
2065 item = Gtk.ImageMenuItem(_('Episode details'))
2066 item.set_image(Gtk.Image.new_from_icon_name('dialog-information',
2067 Gtk.IconSize.MENU))
2068 item.set_action_name('win.toggleShownotes')
2069 menu.append(item)
2071 if len(self.get_selected_episodes()) == 1:
2072 item = Gtk.MenuItem(_('Open download folder'))
2073 item.connect('activate', self.on_open_episode_download_folder)
2074 menu.append(item)
2076 menu.attach_to_widget(treeview)
2077 menu.show_all()
2078 # Disable tooltips while we are showing the menu, so
2079 # the tooltip will not appear over the menu
2080 self.treeview_allow_tooltips(self.treeAvailable, False)
2081 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
2082 if event is None:
2083 func = TreeViewHelper.make_popup_position_func(treeview)
2084 menu.popup(None, None, func, None, 3, Gtk.get_current_event_time())
2085 else:
2086 menu.popup(None, None, None, None, event.button, event.time)
2088 return True
2090 def set_episode_actions(self, open_instead_of_play=False, can_play=False, can_download=False, can_pause=False, can_cancel=False,
2091 can_delete=False, can_lock=False, is_episode_selected=False):
2092 episodes = self.get_selected_episodes() if is_episode_selected else []
2094 # play icon and label
2095 if open_instead_of_play or not is_episode_selected:
2096 self.toolPlay.set_icon_name('document-open')
2097 self.toolPlay.set_label(_('Open'))
2098 else:
2099 self.toolPlay.set_icon_name('media-playback-start')
2101 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
2102 downloading = any(e.downloading for e in episodes)
2104 if downloaded:
2105 self.toolPlay.set_label(_('Play'))
2106 elif downloading:
2107 self.toolPlay.set_label(_('Preview'))
2108 else:
2109 self.toolPlay.set_label(_('Stream'))
2111 # toolbar
2112 self.toolPlay.set_sensitive(can_play)
2113 self.toolDownload.set_sensitive(can_download)
2114 self.toolPause.set_sensitive(can_pause)
2115 self.toolCancel.set_sensitive(can_cancel)
2117 # Episodes menu
2118 self.play_action.set_enabled(can_play and not open_instead_of_play)
2119 self.open_action.set_enabled(can_play and open_instead_of_play)
2120 self.download_action.set_enabled(can_download)
2121 self.pause_action.set_enabled(can_pause)
2122 self.cancel_action.set_enabled(can_cancel)
2123 self.delete_action.set_enabled(can_delete)
2124 self.toggle_episode_new_action.set_enabled(is_episode_selected)
2125 self.toggle_episode_lock_action.set_enabled(can_lock)
2126 self.open_episode_download_folder_action.set_enabled(len(episodes) == 1)
2128 def set_title(self, new_title):
2129 self.default_title = new_title
2130 self.gPodder.set_title(new_title)
2132 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2134 Updates the status icons in the episode list.
2136 If urls is given, it should be a list of URLs
2137 of episodes that should be updated.
2139 If urls is None, set ONE OF selected, all to
2140 True (the former updates just the selected
2141 episodes and the latter updates all episodes).
2143 descriptions = self.config.episode_list_descriptions
2145 if urls is not None:
2146 # We have a list of URLs to walk through
2147 self.episode_list_model.update_by_urls(urls, descriptions)
2148 elif selected and not all:
2149 # We should update all selected episodes
2150 selection = self.treeAvailable.get_selection()
2151 model, paths = selection.get_selected_rows()
2152 for path in reversed(paths):
2153 iter = model.get_iter(path)
2154 self.episode_list_model.update_by_filter_iter(iter, descriptions)
2155 elif all and not selected:
2156 # We update all (even the filter-hidden) episodes
2157 self.episode_list_model.update_all(descriptions)
2158 else:
2159 # Wrong/invalid call - have to specify at least one parameter
2160 raise ValueError('Invalid call to update_episode_list_icons')
2162 def episode_list_status_changed(self, episodes):
2163 self.update_episode_list_icons(set(e.url for e in episodes))
2164 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2165 self.db.commit()
2167 def playback_episodes_for_real(self, episodes):
2168 groups = collections.defaultdict(list)
2169 for episode in episodes:
2170 episode._download_error = None
2172 if episode.download_task is not None and episode.download_task.status == episode.download_task.FAILED:
2173 if not episode.can_stream(self.config):
2174 # Do not cancel failed tasks that can not be streamed
2175 continue
2176 # Cancel failed task and remove from progress list
2177 episode.download_task.cancel()
2178 self.cleanup_downloads()
2180 player = episode.get_player(self.config)
2182 try:
2183 allow_partial = (player != 'default')
2184 filename = episode.get_playback_url(self.config, allow_partial)
2185 except Exception as e:
2186 episode._download_error = str(e)
2187 continue
2189 # Mark episode as played in the database
2190 episode.playback_mark()
2191 self.mygpo_client.on_playback([episode])
2193 # Determine the playback resume position - if the file
2194 # was played 100%, we simply start from the beginning
2195 resume_position = episode.current_position
2196 if resume_position == episode.total_time:
2197 resume_position = 0
2199 # If Panucci is configured, use D-Bus to call it
2200 if player == 'panucci':
2201 try:
2202 PANUCCI_NAME = 'org.panucci.panucciInterface'
2203 PANUCCI_PATH = '/panucciInterface'
2204 PANUCCI_INTF = 'org.panucci.panucciInterface'
2205 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2206 i = dbus.Interface(o, PANUCCI_INTF)
2208 def on_reply(*args):
2209 pass
2211 def error_handler(filename, err):
2212 logger.error('Exception in D-Bus call: %s', str(err))
2214 # Fallback: use the command line client
2215 for command in util.format_desktop_command('panucci',
2216 [filename]):
2217 logger.info('Executing: %s', repr(command))
2218 util.Popen(command, close_fds=True)
2220 def on_error(err):
2221 return error_handler(filename, err)
2223 # This method only exists in Panucci > 0.9 ('new Panucci')
2224 i.playback_from(filename, resume_position,
2225 reply_handler=on_reply, error_handler=on_error)
2227 continue # This file was handled by the D-Bus call
2228 except Exception as e:
2229 logger.error('Calling Panucci using D-Bus', exc_info=True)
2231 groups[player].append(filename)
2233 # Open episodes with system default player
2234 if 'default' in groups:
2235 for filename in groups['default']:
2236 logger.debug('Opening with system default: %s', filename)
2237 util.gui_open(filename, gui=self)
2238 del groups['default']
2240 # For each type now, go and create play commands
2241 for group in groups:
2242 for command in util.format_desktop_command(group, groups[group], resume_position):
2243 logger.debug('Executing: %s', repr(command))
2244 util.Popen(command, close_fds=True)
2246 # Persist episode status changes to the database
2247 self.db.commit()
2249 # Flush updated episode status
2250 if self.mygpo_client.can_access_webservice():
2251 self.mygpo_client.flush()
2253 def playback_episodes(self, episodes):
2254 # We need to create a list, because we run through it more than once
2255 episodes = list(Model.sort_episodes_by_pubdate(e for e in episodes if e.can_play(self.config)))
2257 try:
2258 self.playback_episodes_for_real(episodes)
2259 except Exception as e:
2260 logger.error('Error in playback!', exc_info=True)
2261 self.show_message(_('Please check your media player settings in the preferences dialog.'),
2262 _('Error opening player'))
2264 self.episode_list_status_changed(episodes)
2266 def play_or_download(self, current_page=None):
2267 if current_page is None:
2268 current_page = self.wNotebook.get_current_page()
2269 if current_page == 0:
2270 (open_instead_of_play, can_play, can_download, can_pause, can_cancel, can_delete, can_lock) = (False,) * 7
2272 selection = self.treeAvailable.get_selection()
2273 if selection.count_selected_rows() > 0:
2274 (model, paths) = selection.get_selected_rows()
2276 for path in paths:
2277 try:
2278 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2279 if episode is None:
2280 logger.info('Invalid episode at path %s', str(path))
2281 continue
2282 except TypeError as te:
2283 logger.error('Invalid episode at path %s', str(path))
2284 continue
2286 # These values should only ever be set, never unset them once set.
2287 # Actions filter episodes using these methods.
2288 open_instead_of_play = open_instead_of_play or episode.file_type() not in ('audio', 'video')
2289 can_play = can_play or episode.can_play(self.config)
2290 can_download = can_download or episode.can_download()
2291 can_pause = can_pause or episode.can_pause()
2292 can_cancel = can_cancel or episode.can_cancel()
2293 can_delete = can_delete or episode.can_delete()
2294 can_lock = can_lock or episode.can_lock()
2296 self.set_episode_actions(open_instead_of_play, can_play, can_download, can_pause, can_cancel, can_delete, can_lock,
2297 selection.count_selected_rows() > 0)
2299 return (open_instead_of_play, can_play, can_download, can_pause, can_cancel, can_delete, can_lock)
2300 else:
2301 (can_queue, can_pause, can_cancel, can_remove) = (False,) * 4
2303 selection = self.treeDownloads.get_selection()
2304 if selection.count_selected_rows() > 0:
2305 (model, paths) = selection.get_selected_rows()
2307 for path in paths:
2308 try:
2309 task = model.get_value(model.get_iter(path), 0)
2310 if task is None:
2311 logger.info('Invalid task at path %s', str(path))
2312 continue
2313 except TypeError as te:
2314 logger.error('Invalid task at path %s', str(path))
2315 continue
2317 # These values should only ever be set, never unset them once set.
2318 # Actions filter tasks using these methods.
2319 can_queue = can_queue or task.can_queue()
2320 can_pause = can_pause or task.can_pause()
2321 can_cancel = can_cancel or task.can_cancel()
2322 can_remove = can_remove or task.can_remove()
2324 self.set_episode_actions(False, False, can_queue, can_pause, can_cancel, can_remove, False, False)
2326 return (False, False, can_queue, can_pause, can_cancel, can_remove, False)
2328 def on_cbMaxDownloads_toggled(self, widget, *args):
2329 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2331 def on_cbLimitDownloads_toggled(self, widget, *args):
2332 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2334 def episode_new_status_changed(self, urls):
2335 self.update_podcast_list_model()
2336 self.update_episode_list_icons(urls)
2338 def refresh_episode_dates(self):
2339 t = time.localtime()
2340 current_day = t[:3]
2341 if self.last_episode_date_refresh is not None and self.last_episode_date_refresh != current_day:
2342 # update all episodes in current view
2343 for row in self.episode_list_model:
2344 row[EpisodeListModel.C_PUBLISHED_TEXT] = row[EpisodeListModel.C_EPISODE].cute_pubdate()
2346 self.last_episode_date_refresh = current_day
2348 remaining_seconds = 86400 - 3600 * t.tm_hour - 60 * t.tm_min - t.tm_sec
2349 if remaining_seconds > 3600:
2350 # timeout an hour early in the event daylight savings changes the clock forward
2351 remaining_seconds = remaining_seconds - 3600
2352 GObject.timeout_add(remaining_seconds * 1000, self.refresh_episode_dates)
2354 def update_podcast_list_model(self, urls=None, selected=False, select_url=None,
2355 sections_changed=False):
2356 """Update the podcast list treeview model
2358 If urls is given, it should list the URLs of each
2359 podcast that has to be updated in the list.
2361 If selected is True, only update the model contents
2362 for the currently-selected podcast - nothing more.
2364 The caller can optionally specify "select_url",
2365 which is the URL of the podcast that is to be
2366 selected in the list after the update is complete.
2367 This only works if the podcast list has to be
2368 reloaded; i.e. something has been added or removed
2369 since the last update of the podcast list).
2371 selection = self.treeChannels.get_selection()
2372 model, iter = selection.get_selected()
2374 def is_section(r):
2375 return r[PodcastListModel.C_URL] == '-'
2377 def is_separator(r):
2378 return r[PodcastListModel.C_SEPARATOR]
2380 sections_active = any(is_section(x) for x in self.podcast_list_model)
2382 if self.config.podcast_list_view_all:
2383 # Update "all episodes" view in any case (if enabled)
2384 self.podcast_list_model.update_first_row()
2385 # List model length minus 1, because of "All"
2386 list_model_length = len(self.podcast_list_model) - 1
2387 else:
2388 list_model_length = len(self.podcast_list_model)
2390 force_update = (sections_active != self.config.podcast_list_sections or
2391 sections_changed)
2393 # Filter items in the list model that are not podcasts, so we get the
2394 # correct podcast list count (ignore section headers and separators)
2396 def is_not_podcast(r):
2397 return is_section(r) or is_separator(r)
2399 list_model_length -= len(list(filter(is_not_podcast, self.podcast_list_model)))
2401 if selected and not force_update:
2402 # very cheap! only update selected channel
2403 if iter is not None:
2404 # If we have selected the "all episodes" view, we have
2405 # to update all channels for selected episodes:
2406 if self.config.podcast_list_view_all and \
2407 self.podcast_list_model.iter_is_first_row(iter):
2408 urls = self.get_podcast_urls_from_selected_episodes()
2409 self.podcast_list_model.update_by_urls(urls)
2410 else:
2411 # Otherwise just update the selected row (a podcast)
2412 self.podcast_list_model.update_by_filter_iter(iter)
2414 if self.config.podcast_list_sections:
2415 self.podcast_list_model.update_sections()
2416 elif list_model_length == len(self.channels) and not force_update:
2417 # we can keep the model, but have to update some
2418 if urls is None:
2419 # still cheaper than reloading the whole list
2420 self.podcast_list_model.update_all()
2421 else:
2422 # ok, we got a bunch of urls to update
2423 self.podcast_list_model.update_by_urls(urls)
2424 if self.config.podcast_list_sections:
2425 self.podcast_list_model.update_sections()
2426 else:
2427 if model and iter and select_url is None:
2428 # Get the URL of the currently-selected podcast
2429 select_url = model.get_value(iter, PodcastListModel.C_URL)
2431 # Update the podcast list model with new channels
2432 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2434 try:
2435 selected_iter = model.get_iter_first()
2436 # Find the previously-selected URL in the new
2437 # model if we have an URL (else select first)
2438 if select_url is not None:
2439 pos = model.get_iter_first()
2440 while pos is not None:
2441 url = model.get_value(pos, PodcastListModel.C_URL)
2442 if url == select_url:
2443 selected_iter = pos
2444 break
2445 pos = model.iter_next(pos)
2447 if selected_iter is not None:
2448 selection.select_iter(selected_iter)
2449 self.on_treeChannels_cursor_changed(self.treeChannels)
2450 except:
2451 logger.error('Cannot select podcast in list', exc_info=True)
2453 def on_episode_list_filter_changed(self, has_episodes):
2454 self.play_or_download()
2456 def update_episode_list_model(self):
2457 if self.channels and self.active_channel is not None:
2458 self.treeAvailable.get_selection().unselect_all()
2459 self.treeAvailable.scroll_to_point(0, 0)
2461 descriptions = self.config.episode_list_descriptions
2462 with self.treeAvailable.get_selection().handler_block(self.episode_selection_handler_id):
2463 # have to block the on_episode_list_selection_changed handler because
2464 # when selecting any channel from All Episodes, on_episode_list_selection_changed
2465 # is called once per episode (4k time in my case), causing episode shownotes
2466 # to be updated as many time, resulting in UI freeze for 10 seconds.
2467 self.episode_list_model.replace_from_channel(self.active_channel, descriptions)
2468 else:
2469 self.episode_list_model.clear()
2471 @dbus.service.method(gpodder.dbus_interface)
2472 def offer_new_episodes(self, channels=None):
2473 new_episodes = self.get_new_episodes(channels)
2474 if new_episodes:
2475 self.new_episodes_show(new_episodes)
2476 return True
2477 return False
2479 def add_podcast_list(self, podcasts, auth_tokens=None):
2480 """Subscribe to a list of podcast given (title, url) pairs
2482 If auth_tokens is given, it should be a dictionary
2483 mapping URLs to (username, password) tuples."""
2485 if auth_tokens is None:
2486 auth_tokens = {}
2488 existing_urls = set(podcast.url for podcast in self.channels)
2490 # For a given URL, the desired title (or None)
2491 title_for_url = {}
2493 # Sort and split the URL list into five buckets
2494 queued, failed, existing, worked, authreq = [], [], [], [], []
2495 for input_title, input_url in podcasts:
2496 url = util.normalize_feed_url(input_url)
2498 # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
2499 url = youtube.parse_youtube_url(url)
2501 if url is None:
2502 # Fail this one because the URL is not valid
2503 failed.append(input_url)
2504 elif url in existing_urls:
2505 # A podcast already exists in the list for this URL
2506 existing.append(url)
2507 # XXX: Should we try to update the title of the existing
2508 # subscription from input_title here if it is different?
2509 else:
2510 # This URL has survived the first round - queue for add
2511 title_for_url[url] = input_title
2512 queued.append(url)
2513 if url != input_url and input_url in auth_tokens:
2514 auth_tokens[url] = auth_tokens[input_url]
2516 error_messages = {}
2517 redirections = {}
2519 progress = ProgressIndicator(_('Adding podcasts'),
2520 _('Please wait while episode information is downloaded.'),
2521 parent=self.get_dialog_parent())
2523 def on_after_update():
2524 progress.on_finished()
2525 # Report already-existing subscriptions to the user
2526 if existing:
2527 title = _('Existing subscriptions skipped')
2528 message = _('You are already subscribed to these podcasts:') \
2529 + '\n\n' + '\n'.join(html.escape(url) for url in existing)
2530 self.show_message(message, title, widget=self.treeChannels)
2532 # Report subscriptions that require authentication
2533 retry_podcasts = {}
2534 if authreq:
2535 for url in authreq:
2536 title = _('Podcast requires authentication')
2537 message = _('Please login to %s:') % (html.escape(url),)
2538 success, auth_tokens = self.show_login_dialog(title, message)
2539 if success:
2540 retry_podcasts[url] = auth_tokens
2541 else:
2542 # Stop asking the user for more login data
2543 retry_podcasts = {}
2544 for url in authreq:
2545 error_messages[url] = _('Authentication failed')
2546 failed.append(url)
2547 break
2549 # Report website redirections
2550 for url in redirections:
2551 title = _('Website redirection detected')
2552 message = _('The URL %(url)s redirects to %(target)s.') \
2553 + '\n\n' + _('Do you want to visit the website now?')
2554 message = message % {'url': url, 'target': redirections[url]}
2555 if self.show_confirmation(message, title):
2556 util.open_website(url)
2557 else:
2558 break
2560 # Report failed subscriptions to the user
2561 if failed:
2562 title = _('Could not add some podcasts')
2563 message = _('Some podcasts could not be added to your list:')
2564 details = '\n\n'.join('<b>{}</b>:\n{}'.format(html.escape(url),
2565 html.escape(error_messages.get(url, _('Unknown')))) for url in failed)
2566 self.show_message_details(title, message, details)
2568 # Upload subscription changes to gpodder.net
2569 self.mygpo_client.on_subscribe(worked)
2571 # Fix URLs if mygpo has rewritten them
2572 self.rewrite_urls_mygpo()
2574 # If only one podcast was added, select it after the update
2575 if len(worked) == 1:
2576 url = worked[0]
2577 else:
2578 url = None
2580 # Update the list of subscribed podcasts
2581 self.update_podcast_list_model(select_url=url)
2583 # If we have authentication data to retry, do so here
2584 if retry_podcasts:
2585 podcasts = [(title_for_url.get(url), url)
2586 for url in list(retry_podcasts.keys())]
2587 self.add_podcast_list(podcasts, retry_podcasts)
2588 # This will NOT show new episodes for podcasts that have
2589 # been added ("worked"), but it will prevent problems with
2590 # multiple dialogs being open at the same time ;)
2591 return
2593 # Offer to download new episodes
2594 episodes = []
2595 for podcast in self.channels:
2596 if podcast.url in worked:
2597 episodes.extend(podcast.get_all_episodes())
2599 if episodes:
2600 episodes = list(Model.sort_episodes_by_pubdate(episodes,
2601 reverse=True))
2602 self.new_episodes_show(episodes,
2603 selected=[e.check_is_new() for e in episodes])
2605 @util.run_in_background
2606 def thread_proc():
2607 # After the initial sorting and splitting, try all queued podcasts
2608 length = len(queued)
2609 for index, url in enumerate(queued):
2610 title = title_for_url.get(url)
2611 progress.on_progress(float(index) / float(length))
2612 progress.on_message(title or url)
2613 try:
2614 # The URL is valid and does not exist already - subscribe!
2615 channel = self.model.load_podcast(url=url, create=True,
2616 authentication_tokens=auth_tokens.get(url, None),
2617 max_episodes=self.config.max_episodes_per_feed)
2619 try:
2620 username, password = util.username_password_from_url(url)
2621 except ValueError as ve:
2622 username, password = (None, None)
2624 if title is not None:
2625 # Prefer title from subscription source (bug 1711)
2626 channel.title = title
2628 if username is not None and channel.auth_username is None and \
2629 password is not None and channel.auth_password is None:
2630 channel.auth_username = username
2631 channel.auth_password = password
2633 channel.save()
2635 self._update_cover(channel)
2636 except feedcore.AuthenticationRequired as e:
2637 # use e.url because there might have been a redirection (#571)
2638 if e.url in auth_tokens:
2639 # Fail for wrong authentication data
2640 error_messages[e.url] = _('Authentication failed')
2641 failed.append(e.url)
2642 else:
2643 # Queue for login dialog later
2644 authreq.append(e.url)
2645 continue
2646 except feedcore.WifiLogin as error:
2647 redirections[url] = error.data
2648 failed.append(url)
2649 error_messages[url] = _('Redirection detected')
2650 continue
2651 except Exception as e:
2652 logger.error('Subscription error: %s', e, exc_info=True)
2653 error_messages[url] = str(e)
2654 failed.append(url)
2655 continue
2657 assert channel is not None
2658 worked.append(channel.url)
2660 util.idle_add(on_after_update)
2662 def find_episode(self, podcast_url, episode_url):
2663 """Find an episode given its podcast and episode URL
2665 The function will return a PodcastEpisode object if
2666 the episode is found, or None if it's not found.
2668 for podcast in self.channels:
2669 if podcast_url == podcast.url:
2670 for episode in podcast.get_all_episodes():
2671 if episode_url == episode.url:
2672 return episode
2674 return None
2676 def process_received_episode_actions(self):
2677 """Process/merge episode actions from gpodder.net
2679 This function will merge all changes received from
2680 the server to the local database and update the
2681 status of the affected episodes as necessary.
2683 indicator = ProgressIndicator(_('Merging episode actions'),
2684 _('Episode actions from gpodder.net are merged.'),
2685 False, self.get_dialog_parent())
2687 Gtk.main_iteration()
2689 self.mygpo_client.process_episode_actions(self.find_episode)
2691 indicator.on_finished()
2692 self.db.commit()
2694 def _update_cover(self, channel):
2695 if channel is not None:
2696 self.cover_downloader.request_cover(channel)
2698 def show_update_feeds_buttons(self):
2699 # Make sure that the buttons for updating feeds
2700 # appear - this should happen after a feed update
2701 self.hboxUpdateFeeds.hide()
2702 if not self.application.want_headerbar:
2703 self.btnUpdateFeeds.show()
2704 self.update_action.set_enabled(True)
2705 self.update_channel_action.set_enabled(True)
2707 def on_btnCancelFeedUpdate_clicked(self, widget):
2708 if not self.feed_cache_update_cancelled:
2709 self.pbFeedUpdate.set_text(_('Cancelling...'))
2710 self.feed_cache_update_cancelled = True
2711 self.btnCancelFeedUpdate.set_sensitive(False)
2712 else:
2713 self.show_update_feeds_buttons()
2715 def update_feed_cache(self, channels=None,
2716 show_new_episodes_dialog=True):
2717 if self.config.check_connection and not util.connection_available():
2718 self.show_message(_('Please connect to a network, then try again.'),
2719 _('No network connection'), important=True)
2720 return
2722 # Fix URLs if mygpo has rewritten them
2723 self.rewrite_urls_mygpo()
2725 if channels is None:
2726 # Only update podcasts for which updates are enabled
2727 channels = [c for c in self.channels if not c.pause_subscription]
2729 self.update_action.set_enabled(False)
2730 self.update_channel_action.set_enabled(False)
2732 self.feed_cache_update_cancelled = False
2733 self.btnCancelFeedUpdate.show()
2734 self.btnCancelFeedUpdate.set_sensitive(True)
2735 self.btnCancelFeedUpdate.set_image(Gtk.Image.new_from_icon_name('process-stop', Gtk.IconSize.BUTTON))
2736 self.hboxUpdateFeeds.show_all()
2737 self.btnUpdateFeeds.hide()
2739 count = len(channels)
2740 text = N_('Updating %(count)d feed...', 'Updating %(count)d feeds...',
2741 count) % {'count': count}
2743 self.pbFeedUpdate.set_text(text)
2744 self.pbFeedUpdate.set_fraction(0)
2746 @util.run_in_background
2747 def update_feed_cache_proc():
2748 updated_channels = []
2749 nr_update_errors = 0
2750 for updated, channel in enumerate(channels):
2751 if self.feed_cache_update_cancelled:
2752 break
2754 def indicate_updating_podcast(channel):
2755 d = {'podcast': channel.title, 'position': updated + 1, 'total': count}
2756 progression = _('Updating %(podcast)s (%(position)d/%(total)d)') % d
2757 logger.info(progression)
2758 self.pbFeedUpdate.set_text(progression)
2760 try:
2761 channel._update_error = None
2762 util.idle_add(indicate_updating_podcast, channel)
2763 channel.update(max_episodes=self.config.max_episodes_per_feed)
2764 self._update_cover(channel)
2765 except Exception as e:
2766 message = str(e)
2767 if message:
2768 channel._update_error = message
2769 else:
2770 channel._update_error = '?'
2771 nr_update_errors += 1
2772 logger.error('Error: %s', message, exc_info=(e.__class__ not in [
2773 gpodder.feedcore.BadRequest,
2774 gpodder.feedcore.AuthenticationRequired,
2775 gpodder.feedcore.Unsubscribe,
2776 gpodder.feedcore.NotFound,
2777 gpodder.feedcore.InternalServerError,
2778 gpodder.feedcore.UnknownStatusCode,
2779 requests.exceptions.ConnectionError,
2780 requests.exceptions.RetryError,
2781 urllib3.exceptions.MaxRetryError,
2782 urllib3.exceptions.ReadTimeoutError,
2785 updated_channels.append(channel)
2787 def update_progress(channel):
2788 self.update_podcast_list_model([channel.url])
2790 # If the currently-viewed podcast is updated, reload episodes
2791 if self.active_channel is not None and \
2792 self.active_channel == channel:
2793 logger.debug('Updated channel is active, updating UI')
2794 self.update_episode_list_model()
2796 self.pbFeedUpdate.set_fraction(float(updated + 1) / float(count))
2798 util.idle_add(update_progress, channel)
2800 if nr_update_errors > 0:
2801 self.notification(
2802 N_('%(count)d channel failed to update',
2803 '%(count)d channels failed to update',
2804 nr_update_errors) % {'count': nr_update_errors},
2805 _('Error while updating feeds'), widget=self.treeChannels)
2807 def update_feed_cache_finish_callback():
2808 # Process received episode actions for all updated URLs
2809 self.process_received_episode_actions()
2811 # If we are currently viewing "All episodes" or a section, update its episode list now
2812 if self.active_channel is not None and \
2813 isinstance(self.active_channel, PodcastChannelProxy):
2814 self.update_episode_list_model()
2816 if self.feed_cache_update_cancelled:
2817 # The user decided to abort the feed update
2818 self.show_update_feeds_buttons()
2820 # Only search for new episodes in podcasts that have been
2821 # updated, not in other podcasts (for single-feed updates)
2822 episodes = self.get_new_episodes([c for c in updated_channels])
2824 if self.config.downloads.chronological_order:
2825 # download older episodes first
2826 episodes = list(Model.sort_episodes_by_pubdate(episodes))
2828 # Remove episodes without downloadable content
2829 downloadable_episodes = [e for e in episodes if e.url]
2831 if not downloadable_episodes:
2832 # Nothing new here - but inform the user
2833 self.pbFeedUpdate.set_fraction(1.0)
2834 self.pbFeedUpdate.set_text(
2835 _('No new episodes with downloadable content') if episodes else _('No new episodes'))
2836 self.feed_cache_update_cancelled = True
2837 self.btnCancelFeedUpdate.show()
2838 self.btnCancelFeedUpdate.set_sensitive(True)
2839 self.update_action.set_enabled(True)
2840 self.btnCancelFeedUpdate.set_image(Gtk.Image.new_from_icon_name('edit-clear', Gtk.IconSize.BUTTON))
2841 else:
2842 episodes = downloadable_episodes
2844 count = len(episodes)
2845 # New episodes are available
2846 self.pbFeedUpdate.set_fraction(1.0)
2848 if self.config.auto_download == 'download':
2849 self.download_episode_list(episodes)
2850 title = N_('Downloading %(count)d new episode.',
2851 'Downloading %(count)d new episodes.',
2852 count) % {'count': count}
2853 self.show_message(title, _('New episodes available'))
2854 elif self.config.auto_download == 'queue':
2855 self.download_episode_list_paused(episodes)
2856 title = N_(
2857 '%(count)d new episode added to download list.',
2858 '%(count)d new episodes added to download list.',
2859 count) % {'count': count}
2860 self.show_message(title, _('New episodes available'))
2861 else:
2862 if (show_new_episodes_dialog and
2863 self.config.auto_download == 'show'):
2864 self.new_episodes_show(episodes, notification=True)
2865 else: # !show_new_episodes_dialog or auto_download == 'ignore'
2866 message = N_('%(count)d new episode available',
2867 '%(count)d new episodes available',
2868 count) % {'count': count}
2869 self.pbFeedUpdate.set_text(message)
2871 self.show_update_feeds_buttons()
2873 util.idle_add(update_feed_cache_finish_callback)
2875 def on_gPodder_delete_event(self, *args):
2876 """Called when the GUI wants to close the window
2877 Displays a confirmation dialog (and closes/hides gPodder)
2880 if self.confirm_quit():
2881 self.close_gpodder()
2883 return True
2885 def confirm_quit(self):
2886 """Called when the GUI wants to close the window
2887 Displays a confirmation dialog
2890 downloading = self.download_status_model.are_downloads_in_progress()
2892 if downloading:
2893 dialog = Gtk.MessageDialog(self.gPodder, Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE)
2894 dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
2895 quit_button = dialog.add_button(_('_Quit'), Gtk.ResponseType.CLOSE)
2897 title = _('Quit gPodder')
2898 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
2900 dialog.set_title(title)
2901 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
2903 quit_button.grab_focus()
2904 result = dialog.run()
2905 dialog.destroy()
2907 return result == Gtk.ResponseType.CLOSE
2908 else:
2909 return True
2911 def close_gpodder(self):
2912 """ clean everything and exit properly
2914 # Cancel any running background updates of the episode list model
2915 self.episode_list_model.background_update = None
2917 self.gPodder.hide()
2919 # Notify all tasks to to carry out any clean-up actions
2920 self.download_status_model.tell_all_tasks_to_quit()
2922 while Gtk.events_pending():
2923 Gtk.main_iteration()
2925 self.core.shutdown()
2927 self.application.remove_window(self.gPodder)
2929 def format_delete_message(self, message, things, max_things, max_length):
2930 titles = []
2931 for index, thing in zip(range(max_things), things):
2932 titles.append('• ' + (html.escape(thing.title if len(thing.title) <= max_length else thing.title[:max_length] + '…')))
2933 if len(things) > max_things:
2934 titles.append('+%(count)d more …' % {'count': len(things) - max_things})
2935 return '\n'.join(titles) + '\n\n' + message
2937 def delete_episode_list(self, episodes, confirm=True, callback=None, undownload=False):
2938 if self.wNotebook.get_current_page() > 0:
2939 selection = self.treeDownloads.get_selection()
2940 (model, paths) = selection.get_selected_rows()
2941 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
2942 model.get_value(model.get_iter(path),
2943 DownloadStatusModel.C_TASK)) for path in paths]
2944 self._for_each_task_set_status(selected_tasks, status=None, force_start=False)
2945 return
2947 if not episodes:
2948 return False
2950 episodes = [e for e in episodes if not e.archive]
2952 if not episodes:
2953 title = _('Episodes are locked')
2954 message = _(
2955 'The selected episodes are locked. Please unlock the '
2956 'episodes that you want to delete before trying '
2957 'to delete them.')
2958 self.notification(message, title, widget=self.treeAvailable)
2959 return False
2961 count = len(episodes)
2962 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
2963 count) % {'count': count}
2964 message = _('Deleting episodes removes downloaded files.')
2966 message = self.format_delete_message(message, episodes, 5, 60)
2968 if confirm:
2969 undownloadable = len([e for e in episodes if e.can_undownload()])
2970 if undownloadable:
2971 checkbox = N_("Mark downloaded episodes as new, after deletion, to allow downloading again",
2972 "Mark downloaded episodes as new, after deletion, to allow downloading again",
2973 undownloadable)
2974 else:
2975 checkbox = None
2976 res = self.show_confirmation_extended(
2977 message, title,
2978 checkbox=checkbox, default_checked=undownload)
2979 if not res["confirmed"]:
2980 return False
2981 undownload = res["checked"]
2983 self.on_item_cancel_download_activate(force=True)
2985 progress = ProgressIndicator(_('Deleting episodes'),
2986 _('Please wait while episodes are deleted'),
2987 parent=self.get_dialog_parent())
2989 def finish_deletion(episode_urls, channel_urls):
2990 progress.on_finished()
2992 # Episodes have been deleted - persist the database
2993 self.db.commit()
2995 self.update_episode_list_icons(episode_urls)
2996 self.update_podcast_list_model(channel_urls)
2997 self.play_or_download()
2999 @util.run_in_background
3000 def thread_proc():
3001 episode_urls = set()
3002 channel_urls = set()
3004 episodes_status_update = []
3005 for idx, episode in enumerate(episodes):
3006 progress.on_progress(idx / len(episodes))
3007 if not episode.archive:
3008 progress.on_message(episode.title)
3009 # ep_undownload must be computed before delete_from_disk
3010 ep_undownload = undownload and episode.can_undownload()
3011 episode.delete_from_disk()
3012 episode_urls.add(episode.url)
3013 channel_urls.add(episode.channel.url)
3014 episodes_status_update.append(episode)
3015 if ep_undownload:
3016 # Undelete and mark episode as new
3017 episode.state = gpodder.STATE_NORMAL
3018 episode.is_new = True
3019 episode.save()
3021 # Notify the web service about the status update + upload
3022 if self.mygpo_client.can_access_webservice():
3023 self.mygpo_client.on_delete(episodes_status_update)
3024 self.mygpo_client.flush()
3026 if callback is None:
3027 util.idle_add(finish_deletion, episode_urls, channel_urls)
3028 else:
3029 util.idle_add(callback, episode_urls, channel_urls, progress)
3031 return True
3033 def on_itemRemoveOldEpisodes_activate(self, action, param):
3034 self.show_delete_episodes_window()
3036 def show_delete_episodes_window(self, channel=None):
3037 """Offer deletion of episodes
3039 If channel is None, offer deletion of all episodes.
3040 Otherwise only offer deletion of episodes in the channel.
3042 columns = (
3043 ('markup_delete_episodes', None, None, _('Episode')),
3046 msg_older_than = N_('Select older than %(count)d day', 'Select older than %(count)d days', self.config.episode_old_age)
3047 selection_buttons = {
3048 _('Select played'): lambda episode: not episode.is_new,
3049 _('Select finished'): lambda episode: episode.is_finished(),
3050 msg_older_than % {'count': self.config.episode_old_age}: lambda episode: episode.age_in_days() > self.config.episode_old_age,
3053 instructions = _('Select the episodes you want to delete:')
3055 if channel is None:
3056 channels = self.channels
3057 else:
3058 channels = [channel]
3060 episodes = []
3061 for channel in channels:
3062 for episode in channel.get_episodes(gpodder.STATE_DOWNLOADED):
3063 # Disallow deletion of locked episodes that still exist
3064 if not episode.archive or not episode.file_exists():
3065 episodes.append(episode)
3067 selected = [not e.is_new or not e.file_exists() for e in episodes]
3069 gPodderEpisodeSelector(
3070 self.main_window, title=_('Delete episodes'),
3071 instructions=instructions,
3072 episodes=episodes, selected=selected, columns=columns,
3073 ok_button=_('_Delete'), callback=self.delete_episode_list,
3074 selection_buttons=selection_buttons, _config=self.config)
3076 def on_selected_episodes_status_changed(self):
3077 # The order of the updates here is important! When "All episodes" is
3078 # selected, the update of the podcast list model depends on the episode
3079 # list selection to determine which podcasts are affected. Updating
3080 # the episode list could remove the selection if a filter is active.
3081 self.update_podcast_list_model(selected=True)
3082 self.update_episode_list_icons(selected=True)
3083 self.db.commit()
3085 self.play_or_download()
3087 def mark_selected_episodes_new(self):
3088 for episode in self.get_selected_episodes():
3089 episode.mark(is_played=False)
3090 self.on_selected_episodes_status_changed()
3092 def mark_selected_episodes_old(self):
3093 for episode in self.get_selected_episodes():
3094 episode.mark(is_played=True)
3095 self.on_selected_episodes_status_changed()
3097 def on_item_toggle_played_activate(self, action, param):
3098 for episode in self.get_selected_episodes():
3099 episode.mark(is_played=episode.is_new and episode.state != gpodder.STATE_DELETED)
3100 self.on_selected_episodes_status_changed()
3102 def on_item_toggle_lock_activate(self, unused, toggle=True, new_value=False):
3103 for episode in self.get_selected_episodes():
3104 if episode.state == gpodder.STATE_DELETED:
3105 # Always unlock deleted episodes
3106 episode.mark(is_locked=False)
3107 elif toggle or toggle is None:
3108 # Gio.SimpleAction activate signal passes None (see #681)
3109 episode.mark(is_locked=not episode.archive)
3110 else:
3111 episode.mark(is_locked=new_value)
3112 self.on_selected_episodes_status_changed()
3113 self.play_or_download()
3115 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3116 if self.active_channel is None:
3117 return
3119 self.active_channel.auto_archive_episodes = not self.active_channel.auto_archive_episodes
3120 self.active_channel.save()
3122 for episode in self.active_channel.get_all_episodes():
3123 episode.mark(is_locked=self.active_channel.auto_archive_episodes)
3125 self.update_podcast_list_model(selected=True)
3126 self.update_episode_list_icons(all=True)
3128 def on_itemUpdateChannel_activate(self, *params):
3129 if self.active_channel is None:
3130 title = _('No podcast selected')
3131 message = _('Please select a podcast in the podcasts list to update.')
3132 self.show_message(message, title, widget=self.treeChannels)
3133 return
3135 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3136 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3137 self.update_feed_cache()
3138 else:
3139 self.update_feed_cache(channels=[self.active_channel])
3141 def on_itemUpdate_activate(self, action=None, param=None):
3142 # Check if we have outstanding subscribe/unsubscribe actions
3143 self.on_add_remove_podcasts_mygpo()
3145 if self.channels:
3146 self.update_feed_cache()
3147 else:
3148 def show_welcome_window():
3149 def on_show_example_podcasts(widget):
3150 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3151 self.on_itemImportChannels_activate(None)
3153 def on_add_podcast_via_url(widget):
3154 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3155 self.on_itemAddChannel_activate(None)
3157 def on_setup_my_gpodder(widget):
3158 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3159 self.on_download_subscriptions_from_mygpo(None)
3161 welcome_window = gPodderWelcome(self.main_window,
3162 center_on_widget=self.main_window,
3163 on_show_example_podcasts=on_show_example_podcasts,
3164 on_add_podcast_via_url=on_add_podcast_via_url,
3165 on_setup_my_gpodder=on_setup_my_gpodder)
3167 welcome_window.main_window.run()
3168 welcome_window.main_window.destroy()
3170 util.idle_add(show_welcome_window)
3172 def download_episode_list_paused(self, episodes):
3173 self.download_episode_list(episodes, True)
3175 def download_episode_list(self, episodes, add_paused=False, force_start=False, downloader=None):
3176 def queue_tasks(tasks, queued_existing_task):
3177 for task in tasks:
3178 with task:
3179 if add_paused:
3180 task.status = task.PAUSED
3181 else:
3182 self.mygpo_client.on_download([task.episode])
3183 self.queue_task(task, force_start)
3184 if tasks or queued_existing_task:
3185 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3186 # Flush updated episode status
3187 if self.mygpo_client.can_access_webservice():
3188 self.mygpo_client.flush()
3190 queued_existing_task = False
3191 new_tasks = []
3193 if self.config.downloads.chronological_order:
3194 # Download episodes in chronological order (older episodes first)
3195 episodes = list(Model.sort_episodes_by_pubdate(episodes))
3197 for episode in episodes:
3198 logger.debug('Downloading episode: %s', episode.title)
3199 if not episode.was_downloaded(and_exists=True):
3200 episode._download_error = None
3201 if episode.state == gpodder.STATE_DELETED:
3202 episode.state = gpodder.STATE_NORMAL
3203 episode.save()
3204 task_exists = False
3205 for task in self.download_tasks_seen:
3206 if episode.url == task.url:
3207 task_exists = True
3208 task.unpause()
3209 task.reuse()
3210 if task.status not in (task.DOWNLOADING, task.QUEUED):
3211 if downloader:
3212 # replace existing task's download with forced one
3213 task.downloader = downloader
3214 self.queue_task(task, force_start)
3215 queued_existing_task = True
3216 continue
3218 if task_exists:
3219 continue
3221 try:
3222 task = download.DownloadTask(episode, self.config, downloader=downloader)
3223 except Exception as e:
3224 episode._download_error = str(e)
3225 d = {'episode': html.escape(episode.title), 'message': html.escape(str(e))}
3226 message = _('Download error while downloading %(episode)s: %(message)s')
3227 self.show_message(message % d, _('Download error'), important=True)
3228 logger.error('While downloading %s', episode.title, exc_info=True)
3229 continue
3231 # New Task, we must wait on the GTK Loop
3232 self.download_status_model.register_task(task)
3233 new_tasks.append(task)
3235 # Executes after tasks have been registered
3236 util.idle_add(queue_tasks, new_tasks, queued_existing_task)
3238 def cancel_task_list(self, tasks, force=False):
3239 if not tasks:
3240 return
3242 for task in tasks:
3243 task.cancel()
3245 self.update_episode_list_icons([task.url for task in tasks])
3246 self.play_or_download()
3248 # Update the tab title and downloads list
3249 self.update_downloads_list()
3251 def new_episodes_show(self, episodes, notification=False, selected=None):
3252 columns = (
3253 ('markup_new_episodes', None, None, _('Episode')),
3256 instructions = _('Select the episodes you want to download:')
3258 if self.new_episodes_window is not None:
3259 self.new_episodes_window.main_window.destroy()
3260 self.new_episodes_window = None
3262 def download_episodes_callback(episodes):
3263 self.new_episodes_window = None
3264 self.download_episode_list(episodes)
3266 if selected is None:
3267 # Select all by default
3268 selected = [True] * len(episodes)
3270 self.new_episodes_window = gPodderEpisodeSelector(self.main_window,
3271 title=_('New episodes available'),
3272 instructions=instructions,
3273 episodes=episodes,
3274 columns=columns,
3275 selected=selected,
3276 ok_button='gpodder-download',
3277 callback=download_episodes_callback,
3278 remove_callback=lambda e: e.mark_old(),
3279 remove_action=_('_Mark as old'),
3280 remove_finished=self.episode_new_status_changed,
3281 _config=self.config,
3282 show_notification=False)
3284 def on_itemDownloadAllNew_activate(self, action, param):
3285 if not self.offer_new_episodes():
3286 self.show_message(_('Please check for new episodes later.'),
3287 _('No new episodes available'))
3289 def get_new_episodes(self, channels=None):
3290 return [e for c in channels or self.channels for e in
3291 [e for e in c.get_all_episodes() if e.check_is_new()]]
3293 def commit_changes_to_database(self):
3294 """This will be called after the sync process is finished"""
3295 self.db.commit()
3297 def on_itemShowToolbar_activate(self, action, param):
3298 state = action.get_state()
3299 self.config.show_toolbar = not state
3300 action.set_state(GLib.Variant.new_boolean(not state))
3302 def on_itemShowDescription_activate(self, action, param):
3303 state = action.get_state()
3304 self.config.episode_list_descriptions = not state
3305 action.set_state(GLib.Variant.new_boolean(not state))
3307 def on_item_view_hide_boring_podcasts_toggled(self, action, param):
3308 state = action.get_state()
3309 self.config.podcast_list_hide_boring = not state
3310 action.set_state(GLib.Variant.new_boolean(not state))
3311 self.apply_podcast_list_hide_boring()
3313 def on_item_view_always_show_new_episodes_toggled(self, action, param):
3314 state = action.get_state()
3315 self.config.ui.gtk.episode_list.always_show_new = not state
3316 action.set_state(GLib.Variant.new_boolean(not state))
3318 def on_item_view_ctrl_click_to_sort_episodes_toggled(self, action, param):
3319 state = action.get_state()
3320 self.config.ui.gtk.episode_list.ctrl_click_to_sort = not state
3321 action.set_state(GLib.Variant.new_boolean(not state))
3323 def on_item_view_search_always_visible_toggled(self, action, param):
3324 state = action.get_state()
3325 self.config.ui.gtk.search_always_visible = not state
3326 action.set_state(GLib.Variant.new_boolean(not state))
3327 for search in (self._search_episodes, self._search_podcasts):
3328 if search:
3329 if self.config.ui.gtk.search_always_visible:
3330 search.show_search(grab_focus=False)
3331 else:
3332 search.hide_search()
3334 def on_item_view_episodes_changed(self, action, param):
3335 self.config.episode_list_view_mode = getattr(EpisodeListModel, param.get_string()) or EpisodeListModel.VIEW_ALL
3336 action.set_state(param)
3338 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
3339 self.apply_podcast_list_hide_boring()
3341 def apply_podcast_list_hide_boring(self):
3342 if self.config.podcast_list_hide_boring:
3343 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3344 else:
3345 self.podcast_list_model.set_view_mode(-1)
3347 def on_download_subscriptions_from_mygpo(self, action=None):
3348 def after_login():
3349 title = _('Subscriptions on %(server)s') \
3350 % {'server': self.config.mygpo.server}
3351 dir = gPodderPodcastDirectory(self.gPodder,
3352 _config=self.config,
3353 custom_title=title,
3354 add_podcast_list=self.add_podcast_list,
3355 hide_url_entry=True)
3357 url = self.mygpo_client.get_download_user_subscriptions_url()
3358 dir.download_opml_file(url)
3360 title = _('Login to gpodder.net')
3361 message = _('Please login to download your subscriptions.')
3363 def on_register_button_clicked():
3364 util.open_website('http://gpodder.net/register/')
3366 success, (root_url, username, password) = self.show_login_dialog(title, message,
3367 self.config.mygpo.server,
3368 self.config.mygpo.username, self.config.mygpo.password,
3369 register_callback=on_register_button_clicked,
3370 ask_server=True)
3371 if not success:
3372 return
3374 self.config.mygpo.server = root_url
3375 self.config.mygpo.username = username
3376 self.config.mygpo.password = password
3378 util.idle_add(after_login)
3380 def on_itemAddChannel_activate(self, action=None, param=None):
3381 self._add_podcast_dialog = gPodderAddPodcast(self.gPodder,
3382 add_podcast_list=self.add_podcast_list)
3384 def on_itemEditChannel_activate(self, action, param=None):
3385 if self.active_channel is None:
3386 title = _('No podcast selected')
3387 message = _('Please select a podcast in the podcasts list to edit.')
3388 self.show_message(message, title, widget=self.treeChannels)
3389 return
3391 gPodderChannel(self.main_window,
3392 channel=self.active_channel,
3393 update_podcast_list_model=self.update_podcast_list_model,
3394 cover_downloader=self.cover_downloader,
3395 sections=set(c.section for c in self.channels),
3396 clear_cover_cache=self.podcast_list_model.clear_cover_cache,
3397 _config=self.config)
3399 def on_itemMassUnsubscribe_activate(self, action, param):
3400 columns = (
3401 ('title_markup', None, None, _('Podcast')),
3404 # We're abusing the Episode Selector for selecting Podcasts here,
3405 # but it works and looks good, so why not? -- thp
3406 gPodderEpisodeSelector(self.main_window,
3407 title=_('Delete podcasts'),
3408 instructions=_('Select the podcast you want to delete.'),
3409 episodes=self.channels,
3410 columns=columns,
3411 size_attribute=None,
3412 ok_button=_('_Delete'),
3413 callback=self.remove_podcast_list,
3414 _config=self.config)
3416 def remove_podcast_list(self, channels, confirm=True):
3417 if not channels:
3418 return
3420 if len(channels) == 1:
3421 title = _('Deleting podcast')
3422 info = _('Please wait while the podcast is deleted')
3423 message = _('This podcast and all its episodes will be PERMANENTLY DELETED.\nAre you sure you want to continue?')
3424 else:
3425 title = _('Deleting podcasts')
3426 info = _('Please wait while the podcasts are deleted')
3427 message = _('These podcasts and all their episodes will be PERMANENTLY DELETED.\nAre you sure you want to continue?')
3429 message = self.format_delete_message(message, channels, 5, 60)
3431 if confirm and not self.show_confirmation(message, title):
3432 return
3434 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3436 def finish_deletion(select_url):
3437 # Upload subscription list changes to the web service
3438 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3440 # Re-load the channels and select the desired new channel
3441 self.update_podcast_list_model(select_url=select_url)
3442 progress.on_finished()
3444 @util.run_in_background
3445 def thread_proc():
3446 select_url = None
3448 for idx, channel in enumerate(channels):
3449 # Update the UI for correct status messages
3450 progress.on_progress(idx / len(channels))
3451 progress.on_message(channel.title)
3453 # Delete downloaded episodes
3454 channel.remove_downloaded()
3456 # cancel any active downloads from this channel
3457 for episode in channel.get_all_episodes():
3458 if episode.downloading:
3459 episode.download_task.cancel()
3461 if len(channels) == 1:
3462 # get the URL of the podcast we want to select next
3463 if channel in self.channels:
3464 position = self.channels.index(channel)
3465 else:
3466 position = -1
3468 if position == len(self.channels) - 1:
3469 # this is the last podcast, so select the URL
3470 # of the item before this one (i.e. the "new last")
3471 select_url = self.channels[position - 1].url
3472 else:
3473 # there is a podcast after the deleted one, so
3474 # we simply select the one that comes after it
3475 select_url = self.channels[position + 1].url
3477 # Remove the channel and clean the database entries
3478 channel.delete()
3480 # Clean up downloads and download directories
3481 common.clean_up_downloads()
3483 # The remaining stuff is to be done in the GTK main thread
3484 util.idle_add(finish_deletion, select_url)
3486 def on_itemRefreshCover_activate(self, widget, *args):
3487 assert self.active_channel is not None
3489 self.podcast_list_model.clear_cover_cache(self.active_channel.url)
3490 self.cover_downloader.replace_cover(self.active_channel, custom_url=False)
3492 def on_itemRemoveChannel_activate(self, widget, *args):
3493 if self.active_channel is None:
3494 title = _('No podcast selected')
3495 message = _('Please select a podcast in the podcasts list to remove.')
3496 self.show_message(message, title, widget=self.treeChannels)
3497 return
3499 self.remove_podcast_list([self.active_channel])
3501 def get_opml_filter(self):
3502 filter = Gtk.FileFilter()
3503 filter.add_pattern('*.opml')
3504 filter.add_pattern('*.xml')
3505 filter.set_name(_('OPML files') + ' (*.opml, *.xml)')
3506 return filter
3508 def on_item_import_from_file_activate(self, action, filename=None):
3509 if filename is None:
3510 dlg = Gtk.FileChooserDialog(title=_('Import from OPML'),
3511 parent=self.main_window,
3512 action=Gtk.FileChooserAction.OPEN)
3513 dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
3514 dlg.add_button(_('_Open'), Gtk.ResponseType.OK)
3515 dlg.set_filter(self.get_opml_filter())
3516 response = dlg.run()
3517 filename = None
3518 if response == Gtk.ResponseType.OK:
3519 filename = dlg.get_filename()
3520 dlg.destroy()
3522 if filename is not None:
3523 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config,
3524 custom_title=_('Import podcasts from OPML file'),
3525 add_podcast_list=self.add_podcast_list,
3526 hide_url_entry=True)
3527 dir.download_opml_file(filename)
3529 def on_itemExportChannels_activate(self, widget, *args):
3530 if not self.channels:
3531 title = _('Nothing to export')
3532 message = _('Your list of podcast subscriptions is empty. '
3533 'Please subscribe to some podcasts first before '
3534 'trying to export your subscription list.')
3535 self.show_message(message, title, widget=self.treeChannels)
3536 return
3538 dlg = Gtk.FileChooserDialog(title=_('Export to OPML'),
3539 parent=self.gPodder,
3540 action=Gtk.FileChooserAction.SAVE)
3541 dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
3542 dlg.add_button(_('_Save'), Gtk.ResponseType.OK)
3543 dlg.set_filter(self.get_opml_filter())
3544 response = dlg.run()
3545 if response == Gtk.ResponseType.OK:
3546 filename = dlg.get_filename()
3547 dlg.destroy()
3548 exporter = opml.Exporter(filename)
3549 if filename is not None and exporter.write(self.channels):
3550 count = len(self.channels)
3551 title = N_('%(count)d subscription exported',
3552 '%(count)d subscriptions exported',
3553 count) % {'count': count}
3554 self.show_message(_('Your podcast list has been successfully '
3555 'exported.'),
3556 title, widget=self.treeChannels)
3557 else:
3558 self.show_message(_('Could not export OPML to file. '
3559 'Please check your permissions.'),
3560 _('OPML export failed'), important=True)
3561 else:
3562 dlg.destroy()
3564 def on_itemImportChannels_activate(self, widget, *args):
3565 self._podcast_directory = gPodderPodcastDirectory(self.main_window,
3566 _config=self.config,
3567 add_podcast_list=self.add_podcast_list)
3569 def on_homepage_activate(self, widget, *args):
3570 util.open_website(gpodder.__url__)
3572 def check_for_distro_updates(self):
3573 title = _('Managed by distribution')
3574 message = _('Please check your distribution for gPodder updates.')
3575 self.show_message(message, title, important=True)
3577 def check_for_updates(self, silent):
3578 """Check for updates and (optionally) show a message
3580 If silent=False, a message will be shown even if no updates are
3581 available (set silent=False when the check is manually triggered).
3583 try:
3584 up_to_date, version, released, days = util.get_update_info()
3585 except Exception as e:
3586 if silent:
3587 logger.warning('Could not check for updates.', exc_info=True)
3588 else:
3589 title = _('Could not check for updates')
3590 message = _('Please try again later.')
3591 self.show_message(message, title, important=True)
3592 return
3594 if up_to_date and not silent:
3595 title = _('No updates available')
3596 message = _('You have the latest version of gPodder.')
3597 self.show_message(message, title, important=True)
3599 if not up_to_date:
3600 title = _('New version available')
3601 message = '\n'.join([
3602 _('Installed version: %s') % gpodder.__version__,
3603 _('Newest version: %s') % version,
3604 _('Release date: %s') % released,
3606 _('Download the latest version from gpodder.org?'),
3609 if self.show_confirmation(message, title):
3610 util.open_website('http://gpodder.org/downloads')
3612 def on_wNotebook_switch_page(self, notebook, page, page_num):
3613 self.play_or_download(current_page=page_num)
3614 if page_num == 0:
3615 # The message area in the downloads tab should be hidden
3616 # when the user switches away from the downloads tab
3617 if self.message_area is not None:
3618 self.message_area.hide()
3619 self.message_area = None
3621 def on_treeChannels_row_activated(self, widget, path, *args):
3622 # double-click action of the podcast list or enter
3623 self.treeChannels.set_cursor(path)
3625 # open channel settings
3626 channel = self.get_selected_channels()[0]
3627 if channel and not isinstance(channel, PodcastChannelProxy):
3628 self.on_itemEditChannel_activate(None)
3630 def get_selected_channels(self):
3631 """Get a list of selected channels from treeChannels"""
3632 selection = self.treeChannels.get_selection()
3633 model, paths = selection.get_selected_rows()
3635 channels = [model.get_value(model.get_iter(path), PodcastListModel.C_CHANNEL) for path in paths]
3636 channels = [c for c in channels if c is not None]
3637 return channels
3639 def on_treeChannels_cursor_changed(self, widget, *args):
3640 (model, iter) = self.treeChannels.get_selection().get_selected()
3642 if model is not None and iter is not None:
3643 old_active_channel = self.active_channel
3644 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3646 if self.active_channel == old_active_channel:
3647 return
3649 # Dirty hack to check for "All episodes" or a section (see gpodder.gtkui.model)
3650 if isinstance(self.active_channel, PodcastChannelProxy):
3651 self.edit_channel_action.set_enabled(False)
3652 else:
3653 self.edit_channel_action.set_enabled(True)
3654 else:
3655 self.active_channel = None
3656 self.edit_channel_action.set_enabled(False)
3658 self.update_episode_list_model()
3660 def on_btnEditChannel_clicked(self, widget, *args):
3661 self.on_itemEditChannel_activate(widget, args)
3663 def get_podcast_urls_from_selected_episodes(self):
3664 """Get a set of podcast URLs based on the selected episodes"""
3665 return set(episode.channel.url for episode in
3666 self.get_selected_episodes())
3668 def get_selected_episodes(self):
3669 """Get a list of selected episodes from treeAvailable"""
3670 selection = self.treeAvailable.get_selection()
3671 model, paths = selection.get_selected_rows()
3673 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3674 episodes = [e for e in episodes if e is not None]
3675 return episodes
3677 def on_playback_selected_episodes(self, *params):
3678 self.playback_episodes(self.get_selected_episodes())
3680 def on_shownotes_selected_episodes(self, *params):
3681 episodes = self.get_selected_episodes()
3682 self.shownotes_object.toggle_pane_visibility(episodes)
3684 def on_download_selected_episodes(self, action_or_widget, param=None):
3685 if self.wNotebook.get_current_page() == 0:
3686 episodes = [e for e in self.get_selected_episodes() if e.can_download()]
3687 self.download_episode_list(episodes)
3688 self.update_downloads_list()
3689 else:
3690 selection = self.treeDownloads.get_selection()
3691 (model, paths) = selection.get_selected_rows()
3692 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
3693 model.get_value(model.get_iter(path),
3694 DownloadStatusModel.C_TASK)) for path in paths]
3695 self._for_each_task_set_status(selected_tasks, status=download.DownloadTask.QUEUED, force_start=False)
3697 def on_pause_selected_episodes(self, action_or_widget, param=None):
3698 if self.wNotebook.get_current_page() == 0:
3699 for episode in self.get_selected_episodes():
3700 if episode.can_pause():
3701 episode.download_task.pause()
3702 self.update_downloads_list()
3703 else:
3704 selection = self.treeDownloads.get_selection()
3705 (model, paths) = selection.get_selected_rows()
3706 selected_tasks = [(Gtk.TreeRowReference.new(model, path),
3707 model.get_value(model.get_iter(path),
3708 DownloadStatusModel.C_TASK)) for path in paths]
3709 self._for_each_task_set_status(selected_tasks, status=download.DownloadTask.PAUSING, force_start=False)
3711 def on_treeAvailable_row_activated(self, widget, path, view_column):
3712 """Double-click/enter action handler for treeAvailable"""
3713 self.on_shownotes_selected_episodes(widget)
3715 def restart_auto_update_timer(self):
3716 if self._auto_update_timer_source_id is not None:
3717 logger.debug('Removing existing auto update timer.')
3718 GObject.source_remove(self._auto_update_timer_source_id)
3719 self._auto_update_timer_source_id = None
3721 if (self.config.auto_update_feeds and
3722 self.config.auto_update_frequency):
3723 interval = 60 * 1000 * self.config.auto_update_frequency
3724 logger.debug('Setting up auto update timer with interval %d.',
3725 self.config.auto_update_frequency)
3726 self._auto_update_timer_source_id = GObject.timeout_add(
3727 interval, self._on_auto_update_timer)
3729 def _on_auto_update_timer(self):
3730 if self.config.check_connection and not util.connection_available():
3731 logger.debug('Skipping auto update (no connection available)')
3732 return True
3734 logger.debug('Auto update timer fired.')
3735 self.update_feed_cache()
3737 # Ask web service for sub changes (if enabled)
3738 if self.mygpo_client.can_access_webservice():
3739 self.mygpo_client.flush()
3741 return True
3743 def on_treeDownloads_row_activated(self, widget, *args):
3744 # Use the standard way of working on the treeview
3745 selection = self.treeDownloads.get_selection()
3746 (model, paths) = selection.get_selected_rows()
3747 selected_tasks = [(Gtk.TreeRowReference.new(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3749 for tree_row_reference, task in selected_tasks:
3750 with task:
3751 if task.status in (task.DOWNLOADING, task.QUEUED):
3752 task.pause()
3753 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3754 self.download_queue_manager.queue_task(task)
3755 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3756 elif task.status == task.DONE:
3757 model.remove(model.get_iter(tree_row_reference.get_path()))
3759 self.play_or_download()
3761 # Update the tab title and downloads list
3762 self.update_downloads_list()
3764 def on_item_cancel_download_activate(self, *params, force=False):
3765 if self.wNotebook.get_current_page() == 0:
3766 selection = self.treeAvailable.get_selection()
3767 (model, paths) = selection.get_selected_rows()
3768 urls = [model.get_value(model.get_iter(path),
3769 self.episode_list_model.C_URL) for path in paths]
3770 selected_tasks = [task for task in self.download_tasks_seen
3771 if task.url in urls]
3772 else:
3773 selection = self.treeDownloads.get_selection()
3774 (model, paths) = selection.get_selected_rows()
3775 selected_tasks = [model.get_value(model.get_iter(path),
3776 self.download_status_model.C_TASK) for path in paths]
3777 self.cancel_task_list(selected_tasks, force=force)
3779 def on_btnCancelAll_clicked(self, widget, *args):
3780 self.cancel_task_list(self.download_tasks_seen)
3782 def on_btnDownloadedDelete_clicked(self, widget, *args):
3783 episodes = self.get_selected_episodes()
3784 self.delete_episode_list(episodes)
3786 def on_key_press(self, widget, event):
3787 # Allow tab switching with Ctrl + PgUp/PgDown/Tab
3788 if event.get_state() & Gdk.ModifierType.CONTROL_MASK:
3789 current_page = self.wNotebook.get_current_page()
3790 if event.keyval in (Gdk.KEY_Page_Up, Gdk.KEY_ISO_Left_Tab):
3791 if current_page == 0:
3792 current_page = self.wNotebook.get_n_pages()
3793 self.wNotebook.set_current_page(current_page - 1)
3794 return True
3795 elif event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Tab):
3796 if current_page == self.wNotebook.get_n_pages() - 1:
3797 current_page = -1
3798 self.wNotebook.set_current_page(current_page + 1)
3799 return True
3800 elif event.keyval == Gdk.KEY_Delete:
3801 if isinstance(widget.get_focus(), Gtk.Entry):
3802 logger.debug("Entry has focus, ignoring Delete")
3803 else:
3804 self.main_window.activate_action('delete')
3805 return True
3807 return False
3809 def uniconify_main_window(self):
3810 if self.is_iconified():
3811 # We need to hide and then show the window in WMs like Metacity
3812 # or KWin4 to move the window to the active workspace
3813 # (see http://gpodder.org/bug/1125)
3814 self.gPodder.hide()
3815 self.gPodder.show()
3816 self.gPodder.present()
3818 def iconify_main_window(self):
3819 if not self.is_iconified():
3820 self.gPodder.iconify()
3822 @dbus.service.method(gpodder.dbus_interface)
3823 def show_gui_window(self):
3824 parent = self.get_dialog_parent()
3825 parent.present()
3827 @dbus.service.method(gpodder.dbus_interface)
3828 def subscribe_to_url(self, url):
3829 # Strip leading application protocol, so these URLs work:
3830 # gpodder://example.com/episodes.rss
3831 # gpodder:https://example.org/podcast.xml
3832 if url.startswith('gpodder:'):
3833 url = url[len('gpodder:'):]
3834 while url.startswith('/'):
3835 url = url[1:]
3837 self._add_podcast_dialog = gPodderAddPodcast(self.gPodder,
3838 add_podcast_list=self.add_podcast_list,
3839 preset_url=url)
3841 @dbus.service.method(gpodder.dbus_interface)
3842 def mark_episode_played(self, filename):
3843 if filename is None:
3844 return False
3846 for channel in self.channels:
3847 for episode in channel.get_all_episodes():
3848 fn = episode.local_filename(create=False, check_only=True)
3849 if fn == filename:
3850 episode.mark(is_played=True)
3851 self.db.commit()
3852 self.update_episode_list_icons([episode.url])
3853 self.update_podcast_list_model([episode.channel.url])
3854 return True
3856 return False
3858 def extensions_podcast_update_cb(self, podcast):
3859 logger.debug('extensions_podcast_update_cb(%s)', podcast)
3860 self.update_feed_cache(channels=[podcast],
3861 show_new_episodes_dialog=False)
3863 def extensions_episode_download_cb(self, episode):
3864 logger.debug('extension_episode_download_cb(%s)', episode)
3865 self.download_episode_list(episodes=[episode])
3867 def mount_volume_cb(self, file, res, mount_result):
3868 result = True
3869 try:
3870 file.mount_enclosing_volume_finish(res)
3871 except GLib.Error as err:
3872 if (not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_SUPPORTED) and
3873 not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED)):
3874 logger.error('mounting volume %s failed: %s' % (file.get_uri(), err.message))
3875 result = False
3876 finally:
3877 mount_result["result"] = result
3878 Gtk.main_quit()
3880 def mount_volume_for_file(self, file):
3881 op = Gtk.MountOperation.new(self.main_window)
3882 result, message = util.mount_volume_for_file(file, op)
3883 if not result:
3884 logger.error('mounting volume %s failed: %s' % (file.get_uri(), message))
3885 return result
3887 def on_sync_to_device_activate(self, widget, episodes=None, force_played=True):
3888 self.sync_ui = gPodderSyncUI(self.config, self.notification,
3889 self.main_window,
3890 self.show_confirmation,
3891 self.application.on_itemPreferences_activate,
3892 self.channels,
3893 self.download_status_model,
3894 self.download_queue_manager,
3895 self.set_download_list_state,
3896 self.commit_changes_to_database,
3897 self.delete_episode_list,
3898 gPodderEpisodeSelector,
3899 self.mount_volume_for_file)
3901 self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played)
3903 def on_extension_enabled(self, extension):
3904 if getattr(extension, 'on_ui_object_available', None) is not None:
3905 extension.on_ui_object_available('gpodder-gtk', self)
3906 if getattr(extension, 'on_ui_initialized', None) is not None:
3907 extension.on_ui_initialized(self.model,
3908 self.extensions_podcast_update_cb,
3909 self.extensions_episode_download_cb)
3910 self.inject_extensions_menu()
3912 def on_extension_disabled(self, extension):
3913 self.inject_extensions_menu()