Maemo 5: Add "Select finished" for delete episode
[gpodder.git] / src / gpodder / gui.py
blob806c013e8ba2f9089dd50161fbd650a9ef9da1ae
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 import os
21 import platform
22 import gtk
23 import gtk.gdk
24 import gobject
25 import pango
26 import sys
27 import shutil
28 import subprocess
29 import glob
30 import time
31 import tempfile
32 import collections
33 import threading
35 from xml.sax import saxutils
37 import gpodder
39 try:
40 import dbus
41 import dbus.service
42 import dbus.mainloop
43 import dbus.glib
44 except ImportError:
45 # Mock the required D-Bus interfaces with no-ops (ugly? maybe.)
46 class dbus:
47 class SessionBus:
48 def __init__(self, *args, **kwargs):
49 pass
50 def add_signal_receiver(self, *args, **kwargs):
51 pass
52 class glib:
53 class DBusGMainLoop:
54 def __init__(self, *args, **kwargs):
55 pass
56 class service:
57 @staticmethod
58 def method(*args, **kwargs):
59 return lambda x: x
60 class BusName:
61 def __init__(self, *args, **kwargs):
62 pass
63 class Object:
64 def __init__(self, *args, **kwargs):
65 pass
68 from gpodder import feedcore
69 from gpodder import util
70 from gpodder import opml
71 from gpodder import download
72 from gpodder import my
73 from gpodder import youtube
74 from gpodder import player
75 from gpodder.liblogger import log
77 _ = gpodder.gettext
78 N_ = gpodder.ngettext
80 from gpodder.model import PodcastChannel
81 from gpodder.model import PodcastEpisode
82 from gpodder.dbsqlite import Database
84 from gpodder.gtkui.model import PodcastListModel
85 from gpodder.gtkui.model import EpisodeListModel
86 from gpodder.gtkui.config import UIConfig
87 from gpodder.gtkui.services import CoverDownloader
88 from gpodder.gtkui.widgets import SimpleMessageArea
89 from gpodder.gtkui.desktopfile import UserAppsReader
91 from gpodder.gtkui.draw import draw_text_box_centered
93 from gpodder.gtkui.interface.common import BuilderWidget
94 from gpodder.gtkui.interface.common import TreeViewHelper
95 from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
97 if gpodder.ui.desktop:
98 from gpodder.gtkui.download import DownloadStatusModel
100 from gpodder.gtkui.desktop.sync import gPodderSyncUI
102 from gpodder.gtkui.desktop.channel import gPodderChannel
103 from gpodder.gtkui.desktop.preferences import gPodderPreferences
104 from gpodder.gtkui.desktop.shownotes import gPodderShownotes
105 from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
106 from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
107 from gpodder.gtkui.desktop.dependencymanager import gPodderDependencyManager
108 from gpodder.gtkui.interface.progress import ProgressIndicator
109 try:
110 from gpodder.gtkui.desktop.trayicon import GPodderStatusIcon
111 have_trayicon = True
112 except Exception, exc:
113 log('Warning: Could not import gpodder.trayicon.', traceback=True)
114 log('Warning: This probably means your PyGTK installation is too old!')
115 have_trayicon = False
116 elif gpodder.ui.diablo:
117 from gpodder.gtkui.download import DownloadStatusModel
119 from gpodder.gtkui.maemo.channel import gPodderChannel
120 from gpodder.gtkui.maemo.preferences import gPodderPreferences
121 from gpodder.gtkui.maemo.shownotes import gPodderShownotes
122 from gpodder.gtkui.maemo.episodeselector import gPodderEpisodeSelector
123 from gpodder.gtkui.maemo.podcastdirectory import gPodderPodcastDirectory
124 from gpodder.gtkui.maemo.mygpodder import MygPodderSettings
125 from gpodder.gtkui.interface.progress import ProgressIndicator
126 have_trayicon = False
127 elif gpodder.ui.fremantle:
128 from gpodder.gtkui.frmntl.model import DownloadStatusModel
129 from gpodder.gtkui.frmntl.model import EpisodeListModel
130 from gpodder.gtkui.frmntl.model import PodcastListModel
132 from gpodder.gtkui.maemo.channel import gPodderChannel
133 from gpodder.gtkui.frmntl.preferences import gPodderPreferences
134 from gpodder.gtkui.frmntl.shownotes import gPodderShownotes
135 from gpodder.gtkui.frmntl.episodeselector import gPodderEpisodeSelector
136 from gpodder.gtkui.frmntl.podcastdirectory import gPodderPodcastDirectory
137 from gpodder.gtkui.frmntl.episodes import gPodderEpisodes
138 from gpodder.gtkui.frmntl.downloads import gPodderDownloads
139 from gpodder.gtkui.frmntl.progress import ProgressIndicator
140 from gpodder.gtkui.frmntl.widgets import FancyProgressBar
141 have_trayicon = False
143 from gpodder.gtkui.frmntl.portrait import FremantleRotation
144 from gpodder.gtkui.frmntl.mafw import MafwPlaybackMonitor
146 from gpodder.gtkui.interface.common import Orientation
148 from gpodder.gtkui.interface.welcome import gPodderWelcome
150 if gpodder.ui.maemo:
151 import hildon
153 from gpodder.dbusproxy import DBusPodcastsProxy
154 from gpodder import hooks
156 class gPodder(BuilderWidget, dbus.service.Object):
157 finger_friendly_widgets = ['btnCleanUpDownloads', 'button_search_episodes_clear']
159 ICON_GENERAL_ADD = 'general_add'
160 ICON_GENERAL_REFRESH = 'general_refresh'
162 def __init__(self, bus_name, config):
163 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
164 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels, \
165 self.on_itemUpdate_activate, \
166 self.playback_episodes, \
167 self.download_episode_list, \
168 self.episode_object_by_uri, \
169 bus_name)
170 self.db = Database(gpodder.database_file)
171 self.config = config
172 BuilderWidget.__init__(self, None)
174 def new(self):
175 if gpodder.ui.diablo:
176 import hildon
177 self.app = hildon.Program()
178 self.app.add_window(self.main_window)
179 self.main_window.add_toolbar(self.toolbar)
180 menu = gtk.Menu()
181 for child in self.main_menu.get_children():
182 child.reparent(menu)
183 self.main_window.set_menu(self.set_finger_friendly(menu))
184 self._last_orientation = Orientation.LANDSCAPE
185 elif gpodder.ui.fremantle:
186 import hildon
187 self.app = hildon.Program()
188 self.app.add_window(self.main_window)
190 appmenu = hildon.AppMenu()
192 for filter in (self.item_view_podcasts_all, \
193 self.item_view_podcasts_downloaded, \
194 self.item_view_podcasts_unplayed):
195 button = gtk.ToggleButton()
196 filter.connect_proxy(button)
197 appmenu.add_filter(button)
199 for action in (self.itemPreferences, \
200 self.item_downloads, \
201 self.itemRemoveOldEpisodes, \
202 self.item_unsubscribe, \
203 self.itemAbout):
204 button = hildon.Button(gtk.HILDON_SIZE_AUTO,\
205 hildon.BUTTON_ARRANGEMENT_HORIZONTAL)
206 action.connect_proxy(button)
207 if action == self.item_downloads:
208 button.set_title(_('Downloads'))
209 button.set_value(_('Idle'))
210 self.button_downloads = button
211 appmenu.append(button)
212 appmenu.show_all()
213 self.main_window.set_app_menu(appmenu)
215 # Initialize portrait mode / rotation manager
216 self._fremantle_rotation = FremantleRotation('gPodder', \
217 self.main_window, \
218 gpodder.__version__, \
219 self.config.rotation_mode)
221 if self.config.rotation_mode == FremantleRotation.ALWAYS:
222 util.idle_add(self.on_window_orientation_changed, \
223 Orientation.PORTRAIT)
224 self._last_orientation = Orientation.PORTRAIT
225 else:
226 self._last_orientation = Orientation.LANDSCAPE
228 # Flag set when a notification is being shown (Maemo bug 11235)
229 self._fremantle_notification_visible = False
230 else:
231 self._last_orientation = Orientation.LANDSCAPE
232 self.toolbar.set_property('visible', self.config.show_toolbar)
234 self.bluetooth_available = util.bluetooth_available()
236 self.config.connect_gtk_window(self.gPodder, 'main_window')
237 if not gpodder.ui.fremantle:
238 self.config.connect_gtk_paned('paned_position', self.channelPaned)
239 self.main_window.show()
241 self.player_receiver = player.MediaPlayerDBusReceiver(self.on_played)
243 if gpodder.ui.fremantle:
244 # Create a D-Bus monitoring object that takes care of
245 # tracking MAFW (Nokia Media Player) playback events
246 # and sends episode playback status events via D-Bus
247 self.mafw_monitor = MafwPlaybackMonitor(gpodder.dbus_session_bus)
249 self.gPodder.connect('key-press-event', self.on_key_press)
251 self.preferences_dialog = None
252 self.config.add_observer(self.on_config_changed)
254 self.tray_icon = None
255 self.episode_shownotes_window = None
256 self.new_episodes_window = None
258 if gpodder.ui.desktop:
259 # Mac OS X-specific UI tweaks: Native main menu integration
260 # http://sourceforge.net/apps/trac/gtk-osx/wiki/Integrate
261 if getattr(gtk.gdk, 'WINDOWING', 'x11') == 'quartz':
262 try:
263 import igemacintegration as igemi
265 # Move the menu bar from the window to the Mac menu bar
266 self.mainMenu.hide()
267 igemi.ige_mac_menu_set_menu_bar(self.mainMenu)
269 # Reparent some items to the "Application" menu
270 for widget in ('/mainMenu/menuHelp/itemAbout', \
271 '/mainMenu/menuPodcasts/itemPreferences'):
272 item = self.uimanager1.get_widget(widget)
273 group = igemi.ige_mac_menu_add_app_menu_group()
274 igemi.ige_mac_menu_add_app_menu_item(group, item, None)
276 quit_widget = '/mainMenu/menuPodcasts/itemQuit'
277 quit_item = self.uimanager1.get_widget(quit_widget)
278 igemi.ige_mac_menu_set_quit_menu_item(quit_item)
279 except ImportError:
280 print >>sys.stderr, """
281 Warning: ige-mac-integration not found - no native menus.
284 self.sync_ui = gPodderSyncUI(self.config, self.notification, \
285 self.main_window, self.show_confirmation, \
286 self.update_episode_list_icons, \
287 self.update_podcast_list_model, self.toolPreferences, \
288 gPodderEpisodeSelector, \
289 self.commit_changes_to_database)
290 else:
291 self.sync_ui = None
293 self.download_status_model = DownloadStatusModel()
294 self.download_queue_manager = download.DownloadQueueManager(self.config)
296 if gpodder.ui.desktop:
297 self.show_hide_tray_icon()
298 self.itemShowAllEpisodes.set_active(self.config.podcast_list_view_all)
299 self.itemShowToolbar.set_active(self.config.show_toolbar)
300 self.itemShowDescription.set_active(self.config.episode_list_descriptions)
302 if not gpodder.ui.fremantle:
303 self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
304 self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
305 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
306 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
308 # When the amount of maximum downloads changes, notify the queue manager
309 changed_cb = lambda spinbutton: self.download_queue_manager.spawn_threads()
310 self.spinMaxDownloads.connect('value-changed', changed_cb)
312 self.default_title = 'gPodder'
313 if gpodder.__version__.rfind('git') != -1:
314 self.set_title('gPodder %s' % gpodder.__version__)
315 else:
316 title = self.gPodder.get_title()
317 if title is not None:
318 self.set_title(title)
319 else:
320 self.set_title(_('gPodder'))
322 self.cover_downloader = CoverDownloader()
324 # Generate list models for podcasts and their episodes
325 self.podcast_list_model = PodcastListModel(self.cover_downloader)
327 self.cover_downloader.register('cover-available', self.cover_download_finished)
328 self.cover_downloader.register('cover-removed', self.cover_file_removed)
330 if gpodder.ui.fremantle:
331 # Work around Maemo bug #4718
332 self.button_refresh.set_name('HildonButton-finger')
333 self.button_subscribe.set_name('HildonButton-finger')
335 self.button_refresh.set_sensitive(False)
336 self.button_subscribe.set_sensitive(False)
338 self.button_subscribe.set_image(gtk.image_new_from_icon_name(\
339 self.ICON_GENERAL_ADD, gtk.ICON_SIZE_BUTTON))
340 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
341 self.ICON_GENERAL_REFRESH, gtk.ICON_SIZE_BUTTON))
343 # Make the button scroll together with the TreeView contents
344 action_area_box = self.treeChannels.get_action_area_box()
345 for child in self.buttonbox:
346 child.reparent(action_area_box)
347 self.vbox.remove(self.buttonbox)
348 action_area_box.set_spacing(2)
349 action_area_box.set_border_width(3)
350 self.treeChannels.set_action_area_visible(True)
352 # Set up a very nice progress bar setup
353 self.fancy_progress_bar = FancyProgressBar(self.main_window, \
354 self.on_btnCancelFeedUpdate_clicked)
355 self.pbFeedUpdate = self.fancy_progress_bar.progress_bar
356 self.pbFeedUpdate.set_ellipsize(pango.ELLIPSIZE_MIDDLE)
357 self.vbox.pack_start(self.fancy_progress_bar.event_box, False)
359 from gpodder.gtkui.frmntl import style
360 sub_font = style.get_font_desc('SmallSystemFont')
361 sub_color = style.get_color('SecondaryTextColor')
362 sub = (sub_font.to_string(), sub_color.to_string())
363 sub = '<span font_desc="%s" foreground="%s">%%s</span>' % sub
364 self.label_footer.set_markup(sub % gpodder.__copyright__)
366 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
367 while gtk.events_pending():
368 gtk.main_iteration(False)
370 try:
371 # Try to get the real package version from dpkg
372 p = subprocess.Popen(['dpkg-query', '-W', '-f=${Version}', 'gpodder'], stdout=subprocess.PIPE)
373 version, _stderr = p.communicate()
374 del _stderr
375 del p
376 except:
377 version = gpodder.__version__
378 self.label_footer.set_markup(sub % ('v %s' % version))
379 self.label_footer.hide()
381 self.episodes_window = gPodderEpisodes(self.main_window, \
382 on_treeview_expose_event=self.on_treeview_expose_event, \
383 show_episode_shownotes=self.show_episode_shownotes, \
384 update_podcast_list_model=self.update_podcast_list_model, \
385 on_itemRemoveChannel_activate=self.on_itemRemoveChannel_activate, \
386 item_view_episodes_all=self.item_view_episodes_all, \
387 item_view_episodes_unplayed=self.item_view_episodes_unplayed, \
388 item_view_episodes_downloaded=self.item_view_episodes_downloaded, \
389 item_view_episodes_undeleted=self.item_view_episodes_undeleted, \
390 on_entry_search_episodes_changed=self.on_entry_search_episodes_changed, \
391 on_entry_search_episodes_key_press=self.on_entry_search_episodes_key_press, \
392 hide_episode_search=self.hide_episode_search, \
393 on_itemUpdateChannel_activate=self.on_itemUpdateChannel_activate, \
394 playback_episodes=self.playback_episodes, \
395 delete_episode_list=self.delete_episode_list, \
396 episode_list_status_changed=self.episode_list_status_changed, \
397 download_episode_list=self.download_episode_list, \
398 episode_is_downloading=self.episode_is_downloading, \
399 show_episode_in_download_manager=self.show_episode_in_download_manager, \
400 add_download_task_monitor=self.add_download_task_monitor, \
401 remove_download_task_monitor=self.remove_download_task_monitor, \
402 for_each_episode_set_task_status=self.for_each_episode_set_task_status, \
403 on_delete_episodes_button_clicked=self.on_itemRemoveOldEpisodes_activate, \
404 on_itemUpdate_activate=self.on_itemUpdate_activate)
406 # Expose objects for episode list type-ahead find
407 self.hbox_search_episodes = self.episodes_window.hbox_search_episodes
408 self.entry_search_episodes = self.episodes_window.entry_search_episodes
409 self.button_search_episodes_clear = self.episodes_window.button_search_episodes_clear
411 self.downloads_window = gPodderDownloads(self.main_window, \
412 on_treeview_expose_event=self.on_treeview_expose_event, \
413 cleanup_downloads=self.cleanup_downloads, \
414 _for_each_task_set_status=self._for_each_task_set_status, \
415 downloads_list_get_selection=self.downloads_list_get_selection, \
416 _config=self.config)
418 self.treeAvailable = self.episodes_window.treeview
419 self.treeDownloads = self.downloads_window.treeview
421 # Init the treeviews that we use
422 self.init_podcast_list_treeview()
423 self.init_episode_list_treeview()
424 self.init_download_list_treeview()
426 if self.config.podcast_list_hide_boring:
427 self.item_view_hide_boring_podcasts.set_active(True)
429 self.currently_updating = False
431 if gpodder.ui.maemo:
432 self.context_menu_mouse_button = 1
433 else:
434 self.context_menu_mouse_button = 3
436 if self.config.start_iconified:
437 self.iconify_main_window()
439 self.download_tasks_seen = set()
440 self.download_list_update_enabled = False
441 self.download_task_monitors = set()
443 # Subscribed channels
444 self.active_channel = None
445 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
446 self.channel_list_changed = True
447 self.update_podcasts_tab()
449 # load list of user applications for audio playback
450 self.user_apps_reader = UserAppsReader(['audio', 'video'])
451 threading.Thread(target=self.user_apps_reader.read).start()
453 # Set the "Device" menu item for the first time
454 if gpodder.ui.desktop:
455 self.update_item_device()
457 # Set up the first instance of MygPoClient
458 self.mygpo_client = my.MygPoClient(self.config)
460 # Now, update the feed cache, when everything's in place
461 if not gpodder.ui.fremantle:
462 self.btnUpdateFeeds.show()
463 self.updating_feed_cache = False
464 self.feed_cache_update_cancelled = False
465 self.update_feed_cache(force_update=self.config.update_on_startup)
467 self.message_area = None
469 def find_partial_downloads():
470 # Look for partial file downloads
471 partial_files = glob.glob(os.path.join(self.config.download_dir, '*', '*.partial'))
472 count = len(partial_files)
473 resumable_episodes = []
474 if count:
475 if not gpodder.ui.fremantle:
476 util.idle_add(self.wNotebook.set_current_page, 1)
477 indicator = ProgressIndicator(_('Loading incomplete downloads'), \
478 _('Some episodes have not finished downloading in a previous session.'), \
479 False, self.get_dialog_parent())
480 indicator.on_message(N_('%d partial file', '%d partial files', count) % count)
482 candidates = [f[:-len('.partial')] for f in partial_files]
483 found = 0
485 for c in self.channels:
486 for e in c.get_all_episodes():
487 filename = e.local_filename(create=False, check_only=True)
488 if filename in candidates:
489 log('Found episode: %s', e.title, sender=self)
490 found += 1
491 indicator.on_message(e.title)
492 indicator.on_progress(float(found)/count)
493 candidates.remove(filename)
494 partial_files.remove(filename+'.partial')
495 resumable_episodes.append(e)
497 if not candidates:
498 break
500 if not candidates:
501 break
503 for f in partial_files:
504 log('Partial file without episode: %s', f, sender=self)
505 util.delete_file(f)
507 util.idle_add(indicator.on_finished)
509 if len(resumable_episodes):
510 def offer_resuming():
511 self.download_episode_list_paused(resumable_episodes)
512 if not gpodder.ui.fremantle:
513 resume_all = gtk.Button(_('Resume all'))
514 #resume_all.set_border_width(0)
515 def on_resume_all(button):
516 selection = self.treeDownloads.get_selection()
517 selection.select_all()
518 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = self.downloads_list_get_selection()
519 selection.unselect_all()
520 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
521 self.message_area.hide()
522 resume_all.connect('clicked', on_resume_all)
524 self.message_area = SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all,))
525 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
526 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
527 self.message_area.show_all()
528 self.clean_up_downloads(delete_partial=False)
529 util.idle_add(offer_resuming)
530 elif not gpodder.ui.fremantle:
531 util.idle_add(self.wNotebook.set_current_page, 0)
532 else:
533 util.idle_add(self.clean_up_downloads, True)
534 threading.Thread(target=find_partial_downloads).start()
536 # Start the auto-update procedure
537 self._auto_update_timer_source_id = None
538 if self.config.auto_update_feeds:
539 self.restart_auto_update_timer()
541 # Delete old episodes if the user wishes to
542 if self.config.auto_remove_played_episodes and \
543 self.config.episode_old_age > 0:
544 old_episodes = list(self.get_expired_episodes())
545 if len(old_episodes) > 0:
546 self.delete_episode_list(old_episodes, confirm=False)
547 self.update_podcast_list_model(set(e.channel.url for e in old_episodes))
549 if gpodder.ui.fremantle:
550 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
551 self.button_refresh.set_sensitive(True)
552 self.button_subscribe.set_sensitive(True)
553 self.main_window.set_title(_('gPodder'))
554 hildon.hildon_gtk_window_take_screenshot(self.main_window, True)
556 # Do the initial sync with the web service
557 util.idle_add(self.mygpo_client.flush, True)
559 # First-time users should be asked if they want to see the OPML
560 if not self.channels and not gpodder.ui.fremantle:
561 util.idle_add(self.on_itemUpdate_activate)
563 def episode_object_by_uri(self, uri):
564 """Get an episode object given a local or remote URI
566 This can be used to quickly access an episode object
567 when all we have is its download filename or episode
568 URL (e.g. from external D-Bus calls / signals, etc..)
570 if uri.startswith('/'):
571 uri = 'file://' + uri
573 prefix = 'file://' + self.config.download_dir
575 if uri.startswith(prefix):
576 # File is on the local filesystem in the download folder
577 filename = uri[len(prefix):]
578 file_parts = [x for x in filename.split(os.sep) if x]
580 if len(file_parts) == 2:
581 dir_name, filename = file_parts
582 channels = [c for c in self.channels if c.foldername == dir_name]
583 if len(channels) == 1:
584 channel = channels[0]
585 return channel.get_episode_by_filename(filename)
586 else:
587 # Possibly remote file - search the database for a podcast
588 channel_id = self.db.get_channel_id_from_episode_url(uri)
590 if channel_id is not None:
591 channels = [c for c in self.channels if c.id == channel_id]
592 if len(channels) == 1:
593 channel = channels[0]
594 return channel.get_episode_by_url(uri)
596 return None
598 def on_played(self, start, end, total, file_uri):
599 """Handle the "played" signal from a media player"""
600 if start == 0 and end == 0 and total == 0:
601 # Ignore bogus play event
602 return
603 elif end < start + 5:
604 # Ignore "less than five seconds" segments,
605 # as they can happen with seeking, etc...
606 return
608 log('Received play action: %s (%d, %d, %d)', file_uri, start, end, total, sender=self)
609 episode = self.episode_object_by_uri(file_uri)
611 if episode is not None:
612 file_type = episode.file_type()
613 # Automatically enable D-Bus played status mode
614 if file_type == 'audio':
615 self.config.audio_played_dbus = True
616 elif file_type == 'video':
617 self.config.video_played_dbus = True
619 now = time.time()
620 if total > 0:
621 episode.total_time = total
622 elif total == 0:
623 # Assume the episode's total time for the action
624 total = episode.total_time
625 if episode.current_position_updated is None or \
626 now > episode.current_position_updated:
627 episode.current_position = end
628 episode.current_position_updated = now
629 episode.mark(is_played=True)
630 episode.save()
631 self.db.commit()
632 self.update_episode_list_icons([episode.url])
633 self.update_podcast_list_model([episode.channel.url])
635 # Submit this action to the webservice
636 self.mygpo_client.on_playback_full(episode, \
637 start, end, total)
639 def on_add_remove_podcasts_mygpo(self):
640 actions = self.mygpo_client.get_received_actions()
641 if not actions:
642 return False
644 existing_urls = [c.url for c in self.channels]
646 # Columns for the episode selector window - just one...
647 columns = (
648 ('description', None, None, _('Action')),
651 # A list of actions that have to be chosen from
652 changes = []
654 # Actions that are ignored (already carried out)
655 ignored = []
657 for action in actions:
658 if action.is_add and action.url not in existing_urls:
659 changes.append(my.Change(action))
660 elif action.is_remove and action.url in existing_urls:
661 podcast_object = None
662 for podcast in self.channels:
663 if podcast.url == action.url:
664 podcast_object = podcast
665 break
666 changes.append(my.Change(action, podcast_object))
667 else:
668 log('Ignoring action: %s', action, sender=self)
669 ignored.append(action)
671 # Confirm all ignored changes
672 self.mygpo_client.confirm_received_actions(ignored)
674 def execute_podcast_actions(selected):
675 add_list = [c.action.url for c in selected if c.action.is_add]
676 remove_list = [c.podcast for c in selected if c.action.is_remove]
678 # Apply the accepted changes locally
679 self.add_podcast_list(add_list)
680 self.remove_podcast_list(remove_list, confirm=False)
682 # All selected items are now confirmed
683 self.mygpo_client.confirm_received_actions(c.action for c in selected)
685 # Revert the changes on the server
686 rejected = [c.action for c in changes if c not in selected]
687 self.mygpo_client.reject_received_actions(rejected)
689 def ask():
690 # We're abusing the Episode Selector again ;) -- thp
691 gPodderEpisodeSelector(self.main_window, \
692 title=_('Confirm changes from gpodder.net'), \
693 instructions=_('Select the actions you want to carry out.'), \
694 episodes=changes, \
695 columns=columns, \
696 size_attribute=None, \
697 stock_ok_button=gtk.STOCK_APPLY, \
698 callback=execute_podcast_actions, \
699 _config=self.config)
701 # There are some actions that need the user's attention
702 if changes:
703 util.idle_add(ask)
704 return True
706 # We have no remaining actions - no selection happens
707 return False
709 def rewrite_urls_mygpo(self):
710 # Check if we have to rewrite URLs since the last add
711 rewritten_urls = self.mygpo_client.get_rewritten_urls()
713 for rewritten_url in rewritten_urls:
714 if not rewritten_url.new_url:
715 continue
717 for channel in self.channels:
718 if channel.url == rewritten_url.old_url:
719 log('Updating URL of %s to %s', channel, \
720 rewritten_url.new_url, sender=self)
721 channel.url = rewritten_url.new_url
722 channel.save()
723 self.channel_list_changed = True
724 util.idle_add(self.update_episode_list_model)
725 break
727 def on_send_full_subscriptions(self):
728 # Send the full subscription list to the gpodder.net client
729 # (this will overwrite the subscription list on the server)
730 indicator = ProgressIndicator(_('Uploading subscriptions'), \
731 _('Your subscriptions are being uploaded to the server.'), \
732 False, self.get_dialog_parent())
734 try:
735 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
736 util.idle_add(self.show_message, _('List uploaded successfully.'))
737 except Exception, e:
738 def show_error(e):
739 message = str(e)
740 if not message:
741 message = e.__class__.__name__
742 self.show_message(message, \
743 _('Error while uploading'), \
744 important=True)
745 util.idle_add(show_error, e)
747 util.idle_add(indicator.on_finished)
749 def on_podcast_selected(self, treeview, path, column):
750 # for Maemo 5's UI
751 model = treeview.get_model()
752 channel = model.get_value(model.get_iter(path), \
753 PodcastListModel.C_CHANNEL)
754 self.active_channel = channel
755 self.update_episode_list_model()
756 self.episodes_window.channel = self.active_channel
757 self.episodes_window.show()
759 def on_button_subscribe_clicked(self, button):
760 self.on_itemImportChannels_activate(button)
762 def on_button_downloads_clicked(self, widget):
763 self.downloads_window.show()
765 def show_episode_in_download_manager(self, episode):
766 self.downloads_window.show()
767 model = self.treeDownloads.get_model()
768 selection = self.treeDownloads.get_selection()
769 selection.unselect_all()
770 it = model.get_iter_first()
771 while it is not None:
772 task = model.get_value(it, DownloadStatusModel.C_TASK)
773 if task.episode.url == episode.url:
774 selection.select_iter(it)
775 # FIXME: Scroll to selection in pannable area
776 break
777 it = model.iter_next(it)
779 def for_each_episode_set_task_status(self, episodes, status):
780 episode_urls = set(episode.url for episode in episodes)
781 model = self.treeDownloads.get_model()
782 selected_tasks = [(gtk.TreeRowReference(model, row.path), \
783 model.get_value(row.iter, \
784 DownloadStatusModel.C_TASK)) for row in model \
785 if model.get_value(row.iter, DownloadStatusModel.C_TASK).url \
786 in episode_urls]
787 self._for_each_task_set_status(selected_tasks, status)
789 def on_window_orientation_changed(self, orientation):
790 self._last_orientation = orientation
791 if self.preferences_dialog is not None:
792 self.preferences_dialog.on_window_orientation_changed(orientation)
794 treeview = self.treeChannels
795 if orientation == Orientation.PORTRAIT:
796 treeview.set_action_area_orientation(gtk.ORIENTATION_VERTICAL)
797 # Work around Maemo bug #4718
798 self.button_subscribe.set_name('HildonButton-thumb')
799 self.button_refresh.set_name('HildonButton-thumb')
800 else:
801 treeview.set_action_area_orientation(gtk.ORIENTATION_HORIZONTAL)
802 # Work around Maemo bug #4718
803 self.button_subscribe.set_name('HildonButton-finger')
804 self.button_refresh.set_name('HildonButton-finger')
806 if gpodder.ui.fremantle:
807 self.fancy_progress_bar.relayout()
809 def on_treeview_podcasts_selection_changed(self, selection):
810 model, iter = selection.get_selected()
811 if iter is None:
812 self.active_channel = None
813 self.episode_list_model.clear()
815 def on_treeview_button_pressed(self, treeview, event):
816 if event.window != treeview.get_bin_window():
817 return False
819 TreeViewHelper.save_button_press_event(treeview, event)
821 if getattr(treeview, TreeViewHelper.ROLE) == \
822 TreeViewHelper.ROLE_PODCASTS:
823 return self.currently_updating
825 return event.button == self.context_menu_mouse_button and \
826 gpodder.ui.desktop
828 def on_treeview_podcasts_button_released(self, treeview, event):
829 if event.window != treeview.get_bin_window():
830 return False
832 if gpodder.ui.maemo:
833 return self.treeview_channels_handle_gestures(treeview, event)
834 return self.treeview_channels_show_context_menu(treeview, event)
836 def on_treeview_episodes_button_released(self, treeview, event):
837 if event.window != treeview.get_bin_window():
838 return False
840 if gpodder.ui.maemo:
841 if self.config.enable_fingerscroll or self.config.maemo_enable_gestures:
842 return self.treeview_available_handle_gestures(treeview, event)
844 return self.treeview_available_show_context_menu(treeview, event)
846 def on_treeview_downloads_button_released(self, treeview, event):
847 if event.window != treeview.get_bin_window():
848 return False
850 return self.treeview_downloads_show_context_menu(treeview, event)
852 def on_entry_search_podcasts_changed(self, editable):
853 if self.hbox_search_podcasts.get_property('visible'):
854 self.podcast_list_model.set_search_term(editable.get_chars(0, -1))
856 def on_entry_search_podcasts_key_press(self, editable, event):
857 if event.keyval == gtk.keysyms.Escape:
858 self.hide_podcast_search()
859 return True
861 def hide_podcast_search(self, *args):
862 self.hbox_search_podcasts.hide()
863 self.entry_search_podcasts.set_text('')
864 self.podcast_list_model.set_search_term(None)
865 self.treeChannels.grab_focus()
867 def show_podcast_search(self, input_char):
868 self.hbox_search_podcasts.show()
869 self.entry_search_podcasts.insert_text(input_char, -1)
870 self.entry_search_podcasts.grab_focus()
871 self.entry_search_podcasts.set_position(-1)
873 def init_podcast_list_treeview(self):
874 # Set up podcast channel tree view widget
875 if gpodder.ui.fremantle:
876 if self.config.podcast_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
877 self.item_view_podcasts_downloaded.set_active(True)
878 elif self.config.podcast_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
879 self.item_view_podcasts_unplayed.set_active(True)
880 else:
881 self.item_view_podcasts_all.set_active(True)
882 self.podcast_list_model.set_view_mode(self.config.podcast_list_view_mode)
884 iconcolumn = gtk.TreeViewColumn('')
885 iconcell = gtk.CellRendererPixbuf()
886 iconcolumn.pack_start(iconcell, False)
887 iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
888 self.treeChannels.append_column(iconcolumn)
890 namecolumn = gtk.TreeViewColumn('')
891 namecell = gtk.CellRendererText()
892 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
893 namecolumn.pack_start(namecell, True)
894 namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
896 if gpodder.ui.fremantle:
897 countcell = gtk.CellRendererText()
898 from gpodder.gtkui.frmntl import style
899 countcell.set_property('font-desc', style.get_font_desc('EmpSystemFont'))
900 countcell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
901 countcell.set_property('alignment', pango.ALIGN_RIGHT)
902 countcell.set_property('xalign', 1.)
903 countcell.set_property('xpad', 5)
904 namecolumn.pack_start(countcell, False)
905 namecolumn.add_attribute(countcell, 'text', PodcastListModel.C_DOWNLOADS)
906 namecolumn.add_attribute(countcell, 'visible', PodcastListModel.C_DOWNLOADS)
907 else:
908 iconcell = gtk.CellRendererPixbuf()
909 iconcell.set_property('xalign', 1.0)
910 namecolumn.pack_start(iconcell, False)
911 namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
912 namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
914 self.treeChannels.append_column(namecolumn)
916 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
918 # When no podcast is selected, clear the episode list model
919 selection = self.treeChannels.get_selection()
920 selection.connect('changed', self.on_treeview_podcasts_selection_changed)
922 # Set up type-ahead find for the podcast list
923 def on_key_press(treeview, event):
924 if event.keyval == gtk.keysyms.Escape:
925 self.hide_podcast_search()
926 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
927 self.hide_podcast_search()
928 elif event.state & gtk.gdk.CONTROL_MASK:
929 # Don't handle type-ahead when control is pressed (so shortcuts
930 # with the Ctrl key still work, e.g. Ctrl+A, ...)
931 return True
932 else:
933 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
934 if unicode_char_id == 0:
935 return False
936 input_char = unichr(unicode_char_id)
937 self.show_podcast_search(input_char)
938 return True
939 self.treeChannels.connect('key-press-event', on_key_press)
941 # Enable separators to the podcast list to separate special podcasts
942 # from others (this is used for the "all episodes" view)
943 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
945 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
947 def on_entry_search_episodes_changed(self, editable):
948 if self.hbox_search_episodes.get_property('visible'):
949 self.episode_list_model.set_search_term(editable.get_chars(0, -1))
951 def on_entry_search_episodes_key_press(self, editable, event):
952 if event.keyval == gtk.keysyms.Escape:
953 self.hide_episode_search()
954 return True
956 def hide_episode_search(self, *args):
957 self.hbox_search_episodes.hide()
958 self.entry_search_episodes.set_text('')
959 self.episode_list_model.set_search_term(None)
960 self.treeAvailable.grab_focus()
962 def show_episode_search(self, input_char):
963 self.hbox_search_episodes.show()
964 self.entry_search_episodes.insert_text(input_char, -1)
965 self.entry_search_episodes.grab_focus()
966 self.entry_search_episodes.set_position(-1)
968 def init_episode_list_treeview(self):
969 # For loading the list model
970 self.empty_episode_list_model = EpisodeListModel()
971 self.episode_list_model = EpisodeListModel()
973 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
974 self.item_view_episodes_undeleted.set_active(True)
975 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
976 self.item_view_episodes_downloaded.set_active(True)
977 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
978 self.item_view_episodes_unplayed.set_active(True)
979 else:
980 self.item_view_episodes_all.set_active(True)
982 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
984 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
986 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
988 iconcell = gtk.CellRendererPixbuf()
989 if gpodder.ui.maemo:
990 iconcell.set_fixed_size(50, 50)
991 status_column_label = ''
992 else:
993 status_column_label = _('Status')
994 iconcolumn = gtk.TreeViewColumn(status_column_label, iconcell, pixbuf=EpisodeListModel.C_STATUS_ICON)
996 namecell = gtk.CellRendererText()
997 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
998 namecolumn = gtk.TreeViewColumn(_('Episode'))
999 namecolumn.pack_start(namecell, True)
1000 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
1001 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
1002 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1003 namecolumn.set_resizable(True)
1004 namecolumn.set_expand(True)
1006 if gpodder.ui.fremantle:
1007 from gpodder.gtkui.frmntl import style
1008 timecell = gtk.CellRendererText()
1009 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1010 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1011 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1012 timecell.set_property('xalign', 1.)
1013 timecell.set_property('xpad', 5)
1014 namecolumn.pack_start(timecell, False)
1015 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1016 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME1_VISIBLE)
1018 # Add another cell renderer to fix a sizing issue (one renderer
1019 # only renders short text and the other one longer text to avoid
1020 # having titles of episodes unnecessarily cut off)
1021 timecell = gtk.CellRendererText()
1022 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1023 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1024 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1025 timecell.set_property('xalign', 1.)
1026 timecell.set_property('xpad', 5)
1027 namecolumn.pack_start(timecell, False)
1028 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1029 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME2_VISIBLE)
1031 sizecell = gtk.CellRendererText()
1032 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
1033 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
1035 releasecell = gtk.CellRendererText()
1036 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
1037 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
1039 for itemcolumn in (iconcolumn, namecolumn, sizecolumn, releasecolumn):
1040 itemcolumn.set_reorderable(True)
1041 self.treeAvailable.append_column(itemcolumn)
1043 if gpodder.ui.maemo:
1044 sizecolumn.set_visible(False)
1045 releasecolumn.set_visible(False)
1047 # Set up type-ahead find for the episode list
1048 def on_key_press(treeview, event):
1049 if event.keyval == gtk.keysyms.Escape:
1050 self.hide_episode_search()
1051 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
1052 self.hide_episode_search()
1053 elif event.state & gtk.gdk.CONTROL_MASK:
1054 # Don't handle type-ahead when control is pressed (so shortcuts
1055 # with the Ctrl key still work, e.g. Ctrl+A, ...)
1056 return False
1057 else:
1058 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
1059 if unicode_char_id == 0:
1060 return False
1061 input_char = unichr(unicode_char_id)
1062 self.show_episode_search(input_char)
1063 return True
1064 self.treeAvailable.connect('key-press-event', on_key_press)
1066 if gpodder.ui.desktop:
1067 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
1068 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
1069 def drag_data_get(tree, context, selection_data, info, timestamp):
1070 if self.config.on_drag_mark_played:
1071 for episode in self.get_selected_episodes():
1072 episode.mark(is_played=True)
1073 self.on_selected_episodes_status_changed()
1074 uris = ['file://'+e.local_filename(create=False) \
1075 for e in self.get_selected_episodes() \
1076 if e.was_downloaded(and_exists=True)]
1077 uris.append('') # for the trailing '\r\n'
1078 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
1079 self.treeAvailable.connect('drag-data-get', drag_data_get)
1081 selection = self.treeAvailable.get_selection()
1082 if gpodder.ui.diablo:
1083 if self.config.maemo_enable_gestures or self.config.enable_fingerscroll:
1084 selection.set_mode(gtk.SELECTION_SINGLE)
1085 else:
1086 selection.set_mode(gtk.SELECTION_MULTIPLE)
1087 elif gpodder.ui.fremantle:
1088 selection.set_mode(gtk.SELECTION_SINGLE)
1089 else:
1090 selection.set_mode(gtk.SELECTION_MULTIPLE)
1091 # Update the sensitivity of the toolbar buttons on the Desktop
1092 selection.connect('changed', lambda s: self.play_or_download())
1094 if gpodder.ui.diablo:
1095 # Set up the tap-and-hold context menu for podcasts
1096 menu = gtk.Menu()
1097 menu.append(self.itemUpdateChannel.create_menu_item())
1098 menu.append(self.itemEditChannel.create_menu_item())
1099 menu.append(gtk.SeparatorMenuItem())
1100 menu.append(self.itemRemoveChannel.create_menu_item())
1101 menu.append(gtk.SeparatorMenuItem())
1102 item = gtk.ImageMenuItem(_('Close this menu'))
1103 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, \
1104 gtk.ICON_SIZE_MENU))
1105 menu.append(item)
1106 menu.show_all()
1107 menu = self.set_finger_friendly(menu)
1108 self.treeChannels.tap_and_hold_setup(menu)
1111 def init_download_list_treeview(self):
1112 # enable multiple selection support
1113 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
1114 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
1116 # columns and renderers for "download progress" tab
1117 # First column: [ICON] Episodename
1118 column = gtk.TreeViewColumn(_('Episode'))
1120 cell = gtk.CellRendererPixbuf()
1121 if gpodder.ui.maemo:
1122 cell.set_fixed_size(50, 50)
1123 cell.set_property('stock-size', gtk.ICON_SIZE_MENU)
1124 column.pack_start(cell, expand=False)
1125 column.add_attribute(cell, 'stock-id', \
1126 DownloadStatusModel.C_ICON_NAME)
1128 cell = gtk.CellRendererText()
1129 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
1130 column.pack_start(cell, expand=True)
1131 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
1132 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1133 column.set_expand(True)
1134 self.treeDownloads.append_column(column)
1136 # Second column: Progress
1137 cell = gtk.CellRendererProgress()
1138 cell.set_property('yalign', .5)
1139 cell.set_property('ypad', 6)
1140 column = gtk.TreeViewColumn(_('Progress'), cell,
1141 value=DownloadStatusModel.C_PROGRESS, \
1142 text=DownloadStatusModel.C_PROGRESS_TEXT)
1143 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1144 column.set_expand(False)
1145 self.treeDownloads.append_column(column)
1146 column.set_property('min-width', 150)
1147 column.set_property('max-width', 150)
1149 self.treeDownloads.set_model(self.download_status_model)
1150 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
1152 def on_treeview_expose_event(self, treeview, event):
1153 if event.window == treeview.get_bin_window():
1154 model = treeview.get_model()
1155 if (model is not None and model.get_iter_first() is not None):
1156 return False
1158 role = getattr(treeview, TreeViewHelper.ROLE, None)
1159 if role is None:
1160 return False
1162 ctx = event.window.cairo_create()
1163 ctx.rectangle(event.area.x, event.area.y,
1164 event.area.width, event.area.height)
1165 ctx.clip()
1167 x, y, width, height, depth = event.window.get_geometry()
1168 progress = None
1170 if role == TreeViewHelper.ROLE_EPISODES:
1171 if self.currently_updating:
1172 text = _('Loading episodes')
1173 progress = self.episode_list_model.get_update_progress()
1174 elif self.config.episode_list_view_mode != \
1175 EpisodeListModel.VIEW_ALL:
1176 text = _('No episodes in current view')
1177 else:
1178 text = _('No episodes available')
1179 elif role == TreeViewHelper.ROLE_PODCASTS:
1180 if self.config.episode_list_view_mode != \
1181 EpisodeListModel.VIEW_ALL and \
1182 self.config.podcast_list_hide_boring and \
1183 len(self.channels) > 0:
1184 text = _('No podcasts in this view')
1185 else:
1186 text = _('No subscriptions')
1187 elif role == TreeViewHelper.ROLE_DOWNLOADS:
1188 text = _('No active downloads')
1189 else:
1190 raise Exception('on_treeview_expose_event: unknown role')
1192 if gpodder.ui.fremantle:
1193 from gpodder.gtkui.frmntl import style
1194 font_desc = style.get_font_desc('LargeSystemFont')
1195 else:
1196 font_desc = None
1198 draw_text_box_centered(ctx, treeview, width, height, text, font_desc, progress)
1200 return False
1202 def enable_download_list_update(self):
1203 if not self.download_list_update_enabled:
1204 self.update_downloads_list()
1205 gobject.timeout_add(1500, self.update_downloads_list)
1206 self.download_list_update_enabled = True
1208 def cleanup_downloads(self):
1209 model = self.download_status_model
1211 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
1212 changed_episode_urls = set()
1213 for row_reference, task in all_tasks:
1214 if task.status in (task.DONE, task.CANCELLED):
1215 model.remove(model.get_iter(row_reference.get_path()))
1216 try:
1217 # We don't "see" this task anymore - remove it;
1218 # this is needed, so update_episode_list_icons()
1219 # below gets the correct list of "seen" tasks
1220 self.download_tasks_seen.remove(task)
1221 except KeyError, key_error:
1222 log('Cannot remove task from "seen" list: %s', task, sender=self)
1223 changed_episode_urls.add(task.url)
1224 # Tell the task that it has been removed (so it can clean up)
1225 task.removed_from_list()
1227 # Tell the podcasts tab to update icons for our removed podcasts
1228 self.update_episode_list_icons(changed_episode_urls)
1230 # Tell the shownotes window that we have removed the episode
1231 if self.episode_shownotes_window is not None and \
1232 self.episode_shownotes_window.episode is not None and \
1233 self.episode_shownotes_window.episode.url in changed_episode_urls:
1234 self.episode_shownotes_window._download_status_changed(None)
1236 # Update the downloads list one more time
1237 self.update_downloads_list(can_call_cleanup=False)
1239 def on_tool_downloads_toggled(self, toolbutton):
1240 if toolbutton.get_active():
1241 self.wNotebook.set_current_page(1)
1242 else:
1243 self.wNotebook.set_current_page(0)
1245 def add_download_task_monitor(self, monitor):
1246 self.download_task_monitors.add(monitor)
1247 model = self.download_status_model
1248 if model is None:
1249 model = ()
1250 for row in model:
1251 task = row[self.download_status_model.C_TASK]
1252 monitor.task_updated(task)
1254 def remove_download_task_monitor(self, monitor):
1255 self.download_task_monitors.remove(monitor)
1257 def update_downloads_list(self, can_call_cleanup=True):
1258 try:
1259 model = self.download_status_model
1261 downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
1262 total_speed, total_size, done_size = 0, 0, 0
1264 # Keep a list of all download tasks that we've seen
1265 download_tasks_seen = set()
1267 # Remember the DownloadTask object for the episode that
1268 # has been opened in the episode shownotes dialog (if any)
1269 if self.episode_shownotes_window is not None:
1270 shownotes_episode = self.episode_shownotes_window.episode
1271 shownotes_task = None
1272 else:
1273 shownotes_episode = None
1274 shownotes_task = None
1276 # Do not go through the list of the model is not (yet) available
1277 if model is None:
1278 model = ()
1280 failed_downloads = []
1281 for row in model:
1282 self.download_status_model.request_update(row.iter)
1284 task = row[self.download_status_model.C_TASK]
1285 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
1287 # Let the download task monitors know of changes
1288 for monitor in self.download_task_monitors:
1289 monitor.task_updated(task)
1291 total_size += size
1292 done_size += size*progress
1294 if shownotes_episode is not None and \
1295 shownotes_episode.url == task.episode.url:
1296 shownotes_task = task
1298 download_tasks_seen.add(task)
1300 if status == download.DownloadTask.DOWNLOADING:
1301 downloading += 1
1302 total_speed += speed
1303 elif status == download.DownloadTask.FAILED:
1304 failed_downloads.append(task)
1305 failed += 1
1306 elif status == download.DownloadTask.DONE:
1307 finished += 1
1308 elif status == download.DownloadTask.QUEUED:
1309 queued += 1
1310 elif status == download.DownloadTask.PAUSED:
1311 paused += 1
1312 else:
1313 others += 1
1315 # Remember which tasks we have seen after this run
1316 self.download_tasks_seen = download_tasks_seen
1318 if gpodder.ui.desktop:
1319 text = [_('Downloads')]
1320 if downloading + failed + queued > 0:
1321 s = []
1322 if downloading > 0:
1323 s.append(N_('%d active', '%d active', downloading) % downloading)
1324 if failed > 0:
1325 s.append(N_('%d failed', '%d failed', failed) % failed)
1326 if queued > 0:
1327 s.append(N_('%d queued', '%d queued', queued) % queued)
1328 text.append(' (' + ', '.join(s)+')')
1329 self.labelDownloads.set_text(''.join(text))
1330 elif gpodder.ui.diablo:
1331 sum = downloading + failed + finished + queued + paused + others
1332 if sum:
1333 self.tool_downloads.set_label(_('Downloads (%d)') % sum)
1334 else:
1335 self.tool_downloads.set_label(_('Downloads'))
1336 elif gpodder.ui.fremantle:
1337 if downloading + queued > 0:
1338 self.button_downloads.set_value(N_('%d active', '%d active', downloading+queued) % (downloading+queued))
1339 elif failed > 0:
1340 self.button_downloads.set_value(N_('%d failed', '%d failed', failed) % failed)
1341 elif paused > 0:
1342 self.button_downloads.set_value(N_('%d paused', '%d paused', paused) % paused)
1343 else:
1344 self.button_downloads.set_value(_('Idle'))
1346 title = [self.default_title]
1348 # We have to update all episodes/channels for which the status has
1349 # changed. Accessing task.status_changed has the side effect of
1350 # re-setting the changed flag, so we need to get the "changed" list
1351 # of tuples first and split it into two lists afterwards
1352 changed = [(task.url, task.podcast_url) for task in \
1353 self.download_tasks_seen if task.status_changed]
1354 episode_urls = [episode_url for episode_url, channel_url in changed]
1355 channel_urls = [channel_url for episode_url, channel_url in changed]
1357 count = downloading + queued
1358 if count > 0:
1359 title.append(N_('downloading %d file', 'downloading %d files', count) % count)
1361 if total_size > 0:
1362 percentage = 100.0*done_size/total_size
1363 else:
1364 percentage = 0.0
1365 total_speed = util.format_filesize(total_speed)
1366 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1367 if self.tray_icon is not None:
1368 # Update the tray icon status and progress bar
1369 self.tray_icon.set_status(self.tray_icon.STATUS_DOWNLOAD_IN_PROGRESS, title[1])
1370 self.tray_icon.draw_progress_bar(percentage/100.)
1371 else:
1372 if self.tray_icon is not None:
1373 # Update the tray icon status
1374 self.tray_icon.set_status()
1375 if gpodder.ui.desktop:
1376 self.downloads_finished(self.download_tasks_seen)
1377 if gpodder.ui.diablo:
1378 hildon.hildon_banner_show_information(self.gPodder, '', 'gPodder: %s' % _('All downloads finished'))
1379 log('All downloads have finished.', sender=self)
1380 if self.config.cmd_all_downloads_complete:
1381 util.run_external_command(self.config.cmd_all_downloads_complete)
1383 if gpodder.ui.fremantle and failed:
1384 message = '\n'.join(['%s: %s' % (str(task), \
1385 task.error_message) for task in failed_downloads])
1386 self.show_message(message, _('Downloads failed'), important=True)
1388 # Remove finished episodes
1389 if self.config.auto_cleanup_downloads and can_call_cleanup:
1390 self.cleanup_downloads()
1392 # Stop updating the download list here
1393 self.download_list_update_enabled = False
1395 if not gpodder.ui.fremantle:
1396 self.gPodder.set_title(' - '.join(title))
1398 self.update_episode_list_icons(episode_urls)
1399 if self.episode_shownotes_window is not None:
1400 if (shownotes_task and shownotes_task.url in episode_urls) or \
1401 shownotes_task != self.episode_shownotes_window.task:
1402 self.episode_shownotes_window._download_status_changed(shownotes_task)
1403 self.episode_shownotes_window._download_status_progress()
1404 self.play_or_download()
1405 if channel_urls:
1406 self.update_podcast_list_model(channel_urls)
1408 return self.download_list_update_enabled
1409 except Exception, e:
1410 log('Exception happened while updating download list.', sender=self, traceback=True)
1411 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
1412 # We return False here, so the update loop won't be called again,
1413 # that's why we require the restart of gPodder in the message.
1414 return False
1416 def on_config_changed(self, *args):
1417 util.idle_add(self._on_config_changed, *args)
1419 def _on_config_changed(self, name, old_value, new_value):
1420 if name == 'show_toolbar' and gpodder.ui.desktop:
1421 self.toolbar.set_property('visible', new_value)
1422 elif name == 'videoplayer':
1423 self.config.video_played_dbus = False
1424 elif name == 'player':
1425 self.config.audio_played_dbus = False
1426 elif name == 'episode_list_descriptions':
1427 self.update_episode_list_model()
1428 elif name == 'episode_list_thumbnails':
1429 self.update_episode_list_icons(all=True)
1430 elif name == 'rotation_mode':
1431 self._fremantle_rotation.set_mode(new_value)
1432 elif name in ('auto_update_feeds', 'auto_update_frequency'):
1433 self.restart_auto_update_timer()
1434 elif name == 'podcast_list_view_all':
1435 # Force a update of the podcast list model
1436 self.channel_list_changed = True
1437 if gpodder.ui.fremantle:
1438 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
1439 while gtk.events_pending():
1440 gtk.main_iteration(False)
1441 self.update_podcast_list_model()
1442 if gpodder.ui.fremantle:
1443 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
1445 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1446 # With get_bin_window, we get the window that contains the rows without
1447 # the header. The Y coordinate of this window will be the height of the
1448 # treeview header. This is the amount we have to subtract from the
1449 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1450 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1451 y -= x_bin
1452 y -= y_bin
1453 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
1455 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or (column is not None and column != treeview.get_columns()[0]):
1456 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1457 return False
1459 if path is not None:
1460 model = treeview.get_model()
1461 iter = model.get_iter(path)
1462 role = getattr(treeview, TreeViewHelper.ROLE)
1464 if role == TreeViewHelper.ROLE_EPISODES:
1465 id = model.get_value(iter, EpisodeListModel.C_URL)
1466 elif role == TreeViewHelper.ROLE_PODCASTS:
1467 id = model.get_value(iter, PodcastListModel.C_URL)
1469 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1470 if last_tooltip is not None and last_tooltip != id:
1471 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1472 return False
1473 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1475 if role == TreeViewHelper.ROLE_EPISODES:
1476 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1477 if description:
1478 tooltip.set_text(description)
1479 else:
1480 return False
1481 elif role == TreeViewHelper.ROLE_PODCASTS:
1482 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1483 if channel is None:
1484 return False
1485 channel.request_save_dir_size()
1486 diskspace_str = util.format_filesize(channel.save_dir_size, 0)
1487 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1488 if error_str:
1489 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
1490 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1491 table = gtk.Table(rows=3, columns=3)
1492 table.set_row_spacings(5)
1493 table.set_col_spacings(5)
1494 table.set_border_width(5)
1496 heading = gtk.Label()
1497 heading.set_alignment(0, 1)
1498 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
1499 table.attach(heading, 0, 1, 0, 1)
1500 size_info = gtk.Label()
1501 size_info.set_alignment(1, 1)
1502 size_info.set_justify(gtk.JUSTIFY_RIGHT)
1503 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
1504 table.attach(size_info, 2, 3, 0, 1)
1506 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
1508 if len(channel.description) < 500:
1509 description = channel.description
1510 else:
1511 pos = channel.description.find('\n\n')
1512 if pos == -1 or pos > 500:
1513 description = channel.description[:498]+'[...]'
1514 else:
1515 description = channel.description[:pos]
1517 description = gtk.Label(description)
1518 if error_str:
1519 description.set_markup(error_str)
1520 description.set_alignment(0, 0)
1521 description.set_line_wrap(True)
1522 table.attach(description, 0, 3, 2, 3)
1524 table.show_all()
1525 tooltip.set_custom(table)
1527 return True
1529 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1530 return False
1532 def treeview_allow_tooltips(self, treeview, allow):
1533 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1535 def update_m3u_playlist_clicked(self, widget):
1536 if self.active_channel is not None:
1537 self.active_channel.update_m3u_playlist()
1538 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'), widget=self.treeChannels)
1540 def treeview_handle_context_menu_click(self, treeview, event):
1541 x, y = int(event.x), int(event.y)
1542 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
1544 selection = treeview.get_selection()
1545 model, paths = selection.get_selected_rows()
1547 if path is None or (path not in paths and \
1548 event.button == self.context_menu_mouse_button):
1549 # We have right-clicked, but not into the selection,
1550 # assume we don't want to operate on the selection
1551 paths = []
1553 if path is not None and not paths and \
1554 event.button == self.context_menu_mouse_button:
1555 # No selection or clicked outside selection;
1556 # select the single item where we clicked
1557 treeview.grab_focus()
1558 treeview.set_cursor(path, column, 0)
1559 paths = [path]
1561 if not paths:
1562 # Unselect any remaining items (clicked elsewhere)
1563 if hasattr(treeview, 'is_rubber_banding_active'):
1564 if not treeview.is_rubber_banding_active():
1565 selection.unselect_all()
1566 else:
1567 selection.unselect_all()
1569 return model, paths
1571 def downloads_list_get_selection(self, model=None, paths=None):
1572 if model is None and paths is None:
1573 selection = self.treeDownloads.get_selection()
1574 model, paths = selection.get_selected_rows()
1576 can_queue, can_cancel, can_pause, can_remove, can_force = (True,)*5
1577 selected_tasks = [(gtk.TreeRowReference(model, path), \
1578 model.get_value(model.get_iter(path), \
1579 DownloadStatusModel.C_TASK)) for path in paths]
1581 for row_reference, task in selected_tasks:
1582 if task.status != download.DownloadTask.QUEUED:
1583 can_force = False
1584 if task.status not in (download.DownloadTask.PAUSED, \
1585 download.DownloadTask.FAILED, \
1586 download.DownloadTask.CANCELLED):
1587 can_queue = False
1588 if task.status not in (download.DownloadTask.PAUSED, \
1589 download.DownloadTask.QUEUED, \
1590 download.DownloadTask.DOWNLOADING, \
1591 download.DownloadTask.FAILED):
1592 can_cancel = False
1593 if task.status not in (download.DownloadTask.QUEUED, \
1594 download.DownloadTask.DOWNLOADING):
1595 can_pause = False
1596 if task.status not in (download.DownloadTask.CANCELLED, \
1597 download.DownloadTask.FAILED, \
1598 download.DownloadTask.DONE):
1599 can_remove = False
1601 return selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force
1603 def downloads_finished(self, download_tasks_seen):
1604 # FIXME: Filter all tasks that have already been reported
1605 finished_downloads = [str(task) for task in download_tasks_seen if task.status == task.DONE]
1606 failed_downloads = [str(task)+' ('+task.error_message+')' for task in download_tasks_seen if task.status == task.FAILED]
1608 if finished_downloads and failed_downloads:
1609 message = self.format_episode_list(finished_downloads, 5)
1610 message += '\n\n<i>%s</i>\n' % _('These downloads failed:')
1611 message += self.format_episode_list(failed_downloads, 5)
1612 self.show_message(message, _('Downloads finished'), True, widget=self.labelDownloads)
1613 elif finished_downloads:
1614 message = self.format_episode_list(finished_downloads)
1615 self.show_message(message, _('Downloads finished'), widget=self.labelDownloads)
1616 elif failed_downloads:
1617 message = self.format_episode_list(failed_downloads)
1618 self.show_message(message, _('Downloads failed'), True, widget=self.labelDownloads)
1620 # Open torrent files right after download (bug 1029)
1621 if self.config.open_torrent_after_download:
1622 for task in download_tasks_seen:
1623 if task.status != task.DONE:
1624 continue
1626 episode = task.episode
1627 if episode.mimetype != 'application/x-bittorrent':
1628 continue
1630 self.playback_episodes([episode])
1633 def format_episode_list(self, episode_list, max_episodes=10):
1635 Format a list of episode names for notifications
1637 Will truncate long episode names and limit the amount of
1638 episodes displayed (max_episodes=10).
1640 The episode_list parameter should be a list of strings.
1642 MAX_TITLE_LENGTH = 100
1644 result = []
1645 for title in episode_list[:min(len(episode_list), max_episodes)]:
1646 if len(title) > MAX_TITLE_LENGTH:
1647 middle = (MAX_TITLE_LENGTH/2)-2
1648 title = '%s...%s' % (title[0:middle], title[-middle:])
1649 result.append(saxutils.escape(title))
1650 result.append('\n')
1652 more_episodes = len(episode_list) - max_episodes
1653 if more_episodes > 0:
1654 result.append('(...')
1655 result.append(N_('%d more episode', '%d more episodes', more_episodes) % more_episodes)
1656 result.append('...)')
1658 return (''.join(result)).strip()
1660 def _for_each_task_set_status(self, tasks, status, force_start=False):
1661 episode_urls = set()
1662 model = self.treeDownloads.get_model()
1663 for row_reference, task in tasks:
1664 if status == download.DownloadTask.QUEUED:
1665 # Only queue task when its paused/failed/cancelled (or forced)
1666 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED) or force_start:
1667 self.download_queue_manager.add_task(task, force_start)
1668 self.enable_download_list_update()
1669 elif status == download.DownloadTask.CANCELLED:
1670 # Cancelling a download allowed when downloading/queued
1671 if task.status in (task.QUEUED, task.DOWNLOADING):
1672 task.status = status
1673 # Cancelling paused/failed downloads requires a call to .run()
1674 elif task.status in (task.PAUSED, task.FAILED):
1675 task.status = status
1676 # Call run, so the partial file gets deleted
1677 task.run()
1678 elif status == download.DownloadTask.PAUSED:
1679 # Pausing a download only when queued/downloading
1680 if task.status in (task.DOWNLOADING, task.QUEUED):
1681 task.status = status
1682 elif status is None:
1683 # Remove the selected task - cancel downloading/queued tasks
1684 if task.status in (task.QUEUED, task.DOWNLOADING):
1685 task.status = task.CANCELLED
1686 model.remove(model.get_iter(row_reference.get_path()))
1687 # Remember the URL, so we can tell the UI to update
1688 try:
1689 # We don't "see" this task anymore - remove it;
1690 # this is needed, so update_episode_list_icons()
1691 # below gets the correct list of "seen" tasks
1692 self.download_tasks_seen.remove(task)
1693 except KeyError, key_error:
1694 log('Cannot remove task from "seen" list: %s', task, sender=self)
1695 episode_urls.add(task.url)
1696 # Tell the task that it has been removed (so it can clean up)
1697 task.removed_from_list()
1698 else:
1699 # We can (hopefully) simply set the task status here
1700 task.status = status
1701 # Tell the podcasts tab to update icons for our removed podcasts
1702 self.update_episode_list_icons(episode_urls)
1703 # Update the tab title and downloads list
1704 self.update_downloads_list()
1706 def treeview_downloads_show_context_menu(self, treeview, event):
1707 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1708 if not paths:
1709 if not hasattr(treeview, 'is_rubber_banding_active'):
1710 return True
1711 else:
1712 return not treeview.is_rubber_banding_active()
1714 if event.button == self.context_menu_mouse_button:
1715 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = \
1716 self.downloads_list_get_selection(model, paths)
1718 def make_menu_item(label, stock_id, tasks, status, sensitive, force_start=False):
1719 # This creates a menu item for selection-wide actions
1720 item = gtk.ImageMenuItem(label)
1721 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
1722 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status, force_start))
1723 item.set_sensitive(sensitive)
1724 return self.set_finger_friendly(item)
1726 menu = gtk.Menu()
1728 item = gtk.ImageMenuItem(_('Episode details'))
1729 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1730 if len(selected_tasks) == 1:
1731 row_reference, task = selected_tasks[0]
1732 episode = task.episode
1733 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
1734 else:
1735 item.set_sensitive(False)
1736 menu.append(self.set_finger_friendly(item))
1737 menu.append(gtk.SeparatorMenuItem())
1738 if can_force:
1739 menu.append(make_menu_item(_('Start download now'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, True, True))
1740 else:
1741 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, can_queue, False))
1742 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED, can_cancel))
1743 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED, can_pause))
1744 menu.append(gtk.SeparatorMenuItem())
1745 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None, can_remove))
1747 if gpodder.ui.maemo:
1748 # Because we open the popup on left-click for Maemo,
1749 # we also include a non-action to close the menu
1750 menu.append(gtk.SeparatorMenuItem())
1751 item = gtk.ImageMenuItem(_('Close this menu'))
1752 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1754 menu.append(self.set_finger_friendly(item))
1756 menu.show_all()
1757 menu.popup(None, None, None, event.button, event.time)
1758 return True
1760 def treeview_channels_show_context_menu(self, treeview, event):
1761 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1762 if not paths:
1763 return True
1765 # Check for valid channel id, if there's no id then
1766 # assume that it is a proxy channel or equivalent
1767 # and cannot be operated with right click
1768 if self.active_channel.id is None:
1769 return True
1771 if event.button == 3:
1772 menu = gtk.Menu()
1774 ICON = lambda x: x
1776 item = gtk.ImageMenuItem( _('Update podcast'))
1777 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1778 item.connect('activate', self.on_itemUpdateChannel_activate)
1779 item.set_sensitive(not self.updating_feed_cache)
1780 menu.append(item)
1782 menu.append(gtk.SeparatorMenuItem())
1784 item = gtk.CheckMenuItem(_('Keep episodes'))
1785 item.set_active(self.active_channel.channel_is_locked)
1786 item.connect('activate', self.on_channel_toggle_lock_activate)
1787 menu.append(self.set_finger_friendly(item))
1789 item = gtk.ImageMenuItem(_('Remove podcast'))
1790 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1791 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1792 menu.append( item)
1794 if self.config.device_type != 'none':
1795 item = gtk.MenuItem(_('Synchronize to device'))
1796 item.connect('activate', lambda item: self.on_sync_to_ipod_activate(item, self.active_channel.get_downloaded_episodes()))
1797 menu.append(item)
1799 menu.append( gtk.SeparatorMenuItem())
1801 item = gtk.ImageMenuItem(_('Podcast details'))
1802 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1803 item.connect('activate', self.on_itemEditChannel_activate)
1804 menu.append(item)
1806 menu.show_all()
1807 # Disable tooltips while we are showing the menu, so
1808 # the tooltip will not appear over the menu
1809 self.treeview_allow_tooltips(self.treeChannels, False)
1810 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1811 menu.popup( None, None, None, event.button, event.time)
1813 return True
1815 def on_itemClose_activate(self, widget):
1816 if self.tray_icon is not None:
1817 self.iconify_main_window()
1818 else:
1819 self.on_gPodder_delete_event(widget)
1821 def cover_file_removed(self, channel_url):
1823 The Cover Downloader calls this when a previously-
1824 available cover has been removed from the disk. We
1825 have to update our model to reflect this change.
1827 self.podcast_list_model.delete_cover_by_url(channel_url)
1829 def cover_download_finished(self, channel, pixbuf):
1831 The Cover Downloader calls this when it has finished
1832 downloading (or registering, if already downloaded)
1833 a new channel cover, which is ready for displaying.
1835 self.podcast_list_model.add_cover_by_channel(channel, pixbuf)
1837 def save_episodes_as_file(self, episodes):
1838 for episode in episodes:
1839 self.save_episode_as_file(episode)
1841 def save_episode_as_file(self, episode):
1842 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1843 if episode.was_downloaded(and_exists=True):
1844 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1845 copy_from = episode.local_filename(create=False)
1846 assert copy_from is not None
1847 copy_to = episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)
1848 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1849 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1851 def copy_episodes_bluetooth(self, episodes):
1852 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1854 if gpodder.ui.maemo:
1855 util.bluetooth_send_files_maemo([e.local_filename(create=False) \
1856 for e in episodes_to_copy])
1857 return True
1859 def convert_and_send_thread(episode):
1860 for episode in episodes:
1861 filename = episode.local_filename(create=False)
1862 assert filename is not None
1863 destfile = os.path.join(tempfile.gettempdir(), \
1864 util.sanitize_filename(episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)))
1865 (base, ext) = os.path.splitext(filename)
1866 if not destfile.endswith(ext):
1867 destfile += ext
1869 try:
1870 shutil.copyfile(filename, destfile)
1871 util.bluetooth_send_file(destfile)
1872 except:
1873 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1874 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1876 util.delete_file(destfile)
1878 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1880 def get_device_name(self):
1881 if self.config.device_type == 'ipod':
1882 return _('iPod')
1883 elif self.config.device_type in ('filesystem', 'mtp'):
1884 return _('MP3 player')
1885 else:
1886 return '(unknown device)'
1888 def _treeview_button_released(self, treeview, event):
1889 xpos, ypos = TreeViewHelper.get_button_press_event(treeview)
1890 dy = int(abs(event.y-ypos))
1891 dx = int(event.x-xpos)
1893 selection = treeview.get_selection()
1894 path = treeview.get_path_at_pos(int(event.x), int(event.y))
1895 if path is None or dy > 30:
1896 return (False, dx, dy)
1898 path, column, x, y = path
1899 selection.select_path(path)
1900 treeview.set_cursor(path)
1901 treeview.grab_focus()
1903 return (True, dx, dy)
1905 def treeview_channels_handle_gestures(self, treeview, event):
1906 if self.currently_updating:
1907 return False
1909 selected, dx, dy = self._treeview_button_released(treeview, event)
1911 if selected:
1912 if self.config.maemo_enable_gestures:
1913 if dx > 70:
1914 self.on_itemUpdateChannel_activate()
1915 elif dx < -70:
1916 self.on_itemEditChannel_activate(treeview)
1918 return False
1920 def treeview_available_handle_gestures(self, treeview, event):
1921 selected, dx, dy = self._treeview_button_released(treeview, event)
1923 if selected:
1924 if self.config.maemo_enable_gestures:
1925 if dx > 70:
1926 self.on_playback_selected_episodes(None)
1927 return True
1928 elif dx < -70:
1929 self.on_shownotes_selected_episodes(None)
1930 return True
1932 # Pass the event to the context menu handler for treeAvailable
1933 self.treeview_available_show_context_menu(treeview, event)
1935 return True
1937 def treeview_available_show_context_menu(self, treeview, event):
1938 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1939 if not paths:
1940 if not hasattr(treeview, 'is_rubber_banding_active'):
1941 return True
1942 else:
1943 return not treeview.is_rubber_banding_active()
1945 if event.button == self.context_menu_mouse_button:
1946 episodes = self.get_selected_episodes()
1947 any_locked = any(e.is_locked for e in episodes)
1948 any_played = any(e.is_played for e in episodes)
1949 one_is_new = any(e.state == gpodder.STATE_NORMAL and not e.is_played for e in episodes)
1950 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1951 downloading = any(self.episode_is_downloading(e) for e in episodes)
1953 menu = gtk.Menu()
1955 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1957 if open_instead_of_play:
1958 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1959 elif downloaded:
1960 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1961 else:
1962 item = gtk.ImageMenuItem(_('Stream'))
1963 item.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PLAY, gtk.ICON_SIZE_MENU))
1965 item.set_sensitive(can_play and not downloading)
1966 item.connect('activate', self.on_playback_selected_episodes)
1967 menu.append(self.set_finger_friendly(item))
1969 if not can_cancel:
1970 item = gtk.ImageMenuItem(_('Download'))
1971 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1972 item.set_sensitive(can_download)
1973 item.connect('activate', self.on_download_selected_episodes)
1974 menu.append(self.set_finger_friendly(item))
1975 else:
1976 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
1977 item.connect('activate', self.on_item_cancel_download_activate)
1978 menu.append(self.set_finger_friendly(item))
1980 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1981 item.set_sensitive(can_delete)
1982 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1983 menu.append(self.set_finger_friendly(item))
1985 ICON = lambda x: x
1987 # Ok, this probably makes sense to only display for downloaded files
1988 if downloaded:
1989 menu.append(gtk.SeparatorMenuItem())
1990 share_item = gtk.MenuItem(_('Send to'))
1991 menu.append(self.set_finger_friendly(share_item))
1992 share_menu = gtk.Menu()
1994 item = gtk.ImageMenuItem(_('Local folder'))
1995 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU))
1996 item.connect('button-press-event', lambda w, ee: self.save_episodes_as_file(episodes))
1997 share_menu.append(self.set_finger_friendly(item))
1998 if self.bluetooth_available:
1999 item = gtk.ImageMenuItem(_('Bluetooth device'))
2000 if gpodder.ui.maemo:
2001 icon_name = ICON('qgn_list_filesys_bluetooth')
2002 else:
2003 icon_name = ICON('bluetooth')
2004 item.set_image(gtk.image_new_from_icon_name(icon_name, gtk.ICON_SIZE_MENU))
2005 item.connect('button-press-event', lambda w, ee: self.copy_episodes_bluetooth(episodes))
2006 share_menu.append(self.set_finger_friendly(item))
2007 if can_transfer:
2008 item = gtk.ImageMenuItem(self.get_device_name())
2009 item.set_image(gtk.image_new_from_icon_name(ICON('multimedia-player'), gtk.ICON_SIZE_MENU))
2010 item.connect('button-press-event', lambda w, ee: self.on_sync_to_ipod_activate(w, episodes))
2011 share_menu.append(self.set_finger_friendly(item))
2013 share_item.set_submenu(share_menu)
2015 if (downloaded or one_is_new or can_download) and not downloading:
2016 menu.append(gtk.SeparatorMenuItem())
2017 if one_is_new:
2018 item = gtk.CheckMenuItem(_('New'))
2019 item.set_active(True)
2020 item.connect('activate', lambda w: self.mark_selected_episodes_old())
2021 menu.append(self.set_finger_friendly(item))
2022 elif can_download:
2023 item = gtk.CheckMenuItem(_('New'))
2024 item.set_active(False)
2025 item.connect('activate', lambda w: self.mark_selected_episodes_new())
2026 menu.append(self.set_finger_friendly(item))
2028 if downloaded:
2029 item = gtk.CheckMenuItem(_('Played'))
2030 item.set_active(any_played)
2031 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, not any_played))
2032 menu.append(self.set_finger_friendly(item))
2034 item = gtk.CheckMenuItem(_('Keep episode'))
2035 item.set_active(any_locked)
2036 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, not any_locked))
2037 menu.append(self.set_finger_friendly(item))
2039 menu.append(gtk.SeparatorMenuItem())
2040 # Single item, add episode information menu item
2041 item = gtk.ImageMenuItem(_('Episode details'))
2042 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
2043 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
2044 menu.append(self.set_finger_friendly(item))
2046 if gpodder.ui.maemo:
2047 # Because we open the popup on left-click for Maemo,
2048 # we also include a non-action to close the menu
2049 menu.append(gtk.SeparatorMenuItem())
2050 item = gtk.ImageMenuItem(_('Close this menu'))
2051 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
2052 menu.append(self.set_finger_friendly(item))
2054 menu.show_all()
2055 # Disable tooltips while we are showing the menu, so
2056 # the tooltip will not appear over the menu
2057 self.treeview_allow_tooltips(self.treeAvailable, False)
2058 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
2059 menu.popup( None, None, None, event.button, event.time)
2061 return True
2063 def set_title(self, new_title):
2064 if not gpodder.ui.fremantle:
2065 self.default_title = new_title
2066 self.gPodder.set_title(new_title)
2068 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2070 Updates the status icons in the episode list.
2072 If urls is given, it should be a list of URLs
2073 of episodes that should be updated.
2075 If urls is None, set ONE OF selected, all to
2076 True (the former updates just the selected
2077 episodes and the latter updates all episodes).
2079 additional_args = (self.episode_is_downloading, \
2080 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2081 self.config.episode_list_thumbnails and gpodder.ui.desktop)
2083 if urls is not None:
2084 # We have a list of URLs to walk through
2085 self.episode_list_model.update_by_urls(urls, *additional_args)
2086 elif selected and not all:
2087 # We should update all selected episodes
2088 selection = self.treeAvailable.get_selection()
2089 model, paths = selection.get_selected_rows()
2090 for path in reversed(paths):
2091 iter = model.get_iter(path)
2092 self.episode_list_model.update_by_filter_iter(iter, \
2093 *additional_args)
2094 elif all and not selected:
2095 # We update all (even the filter-hidden) episodes
2096 self.episode_list_model.update_all(*additional_args)
2097 else:
2098 # Wrong/invalid call - have to specify at least one parameter
2099 raise ValueError('Invalid call to update_episode_list_icons')
2101 def episode_list_status_changed(self, episodes):
2102 self.update_episode_list_icons(set(e.url for e in episodes))
2103 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2104 self.db.commit()
2106 def clean_up_downloads(self, delete_partial=False):
2107 # Clean up temporary files left behind by old gPodder versions
2108 temporary_files = glob.glob('%s/*/.tmp-*' % self.config.download_dir)
2110 if delete_partial:
2111 temporary_files += glob.glob('%s/*/*.partial' % self.config.download_dir)
2113 for tempfile in temporary_files:
2114 util.delete_file(tempfile)
2116 # Clean up empty download folders and abandoned download folders
2117 download_dirs = glob.glob(os.path.join(self.config.download_dir, '*'))
2118 for ddir in download_dirs:
2119 if os.path.isdir(ddir) and False: # FIXME not db.channel_foldername_exists(os.path.basename(ddir)):
2120 globr = glob.glob(os.path.join(ddir, '*'))
2121 if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
2122 log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
2123 shutil.rmtree(ddir, ignore_errors=True)
2125 def streaming_possible(self):
2126 if gpodder.ui.desktop:
2127 # User has to have a media player set on the Desktop, or else we
2128 # would probably open the browser when giving a URL to xdg-open..
2129 return (self.config.player and self.config.player != 'default')
2130 elif gpodder.ui.maemo:
2131 # On Maemo, the default is to use the Nokia Media Player, which is
2132 # already able to deal with HTTP URLs the right way, so we
2133 # unconditionally enable streaming always on Maemo
2134 return True
2136 return False
2138 def playback_episodes_for_real(self, episodes):
2139 groups = collections.defaultdict(list)
2140 for episode in episodes:
2141 file_type = episode.file_type()
2142 if file_type == 'video' and self.config.videoplayer and \
2143 self.config.videoplayer != 'default':
2144 player = self.config.videoplayer
2145 if gpodder.ui.diablo:
2146 # Use the wrapper script if it's installed to crop 3GP YouTube
2147 # videos to fit the screen (looks much nicer than w/ black border)
2148 if player == 'mplayer' and util.find_command('gpodder-mplayer'):
2149 player = 'gpodder-mplayer'
2150 elif gpodder.ui.fremantle and player == 'mplayer':
2151 player = 'mplayer -fs %F'
2152 elif file_type == 'audio' and self.config.player and \
2153 self.config.player != 'default':
2154 player = self.config.player
2155 else:
2156 player = 'default'
2158 if file_type not in ('audio', 'video') or \
2159 (file_type == 'audio' and not self.config.audio_played_dbus) or \
2160 (file_type == 'video' and not self.config.video_played_dbus):
2161 # Mark episode as played in the database
2162 episode.mark(is_played=True)
2163 self.mygpo_client.on_playback([episode])
2165 filename = episode.local_filename(create=False)
2166 if filename is None or not os.path.exists(filename):
2167 filename = episode.url
2168 if youtube.is_video_link(filename):
2169 fmt_id = self.config.youtube_preferred_fmt_id
2170 if gpodder.ui.fremantle:
2171 fmt_id = 5
2172 filename = youtube.get_real_download_url(filename, fmt_id)
2174 # Determine the playback resume position - if the file
2175 # was played 100%, we simply start from the beginning
2176 resume_position = episode.current_position
2177 if resume_position == episode.total_time:
2178 resume_position = 0
2180 if gpodder.ui.fremantle:
2181 self.mafw_monitor.set_resume_point(filename, resume_position)
2183 # If Panucci is configured, use D-Bus on Maemo to call it
2184 if player == 'panucci':
2185 try:
2186 PANUCCI_NAME = 'org.panucci.panucciInterface'
2187 PANUCCI_PATH = '/panucciInterface'
2188 PANUCCI_INTF = 'org.panucci.panucciInterface'
2189 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2190 i = dbus.Interface(o, PANUCCI_INTF)
2192 def on_reply(*args):
2193 pass
2195 def error_handler(filename, err):
2196 log('Exception in D-Bus call: %s', str(err), \
2197 sender=self)
2199 # Fallback: use the command line client
2200 for command in util.format_desktop_command('panucci', \
2201 [filename]):
2202 log('Executing: %s', repr(command), sender=self)
2203 subprocess.Popen(command)
2205 on_error = lambda err: error_handler(filename, err)
2207 # This method only exists in Panucci > 0.9 ('new Panucci')
2208 i.playback_from(filename, resume_position, \
2209 reply_handler=on_reply, error_handler=on_error)
2211 continue # This file was handled by the D-Bus call
2212 except Exception, e:
2213 log('Error calling Panucci using D-Bus', sender=self, traceback=True)
2214 elif player == 'MediaBox' and gpodder.ui.maemo:
2215 try:
2216 MEDIABOX_NAME = 'de.pycage.mediabox'
2217 MEDIABOX_PATH = '/de/pycage/mediabox/control'
2218 MEDIABOX_INTF = 'de.pycage.mediabox.control'
2219 o = gpodder.dbus_session_bus.get_object(MEDIABOX_NAME, MEDIABOX_PATH)
2220 i = dbus.Interface(o, MEDIABOX_INTF)
2222 def on_reply(*args):
2223 pass
2225 def on_error(err):
2226 log('Exception in D-Bus call: %s', str(err), \
2227 sender=self)
2229 i.load(filename, '%s/x-unknown' % file_type, \
2230 reply_handler=on_reply, error_handler=on_error)
2232 continue # This file was handled by the D-Bus call
2233 except Exception, e:
2234 log('Error calling MediaBox using D-Bus', sender=self, traceback=True)
2236 groups[player].append(filename)
2238 # Open episodes with system default player
2239 if 'default' in groups:
2240 if gpodder.ui.maemo:
2241 # The Nokia Media Player app does not support receiving multiple
2242 # file names via D-Bus, so we simply place all file names into a
2243 # temporary M3U playlist and open that with the Media Player.
2244 m3u_filename = os.path.join(gpodder.home, 'gpodder_open_with.m3u')
2245 util.write_m3u_playlist(m3u_filename, groups['default'], extm3u=False)
2246 util.gui_open(m3u_filename)
2247 else:
2248 for filename in groups['default']:
2249 log('Opening with system default: %s', filename, sender=self)
2250 util.gui_open(filename)
2251 del groups['default']
2252 elif gpodder.ui.maemo and groups:
2253 # When on Maemo and not opening with default, show a notification
2254 # (no startup notification for Panucci / MPlayer yet...)
2255 if len(episodes) == 1:
2256 text = _('Opening %s') % episodes[0].title
2257 else:
2258 count = len(episodes)
2259 text = N_('Opening %d episode', 'Opening %d episodes', count) % count
2261 banner = hildon.hildon_banner_show_animation(self.gPodder, '', text)
2263 def destroy_banner_later(banner):
2264 banner.destroy()
2265 return False
2266 gobject.timeout_add(5000, destroy_banner_later, banner)
2268 # For each type now, go and create play commands
2269 for group in groups:
2270 for command in util.format_desktop_command(group, groups[group]):
2271 log('Executing: %s', repr(command), sender=self)
2272 subprocess.Popen(command)
2274 # Persist episode status changes to the database
2275 self.db.commit()
2277 # Flush updated episode status
2278 self.mygpo_client.flush()
2280 def playback_episodes(self, episodes):
2281 # We need to create a list, because we run through it more than once
2282 episodes = list(PodcastEpisode.sort_by_pubdate(e for e in episodes if \
2283 e.was_downloaded(and_exists=True) or self.streaming_possible()))
2285 try:
2286 self.playback_episodes_for_real(episodes)
2287 except Exception, e:
2288 log('Error in playback!', sender=self, traceback=True)
2289 if gpodder.ui.desktop:
2290 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
2291 _('Error opening player'), widget=self.toolPreferences)
2292 else:
2293 self.show_message(_('Please check your media player settings in the preferences dialog.'))
2295 channel_urls = set()
2296 episode_urls = set()
2297 for episode in episodes:
2298 channel_urls.add(episode.channel.url)
2299 episode_urls.add(episode.url)
2300 self.update_episode_list_icons(episode_urls)
2301 self.update_podcast_list_model(channel_urls)
2303 def play_or_download(self):
2304 if not gpodder.ui.fremantle:
2305 if self.wNotebook.get_current_page() > 0:
2306 if gpodder.ui.desktop:
2307 self.toolCancel.set_sensitive(True)
2308 return
2310 if self.currently_updating:
2311 return (False, False, False, False, False, False)
2313 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
2314 ( is_played, is_locked ) = (False,)*2
2316 open_instead_of_play = False
2318 selection = self.treeAvailable.get_selection()
2319 if selection.count_selected_rows() > 0:
2320 (model, paths) = selection.get_selected_rows()
2322 for path in paths:
2323 try:
2324 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2325 except TypeError, te:
2326 log('Invalid episode at path %s', str(path), sender=self)
2327 continue
2329 if episode.file_type() not in ('audio', 'video'):
2330 open_instead_of_play = True
2332 if episode.was_downloaded():
2333 can_play = episode.was_downloaded(and_exists=True)
2334 is_played = episode.is_played
2335 is_locked = episode.is_locked
2336 if not can_play:
2337 can_download = True
2338 else:
2339 if self.episode_is_downloading(episode):
2340 can_cancel = True
2341 else:
2342 can_download = True
2344 can_download = can_download and not can_cancel
2345 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
2346 can_transfer = can_play and self.config.device_type != 'none' and not can_cancel and not can_download and not open_instead_of_play
2347 can_delete = not can_cancel
2349 if gpodder.ui.desktop:
2350 if open_instead_of_play:
2351 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
2352 else:
2353 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
2354 self.toolPlay.set_sensitive( can_play)
2355 self.toolDownload.set_sensitive( can_download)
2356 self.toolTransfer.set_sensitive( can_transfer)
2357 self.toolCancel.set_sensitive( can_cancel)
2359 if not gpodder.ui.fremantle:
2360 self.item_cancel_download.set_sensitive(can_cancel)
2361 self.itemDownloadSelected.set_sensitive(can_download)
2362 self.itemOpenSelected.set_sensitive(can_play)
2363 self.itemPlaySelected.set_sensitive(can_play)
2364 self.itemDeleteSelected.set_sensitive(can_delete)
2365 self.item_toggle_played.set_sensitive(can_play)
2366 self.item_toggle_lock.set_sensitive(can_play)
2367 self.itemOpenSelected.set_visible(open_instead_of_play)
2368 self.itemPlaySelected.set_visible(not open_instead_of_play)
2370 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
2372 def on_cbMaxDownloads_toggled(self, widget, *args):
2373 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2375 def on_cbLimitDownloads_toggled(self, widget, *args):
2376 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2378 def episode_new_status_changed(self, urls):
2379 self.update_podcast_list_model()
2380 self.update_episode_list_icons(urls)
2382 def update_podcast_list_model(self, urls=None, selected=False, select_url=None):
2383 """Update the podcast list treeview model
2385 If urls is given, it should list the URLs of each
2386 podcast that has to be updated in the list.
2388 If selected is True, only update the model contents
2389 for the currently-selected podcast - nothing more.
2391 The caller can optionally specify "select_url",
2392 which is the URL of the podcast that is to be
2393 selected in the list after the update is complete.
2394 This only works if the podcast list has to be
2395 reloaded; i.e. something has been added or removed
2396 since the last update of the podcast list).
2398 selection = self.treeChannels.get_selection()
2399 model, iter = selection.get_selected()
2401 if self.config.podcast_list_view_all and not self.channel_list_changed:
2402 # Update "all episodes" view in any case (if enabled)
2403 self.podcast_list_model.update_first_row()
2405 if selected:
2406 # very cheap! only update selected channel
2407 if iter is not None:
2408 # If we have selected the "all episodes" view, we have
2409 # to update all channels for selected episodes:
2410 if self.config.podcast_list_view_all and \
2411 self.podcast_list_model.iter_is_first_row(iter):
2412 urls = self.get_podcast_urls_from_selected_episodes()
2413 self.podcast_list_model.update_by_urls(urls)
2414 else:
2415 # Otherwise just update the selected row (a podcast)
2416 self.podcast_list_model.update_by_filter_iter(iter)
2417 elif not self.channel_list_changed:
2418 # we can keep the model, but have to update some
2419 if urls is None:
2420 # still cheaper than reloading the whole list
2421 self.podcast_list_model.update_all()
2422 else:
2423 # ok, we got a bunch of urls to update
2424 self.podcast_list_model.update_by_urls(urls)
2425 else:
2426 if model and iter and select_url is None:
2427 # Get the URL of the currently-selected podcast
2428 select_url = model.get_value(iter, PodcastListModel.C_URL)
2430 # Update the podcast list model with new channels
2431 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2433 try:
2434 selected_iter = model.get_iter_first()
2435 # Find the previously-selected URL in the new
2436 # model if we have an URL (else select first)
2437 if select_url is not None:
2438 pos = model.get_iter_first()
2439 while pos is not None:
2440 url = model.get_value(pos, PodcastListModel.C_URL)
2441 if url == select_url:
2442 selected_iter = pos
2443 break
2444 pos = model.iter_next(pos)
2446 if not gpodder.ui.fremantle:
2447 if selected_iter is not None:
2448 selection.select_iter(selected_iter)
2449 self.on_treeChannels_cursor_changed(self.treeChannels)
2450 except:
2451 log('Cannot select podcast in list', traceback=True, sender=self)
2452 self.channel_list_changed = False
2454 def episode_is_downloading(self, episode):
2455 """Returns True if the given episode is being downloaded at the moment"""
2456 if episode is None:
2457 return False
2459 return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
2461 def update_episode_list_model(self):
2462 if self.channels and self.active_channel is not None:
2463 if gpodder.ui.fremantle:
2464 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2466 self.currently_updating = True
2467 self.episode_list_model.clear()
2468 self.episode_list_model.reset_update_progress()
2469 self.treeAvailable.set_model(self.empty_episode_list_model)
2470 def do_update_episode_list_model():
2471 additional_args = (self.episode_is_downloading, \
2472 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2473 self.config.episode_list_thumbnails and gpodder.ui.desktop, \
2474 self.treeAvailable)
2475 self.episode_list_model.add_from_channel(self.active_channel, *additional_args)
2477 def on_episode_list_model_updated():
2478 if gpodder.ui.fremantle:
2479 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2480 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
2481 self.treeAvailable.columns_autosize()
2482 self.currently_updating = False
2483 self.play_or_download()
2484 util.idle_add(on_episode_list_model_updated)
2485 threading.Thread(target=do_update_episode_list_model).start()
2486 else:
2487 self.episode_list_model.clear()
2489 @dbus.service.method(gpodder.dbus_interface)
2490 def offer_new_episodes(self, channels=None):
2491 if gpodder.ui.fremantle:
2492 # Assume that when this function is called that the
2493 # notification is not shown anymore (Maemo bug 11345)
2494 self._fremantle_notification_visible = False
2496 new_episodes = self.get_new_episodes(channels)
2497 if new_episodes:
2498 self.new_episodes_show(new_episodes)
2499 return True
2500 return False
2502 def add_podcast_list(self, urls, auth_tokens=None):
2503 """Subscribe to a list of podcast given their URLs
2505 If auth_tokens is given, it should be a dictionary
2506 mapping URLs to (username, password) tuples."""
2508 if auth_tokens is None:
2509 auth_tokens = {}
2511 # Sort and split the URL list into five buckets
2512 queued, failed, existing, worked, authreq = [], [], [], [], []
2513 for input_url in urls:
2514 url = util.normalize_feed_url(input_url)
2515 if url is None:
2516 # Fail this one because the URL is not valid
2517 failed.append(input_url)
2518 elif self.podcast_list_model.get_filter_path_from_url(url) is not None:
2519 # A podcast already exists in the list for this URL
2520 existing.append(url)
2521 else:
2522 # This URL has survived the first round - queue for add
2523 queued.append(url)
2524 if url != input_url and input_url in auth_tokens:
2525 auth_tokens[url] = auth_tokens[input_url]
2527 error_messages = {}
2528 redirections = {}
2530 progress = ProgressIndicator(_('Adding podcasts'), \
2531 _('Please wait while episode information is downloaded.'), \
2532 parent=self.get_dialog_parent())
2534 def on_after_update():
2535 progress.on_finished()
2536 # Report already-existing subscriptions to the user
2537 if existing:
2538 title = _('Existing subscriptions skipped')
2539 message = _('You are already subscribed to these podcasts:') \
2540 + '\n\n' + '\n'.join(saxutils.escape(url) for url in existing)
2541 self.show_message(message, title, widget=self.treeChannels)
2543 # Report subscriptions that require authentication
2544 if authreq:
2545 retry_podcasts = {}
2546 for url in authreq:
2547 title = _('Podcast requires authentication')
2548 message = _('Please login to %s:') % (saxutils.escape(url),)
2549 success, auth_tokens = self.show_login_dialog(title, message)
2550 if success:
2551 retry_podcasts[url] = auth_tokens
2552 else:
2553 # Stop asking the user for more login data
2554 retry_podcasts = {}
2555 for url in authreq:
2556 error_messages[url] = _('Authentication failed')
2557 failed.append(url)
2558 break
2560 # If we have authentication data to retry, do so here
2561 if retry_podcasts:
2562 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
2564 # Report website redirections
2565 for url in redirections:
2566 title = _('Website redirection detected')
2567 message = _('The URL %(url)s redirects to %(target)s.') \
2568 + '\n\n' + _('Do you want to visit the website now?')
2569 message = message % {'url': url, 'target': redirections[url]}
2570 if self.show_confirmation(message, title):
2571 util.open_website(url)
2572 else:
2573 break
2575 # Report failed subscriptions to the user
2576 if failed:
2577 title = _('Could not add some podcasts')
2578 message = _('Some podcasts could not be added to your list:') \
2579 + '\n\n' + '\n'.join(saxutils.escape('%s: %s' % (url, \
2580 error_messages.get(url, _('Unknown')))) for url in failed)
2581 self.show_message(message, title, important=True)
2583 # Upload subscription changes to gpodder.net
2584 self.mygpo_client.on_subscribe(worked)
2586 # If at least one podcast has been added, save and update all
2587 if self.channel_list_changed:
2588 # Fix URLs if mygpo has rewritten them
2589 self.rewrite_urls_mygpo()
2591 self.save_channels_opml()
2593 # If only one podcast was added, select it after the update
2594 if len(worked) == 1:
2595 url = worked[0]
2596 else:
2597 url = None
2599 # Update the list of subscribed podcasts
2600 self.update_feed_cache(force_update=False, select_url_afterwards=url)
2601 self.update_podcasts_tab()
2603 # Offer to download new episodes
2604 episodes = []
2605 for podcast in self.channels:
2606 if podcast.url in worked:
2607 episodes.extend(podcast.get_all_episodes())
2609 if episodes:
2610 episodes = list(PodcastEpisode.sort_by_pubdate(episodes, \
2611 reverse=True))
2612 self.new_episodes_show(episodes, \
2613 selected=[e.check_is_new() for e in episodes])
2616 def thread_proc():
2617 # After the initial sorting and splitting, try all queued podcasts
2618 length = len(queued)
2619 for index, url in enumerate(queued):
2620 progress.on_progress(float(index)/float(length))
2621 progress.on_message(url)
2622 log('QUEUE RUNNER: %s', url, sender=self)
2623 try:
2624 # The URL is valid and does not exist already - subscribe!
2625 channel = PodcastChannel.load(self.db, url=url, create=True, \
2626 authentication_tokens=auth_tokens.get(url, None), \
2627 max_episodes=self.config.max_episodes_per_feed, \
2628 download_dir=self.config.download_dir, \
2629 allow_empty_feeds=self.config.allow_empty_feeds, \
2630 mimetype_prefs=self.config.mimetype_prefs)
2632 try:
2633 username, password = util.username_password_from_url(url)
2634 except ValueError, ve:
2635 username, password = (None, None)
2637 if username is not None and channel.username is None and \
2638 password is not None and channel.password is None:
2639 channel.username = username
2640 channel.password = password
2641 channel.save()
2643 self._update_cover(channel)
2644 except feedcore.AuthenticationRequired:
2645 if url in auth_tokens:
2646 # Fail for wrong authentication data
2647 error_messages[url] = _('Authentication failed')
2648 failed.append(url)
2649 else:
2650 # Queue for login dialog later
2651 authreq.append(url)
2652 continue
2653 except feedcore.WifiLogin, error:
2654 redirections[url] = error.data
2655 failed.append(url)
2656 error_messages[url] = _('Redirection detected')
2657 continue
2658 except Exception, e:
2659 log('Subscription error: %s', e, traceback=True, sender=self)
2660 error_messages[url] = str(e)
2661 failed.append(url)
2662 continue
2664 assert channel is not None
2665 worked.append(channel.url)
2666 self.channels.append(channel)
2667 self.channel_list_changed = True
2668 util.idle_add(on_after_update)
2669 threading.Thread(target=thread_proc).start()
2671 def save_channels_opml(self):
2672 exporter = opml.Exporter(gpodder.subscription_file)
2673 return exporter.write(self.channels)
2675 def find_episode(self, podcast_url, episode_url):
2676 """Find an episode given its podcast and episode URL
2678 The function will return a PodcastEpisode object if
2679 the episode is found, or None if it's not found.
2681 for podcast in self.channels:
2682 if podcast_url == podcast.url:
2683 for episode in podcast.get_all_episodes():
2684 if episode_url == episode.url:
2685 return episode
2687 return None
2689 def process_received_episode_actions(self, updated_urls):
2690 """Process/merge episode actions from gpodder.net
2692 This function will merge all changes received from
2693 the server to the local database and update the
2694 status of the affected episodes as necessary.
2696 indicator = ProgressIndicator(_('Merging episode actions'), \
2697 _('Episode actions from gpodder.net are merged.'), \
2698 False, self.get_dialog_parent())
2700 for idx, action in enumerate(self.mygpo_client.get_episode_actions(updated_urls)):
2701 if action.action == 'play':
2702 episode = self.find_episode(action.podcast_url, \
2703 action.episode_url)
2705 if episode is not None:
2706 log('Play action for %s', episode.url, sender=self)
2707 episode.mark(is_played=True)
2709 if action.timestamp > episode.current_position_updated:
2710 log('Updating position for %s', episode.url, sender=self)
2711 episode.current_position = action.position
2712 episode.current_position_updated = action.timestamp
2714 if action.total:
2715 log('Updating total time for %s', episode.url, sender=self)
2716 episode.total_time = action.total
2718 episode.save()
2719 elif action.action == 'delete':
2720 episode = self.find_episode(action.podcast_url, \
2721 action.episode_url)
2723 if episode is not None:
2724 if not episode.was_downloaded(and_exists=True):
2725 # Set the episode to a "deleted" state
2726 log('Marking as deleted: %s', episode.url, sender=self)
2727 episode.delete_from_disk()
2728 episode.save()
2730 indicator.on_message(N_('%d action processed', '%d actions processed', idx) % idx)
2731 gtk.main_iteration(False)
2733 indicator.on_finished()
2734 self.db.commit()
2737 def update_feed_cache_finish_callback(self, updated_urls=None, select_url_afterwards=None):
2738 self.db.commit()
2739 self.updating_feed_cache = False
2741 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2743 # Process received episode actions for all updated URLs
2744 self.process_received_episode_actions(updated_urls)
2746 self.channel_list_changed = True
2747 self.update_podcast_list_model(select_url=select_url_afterwards)
2749 # Only search for new episodes in podcasts that have been
2750 # updated, not in other podcasts (for single-feed updates)
2751 episodes = self.get_new_episodes([c for c in self.channels if c.url in updated_urls])
2753 if gpodder.ui.fremantle:
2754 self.fancy_progress_bar.hide()
2755 self.button_subscribe.set_sensitive(True)
2756 self.button_refresh.set_sensitive(True)
2757 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
2758 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2759 self.update_podcasts_tab()
2760 self.update_episode_list_model()
2761 if self.feed_cache_update_cancelled:
2762 return
2764 def application_in_foreground():
2765 try:
2766 return any(w.get_property('is-topmost') for w in hildon.WindowStack.get_default().get_windows())
2767 except Exception, e:
2768 log('Could not determine is-topmost', traceback=True)
2769 # When in doubt, assume not in foreground
2770 return False
2772 if episodes:
2773 if self.config.auto_download == 'quiet' and not self.config.auto_update_feeds:
2774 # New episodes found, but we should do nothing
2775 self.show_message(_('New episodes are available.'))
2776 elif self.config.auto_download == 'always':
2777 count = len(episodes)
2778 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2779 self.show_message(title)
2780 self.download_episode_list(episodes)
2781 elif self.config.auto_download == 'queue':
2782 self.show_message(_('New episodes have been added to the download list.'))
2783 self.download_episode_list_paused(episodes)
2784 elif application_in_foreground():
2785 if not self._fremantle_notification_visible:
2786 self.new_episodes_show(episodes)
2787 elif not self._fremantle_notification_visible:
2788 try:
2789 import pynotify
2790 pynotify.init('gPodder')
2791 n = pynotify.Notification('gPodder', _('New episodes available'), 'gpodder')
2792 n.set_urgency(pynotify.URGENCY_CRITICAL)
2793 n.set_hint('dbus-callback-default', ' '.join([
2794 gpodder.dbus_bus_name,
2795 gpodder.dbus_gui_object_path,
2796 gpodder.dbus_interface,
2797 'offer_new_episodes',
2799 n.set_category('gpodder-new-episodes')
2800 n.show()
2801 self._fremantle_notification_visible = True
2802 except Exception, e:
2803 log('Error: %s', str(e), sender=self, traceback=True)
2804 self.new_episodes_show(episodes)
2805 self._fremantle_notification_visible = False
2806 elif not self.config.auto_update_feeds:
2807 self.show_message(_('No new episodes. Please check for new episodes later.'))
2808 return
2810 if self.tray_icon:
2811 self.tray_icon.set_status()
2813 if self.feed_cache_update_cancelled:
2814 # The user decided to abort the feed update
2815 self.show_update_feeds_buttons()
2816 elif not episodes:
2817 # Nothing new here - but inform the user
2818 self.pbFeedUpdate.set_fraction(1.0)
2819 self.pbFeedUpdate.set_text(_('No new episodes'))
2820 self.feed_cache_update_cancelled = True
2821 self.btnCancelFeedUpdate.show()
2822 self.btnCancelFeedUpdate.set_sensitive(True)
2823 if gpodder.ui.maemo:
2824 # btnCancelFeedUpdate is a ToolButton on Maemo
2825 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_APPLY)
2826 else:
2827 # btnCancelFeedUpdate is a normal gtk.Button
2828 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
2829 else:
2830 count = len(episodes)
2831 # New episodes are available
2832 self.pbFeedUpdate.set_fraction(1.0)
2833 # Are we minimized and should we auto download?
2834 if (self.is_iconified() and (self.config.auto_download == 'minimized')) or (self.config.auto_download == 'always'):
2835 self.download_episode_list(episodes)
2836 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2837 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2838 self.show_update_feeds_buttons()
2839 elif self.config.auto_download == 'queue':
2840 self.download_episode_list_paused(episodes)
2841 title = N_('%d new episode added to download list.', '%d new episodes added to download list.', count) % count
2842 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2843 self.show_update_feeds_buttons()
2844 else:
2845 self.show_update_feeds_buttons()
2846 # New episodes are available and we are not minimized
2847 if not self.config.do_not_show_new_episodes_dialog:
2848 self.new_episodes_show(episodes, notification=True)
2849 else:
2850 message = N_('%d new episode available', '%d new episodes available', count) % count
2851 self.pbFeedUpdate.set_text(message)
2853 def _update_cover(self, channel):
2854 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
2855 self.cover_downloader.request_cover(channel)
2857 def update_feed_cache_proc(self, channels, select_url_afterwards):
2858 total = len(channels)
2860 for updated, channel in enumerate(channels):
2861 if not self.feed_cache_update_cancelled:
2862 try:
2863 channel.update(max_episodes=self.config.max_episodes_per_feed, \
2864 mimetype_prefs=self.config.mimetype_prefs)
2865 self._update_cover(channel)
2866 except Exception, e:
2867 d = {'url': saxutils.escape(channel.url), 'message': saxutils.escape(str(e))}
2868 if d['message']:
2869 message = _('Error while updating %(url)s: %(message)s')
2870 else:
2871 message = _('The feed at %(url)s could not be updated.')
2872 self.notification(message % d, _('Error while updating feed'), widget=self.treeChannels)
2873 log('Error: %s', str(e), sender=self, traceback=True)
2875 if self.feed_cache_update_cancelled:
2876 break
2878 # By the time we get here the update may have already been cancelled
2879 if not self.feed_cache_update_cancelled:
2880 def update_progress():
2881 d = {'podcast': channel.title, 'position': updated+1, 'total': total}
2882 progression = _('Updated %(podcast)s (%(position)d/%(total)d)') % d
2883 self.pbFeedUpdate.set_text(progression)
2884 if self.tray_icon:
2885 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression)
2886 self.pbFeedUpdate.set_fraction(float(updated)/float(total))
2887 util.idle_add(update_progress)
2889 updated_urls = [c.url for c in channels]
2890 util.idle_add(self.update_feed_cache_finish_callback, updated_urls, select_url_afterwards)
2892 def show_update_feeds_buttons(self):
2893 # Make sure that the buttons for updating feeds
2894 # appear - this should happen after a feed update
2895 if gpodder.ui.maemo:
2896 self.btnUpdateSelectedFeed.show()
2897 self.toolFeedUpdateProgress.hide()
2898 self.btnCancelFeedUpdate.hide()
2899 self.btnCancelFeedUpdate.set_is_important(False)
2900 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_CLOSE)
2901 self.toolbarSpacer.set_expand(True)
2902 self.toolbarSpacer.set_draw(False)
2903 else:
2904 self.hboxUpdateFeeds.hide()
2905 self.btnUpdateFeeds.show()
2906 self.itemUpdate.set_sensitive(True)
2907 self.itemUpdateChannel.set_sensitive(True)
2909 def on_btnCancelFeedUpdate_clicked(self, widget):
2910 if not self.feed_cache_update_cancelled:
2911 self.pbFeedUpdate.set_text(_('Cancelling...'))
2912 self.feed_cache_update_cancelled = True
2913 if not gpodder.ui.fremantle:
2914 self.btnCancelFeedUpdate.set_sensitive(False)
2915 else:
2916 self.show_update_feeds_buttons()
2918 def update_feed_cache(self, channels=None, force_update=True, select_url_afterwards=None):
2919 if self.updating_feed_cache:
2920 if gpodder.ui.fremantle:
2921 self.feed_cache_update_cancelled = True
2922 return
2924 if not force_update:
2925 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2926 self.channel_list_changed = True
2927 self.update_podcast_list_model(select_url=select_url_afterwards)
2928 return
2930 # Fix URLs if mygpo has rewritten them
2931 self.rewrite_urls_mygpo()
2933 self.updating_feed_cache = True
2935 if channels is None:
2936 # Only update podcasts for which updates are enabled
2937 channels = [c for c in self.channels if c.feed_update_enabled]
2939 if gpodder.ui.fremantle:
2940 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
2941 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2942 self.fancy_progress_bar.show()
2943 self.button_subscribe.set_sensitive(False)
2944 self.button_refresh.set_sensitive(False)
2945 self.feed_cache_update_cancelled = False
2946 else:
2947 self.itemUpdate.set_sensitive(False)
2948 self.itemUpdateChannel.set_sensitive(False)
2950 if self.tray_icon:
2951 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
2953 self.feed_cache_update_cancelled = False
2954 self.btnCancelFeedUpdate.show()
2955 self.btnCancelFeedUpdate.set_sensitive(True)
2956 if gpodder.ui.maemo:
2957 self.toolbarSpacer.set_expand(False)
2958 self.toolbarSpacer.set_draw(True)
2959 self.btnUpdateSelectedFeed.hide()
2960 self.toolFeedUpdateProgress.show_all()
2961 else:
2962 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
2963 self.hboxUpdateFeeds.show_all()
2964 self.btnUpdateFeeds.hide()
2966 if len(channels) == 1:
2967 text = _('Updating "%s"...') % channels[0].title
2968 else:
2969 count = len(channels)
2970 text = N_('Updating %d feed...', 'Updating %d feeds...', count) % count
2971 self.pbFeedUpdate.set_text(text)
2972 self.pbFeedUpdate.set_fraction(0)
2974 args = (channels, select_url_afterwards)
2975 threading.Thread(target=self.update_feed_cache_proc, args=args).start()
2977 def on_gPodder_delete_event(self, widget, *args):
2978 """Called when the GUI wants to close the window
2979 Displays a confirmation dialog (and closes/hides gPodder)
2982 downloading = self.download_status_model.are_downloads_in_progress()
2984 # Only iconify if we are using the window's "X" button,
2985 # but not when we are using "Quit" in the menu or toolbar
2986 if self.config.on_quit_systray and self.tray_icon and widget.get_name() not in ('toolQuit', 'itemQuit'):
2987 self.iconify_main_window()
2988 elif self.config.on_quit_ask or downloading:
2989 if gpodder.ui.fremantle:
2990 self.close_gpodder()
2991 elif gpodder.ui.diablo:
2992 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
2993 if result:
2994 self.close_gpodder()
2995 else:
2996 return True
2997 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2998 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2999 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
3001 title = _('Quit gPodder')
3002 if downloading:
3003 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
3004 else:
3005 message = _('Do you really want to quit gPodder now?')
3007 dialog.set_title(title)
3008 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
3009 if not downloading:
3010 cb_ask = gtk.CheckButton(_("Don't ask me again"))
3011 dialog.vbox.pack_start(cb_ask)
3012 cb_ask.show_all()
3014 quit_button.grab_focus()
3015 result = dialog.run()
3016 dialog.destroy()
3018 if result == gtk.RESPONSE_CLOSE:
3019 if not downloading and cb_ask.get_active() == True:
3020 self.config.on_quit_ask = False
3021 self.close_gpodder()
3022 else:
3023 self.close_gpodder()
3025 return True
3027 def close_gpodder(self):
3028 """ clean everything and exit properly
3030 if self.channels:
3031 if self.save_channels_opml():
3032 pass # FIXME: Add mygpo synchronization here
3033 else:
3034 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'), important=True)
3036 self.gPodder.hide()
3038 if self.tray_icon is not None:
3039 self.tray_icon.set_visible(False)
3041 # Notify all tasks to to carry out any clean-up actions
3042 self.download_status_model.tell_all_tasks_to_quit()
3044 while gtk.events_pending():
3045 gtk.main_iteration(False)
3047 self.db.close()
3049 self.quit()
3050 sys.exit(0)
3052 def get_expired_episodes(self):
3053 for channel in self.channels:
3054 for episode in channel.get_downloaded_episodes():
3055 # Never consider locked episodes as old
3056 if episode.is_locked:
3057 continue
3059 # Never consider fresh episodes as old
3060 if episode.age_in_days() < self.config.episode_old_age:
3061 continue
3063 # Do not delete played episodes (except if configured)
3064 if episode.is_played:
3065 if not self.config.auto_remove_played_episodes:
3066 continue
3068 # Do not delete unplayed episodes (except if configured)
3069 if not episode.is_played:
3070 if not self.config.auto_remove_unplayed_episodes:
3071 continue
3073 yield episode
3075 def delete_episode_list(self, episodes, confirm=True, skip_locked=True):
3076 if not episodes:
3077 return False
3079 if skip_locked:
3080 episodes = [e for e in episodes if not e.is_locked]
3082 if not episodes:
3083 title = _('Episodes are locked')
3084 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
3085 self.notification(message, title, widget=self.treeAvailable)
3086 return False
3088 count = len(episodes)
3089 title = N_('Delete %d episode?', 'Delete %d episodes?', count) % count
3090 message = _('Deleting episodes removes downloaded files.')
3092 if gpodder.ui.fremantle:
3093 message = '\n'.join([title, message])
3095 if confirm and not self.show_confirmation(message, title):
3096 return False
3098 progress = ProgressIndicator(_('Deleting episodes'), \
3099 _('Please wait while episodes are deleted'), \
3100 parent=self.get_dialog_parent())
3102 def finish_deletion(episode_urls, channel_urls):
3103 progress.on_finished()
3105 # Episodes have been deleted - persist the database
3106 self.db.commit()
3108 self.update_episode_list_icons(episode_urls)
3109 self.update_podcast_list_model(channel_urls)
3110 self.play_or_download()
3112 def thread_proc():
3113 episode_urls = set()
3114 channel_urls = set()
3116 episodes_status_update = []
3117 for idx, episode in enumerate(episodes):
3118 progress.on_progress(float(idx)/float(len(episodes)))
3119 if episode.is_locked and skip_locked:
3120 log('Not deleting episode (is locked): %s', episode.title)
3121 else:
3122 log('Deleting episode: %s', episode.title)
3123 progress.on_message(episode.title)
3124 episode.delete_from_disk()
3125 episode_urls.add(episode.url)
3126 channel_urls.add(episode.channel.url)
3127 episodes_status_update.append(episode)
3129 # Tell the shownotes window that we have removed the episode
3130 if self.episode_shownotes_window is not None and \
3131 self.episode_shownotes_window.episode is not None and \
3132 self.episode_shownotes_window.episode.url == episode.url:
3133 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
3135 # Notify the web service about the status update + upload
3136 self.mygpo_client.on_delete(episodes_status_update)
3137 self.mygpo_client.flush()
3139 util.idle_add(finish_deletion, episode_urls, channel_urls)
3141 threading.Thread(target=thread_proc).start()
3143 return True
3145 def on_itemRemoveOldEpisodes_activate( self, widget):
3146 if gpodder.ui.maemo:
3147 columns = (
3148 ('maemo_remove_markup', None, None, _('Episode')),
3150 else:
3151 columns = (
3152 ('title_markup', None, None, _('Episode')),
3153 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3154 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3155 ('played_prop', None, None, _('Status')),
3156 ('age_prop', 'age_int_prop', gobject.TYPE_INT, _('Downloaded')),
3159 msg_older_than = N_('Select older than %d day', 'Select older than %d days', self.config.episode_old_age)
3160 selection_buttons = {
3161 _('Select played'): lambda episode: episode.is_played,
3162 _('Select finished'): lambda episode: episode.is_finished(),
3163 msg_older_than % self.config.episode_old_age: lambda episode: episode.age_in_days() > self.config.episode_old_age,
3166 instructions = _('Select the episodes you want to delete:')
3168 episodes = []
3169 selected = []
3170 for channel in self.channels:
3171 for episode in channel.get_downloaded_episodes():
3172 # Disallow deletion of locked episodes that still exist
3173 if not episode.is_locked or not episode.file_exists():
3174 episodes.append(episode)
3175 # Automatically select played and file-less episodes
3176 selected.append(episode.is_played or \
3177 not episode.file_exists())
3179 gPodderEpisodeSelector(self.gPodder, title = _('Delete episodes'), instructions = instructions, \
3180 episodes = episodes, selected = selected, columns = columns, \
3181 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
3182 selection_buttons = selection_buttons, _config=self.config, \
3183 show_episode_shownotes=self.show_episode_shownotes)
3185 def on_selected_episodes_status_changed(self):
3186 # The order of the updates here is important! When "All episodes" is
3187 # selected, the update of the podcast list model depends on the episode
3188 # list selection to determine which podcasts are affected. Updating
3189 # the episode list could remove the selection if a filter is active.
3190 self.update_podcast_list_model(selected=True)
3191 self.update_episode_list_icons(selected=True)
3192 self.db.commit()
3194 def mark_selected_episodes_new(self):
3195 for episode in self.get_selected_episodes():
3196 episode.mark_new()
3197 self.on_selected_episodes_status_changed()
3199 def mark_selected_episodes_old(self):
3200 for episode in self.get_selected_episodes():
3201 episode.mark_old()
3202 self.on_selected_episodes_status_changed()
3204 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
3205 for episode in self.get_selected_episodes():
3206 if toggle:
3207 episode.mark(is_played=not episode.is_played)
3208 else:
3209 episode.mark(is_played=new_value)
3210 self.on_selected_episodes_status_changed()
3212 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3213 for episode in self.get_selected_episodes():
3214 if toggle:
3215 episode.mark(is_locked=not episode.is_locked)
3216 else:
3217 episode.mark(is_locked=new_value)
3218 self.on_selected_episodes_status_changed()
3220 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3221 if self.active_channel is None:
3222 return
3224 self.active_channel.channel_is_locked = not self.active_channel.channel_is_locked
3225 self.active_channel.update_channel_lock()
3227 for episode in self.active_channel.get_all_episodes():
3228 episode.mark(is_locked=self.active_channel.channel_is_locked)
3230 self.update_podcast_list_model(selected=True)
3231 self.update_episode_list_icons(all=True)
3233 def on_itemUpdateChannel_activate(self, widget=None):
3234 if self.active_channel is None:
3235 title = _('No podcast selected')
3236 message = _('Please select a podcast in the podcasts list to update.')
3237 self.show_message( message, title, widget=self.treeChannels)
3238 return
3240 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3241 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3242 self.update_feed_cache()
3243 else:
3244 self.update_feed_cache(channels=[self.active_channel])
3246 def on_itemUpdate_activate(self, widget=None):
3247 # Check if we have outstanding subscribe/unsubscribe actions
3248 if self.on_add_remove_podcasts_mygpo():
3249 log('Update cancelled (received server changes)', sender=self)
3250 return
3252 if self.channels:
3253 self.update_feed_cache()
3254 else:
3255 gPodderWelcome(self.gPodder,
3256 center_on_widget=self.gPodder,
3257 show_example_podcasts_callback=self.on_itemImportChannels_activate,
3258 setup_my_gpodder_callback=self.on_download_subscriptions_from_mygpo)
3260 def download_episode_list_paused(self, episodes):
3261 self.download_episode_list(episodes, True)
3263 def download_episode_list(self, episodes, add_paused=False, force_start=False):
3264 enable_update = False
3266 for episode in episodes:
3267 log('Downloading episode: %s', episode.title, sender = self)
3268 if not episode.was_downloaded(and_exists=True):
3269 task_exists = False
3270 for task in self.download_tasks_seen:
3271 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
3272 self.download_queue_manager.add_task(task, force_start)
3273 enable_update = True
3274 task_exists = True
3275 continue
3277 if task_exists:
3278 continue
3280 try:
3281 task = download.DownloadTask(episode, self.config)
3282 except Exception, e:
3283 d = {'episode': episode.title, 'message': str(e)}
3284 message = _('Download error while downloading %(episode)s: %(message)s')
3285 self.show_message(message % d, _('Download error'), important=True)
3286 log('Download error while downloading %s', episode.title, sender=self, traceback=True)
3287 continue
3289 if add_paused:
3290 task.status = task.PAUSED
3291 else:
3292 self.mygpo_client.on_download([task.episode])
3293 self.download_queue_manager.add_task(task, force_start)
3295 self.download_status_model.register_task(task)
3296 enable_update = True
3298 if enable_update:
3299 self.enable_download_list_update()
3301 # Flush updated episode status
3302 self.mygpo_client.flush()
3304 def cancel_task_list(self, tasks):
3305 if not tasks:
3306 return
3308 for task in tasks:
3309 if task.status in (task.QUEUED, task.DOWNLOADING):
3310 task.status = task.CANCELLED
3311 elif task.status == task.PAUSED:
3312 task.status = task.CANCELLED
3313 # Call run, so the partial file gets deleted
3314 task.run()
3316 self.update_episode_list_icons([task.url for task in tasks])
3317 self.play_or_download()
3319 # Update the tab title and downloads list
3320 self.update_downloads_list()
3322 def new_episodes_show(self, episodes, notification=False, selected=None):
3323 if gpodder.ui.maemo:
3324 columns = (
3325 ('maemo_markup', None, None, _('Episode')),
3327 show_notification = notification
3328 else:
3329 columns = (
3330 ('title_markup', None, None, _('Episode')),
3331 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3332 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3334 show_notification = False
3336 instructions = _('Select the episodes you want to download:')
3338 if self.new_episodes_window is not None:
3339 self.new_episodes_window.main_window.destroy()
3340 self.new_episodes_window = None
3342 def download_episodes_callback(episodes):
3343 self.new_episodes_window = None
3344 self.download_episode_list(episodes)
3346 if selected is None:
3347 # Select all by default
3348 selected = [True]*len(episodes)
3350 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
3351 title=_('New episodes available'), \
3352 instructions=instructions, \
3353 episodes=episodes, \
3354 columns=columns, \
3355 selected=selected, \
3356 stock_ok_button = 'gpodder-download', \
3357 callback=download_episodes_callback, \
3358 remove_callback=lambda e: e.mark_old(), \
3359 remove_action=_('Mark as old'), \
3360 remove_finished=self.episode_new_status_changed, \
3361 _config=self.config, \
3362 show_notification=show_notification, \
3363 show_episode_shownotes=self.show_episode_shownotes)
3365 def on_itemDownloadAllNew_activate(self, widget, *args):
3366 if not self.offer_new_episodes():
3367 self.show_message(_('Please check for new episodes later.'), \
3368 _('No new episodes available'), widget=self.btnUpdateFeeds)
3370 def get_new_episodes(self, channels=None):
3371 if channels is None:
3372 channels = self.channels
3373 episodes = []
3374 for channel in channels:
3375 for episode in channel.get_new_episodes(downloading=self.episode_is_downloading):
3376 episodes.append(episode)
3378 return episodes
3380 def on_sync_to_ipod_activate(self, widget, episodes=None):
3381 self.sync_ui.on_synchronize_episodes(self.channels, episodes)
3383 def commit_changes_to_database(self):
3384 """This will be called after the sync process is finished"""
3385 self.db.commit()
3387 def on_cleanup_ipod_activate(self, widget, *args):
3388 self.sync_ui.on_cleanup_device()
3390 def on_manage_device_playlist(self, widget):
3391 self.sync_ui.on_manage_device_playlist()
3393 def show_hide_tray_icon(self):
3394 if self.config.display_tray_icon and have_trayicon and self.tray_icon is None:
3395 self.tray_icon = GPodderStatusIcon(self, gpodder.icon_file, self.config)
3396 elif not self.config.display_tray_icon and self.tray_icon is not None:
3397 self.tray_icon.set_visible(False)
3398 del self.tray_icon
3399 self.tray_icon = None
3401 if self.config.minimize_to_tray and self.tray_icon:
3402 self.tray_icon.set_visible(self.is_iconified())
3403 elif self.tray_icon:
3404 self.tray_icon.set_visible(True)
3406 def on_itemShowAllEpisodes_activate(self, widget):
3407 self.config.podcast_list_view_all = widget.get_active()
3409 def on_itemShowToolbar_activate(self, widget):
3410 self.config.show_toolbar = self.itemShowToolbar.get_active()
3412 def on_itemShowDescription_activate(self, widget):
3413 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
3415 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
3416 self.config.podcast_list_hide_boring = toggleaction.get_active()
3417 if self.config.podcast_list_hide_boring:
3418 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3419 else:
3420 self.podcast_list_model.set_view_mode(-1)
3422 def on_item_view_podcasts_changed(self, radioaction, current):
3423 # Only on Fremantle
3424 if current == self.item_view_podcasts_all:
3425 self.podcast_list_model.set_view_mode(-1)
3426 elif current == self.item_view_podcasts_downloaded:
3427 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3428 elif current == self.item_view_podcasts_unplayed:
3429 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3431 self.config.podcast_list_view_mode = self.podcast_list_model.get_view_mode()
3433 def on_item_view_episodes_changed(self, radioaction, current):
3434 if current == self.item_view_episodes_all:
3435 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_ALL)
3436 elif current == self.item_view_episodes_undeleted:
3437 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNDELETED)
3438 elif current == self.item_view_episodes_downloaded:
3439 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3440 elif current == self.item_view_episodes_unplayed:
3441 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3443 self.config.episode_list_view_mode = self.episode_list_model.get_view_mode()
3445 if self.config.podcast_list_hide_boring and not gpodder.ui.fremantle:
3446 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3448 def update_item_device( self):
3449 if not gpodder.ui.fremantle:
3450 if self.config.device_type != 'none':
3451 self.itemDevice.set_visible(True)
3452 self.itemDevice.label = self.get_device_name()
3453 else:
3454 self.itemDevice.set_visible(False)
3456 def properties_closed( self):
3457 self.preferences_dialog = None
3458 self.show_hide_tray_icon()
3459 self.update_item_device()
3460 if gpodder.ui.maemo:
3461 selection = self.treeAvailable.get_selection()
3462 if self.config.maemo_enable_gestures or \
3463 self.config.enable_fingerscroll:
3464 selection.set_mode(gtk.SELECTION_SINGLE)
3465 else:
3466 selection.set_mode(gtk.SELECTION_MULTIPLE)
3468 def on_itemPreferences_activate(self, widget, *args):
3469 self.preferences_dialog = gPodderPreferences(self.main_window, \
3470 _config=self.config, \
3471 callback_finished=self.properties_closed, \
3472 user_apps_reader=self.user_apps_reader, \
3473 parent_window=self.main_window, \
3474 mygpo_client=self.mygpo_client, \
3475 on_send_full_subscriptions=self.on_send_full_subscriptions)
3477 # Initial message to relayout window (in case it's opened in portrait mode
3478 self.preferences_dialog.on_window_orientation_changed(self._last_orientation)
3480 def on_itemDependencies_activate(self, widget):
3481 gPodderDependencyManager(self.gPodder)
3483 def on_goto_mygpo(self, widget):
3484 self.mygpo_client.open_website()
3486 def on_download_subscriptions_from_mygpo(self, action=None):
3487 title = _('Login to gpodder.net')
3488 message = _('Please login to download your subscriptions.')
3489 success, (username, password) = self.show_login_dialog(title, message, \
3490 self.config.mygpo_username, self.config.mygpo_password)
3491 if not success:
3492 return
3494 self.config.mygpo_username = username
3495 self.config.mygpo_password = password
3497 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3498 custom_title=_('Subscriptions on gpodder.net'), \
3499 add_urls_callback=self.add_podcast_list, \
3500 hide_url_entry=True)
3502 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
3503 # we do not have to hardcode the URL here
3504 OPML_URL = 'http://gpodder.net/subscriptions/%s.opml' % self.config.mygpo_username
3505 url = util.url_add_authentication(OPML_URL, \
3506 self.config.mygpo_username, \
3507 self.config.mygpo_password)
3508 dir.download_opml_file(url)
3510 def on_mygpo_settings_activate(self, action=None):
3511 # This dialog is only used for Maemo 4
3512 if not gpodder.ui.diablo:
3513 return
3515 settings = MygPodderSettings(self.main_window, \
3516 config=self.config, \
3517 mygpo_client=self.mygpo_client, \
3518 on_send_full_subscriptions=self.on_send_full_subscriptions)
3520 def on_itemAddChannel_activate(self, widget=None):
3521 gPodderAddPodcast(self.gPodder, \
3522 add_urls_callback=self.add_podcast_list)
3524 def on_itemEditChannel_activate(self, widget, *args):
3525 if self.active_channel is None:
3526 title = _('No podcast selected')
3527 message = _('Please select a podcast in the podcasts list to edit.')
3528 self.show_message( message, title, widget=self.treeChannels)
3529 return
3531 callback_closed = lambda: self.update_podcast_list_model(selected=True)
3532 gPodderChannel(self.main_window, \
3533 channel=self.active_channel, \
3534 callback_closed=callback_closed, \
3535 cover_downloader=self.cover_downloader)
3537 def on_itemMassUnsubscribe_activate(self, item=None):
3538 columns = (
3539 ('title', None, None, _('Podcast')),
3542 # We're abusing the Episode Selector for selecting Podcasts here,
3543 # but it works and looks good, so why not? -- thp
3544 gPodderEpisodeSelector(self.main_window, \
3545 title=_('Remove podcasts'), \
3546 instructions=_('Select the podcast you want to remove.'), \
3547 episodes=self.channels, \
3548 columns=columns, \
3549 size_attribute=None, \
3550 stock_ok_button=_('Remove'), \
3551 callback=self.remove_podcast_list, \
3552 _config=self.config)
3554 def remove_podcast_list(self, channels, confirm=True):
3555 if not channels:
3556 log('No podcasts selected for deletion', sender=self)
3557 return
3559 if len(channels) == 1:
3560 title = _('Removing podcast')
3561 info = _('Please wait while the podcast is removed')
3562 message = _('Do you really want to remove this podcast and its episodes?')
3563 else:
3564 title = _('Removing podcasts')
3565 info = _('Please wait while the podcasts are removed')
3566 message = _('Do you really want to remove the selected podcasts and their episodes?')
3568 if confirm and not self.show_confirmation(message, title):
3569 return
3571 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3573 def finish_deletion(select_url):
3574 # Upload subscription list changes to the web service
3575 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3577 # Re-load the channels and select the desired new channel
3578 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
3579 progress.on_finished()
3580 self.update_podcasts_tab()
3582 def thread_proc():
3583 select_url = None
3585 for idx, channel in enumerate(channels):
3586 # Update the UI for correct status messages
3587 progress.on_progress(float(idx)/float(len(channels)))
3588 progress.on_message(channel.title)
3590 # Delete downloaded episodes
3591 channel.remove_downloaded()
3593 # cancel any active downloads from this channel
3594 for episode in channel.get_all_episodes():
3595 util.idle_add(self.download_status_model.cancel_by_url,
3596 episode.url)
3598 if len(channels) == 1:
3599 # get the URL of the podcast we want to select next
3600 if channel in self.channels:
3601 position = self.channels.index(channel)
3602 else:
3603 position = -1
3605 if position == len(self.channels)-1:
3606 # this is the last podcast, so select the URL
3607 # of the item before this one (i.e. the "new last")
3608 select_url = self.channels[position-1].url
3609 else:
3610 # there is a podcast after the deleted one, so
3611 # we simply select the one that comes after it
3612 select_url = self.channels[position+1].url
3614 # Remove the channel and clean the database entries
3615 channel.delete()
3616 self.channels.remove(channel)
3618 # Clean up downloads and download directories
3619 self.clean_up_downloads()
3621 self.channel_list_changed = True
3622 self.save_channels_opml()
3624 # The remaining stuff is to be done in the GTK main thread
3625 util.idle_add(finish_deletion, select_url)
3627 threading.Thread(target=thread_proc).start()
3629 def on_itemRemoveChannel_activate(self, widget, *args):
3630 if self.active_channel is None:
3631 title = _('No podcast selected')
3632 message = _('Please select a podcast in the podcasts list to remove.')
3633 self.show_message( message, title, widget=self.treeChannels)
3634 return
3636 self.remove_podcast_list([self.active_channel])
3638 def get_opml_filter(self):
3639 filter = gtk.FileFilter()
3640 filter.add_pattern('*.opml')
3641 filter.add_pattern('*.xml')
3642 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
3643 return filter
3645 def on_item_import_from_file_activate(self, widget, filename=None):
3646 if filename is None:
3647 if gpodder.ui.desktop or gpodder.ui.fremantle:
3648 # FIXME: Hildonization on Fremantle
3649 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
3650 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3651 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3652 elif gpodder.ui.diablo:
3653 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
3654 dlg.set_filter(self.get_opml_filter())
3655 response = dlg.run()
3656 filename = None
3657 if response == gtk.RESPONSE_OK:
3658 filename = dlg.get_filename()
3659 dlg.destroy()
3661 if filename is not None:
3662 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3663 custom_title=_('Import podcasts from OPML file'), \
3664 add_urls_callback=self.add_podcast_list, \
3665 hide_url_entry=True)
3666 dir.download_opml_file(filename)
3668 def on_itemExportChannels_activate(self, widget, *args):
3669 if not self.channels:
3670 title = _('Nothing to export')
3671 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3672 self.show_message(message, title, widget=self.treeChannels)
3673 return
3675 if gpodder.ui.desktop or gpodder.ui.fremantle:
3676 # FIXME: Hildonization on Fremantle
3677 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3678 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3679 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3680 elif gpodder.ui.diablo:
3681 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
3682 dlg.set_filter(self.get_opml_filter())
3683 response = dlg.run()
3684 if response == gtk.RESPONSE_OK:
3685 filename = dlg.get_filename()
3686 dlg.destroy()
3687 exporter = opml.Exporter( filename)
3688 if exporter.write(self.channels):
3689 count = len(self.channels)
3690 title = N_('%d subscription exported', '%d subscriptions exported', count) % count
3691 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3692 else:
3693 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3694 else:
3695 dlg.destroy()
3697 def on_itemImportChannels_activate(self, widget, *args):
3698 if gpodder.ui.fremantle:
3699 gPodderPodcastDirectory.show_add_podcast_picker(self.main_window, \
3700 self.config.toplist_url, \
3701 self.config.opml_url, \
3702 self.add_podcast_list, \
3703 self.on_itemAddChannel_activate, \
3704 self.on_download_subscriptions_from_mygpo, \
3705 self.show_text_edit_dialog)
3706 else:
3707 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3708 add_urls_callback=self.add_podcast_list)
3709 util.idle_add(dir.download_opml_file, self.config.opml_url)
3711 def on_homepage_activate(self, widget, *args):
3712 util.open_website(gpodder.__url__)
3714 def on_wiki_activate(self, widget, *args):
3715 util.open_website('http://gpodder.org/wiki/User_Manual')
3717 def on_bug_tracker_activate(self, widget, *args):
3718 if gpodder.ui.maemo:
3719 util.open_website('http://bugs.maemo.org/enter_bug.cgi?product=gPodder')
3720 else:
3721 util.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder')
3723 def on_item_support_activate(self, widget):
3724 util.open_website('http://gpodder.org/donate')
3726 def on_itemAbout_activate(self, widget, *args):
3727 if gpodder.ui.fremantle:
3728 from gpodder.gtkui.frmntl.about import HeAboutDialog
3729 HeAboutDialog.present(self.main_window,
3730 'gPodder',
3731 'gpodder',
3732 gpodder.__version__,
3733 _('A podcast client with focus on usability'),
3734 gpodder.__copyright__,
3735 gpodder.__url__,
3736 'http://bugs.maemo.org/enter_bug.cgi?product=gPodder',
3737 'http://gpodder.org/donate')
3738 return
3740 dlg = gtk.AboutDialog()
3741 dlg.set_transient_for(self.main_window)
3742 dlg.set_name('gPodder')
3743 dlg.set_version(gpodder.__version__)
3744 dlg.set_copyright(gpodder.__copyright__)
3745 dlg.set_comments(_('A podcast client with focus on usability'))
3746 dlg.set_website(gpodder.__url__)
3747 dlg.set_translator_credits( _('translator-credits'))
3748 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
3750 if gpodder.ui.desktop:
3751 # For the "GUI" version, we add some more
3752 # items to the about dialog (credits and logo)
3753 app_authors = [
3754 _('Maintainer:'),
3755 'Thomas Perl <thpinfo.com>',
3758 if os.path.exists(gpodder.credits_file):
3759 credits = open(gpodder.credits_file).read().strip().split('\n')
3760 app_authors += ['', _('Patches, bug reports and donations by:')]
3761 app_authors += credits
3763 dlg.set_authors(app_authors)
3764 try:
3765 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(gpodder.icon_file))
3766 except:
3767 dlg.set_logo_icon_name('gpodder')
3769 dlg.run()
3771 def on_wNotebook_switch_page(self, widget, *args):
3772 page_num = args[1]
3773 if gpodder.ui.maemo:
3774 self.tool_downloads.set_active(page_num == 1)
3775 page = self.wNotebook.get_nth_page(page_num)
3776 tab_label = self.wNotebook.get_tab_label(page).get_text()
3777 if page_num == 0 and self.active_channel is not None:
3778 self.set_title(self.active_channel.title)
3779 else:
3780 self.set_title(tab_label)
3781 if page_num == 0:
3782 self.play_or_download()
3783 self.menuChannels.set_sensitive(True)
3784 self.menuSubscriptions.set_sensitive(True)
3785 # The message area in the downloads tab should be hidden
3786 # when the user switches away from the downloads tab
3787 if self.message_area is not None:
3788 self.message_area.hide()
3789 self.message_area = None
3790 else:
3791 self.menuChannels.set_sensitive(False)
3792 self.menuSubscriptions.set_sensitive(False)
3793 if gpodder.ui.desktop:
3794 self.toolDownload.set_sensitive(False)
3795 self.toolPlay.set_sensitive(False)
3796 self.toolTransfer.set_sensitive(False)
3797 self.toolCancel.set_sensitive(False)
3799 def on_treeChannels_row_activated(self, widget, path, *args):
3800 # double-click action of the podcast list or enter
3801 self.treeChannels.set_cursor(path)
3803 def on_treeChannels_cursor_changed(self, widget, *args):
3804 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3806 if model is not None and iter is not None:
3807 old_active_channel = self.active_channel
3808 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3810 if self.active_channel == old_active_channel:
3811 return
3813 if gpodder.ui.maemo:
3814 self.set_title(self.active_channel.title)
3816 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3817 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3818 self.itemEditChannel.set_visible(False)
3819 self.itemRemoveChannel.set_visible(False)
3820 else:
3821 self.itemEditChannel.set_visible(True)
3822 self.itemRemoveChannel.set_visible(True)
3823 else:
3824 self.active_channel = None
3825 self.itemEditChannel.set_visible(False)
3826 self.itemRemoveChannel.set_visible(False)
3828 self.update_episode_list_model()
3830 def on_btnEditChannel_clicked(self, widget, *args):
3831 self.on_itemEditChannel_activate( widget, args)
3833 def get_podcast_urls_from_selected_episodes(self):
3834 """Get a set of podcast URLs based on the selected episodes"""
3835 return set(episode.channel.url for episode in \
3836 self.get_selected_episodes())
3838 def get_selected_episodes(self):
3839 """Get a list of selected episodes from treeAvailable"""
3840 selection = self.treeAvailable.get_selection()
3841 model, paths = selection.get_selected_rows()
3843 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3844 return episodes
3846 def on_transfer_selected_episodes(self, widget):
3847 self.on_sync_to_ipod_activate(widget, self.get_selected_episodes())
3849 def on_playback_selected_episodes(self, widget):
3850 self.playback_episodes(self.get_selected_episodes())
3852 def on_shownotes_selected_episodes(self, widget):
3853 episodes = self.get_selected_episodes()
3854 if episodes:
3855 episode = episodes.pop(0)
3856 self.show_episode_shownotes(episode)
3857 else:
3858 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3860 def on_download_selected_episodes(self, widget):
3861 episodes = self.get_selected_episodes()
3862 self.download_episode_list(episodes)
3863 self.update_episode_list_icons([episode.url for episode in episodes])
3864 self.play_or_download()
3866 def on_treeAvailable_row_activated(self, widget, path, view_column):
3867 """Double-click/enter action handler for treeAvailable"""
3868 # We should only have one one selected as it was double clicked!
3869 e = self.get_selected_episodes()[0]
3871 if (self.config.double_click_episode_action == 'download'):
3872 # If the episode has already been downloaded and exists then play it
3873 if e.was_downloaded(and_exists=True):
3874 self.playback_episodes(self.get_selected_episodes())
3875 # else download it if it is not already downloading
3876 elif not self.episode_is_downloading(e):
3877 self.download_episode_list([e])
3878 self.update_episode_list_icons([e.url])
3879 self.play_or_download()
3880 elif (self.config.double_click_episode_action == 'stream'):
3881 # If we happen to have downloaded this episode simple play it
3882 if e.was_downloaded(and_exists=True):
3883 self.playback_episodes(self.get_selected_episodes())
3884 # else if streaming is possible stream it
3885 elif self.streaming_possible():
3886 self.playback_episodes(self.get_selected_episodes())
3887 else:
3888 log('Unable to stream episode - default media player selected!', sender=self, traceback=True)
3889 self.show_message(_('Please check your media player settings in the preferences dialog.'), _('Unable to stream episode'), widget=self.toolPreferences)
3890 else:
3891 # default action is to display show notes
3892 self.on_shownotes_selected_episodes(widget)
3894 def show_episode_shownotes(self, episode):
3895 if self.episode_shownotes_window is None:
3896 log('First-time use of episode window --- creating', sender=self)
3897 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3898 _download_episode_list=self.download_episode_list, \
3899 _playback_episodes=self.playback_episodes, \
3900 _delete_episode_list=self.delete_episode_list, \
3901 _episode_list_status_changed=self.episode_list_status_changed, \
3902 _cancel_task_list=self.cancel_task_list, \
3903 _episode_is_downloading=self.episode_is_downloading, \
3904 _streaming_possible=self.streaming_possible())
3905 self.episode_shownotes_window.show(episode)
3906 if self.episode_is_downloading(episode):
3907 self.update_downloads_list()
3909 def restart_auto_update_timer(self):
3910 if self._auto_update_timer_source_id is not None:
3911 log('Removing existing auto update timer.', sender=self)
3912 gobject.source_remove(self._auto_update_timer_source_id)
3913 self._auto_update_timer_source_id = None
3915 if self.config.auto_update_feeds and \
3916 self.config.auto_update_frequency:
3917 interval = 60*1000*self.config.auto_update_frequency
3918 log('Setting up auto update timer with interval %d.', \
3919 self.config.auto_update_frequency, sender=self)
3920 self._auto_update_timer_source_id = gobject.timeout_add(\
3921 interval, self._on_auto_update_timer)
3923 def _on_auto_update_timer(self):
3924 log('Auto update timer fired.', sender=self)
3925 self.update_feed_cache(force_update=True)
3927 # Ask web service for sub changes (if enabled)
3928 self.mygpo_client.flush()
3930 return True
3932 def on_treeDownloads_row_activated(self, widget, *args):
3933 # Use the standard way of working on the treeview
3934 selection = self.treeDownloads.get_selection()
3935 (model, paths) = selection.get_selected_rows()
3936 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3938 for tree_row_reference, task in selected_tasks:
3939 if task.status in (task.DOWNLOADING, task.QUEUED):
3940 task.status = task.PAUSED
3941 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3942 self.download_queue_manager.add_task(task)
3943 self.enable_download_list_update()
3944 elif task.status == task.DONE:
3945 model.remove(model.get_iter(tree_row_reference.get_path()))
3947 self.play_or_download()
3949 # Update the tab title and downloads list
3950 self.update_downloads_list()
3952 def on_item_cancel_download_activate(self, widget):
3953 if self.wNotebook.get_current_page() == 0:
3954 selection = self.treeAvailable.get_selection()
3955 (model, paths) = selection.get_selected_rows()
3956 urls = [model.get_value(model.get_iter(path), \
3957 self.episode_list_model.C_URL) for path in paths]
3958 selected_tasks = [task for task in self.download_tasks_seen \
3959 if task.url in urls]
3960 else:
3961 selection = self.treeDownloads.get_selection()
3962 (model, paths) = selection.get_selected_rows()
3963 selected_tasks = [model.get_value(model.get_iter(path), \
3964 self.download_status_model.C_TASK) for path in paths]
3965 self.cancel_task_list(selected_tasks)
3967 def on_btnCancelAll_clicked(self, widget, *args):
3968 self.cancel_task_list(self.download_tasks_seen)
3970 def on_btnDownloadedDelete_clicked(self, widget, *args):
3971 episodes = self.get_selected_episodes()
3972 if len(episodes) == 1:
3973 self.delete_episode_list(episodes, skip_locked=False)
3974 else:
3975 self.delete_episode_list(episodes)
3977 def on_key_press(self, widget, event):
3978 # Allow tab switching with Ctrl + PgUp/PgDown
3979 if event.state & gtk.gdk.CONTROL_MASK:
3980 if event.keyval == gtk.keysyms.Page_Up:
3981 self.wNotebook.prev_page()
3982 return True
3983 elif event.keyval == gtk.keysyms.Page_Down:
3984 self.wNotebook.next_page()
3985 return True
3987 # After this code we only handle Maemo hardware keys,
3988 # so if we are not a Maemo app, we don't do anything
3989 if not gpodder.ui.maemo:
3990 return False
3992 diff = 0
3993 if event.keyval == gtk.keysyms.F7: #plus
3994 diff = 1
3995 elif event.keyval == gtk.keysyms.F8: #minus
3996 diff = -1
3998 if diff != 0 and not self.currently_updating:
3999 selection = self.treeChannels.get_selection()
4000 (model, iter) = selection.get_selected()
4001 new_path = ((model.get_path(iter)[0]+diff)%len(model),)
4002 selection.select_path(new_path)
4003 self.treeChannels.set_cursor(new_path)
4004 return True
4006 return False
4008 def on_iconify(self):
4009 if self.tray_icon:
4010 self.gPodder.set_skip_taskbar_hint(True)
4011 if self.config.minimize_to_tray:
4012 self.tray_icon.set_visible(True)
4013 else:
4014 self.gPodder.set_skip_taskbar_hint(False)
4016 def on_uniconify(self):
4017 if self.tray_icon:
4018 self.gPodder.set_skip_taskbar_hint(False)
4019 if self.config.minimize_to_tray:
4020 self.tray_icon.set_visible(False)
4021 else:
4022 self.gPodder.set_skip_taskbar_hint(False)
4024 def uniconify_main_window(self):
4025 if self.is_iconified():
4026 # We need to hide and then show the window in WMs like Metacity
4027 # or KWin4 to move the window to the active workspace
4028 # (see http://gpodder.org/bug/1125)
4029 self.gPodder.hide()
4030 self.gPodder.show()
4031 self.gPodder.present()
4033 def iconify_main_window(self):
4034 if not self.is_iconified():
4035 self.gPodder.iconify()
4037 def update_podcasts_tab(self):
4038 if len(self.channels):
4039 if gpodder.ui.fremantle:
4040 self.button_refresh.set_title(_('Check for new episodes'))
4041 self.button_refresh.show()
4042 else:
4043 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
4044 else:
4045 if gpodder.ui.fremantle:
4046 self.button_refresh.hide()
4047 else:
4048 self.label2.set_text(_('Podcasts'))
4050 @dbus.service.method(gpodder.dbus_interface)
4051 def show_gui_window(self):
4052 parent = self.get_dialog_parent()
4053 parent.present()
4055 @dbus.service.method(gpodder.dbus_interface)
4056 def subscribe_to_url(self, url):
4057 gPodderAddPodcast(self.gPodder,
4058 add_urls_callback=self.add_podcast_list,
4059 preset_url=url)
4061 @dbus.service.method(gpodder.dbus_interface)
4062 def mark_episode_played(self, filename):
4063 if filename is None:
4064 return False
4066 for channel in self.channels:
4067 for episode in channel.get_all_episodes():
4068 fn = episode.local_filename(create=False, check_only=True)
4069 if fn == filename:
4070 episode.mark(is_played=True)
4071 self.db.commit()
4072 self.update_episode_list_icons([episode.url])
4073 self.update_podcast_list_model([episode.channel.url])
4074 return True
4076 return False
4079 def main(options=None):
4080 gobject.threads_init()
4081 gobject.set_application_name('gPodder')
4083 if gpodder.ui.maemo:
4084 # Try to enable the custom icon theme for gPodder on Maemo
4085 settings = gtk.settings_get_default()
4086 settings.set_string_property('gtk-icon-theme-name', \
4087 'gpodder', __file__)
4088 # Extend the search path for the optified icon theme (Maemo 5)
4089 icon_theme = gtk.icon_theme_get_default()
4090 icon_theme.prepend_search_path('/opt/gpodder-icon-theme/')
4092 gtk.window_set_default_icon_name('gpodder')
4093 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
4095 try:
4096 dbus_main_loop = dbus.glib.DBusGMainLoop(set_as_default=True)
4097 gpodder.dbus_session_bus = dbus.SessionBus(dbus_main_loop)
4099 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=gpodder.dbus_session_bus)
4100 except dbus.exceptions.DBusException, dbe:
4101 log('Warning: Cannot get "on the bus".', traceback=True)
4102 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
4103 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
4104 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
4105 dlg.set_title('gPodder')
4106 dlg.run()
4107 dlg.destroy()
4108 sys.exit(0)
4110 util.make_directory(gpodder.home)
4111 gpodder.load_plugins()
4113 config = UIConfig(gpodder.config_file)
4115 # Load hook modules and install the hook manager globally
4116 # if modules have been found an instantiated by the manager
4117 user_hooks = hooks.HookManager()
4118 if user_hooks.has_modules():
4119 gpodder.user_hooks = user_hooks
4121 if gpodder.ui.diablo:
4122 # Detect changing of SD cards between mmc1/mmc2 if a gpodder
4123 # folder exists there (allow moving "gpodder" between SD cards or USB)
4124 # Also allow moving "gpodder" to home folder (e.g. rootfs on SD)
4125 if not os.path.exists(config.download_dir):
4126 log('Downloads might have been moved. Trying to locate them...')
4127 for basedir in ['/media/mmc1', '/media/mmc2']+glob.glob('/media/usb/*')+['/home/user/MyDocs']:
4128 dir = os.path.join(basedir, 'gpodder')
4129 if os.path.exists(dir):
4130 log('Downloads found in: %s', dir)
4131 config.download_dir = dir
4132 break
4133 else:
4134 log('Downloads NOT FOUND in %s', dir)
4136 if config.enable_fingerscroll:
4137 BuilderWidget.use_fingerscroll = True
4138 elif gpodder.ui.fremantle:
4139 config.on_quit_ask = False
4141 config.mygpo_device_type = util.detect_device_type()
4143 gp = gPodder(bus_name, config)
4145 # Handle options
4146 if options.subscribe:
4147 util.idle_add(gp.subscribe_to_url, options.subscribe)
4149 # mac OS X stuff :
4150 # handle "subscribe to podcast" events from firefox
4151 if platform.system() == 'Darwin':
4152 from gpodder import gpodderosx
4153 gpodderosx.register_handlers(gp)
4154 # end mac OS X stuff
4156 gp.run()