Maemo 5: Avoid notification when in foreground
[gpodder.git] / src / gpodder / gui.py
blob0ec33b937aefb9923aef2f722c15b9be0db3098c
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 def on_treeview_podcasts_selection_changed(self, selection):
807 model, iter = selection.get_selected()
808 if iter is None:
809 self.active_channel = None
810 self.episode_list_model.clear()
812 def on_treeview_button_pressed(self, treeview, event):
813 if event.window != treeview.get_bin_window():
814 return False
816 TreeViewHelper.save_button_press_event(treeview, event)
818 if getattr(treeview, TreeViewHelper.ROLE) == \
819 TreeViewHelper.ROLE_PODCASTS:
820 return self.currently_updating
822 return event.button == self.context_menu_mouse_button and \
823 gpodder.ui.desktop
825 def on_treeview_podcasts_button_released(self, treeview, event):
826 if event.window != treeview.get_bin_window():
827 return False
829 if gpodder.ui.maemo:
830 return self.treeview_channels_handle_gestures(treeview, event)
831 return self.treeview_channels_show_context_menu(treeview, event)
833 def on_treeview_episodes_button_released(self, treeview, event):
834 if event.window != treeview.get_bin_window():
835 return False
837 if gpodder.ui.maemo:
838 if self.config.enable_fingerscroll or self.config.maemo_enable_gestures:
839 return self.treeview_available_handle_gestures(treeview, event)
841 return self.treeview_available_show_context_menu(treeview, event)
843 def on_treeview_downloads_button_released(self, treeview, event):
844 if event.window != treeview.get_bin_window():
845 return False
847 return self.treeview_downloads_show_context_menu(treeview, event)
849 def on_entry_search_podcasts_changed(self, editable):
850 if self.hbox_search_podcasts.get_property('visible'):
851 self.podcast_list_model.set_search_term(editable.get_chars(0, -1))
853 def on_entry_search_podcasts_key_press(self, editable, event):
854 if event.keyval == gtk.keysyms.Escape:
855 self.hide_podcast_search()
856 return True
858 def hide_podcast_search(self, *args):
859 self.hbox_search_podcasts.hide()
860 self.entry_search_podcasts.set_text('')
861 self.podcast_list_model.set_search_term(None)
862 self.treeChannels.grab_focus()
864 def show_podcast_search(self, input_char):
865 self.hbox_search_podcasts.show()
866 self.entry_search_podcasts.insert_text(input_char, -1)
867 self.entry_search_podcasts.grab_focus()
868 self.entry_search_podcasts.set_position(-1)
870 def init_podcast_list_treeview(self):
871 # Set up podcast channel tree view widget
872 if gpodder.ui.fremantle:
873 if self.config.podcast_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
874 self.item_view_podcasts_downloaded.set_active(True)
875 elif self.config.podcast_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
876 self.item_view_podcasts_unplayed.set_active(True)
877 else:
878 self.item_view_podcasts_all.set_active(True)
879 self.podcast_list_model.set_view_mode(self.config.podcast_list_view_mode)
881 iconcolumn = gtk.TreeViewColumn('')
882 iconcell = gtk.CellRendererPixbuf()
883 iconcolumn.pack_start(iconcell, False)
884 iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
885 self.treeChannels.append_column(iconcolumn)
887 namecolumn = gtk.TreeViewColumn('')
888 namecell = gtk.CellRendererText()
889 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
890 namecolumn.pack_start(namecell, True)
891 namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
893 if gpodder.ui.fremantle:
894 countcell = gtk.CellRendererText()
895 from gpodder.gtkui.frmntl import style
896 countcell.set_property('font-desc', style.get_font_desc('EmpSystemFont'))
897 countcell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
898 countcell.set_property('alignment', pango.ALIGN_RIGHT)
899 countcell.set_property('xalign', 1.)
900 countcell.set_property('xpad', 5)
901 namecolumn.pack_start(countcell, False)
902 namecolumn.add_attribute(countcell, 'text', PodcastListModel.C_DOWNLOADS)
903 namecolumn.add_attribute(countcell, 'visible', PodcastListModel.C_DOWNLOADS)
904 else:
905 iconcell = gtk.CellRendererPixbuf()
906 iconcell.set_property('xalign', 1.0)
907 namecolumn.pack_start(iconcell, False)
908 namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
909 namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
911 self.treeChannels.append_column(namecolumn)
913 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
915 # When no podcast is selected, clear the episode list model
916 selection = self.treeChannels.get_selection()
917 selection.connect('changed', self.on_treeview_podcasts_selection_changed)
919 # Set up type-ahead find for the podcast list
920 def on_key_press(treeview, event):
921 if event.keyval == gtk.keysyms.Escape:
922 self.hide_podcast_search()
923 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
924 self.hide_podcast_search()
925 elif event.state & gtk.gdk.CONTROL_MASK:
926 # Don't handle type-ahead when control is pressed (so shortcuts
927 # with the Ctrl key still work, e.g. Ctrl+A, ...)
928 return True
929 else:
930 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
931 if unicode_char_id == 0:
932 return False
933 input_char = unichr(unicode_char_id)
934 self.show_podcast_search(input_char)
935 return True
936 self.treeChannels.connect('key-press-event', on_key_press)
938 # Enable separators to the podcast list to separate special podcasts
939 # from others (this is used for the "all episodes" view)
940 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
942 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
944 def on_entry_search_episodes_changed(self, editable):
945 if self.hbox_search_episodes.get_property('visible'):
946 self.episode_list_model.set_search_term(editable.get_chars(0, -1))
948 def on_entry_search_episodes_key_press(self, editable, event):
949 if event.keyval == gtk.keysyms.Escape:
950 self.hide_episode_search()
951 return True
953 def hide_episode_search(self, *args):
954 self.hbox_search_episodes.hide()
955 self.entry_search_episodes.set_text('')
956 self.episode_list_model.set_search_term(None)
957 self.treeAvailable.grab_focus()
959 def show_episode_search(self, input_char):
960 self.hbox_search_episodes.show()
961 self.entry_search_episodes.insert_text(input_char, -1)
962 self.entry_search_episodes.grab_focus()
963 self.entry_search_episodes.set_position(-1)
965 def init_episode_list_treeview(self):
966 # For loading the list model
967 self.empty_episode_list_model = EpisodeListModel()
968 self.episode_list_model = EpisodeListModel()
970 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
971 self.item_view_episodes_undeleted.set_active(True)
972 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
973 self.item_view_episodes_downloaded.set_active(True)
974 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
975 self.item_view_episodes_unplayed.set_active(True)
976 else:
977 self.item_view_episodes_all.set_active(True)
979 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
981 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
983 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
985 iconcell = gtk.CellRendererPixbuf()
986 if gpodder.ui.maemo:
987 iconcell.set_fixed_size(50, 50)
988 status_column_label = ''
989 else:
990 status_column_label = _('Status')
991 iconcolumn = gtk.TreeViewColumn(status_column_label, iconcell, pixbuf=EpisodeListModel.C_STATUS_ICON)
993 namecell = gtk.CellRendererText()
994 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
995 namecolumn = gtk.TreeViewColumn(_('Episode'))
996 namecolumn.pack_start(namecell, True)
997 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
998 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
999 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1000 namecolumn.set_resizable(True)
1001 namecolumn.set_expand(True)
1003 if gpodder.ui.fremantle:
1004 from gpodder.gtkui.frmntl import style
1005 timecell = gtk.CellRendererText()
1006 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1007 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1008 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1009 timecell.set_property('xalign', 1.)
1010 timecell.set_property('xpad', 5)
1011 namecolumn.pack_start(timecell, False)
1012 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1013 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME1_VISIBLE)
1015 # Add another cell renderer to fix a sizing issue (one renderer
1016 # only renders short text and the other one longer text to avoid
1017 # having titles of episodes unnecessarily cut off)
1018 timecell = gtk.CellRendererText()
1019 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1020 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1021 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1022 timecell.set_property('xalign', 1.)
1023 timecell.set_property('xpad', 5)
1024 namecolumn.pack_start(timecell, False)
1025 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1026 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME2_VISIBLE)
1028 sizecell = gtk.CellRendererText()
1029 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
1030 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
1032 releasecell = gtk.CellRendererText()
1033 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
1034 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
1036 for itemcolumn in (iconcolumn, namecolumn, sizecolumn, releasecolumn):
1037 itemcolumn.set_reorderable(True)
1038 self.treeAvailable.append_column(itemcolumn)
1040 if gpodder.ui.maemo:
1041 sizecolumn.set_visible(False)
1042 releasecolumn.set_visible(False)
1044 # Set up type-ahead find for the episode list
1045 def on_key_press(treeview, event):
1046 if event.keyval == gtk.keysyms.Escape:
1047 self.hide_episode_search()
1048 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
1049 self.hide_episode_search()
1050 elif event.state & gtk.gdk.CONTROL_MASK:
1051 # Don't handle type-ahead when control is pressed (so shortcuts
1052 # with the Ctrl key still work, e.g. Ctrl+A, ...)
1053 return False
1054 else:
1055 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
1056 if unicode_char_id == 0:
1057 return False
1058 input_char = unichr(unicode_char_id)
1059 self.show_episode_search(input_char)
1060 return True
1061 self.treeAvailable.connect('key-press-event', on_key_press)
1063 if gpodder.ui.desktop:
1064 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
1065 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
1066 def drag_data_get(tree, context, selection_data, info, timestamp):
1067 if self.config.on_drag_mark_played:
1068 for episode in self.get_selected_episodes():
1069 episode.mark(is_played=True)
1070 self.on_selected_episodes_status_changed()
1071 uris = ['file://'+e.local_filename(create=False) \
1072 for e in self.get_selected_episodes() \
1073 if e.was_downloaded(and_exists=True)]
1074 uris.append('') # for the trailing '\r\n'
1075 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
1076 self.treeAvailable.connect('drag-data-get', drag_data_get)
1078 selection = self.treeAvailable.get_selection()
1079 if gpodder.ui.diablo:
1080 if self.config.maemo_enable_gestures or self.config.enable_fingerscroll:
1081 selection.set_mode(gtk.SELECTION_SINGLE)
1082 else:
1083 selection.set_mode(gtk.SELECTION_MULTIPLE)
1084 elif gpodder.ui.fremantle:
1085 selection.set_mode(gtk.SELECTION_SINGLE)
1086 else:
1087 selection.set_mode(gtk.SELECTION_MULTIPLE)
1088 # Update the sensitivity of the toolbar buttons on the Desktop
1089 selection.connect('changed', lambda s: self.play_or_download())
1091 if gpodder.ui.diablo:
1092 # Set up the tap-and-hold context menu for podcasts
1093 menu = gtk.Menu()
1094 menu.append(self.itemUpdateChannel.create_menu_item())
1095 menu.append(self.itemEditChannel.create_menu_item())
1096 menu.append(gtk.SeparatorMenuItem())
1097 menu.append(self.itemRemoveChannel.create_menu_item())
1098 menu.append(gtk.SeparatorMenuItem())
1099 item = gtk.ImageMenuItem(_('Close this menu'))
1100 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, \
1101 gtk.ICON_SIZE_MENU))
1102 menu.append(item)
1103 menu.show_all()
1104 menu = self.set_finger_friendly(menu)
1105 self.treeChannels.tap_and_hold_setup(menu)
1108 def init_download_list_treeview(self):
1109 # enable multiple selection support
1110 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
1111 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
1113 # columns and renderers for "download progress" tab
1114 # First column: [ICON] Episodename
1115 column = gtk.TreeViewColumn(_('Episode'))
1117 cell = gtk.CellRendererPixbuf()
1118 if gpodder.ui.maemo:
1119 cell.set_fixed_size(50, 50)
1120 cell.set_property('stock-size', gtk.ICON_SIZE_MENU)
1121 column.pack_start(cell, expand=False)
1122 column.add_attribute(cell, 'stock-id', \
1123 DownloadStatusModel.C_ICON_NAME)
1125 cell = gtk.CellRendererText()
1126 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
1127 column.pack_start(cell, expand=True)
1128 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
1129 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1130 column.set_expand(True)
1131 self.treeDownloads.append_column(column)
1133 # Second column: Progress
1134 cell = gtk.CellRendererProgress()
1135 cell.set_property('yalign', .5)
1136 cell.set_property('ypad', 6)
1137 column = gtk.TreeViewColumn(_('Progress'), cell,
1138 value=DownloadStatusModel.C_PROGRESS, \
1139 text=DownloadStatusModel.C_PROGRESS_TEXT)
1140 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1141 column.set_expand(False)
1142 self.treeDownloads.append_column(column)
1143 column.set_property('min-width', 150)
1144 column.set_property('max-width', 150)
1146 self.treeDownloads.set_model(self.download_status_model)
1147 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
1149 def on_treeview_expose_event(self, treeview, event):
1150 if event.window == treeview.get_bin_window():
1151 model = treeview.get_model()
1152 if (model is not None and model.get_iter_first() is not None):
1153 return False
1155 role = getattr(treeview, TreeViewHelper.ROLE, None)
1156 if role is None:
1157 return False
1159 ctx = event.window.cairo_create()
1160 ctx.rectangle(event.area.x, event.area.y,
1161 event.area.width, event.area.height)
1162 ctx.clip()
1164 x, y, width, height, depth = event.window.get_geometry()
1165 progress = None
1167 if role == TreeViewHelper.ROLE_EPISODES:
1168 if self.currently_updating:
1169 text = _('Loading episodes')
1170 progress = self.episode_list_model.get_update_progress()
1171 elif self.config.episode_list_view_mode != \
1172 EpisodeListModel.VIEW_ALL:
1173 text = _('No episodes in current view')
1174 else:
1175 text = _('No episodes available')
1176 elif role == TreeViewHelper.ROLE_PODCASTS:
1177 if self.config.episode_list_view_mode != \
1178 EpisodeListModel.VIEW_ALL and \
1179 self.config.podcast_list_hide_boring and \
1180 len(self.channels) > 0:
1181 text = _('No podcasts in this view')
1182 else:
1183 text = _('No subscriptions')
1184 elif role == TreeViewHelper.ROLE_DOWNLOADS:
1185 text = _('No active downloads')
1186 else:
1187 raise Exception('on_treeview_expose_event: unknown role')
1189 if gpodder.ui.fremantle:
1190 from gpodder.gtkui.frmntl import style
1191 font_desc = style.get_font_desc('LargeSystemFont')
1192 else:
1193 font_desc = None
1195 draw_text_box_centered(ctx, treeview, width, height, text, font_desc, progress)
1197 return False
1199 def enable_download_list_update(self):
1200 if not self.download_list_update_enabled:
1201 self.update_downloads_list()
1202 gobject.timeout_add(1500, self.update_downloads_list)
1203 self.download_list_update_enabled = True
1205 def cleanup_downloads(self):
1206 model = self.download_status_model
1208 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
1209 changed_episode_urls = set()
1210 for row_reference, task in all_tasks:
1211 if task.status in (task.DONE, task.CANCELLED):
1212 model.remove(model.get_iter(row_reference.get_path()))
1213 try:
1214 # We don't "see" this task anymore - remove it;
1215 # this is needed, so update_episode_list_icons()
1216 # below gets the correct list of "seen" tasks
1217 self.download_tasks_seen.remove(task)
1218 except KeyError, key_error:
1219 log('Cannot remove task from "seen" list: %s', task, sender=self)
1220 changed_episode_urls.add(task.url)
1221 # Tell the task that it has been removed (so it can clean up)
1222 task.removed_from_list()
1224 # Tell the podcasts tab to update icons for our removed podcasts
1225 self.update_episode_list_icons(changed_episode_urls)
1227 # Tell the shownotes window that we have removed the episode
1228 if self.episode_shownotes_window is not None and \
1229 self.episode_shownotes_window.episode is not None and \
1230 self.episode_shownotes_window.episode.url in changed_episode_urls:
1231 self.episode_shownotes_window._download_status_changed(None)
1233 # Update the downloads list one more time
1234 self.update_downloads_list(can_call_cleanup=False)
1236 def on_tool_downloads_toggled(self, toolbutton):
1237 if toolbutton.get_active():
1238 self.wNotebook.set_current_page(1)
1239 else:
1240 self.wNotebook.set_current_page(0)
1242 def add_download_task_monitor(self, monitor):
1243 self.download_task_monitors.add(monitor)
1244 model = self.download_status_model
1245 if model is None:
1246 model = ()
1247 for row in model:
1248 task = row[self.download_status_model.C_TASK]
1249 monitor.task_updated(task)
1251 def remove_download_task_monitor(self, monitor):
1252 self.download_task_monitors.remove(monitor)
1254 def update_downloads_list(self, can_call_cleanup=True):
1255 try:
1256 model = self.download_status_model
1258 downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
1259 total_speed, total_size, done_size = 0, 0, 0
1261 # Keep a list of all download tasks that we've seen
1262 download_tasks_seen = set()
1264 # Remember the DownloadTask object for the episode that
1265 # has been opened in the episode shownotes dialog (if any)
1266 if self.episode_shownotes_window is not None:
1267 shownotes_episode = self.episode_shownotes_window.episode
1268 shownotes_task = None
1269 else:
1270 shownotes_episode = None
1271 shownotes_task = None
1273 # Do not go through the list of the model is not (yet) available
1274 if model is None:
1275 model = ()
1277 failed_downloads = []
1278 for row in model:
1279 self.download_status_model.request_update(row.iter)
1281 task = row[self.download_status_model.C_TASK]
1282 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
1284 # Let the download task monitors know of changes
1285 for monitor in self.download_task_monitors:
1286 monitor.task_updated(task)
1288 total_size += size
1289 done_size += size*progress
1291 if shownotes_episode is not None and \
1292 shownotes_episode.url == task.episode.url:
1293 shownotes_task = task
1295 download_tasks_seen.add(task)
1297 if status == download.DownloadTask.DOWNLOADING:
1298 downloading += 1
1299 total_speed += speed
1300 elif status == download.DownloadTask.FAILED:
1301 failed_downloads.append(task)
1302 failed += 1
1303 elif status == download.DownloadTask.DONE:
1304 finished += 1
1305 elif status == download.DownloadTask.QUEUED:
1306 queued += 1
1307 elif status == download.DownloadTask.PAUSED:
1308 paused += 1
1309 else:
1310 others += 1
1312 # Remember which tasks we have seen after this run
1313 self.download_tasks_seen = download_tasks_seen
1315 if gpodder.ui.desktop:
1316 text = [_('Downloads')]
1317 if downloading + failed + queued > 0:
1318 s = []
1319 if downloading > 0:
1320 s.append(N_('%d active', '%d active', downloading) % downloading)
1321 if failed > 0:
1322 s.append(N_('%d failed', '%d failed', failed) % failed)
1323 if queued > 0:
1324 s.append(N_('%d queued', '%d queued', queued) % queued)
1325 text.append(' (' + ', '.join(s)+')')
1326 self.labelDownloads.set_text(''.join(text))
1327 elif gpodder.ui.diablo:
1328 sum = downloading + failed + finished + queued + paused + others
1329 if sum:
1330 self.tool_downloads.set_label(_('Downloads (%d)') % sum)
1331 else:
1332 self.tool_downloads.set_label(_('Downloads'))
1333 elif gpodder.ui.fremantle:
1334 if downloading + queued > 0:
1335 self.button_downloads.set_value(N_('%d active', '%d active', downloading+queued) % (downloading+queued))
1336 elif failed > 0:
1337 self.button_downloads.set_value(N_('%d failed', '%d failed', failed) % failed)
1338 elif paused > 0:
1339 self.button_downloads.set_value(N_('%d paused', '%d paused', paused) % paused)
1340 else:
1341 self.button_downloads.set_value(_('Idle'))
1343 title = [self.default_title]
1345 # We have to update all episodes/channels for which the status has
1346 # changed. Accessing task.status_changed has the side effect of
1347 # re-setting the changed flag, so we need to get the "changed" list
1348 # of tuples first and split it into two lists afterwards
1349 changed = [(task.url, task.podcast_url) for task in \
1350 self.download_tasks_seen if task.status_changed]
1351 episode_urls = [episode_url for episode_url, channel_url in changed]
1352 channel_urls = [channel_url for episode_url, channel_url in changed]
1354 count = downloading + queued
1355 if count > 0:
1356 title.append(N_('downloading %d file', 'downloading %d files', count) % count)
1358 if total_size > 0:
1359 percentage = 100.0*done_size/total_size
1360 else:
1361 percentage = 0.0
1362 total_speed = util.format_filesize(total_speed)
1363 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1364 if self.tray_icon is not None:
1365 # Update the tray icon status and progress bar
1366 self.tray_icon.set_status(self.tray_icon.STATUS_DOWNLOAD_IN_PROGRESS, title[1])
1367 self.tray_icon.draw_progress_bar(percentage/100.)
1368 else:
1369 if self.tray_icon is not None:
1370 # Update the tray icon status
1371 self.tray_icon.set_status()
1372 if gpodder.ui.desktop:
1373 self.downloads_finished(self.download_tasks_seen)
1374 if gpodder.ui.diablo:
1375 hildon.hildon_banner_show_information(self.gPodder, '', 'gPodder: %s' % _('All downloads finished'))
1376 log('All downloads have finished.', sender=self)
1377 if self.config.cmd_all_downloads_complete:
1378 util.run_external_command(self.config.cmd_all_downloads_complete)
1380 if gpodder.ui.fremantle and failed:
1381 message = '\n'.join(['%s: %s' % (str(task), \
1382 task.error_message) for task in failed_downloads])
1383 self.show_message(message, _('Downloads failed'), important=True)
1385 # Remove finished episodes
1386 if self.config.auto_cleanup_downloads and can_call_cleanup:
1387 self.cleanup_downloads()
1389 # Stop updating the download list here
1390 self.download_list_update_enabled = False
1392 if not gpodder.ui.fremantle:
1393 self.gPodder.set_title(' - '.join(title))
1395 self.update_episode_list_icons(episode_urls)
1396 if self.episode_shownotes_window is not None:
1397 if (shownotes_task and shownotes_task.url in episode_urls) or \
1398 shownotes_task != self.episode_shownotes_window.task:
1399 self.episode_shownotes_window._download_status_changed(shownotes_task)
1400 self.episode_shownotes_window._download_status_progress()
1401 self.play_or_download()
1402 if channel_urls:
1403 self.update_podcast_list_model(channel_urls)
1405 return self.download_list_update_enabled
1406 except Exception, e:
1407 log('Exception happened while updating download list.', sender=self, traceback=True)
1408 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
1409 # We return False here, so the update loop won't be called again,
1410 # that's why we require the restart of gPodder in the message.
1411 return False
1413 def on_config_changed(self, *args):
1414 util.idle_add(self._on_config_changed, *args)
1416 def _on_config_changed(self, name, old_value, new_value):
1417 if name == 'show_toolbar' and gpodder.ui.desktop:
1418 self.toolbar.set_property('visible', new_value)
1419 elif name == 'videoplayer':
1420 self.config.video_played_dbus = False
1421 elif name == 'player':
1422 self.config.audio_played_dbus = False
1423 elif name == 'episode_list_descriptions':
1424 self.update_episode_list_model()
1425 elif name == 'episode_list_thumbnails':
1426 self.update_episode_list_icons(all=True)
1427 elif name == 'rotation_mode':
1428 self._fremantle_rotation.set_mode(new_value)
1429 elif name in ('auto_update_feeds', 'auto_update_frequency'):
1430 self.restart_auto_update_timer()
1431 elif name == 'podcast_list_view_all':
1432 # Force a update of the podcast list model
1433 self.channel_list_changed = True
1434 if gpodder.ui.fremantle:
1435 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
1436 while gtk.events_pending():
1437 gtk.main_iteration(False)
1438 self.update_podcast_list_model()
1439 if gpodder.ui.fremantle:
1440 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
1442 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1443 # With get_bin_window, we get the window that contains the rows without
1444 # the header. The Y coordinate of this window will be the height of the
1445 # treeview header. This is the amount we have to subtract from the
1446 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1447 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1448 y -= x_bin
1449 y -= y_bin
1450 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
1452 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or (column is not None and column != treeview.get_columns()[0]):
1453 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1454 return False
1456 if path is not None:
1457 model = treeview.get_model()
1458 iter = model.get_iter(path)
1459 role = getattr(treeview, TreeViewHelper.ROLE)
1461 if role == TreeViewHelper.ROLE_EPISODES:
1462 id = model.get_value(iter, EpisodeListModel.C_URL)
1463 elif role == TreeViewHelper.ROLE_PODCASTS:
1464 id = model.get_value(iter, PodcastListModel.C_URL)
1466 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1467 if last_tooltip is not None and last_tooltip != id:
1468 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1469 return False
1470 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1472 if role == TreeViewHelper.ROLE_EPISODES:
1473 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1474 if description:
1475 tooltip.set_text(description)
1476 else:
1477 return False
1478 elif role == TreeViewHelper.ROLE_PODCASTS:
1479 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1480 if channel is None:
1481 return False
1482 channel.request_save_dir_size()
1483 diskspace_str = util.format_filesize(channel.save_dir_size, 0)
1484 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1485 if error_str:
1486 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
1487 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1488 table = gtk.Table(rows=3, columns=3)
1489 table.set_row_spacings(5)
1490 table.set_col_spacings(5)
1491 table.set_border_width(5)
1493 heading = gtk.Label()
1494 heading.set_alignment(0, 1)
1495 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
1496 table.attach(heading, 0, 1, 0, 1)
1497 size_info = gtk.Label()
1498 size_info.set_alignment(1, 1)
1499 size_info.set_justify(gtk.JUSTIFY_RIGHT)
1500 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
1501 table.attach(size_info, 2, 3, 0, 1)
1503 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
1505 if len(channel.description) < 500:
1506 description = channel.description
1507 else:
1508 pos = channel.description.find('\n\n')
1509 if pos == -1 or pos > 500:
1510 description = channel.description[:498]+'[...]'
1511 else:
1512 description = channel.description[:pos]
1514 description = gtk.Label(description)
1515 if error_str:
1516 description.set_markup(error_str)
1517 description.set_alignment(0, 0)
1518 description.set_line_wrap(True)
1519 table.attach(description, 0, 3, 2, 3)
1521 table.show_all()
1522 tooltip.set_custom(table)
1524 return True
1526 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1527 return False
1529 def treeview_allow_tooltips(self, treeview, allow):
1530 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1532 def update_m3u_playlist_clicked(self, widget):
1533 if self.active_channel is not None:
1534 self.active_channel.update_m3u_playlist()
1535 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'), widget=self.treeChannels)
1537 def treeview_handle_context_menu_click(self, treeview, event):
1538 x, y = int(event.x), int(event.y)
1539 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
1541 selection = treeview.get_selection()
1542 model, paths = selection.get_selected_rows()
1544 if path is None or (path not in paths and \
1545 event.button == self.context_menu_mouse_button):
1546 # We have right-clicked, but not into the selection,
1547 # assume we don't want to operate on the selection
1548 paths = []
1550 if path is not None and not paths and \
1551 event.button == self.context_menu_mouse_button:
1552 # No selection or clicked outside selection;
1553 # select the single item where we clicked
1554 treeview.grab_focus()
1555 treeview.set_cursor(path, column, 0)
1556 paths = [path]
1558 if not paths:
1559 # Unselect any remaining items (clicked elsewhere)
1560 if hasattr(treeview, 'is_rubber_banding_active'):
1561 if not treeview.is_rubber_banding_active():
1562 selection.unselect_all()
1563 else:
1564 selection.unselect_all()
1566 return model, paths
1568 def downloads_list_get_selection(self, model=None, paths=None):
1569 if model is None and paths is None:
1570 selection = self.treeDownloads.get_selection()
1571 model, paths = selection.get_selected_rows()
1573 can_queue, can_cancel, can_pause, can_remove, can_force = (True,)*5
1574 selected_tasks = [(gtk.TreeRowReference(model, path), \
1575 model.get_value(model.get_iter(path), \
1576 DownloadStatusModel.C_TASK)) for path in paths]
1578 for row_reference, task in selected_tasks:
1579 if task.status != download.DownloadTask.QUEUED:
1580 can_force = False
1581 if task.status not in (download.DownloadTask.PAUSED, \
1582 download.DownloadTask.FAILED, \
1583 download.DownloadTask.CANCELLED):
1584 can_queue = False
1585 if task.status not in (download.DownloadTask.PAUSED, \
1586 download.DownloadTask.QUEUED, \
1587 download.DownloadTask.DOWNLOADING, \
1588 download.DownloadTask.FAILED):
1589 can_cancel = False
1590 if task.status not in (download.DownloadTask.QUEUED, \
1591 download.DownloadTask.DOWNLOADING):
1592 can_pause = False
1593 if task.status not in (download.DownloadTask.CANCELLED, \
1594 download.DownloadTask.FAILED, \
1595 download.DownloadTask.DONE):
1596 can_remove = False
1598 return selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force
1600 def downloads_finished(self, download_tasks_seen):
1601 # FIXME: Filter all tasks that have already been reported
1602 finished_downloads = [str(task) for task in download_tasks_seen if task.status == task.DONE]
1603 failed_downloads = [str(task)+' ('+task.error_message+')' for task in download_tasks_seen if task.status == task.FAILED]
1605 if finished_downloads and failed_downloads:
1606 message = self.format_episode_list(finished_downloads, 5)
1607 message += '\n\n<i>%s</i>\n' % _('These downloads failed:')
1608 message += self.format_episode_list(failed_downloads, 5)
1609 self.show_message(message, _('Downloads finished'), True, widget=self.labelDownloads)
1610 elif finished_downloads:
1611 message = self.format_episode_list(finished_downloads)
1612 self.show_message(message, _('Downloads finished'), widget=self.labelDownloads)
1613 elif failed_downloads:
1614 message = self.format_episode_list(failed_downloads)
1615 self.show_message(message, _('Downloads failed'), True, widget=self.labelDownloads)
1617 # Open torrent files right after download (bug 1029)
1618 if self.config.open_torrent_after_download:
1619 for task in download_tasks_seen:
1620 if task.status != task.DONE:
1621 continue
1623 episode = task.episode
1624 if episode.mimetype != 'application/x-bittorrent':
1625 continue
1627 self.playback_episodes([episode])
1630 def format_episode_list(self, episode_list, max_episodes=10):
1632 Format a list of episode names for notifications
1634 Will truncate long episode names and limit the amount of
1635 episodes displayed (max_episodes=10).
1637 The episode_list parameter should be a list of strings.
1639 MAX_TITLE_LENGTH = 100
1641 result = []
1642 for title in episode_list[:min(len(episode_list), max_episodes)]:
1643 if len(title) > MAX_TITLE_LENGTH:
1644 middle = (MAX_TITLE_LENGTH/2)-2
1645 title = '%s...%s' % (title[0:middle], title[-middle:])
1646 result.append(saxutils.escape(title))
1647 result.append('\n')
1649 more_episodes = len(episode_list) - max_episodes
1650 if more_episodes > 0:
1651 result.append('(...')
1652 result.append(N_('%d more episode', '%d more episodes', more_episodes) % more_episodes)
1653 result.append('...)')
1655 return (''.join(result)).strip()
1657 def _for_each_task_set_status(self, tasks, status, force_start=False):
1658 episode_urls = set()
1659 model = self.treeDownloads.get_model()
1660 for row_reference, task in tasks:
1661 if status == download.DownloadTask.QUEUED:
1662 # Only queue task when its paused/failed/cancelled (or forced)
1663 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED) or force_start:
1664 self.download_queue_manager.add_task(task, force_start)
1665 self.enable_download_list_update()
1666 elif status == download.DownloadTask.CANCELLED:
1667 # Cancelling a download allowed when downloading/queued
1668 if task.status in (task.QUEUED, task.DOWNLOADING):
1669 task.status = status
1670 # Cancelling paused/failed downloads requires a call to .run()
1671 elif task.status in (task.PAUSED, task.FAILED):
1672 task.status = status
1673 # Call run, so the partial file gets deleted
1674 task.run()
1675 elif status == download.DownloadTask.PAUSED:
1676 # Pausing a download only when queued/downloading
1677 if task.status in (task.DOWNLOADING, task.QUEUED):
1678 task.status = status
1679 elif status is None:
1680 # Remove the selected task - cancel downloading/queued tasks
1681 if task.status in (task.QUEUED, task.DOWNLOADING):
1682 task.status = task.CANCELLED
1683 model.remove(model.get_iter(row_reference.get_path()))
1684 # Remember the URL, so we can tell the UI to update
1685 try:
1686 # We don't "see" this task anymore - remove it;
1687 # this is needed, so update_episode_list_icons()
1688 # below gets the correct list of "seen" tasks
1689 self.download_tasks_seen.remove(task)
1690 except KeyError, key_error:
1691 log('Cannot remove task from "seen" list: %s', task, sender=self)
1692 episode_urls.add(task.url)
1693 # Tell the task that it has been removed (so it can clean up)
1694 task.removed_from_list()
1695 else:
1696 # We can (hopefully) simply set the task status here
1697 task.status = status
1698 # Tell the podcasts tab to update icons for our removed podcasts
1699 self.update_episode_list_icons(episode_urls)
1700 # Update the tab title and downloads list
1701 self.update_downloads_list()
1703 def treeview_downloads_show_context_menu(self, treeview, event):
1704 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1705 if not paths:
1706 if not hasattr(treeview, 'is_rubber_banding_active'):
1707 return True
1708 else:
1709 return not treeview.is_rubber_banding_active()
1711 if event.button == self.context_menu_mouse_button:
1712 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = \
1713 self.downloads_list_get_selection(model, paths)
1715 def make_menu_item(label, stock_id, tasks, status, sensitive, force_start=False):
1716 # This creates a menu item for selection-wide actions
1717 item = gtk.ImageMenuItem(label)
1718 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
1719 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status, force_start))
1720 item.set_sensitive(sensitive)
1721 return self.set_finger_friendly(item)
1723 menu = gtk.Menu()
1725 item = gtk.ImageMenuItem(_('Episode details'))
1726 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1727 if len(selected_tasks) == 1:
1728 row_reference, task = selected_tasks[0]
1729 episode = task.episode
1730 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
1731 else:
1732 item.set_sensitive(False)
1733 menu.append(self.set_finger_friendly(item))
1734 menu.append(gtk.SeparatorMenuItem())
1735 if can_force:
1736 menu.append(make_menu_item(_('Start download now'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, True, True))
1737 else:
1738 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, can_queue, False))
1739 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED, can_cancel))
1740 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED, can_pause))
1741 menu.append(gtk.SeparatorMenuItem())
1742 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None, can_remove))
1744 if gpodder.ui.maemo:
1745 # Because we open the popup on left-click for Maemo,
1746 # we also include a non-action to close the menu
1747 menu.append(gtk.SeparatorMenuItem())
1748 item = gtk.ImageMenuItem(_('Close this menu'))
1749 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1751 menu.append(self.set_finger_friendly(item))
1753 menu.show_all()
1754 menu.popup(None, None, None, event.button, event.time)
1755 return True
1757 def treeview_channels_show_context_menu(self, treeview, event):
1758 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1759 if not paths:
1760 return True
1762 # Check for valid channel id, if there's no id then
1763 # assume that it is a proxy channel or equivalent
1764 # and cannot be operated with right click
1765 if self.active_channel.id is None:
1766 return True
1768 if event.button == 3:
1769 menu = gtk.Menu()
1771 ICON = lambda x: x
1773 item = gtk.ImageMenuItem( _('Update podcast'))
1774 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1775 item.connect('activate', self.on_itemUpdateChannel_activate)
1776 item.set_sensitive(not self.updating_feed_cache)
1777 menu.append(item)
1779 menu.append(gtk.SeparatorMenuItem())
1781 item = gtk.CheckMenuItem(_('Keep episodes'))
1782 item.set_active(self.active_channel.channel_is_locked)
1783 item.connect('activate', self.on_channel_toggle_lock_activate)
1784 menu.append(self.set_finger_friendly(item))
1786 item = gtk.ImageMenuItem(_('Remove podcast'))
1787 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1788 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1789 menu.append( item)
1791 if self.config.device_type != 'none':
1792 item = gtk.MenuItem(_('Synchronize to device'))
1793 item.connect('activate', lambda item: self.on_sync_to_ipod_activate(item, self.active_channel.get_downloaded_episodes()))
1794 menu.append(item)
1796 menu.append( gtk.SeparatorMenuItem())
1798 item = gtk.ImageMenuItem(_('Podcast details'))
1799 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1800 item.connect('activate', self.on_itemEditChannel_activate)
1801 menu.append(item)
1803 menu.show_all()
1804 # Disable tooltips while we are showing the menu, so
1805 # the tooltip will not appear over the menu
1806 self.treeview_allow_tooltips(self.treeChannels, False)
1807 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1808 menu.popup( None, None, None, event.button, event.time)
1810 return True
1812 def on_itemClose_activate(self, widget):
1813 if self.tray_icon is not None:
1814 self.iconify_main_window()
1815 else:
1816 self.on_gPodder_delete_event(widget)
1818 def cover_file_removed(self, channel_url):
1820 The Cover Downloader calls this when a previously-
1821 available cover has been removed from the disk. We
1822 have to update our model to reflect this change.
1824 self.podcast_list_model.delete_cover_by_url(channel_url)
1826 def cover_download_finished(self, channel, pixbuf):
1828 The Cover Downloader calls this when it has finished
1829 downloading (or registering, if already downloaded)
1830 a new channel cover, which is ready for displaying.
1832 self.podcast_list_model.add_cover_by_channel(channel, pixbuf)
1834 def save_episodes_as_file(self, episodes):
1835 for episode in episodes:
1836 self.save_episode_as_file(episode)
1838 def save_episode_as_file(self, episode):
1839 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1840 if episode.was_downloaded(and_exists=True):
1841 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1842 copy_from = episode.local_filename(create=False)
1843 assert copy_from is not None
1844 copy_to = episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)
1845 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1846 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1848 def copy_episodes_bluetooth(self, episodes):
1849 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1851 if gpodder.ui.maemo:
1852 util.bluetooth_send_files_maemo([e.local_filename(create=False) \
1853 for e in episodes_to_copy])
1854 return True
1856 def convert_and_send_thread(episode):
1857 for episode in episodes:
1858 filename = episode.local_filename(create=False)
1859 assert filename is not None
1860 destfile = os.path.join(tempfile.gettempdir(), \
1861 util.sanitize_filename(episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)))
1862 (base, ext) = os.path.splitext(filename)
1863 if not destfile.endswith(ext):
1864 destfile += ext
1866 try:
1867 shutil.copyfile(filename, destfile)
1868 util.bluetooth_send_file(destfile)
1869 except:
1870 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1871 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1873 util.delete_file(destfile)
1875 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1877 def get_device_name(self):
1878 if self.config.device_type == 'ipod':
1879 return _('iPod')
1880 elif self.config.device_type in ('filesystem', 'mtp'):
1881 return _('MP3 player')
1882 else:
1883 return '(unknown device)'
1885 def _treeview_button_released(self, treeview, event):
1886 xpos, ypos = TreeViewHelper.get_button_press_event(treeview)
1887 dy = int(abs(event.y-ypos))
1888 dx = int(event.x-xpos)
1890 selection = treeview.get_selection()
1891 path = treeview.get_path_at_pos(int(event.x), int(event.y))
1892 if path is None or dy > 30:
1893 return (False, dx, dy)
1895 path, column, x, y = path
1896 selection.select_path(path)
1897 treeview.set_cursor(path)
1898 treeview.grab_focus()
1900 return (True, dx, dy)
1902 def treeview_channels_handle_gestures(self, treeview, event):
1903 if self.currently_updating:
1904 return False
1906 selected, dx, dy = self._treeview_button_released(treeview, event)
1908 if selected:
1909 if self.config.maemo_enable_gestures:
1910 if dx > 70:
1911 self.on_itemUpdateChannel_activate()
1912 elif dx < -70:
1913 self.on_itemEditChannel_activate(treeview)
1915 return False
1917 def treeview_available_handle_gestures(self, treeview, event):
1918 selected, dx, dy = self._treeview_button_released(treeview, event)
1920 if selected:
1921 if self.config.maemo_enable_gestures:
1922 if dx > 70:
1923 self.on_playback_selected_episodes(None)
1924 return True
1925 elif dx < -70:
1926 self.on_shownotes_selected_episodes(None)
1927 return True
1929 # Pass the event to the context menu handler for treeAvailable
1930 self.treeview_available_show_context_menu(treeview, event)
1932 return True
1934 def treeview_available_show_context_menu(self, treeview, event):
1935 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1936 if not paths:
1937 if not hasattr(treeview, 'is_rubber_banding_active'):
1938 return True
1939 else:
1940 return not treeview.is_rubber_banding_active()
1942 if event.button == self.context_menu_mouse_button:
1943 episodes = self.get_selected_episodes()
1944 any_locked = any(e.is_locked for e in episodes)
1945 any_played = any(e.is_played for e in episodes)
1946 one_is_new = any(e.state == gpodder.STATE_NORMAL and not e.is_played for e in episodes)
1947 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1948 downloading = any(self.episode_is_downloading(e) for e in episodes)
1950 menu = gtk.Menu()
1952 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1954 if open_instead_of_play:
1955 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1956 elif downloaded:
1957 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1958 else:
1959 item = gtk.ImageMenuItem(_('Stream'))
1960 item.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PLAY, gtk.ICON_SIZE_MENU))
1962 item.set_sensitive(can_play and not downloading)
1963 item.connect('activate', self.on_playback_selected_episodes)
1964 menu.append(self.set_finger_friendly(item))
1966 if not can_cancel:
1967 item = gtk.ImageMenuItem(_('Download'))
1968 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1969 item.set_sensitive(can_download)
1970 item.connect('activate', self.on_download_selected_episodes)
1971 menu.append(self.set_finger_friendly(item))
1972 else:
1973 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
1974 item.connect('activate', self.on_item_cancel_download_activate)
1975 menu.append(self.set_finger_friendly(item))
1977 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1978 item.set_sensitive(can_delete)
1979 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1980 menu.append(self.set_finger_friendly(item))
1982 ICON = lambda x: x
1984 # Ok, this probably makes sense to only display for downloaded files
1985 if downloaded:
1986 menu.append(gtk.SeparatorMenuItem())
1987 share_item = gtk.MenuItem(_('Send to'))
1988 menu.append(self.set_finger_friendly(share_item))
1989 share_menu = gtk.Menu()
1991 item = gtk.ImageMenuItem(_('Local folder'))
1992 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU))
1993 item.connect('button-press-event', lambda w, ee: self.save_episodes_as_file(episodes))
1994 share_menu.append(self.set_finger_friendly(item))
1995 if self.bluetooth_available:
1996 item = gtk.ImageMenuItem(_('Bluetooth device'))
1997 if gpodder.ui.maemo:
1998 icon_name = ICON('qgn_list_filesys_bluetooth')
1999 else:
2000 icon_name = ICON('bluetooth')
2001 item.set_image(gtk.image_new_from_icon_name(icon_name, gtk.ICON_SIZE_MENU))
2002 item.connect('button-press-event', lambda w, ee: self.copy_episodes_bluetooth(episodes))
2003 share_menu.append(self.set_finger_friendly(item))
2004 if can_transfer:
2005 item = gtk.ImageMenuItem(self.get_device_name())
2006 item.set_image(gtk.image_new_from_icon_name(ICON('multimedia-player'), gtk.ICON_SIZE_MENU))
2007 item.connect('button-press-event', lambda w, ee: self.on_sync_to_ipod_activate(w, episodes))
2008 share_menu.append(self.set_finger_friendly(item))
2010 share_item.set_submenu(share_menu)
2012 if (downloaded or one_is_new or can_download) and not downloading:
2013 menu.append(gtk.SeparatorMenuItem())
2014 if one_is_new:
2015 item = gtk.CheckMenuItem(_('New'))
2016 item.set_active(True)
2017 item.connect('activate', lambda w: self.mark_selected_episodes_old())
2018 menu.append(self.set_finger_friendly(item))
2019 elif can_download:
2020 item = gtk.CheckMenuItem(_('New'))
2021 item.set_active(False)
2022 item.connect('activate', lambda w: self.mark_selected_episodes_new())
2023 menu.append(self.set_finger_friendly(item))
2025 if downloaded:
2026 item = gtk.CheckMenuItem(_('Played'))
2027 item.set_active(any_played)
2028 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, not any_played))
2029 menu.append(self.set_finger_friendly(item))
2031 item = gtk.CheckMenuItem(_('Keep episode'))
2032 item.set_active(any_locked)
2033 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, not any_locked))
2034 menu.append(self.set_finger_friendly(item))
2036 menu.append(gtk.SeparatorMenuItem())
2037 # Single item, add episode information menu item
2038 item = gtk.ImageMenuItem(_('Episode details'))
2039 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
2040 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
2041 menu.append(self.set_finger_friendly(item))
2043 if gpodder.ui.maemo:
2044 # Because we open the popup on left-click for Maemo,
2045 # we also include a non-action to close the menu
2046 menu.append(gtk.SeparatorMenuItem())
2047 item = gtk.ImageMenuItem(_('Close this menu'))
2048 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
2049 menu.append(self.set_finger_friendly(item))
2051 menu.show_all()
2052 # Disable tooltips while we are showing the menu, so
2053 # the tooltip will not appear over the menu
2054 self.treeview_allow_tooltips(self.treeAvailable, False)
2055 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
2056 menu.popup( None, None, None, event.button, event.time)
2058 return True
2060 def set_title(self, new_title):
2061 if not gpodder.ui.fremantle:
2062 self.default_title = new_title
2063 self.gPodder.set_title(new_title)
2065 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2067 Updates the status icons in the episode list.
2069 If urls is given, it should be a list of URLs
2070 of episodes that should be updated.
2072 If urls is None, set ONE OF selected, all to
2073 True (the former updates just the selected
2074 episodes and the latter updates all episodes).
2076 additional_args = (self.episode_is_downloading, \
2077 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2078 self.config.episode_list_thumbnails and gpodder.ui.desktop)
2080 if urls is not None:
2081 # We have a list of URLs to walk through
2082 self.episode_list_model.update_by_urls(urls, *additional_args)
2083 elif selected and not all:
2084 # We should update all selected episodes
2085 selection = self.treeAvailable.get_selection()
2086 model, paths = selection.get_selected_rows()
2087 for path in reversed(paths):
2088 iter = model.get_iter(path)
2089 self.episode_list_model.update_by_filter_iter(iter, \
2090 *additional_args)
2091 elif all and not selected:
2092 # We update all (even the filter-hidden) episodes
2093 self.episode_list_model.update_all(*additional_args)
2094 else:
2095 # Wrong/invalid call - have to specify at least one parameter
2096 raise ValueError('Invalid call to update_episode_list_icons')
2098 def episode_list_status_changed(self, episodes):
2099 self.update_episode_list_icons(set(e.url for e in episodes))
2100 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2101 self.db.commit()
2103 def clean_up_downloads(self, delete_partial=False):
2104 # Clean up temporary files left behind by old gPodder versions
2105 temporary_files = glob.glob('%s/*/.tmp-*' % self.config.download_dir)
2107 if delete_partial:
2108 temporary_files += glob.glob('%s/*/*.partial' % self.config.download_dir)
2110 for tempfile in temporary_files:
2111 util.delete_file(tempfile)
2113 # Clean up empty download folders and abandoned download folders
2114 download_dirs = glob.glob(os.path.join(self.config.download_dir, '*'))
2115 for ddir in download_dirs:
2116 if os.path.isdir(ddir) and False: # FIXME not db.channel_foldername_exists(os.path.basename(ddir)):
2117 globr = glob.glob(os.path.join(ddir, '*'))
2118 if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
2119 log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
2120 shutil.rmtree(ddir, ignore_errors=True)
2122 def streaming_possible(self):
2123 if gpodder.ui.desktop:
2124 # User has to have a media player set on the Desktop, or else we
2125 # would probably open the browser when giving a URL to xdg-open..
2126 return (self.config.player and self.config.player != 'default')
2127 elif gpodder.ui.maemo:
2128 # On Maemo, the default is to use the Nokia Media Player, which is
2129 # already able to deal with HTTP URLs the right way, so we
2130 # unconditionally enable streaming always on Maemo
2131 return True
2133 return False
2135 def playback_episodes_for_real(self, episodes):
2136 groups = collections.defaultdict(list)
2137 for episode in episodes:
2138 file_type = episode.file_type()
2139 if file_type == 'video' and self.config.videoplayer and \
2140 self.config.videoplayer != 'default':
2141 player = self.config.videoplayer
2142 if gpodder.ui.diablo:
2143 # Use the wrapper script if it's installed to crop 3GP YouTube
2144 # videos to fit the screen (looks much nicer than w/ black border)
2145 if player == 'mplayer' and util.find_command('gpodder-mplayer'):
2146 player = 'gpodder-mplayer'
2147 elif gpodder.ui.fremantle and player == 'mplayer':
2148 player = 'mplayer -fs %F'
2149 elif file_type == 'audio' and self.config.player and \
2150 self.config.player != 'default':
2151 player = self.config.player
2152 else:
2153 player = 'default'
2155 if file_type not in ('audio', 'video') or \
2156 (file_type == 'audio' and not self.config.audio_played_dbus) or \
2157 (file_type == 'video' and not self.config.video_played_dbus):
2158 # Mark episode as played in the database
2159 episode.mark(is_played=True)
2160 self.mygpo_client.on_playback([episode])
2162 filename = episode.local_filename(create=False)
2163 if filename is None or not os.path.exists(filename):
2164 filename = episode.url
2165 if youtube.is_video_link(filename):
2166 fmt_id = self.config.youtube_preferred_fmt_id
2167 if gpodder.ui.fremantle:
2168 fmt_id = 5
2169 filename = youtube.get_real_download_url(filename, fmt_id)
2171 # Determine the playback resume position - if the file
2172 # was played 100%, we simply start from the beginning
2173 resume_position = episode.current_position
2174 if resume_position == episode.total_time:
2175 resume_position = 0
2177 if gpodder.ui.fremantle:
2178 self.mafw_monitor.set_resume_point(filename, resume_position)
2180 # If Panucci is configured, use D-Bus on Maemo to call it
2181 if player == 'panucci':
2182 try:
2183 PANUCCI_NAME = 'org.panucci.panucciInterface'
2184 PANUCCI_PATH = '/panucciInterface'
2185 PANUCCI_INTF = 'org.panucci.panucciInterface'
2186 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2187 i = dbus.Interface(o, PANUCCI_INTF)
2189 def on_reply(*args):
2190 pass
2192 def error_handler(filename, err):
2193 log('Exception in D-Bus call: %s', str(err), \
2194 sender=self)
2196 # Fallback: use the command line client
2197 for command in util.format_desktop_command('panucci', \
2198 [filename]):
2199 log('Executing: %s', repr(command), sender=self)
2200 subprocess.Popen(command)
2202 on_error = lambda err: error_handler(filename, err)
2204 # This method only exists in Panucci > 0.9 ('new Panucci')
2205 i.playback_from(filename, resume_position, \
2206 reply_handler=on_reply, error_handler=on_error)
2208 continue # This file was handled by the D-Bus call
2209 except Exception, e:
2210 log('Error calling Panucci using D-Bus', sender=self, traceback=True)
2211 elif player == 'MediaBox' and gpodder.ui.maemo:
2212 try:
2213 MEDIABOX_NAME = 'de.pycage.mediabox'
2214 MEDIABOX_PATH = '/de/pycage/mediabox/control'
2215 MEDIABOX_INTF = 'de.pycage.mediabox.control'
2216 o = gpodder.dbus_session_bus.get_object(MEDIABOX_NAME, MEDIABOX_PATH)
2217 i = dbus.Interface(o, MEDIABOX_INTF)
2219 def on_reply(*args):
2220 pass
2222 def on_error(err):
2223 log('Exception in D-Bus call: %s', str(err), \
2224 sender=self)
2226 i.load(filename, '%s/x-unknown' % file_type, \
2227 reply_handler=on_reply, error_handler=on_error)
2229 continue # This file was handled by the D-Bus call
2230 except Exception, e:
2231 log('Error calling MediaBox using D-Bus', sender=self, traceback=True)
2233 groups[player].append(filename)
2235 # Open episodes with system default player
2236 if 'default' in groups:
2237 if gpodder.ui.maemo:
2238 # The Nokia Media Player app does not support receiving multiple
2239 # file names via D-Bus, so we simply place all file names into a
2240 # temporary M3U playlist and open that with the Media Player.
2241 m3u_filename = os.path.join(gpodder.home, 'gpodder_open_with.m3u')
2242 util.write_m3u_playlist(m3u_filename, groups['default'], extm3u=False)
2243 util.gui_open(m3u_filename)
2244 else:
2245 for filename in groups['default']:
2246 log('Opening with system default: %s', filename, sender=self)
2247 util.gui_open(filename)
2248 del groups['default']
2249 elif gpodder.ui.maemo and groups:
2250 # When on Maemo and not opening with default, show a notification
2251 # (no startup notification for Panucci / MPlayer yet...)
2252 if len(episodes) == 1:
2253 text = _('Opening %s') % episodes[0].title
2254 else:
2255 count = len(episodes)
2256 text = N_('Opening %d episode', 'Opening %d episodes', count) % count
2258 banner = hildon.hildon_banner_show_animation(self.gPodder, '', text)
2260 def destroy_banner_later(banner):
2261 banner.destroy()
2262 return False
2263 gobject.timeout_add(5000, destroy_banner_later, banner)
2265 # For each type now, go and create play commands
2266 for group in groups:
2267 for command in util.format_desktop_command(group, groups[group]):
2268 log('Executing: %s', repr(command), sender=self)
2269 subprocess.Popen(command)
2271 # Persist episode status changes to the database
2272 self.db.commit()
2274 # Flush updated episode status
2275 self.mygpo_client.flush()
2277 def playback_episodes(self, episodes):
2278 # We need to create a list, because we run through it more than once
2279 episodes = list(PodcastEpisode.sort_by_pubdate(e for e in episodes if \
2280 e.was_downloaded(and_exists=True) or self.streaming_possible()))
2282 try:
2283 self.playback_episodes_for_real(episodes)
2284 except Exception, e:
2285 log('Error in playback!', sender=self, traceback=True)
2286 if gpodder.ui.desktop:
2287 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
2288 _('Error opening player'), widget=self.toolPreferences)
2289 else:
2290 self.show_message(_('Please check your media player settings in the preferences dialog.'))
2292 channel_urls = set()
2293 episode_urls = set()
2294 for episode in episodes:
2295 channel_urls.add(episode.channel.url)
2296 episode_urls.add(episode.url)
2297 self.update_episode_list_icons(episode_urls)
2298 self.update_podcast_list_model(channel_urls)
2300 def play_or_download(self):
2301 if not gpodder.ui.fremantle:
2302 if self.wNotebook.get_current_page() > 0:
2303 if gpodder.ui.desktop:
2304 self.toolCancel.set_sensitive(True)
2305 return
2307 if self.currently_updating:
2308 return (False, False, False, False, False, False)
2310 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
2311 ( is_played, is_locked ) = (False,)*2
2313 open_instead_of_play = False
2315 selection = self.treeAvailable.get_selection()
2316 if selection.count_selected_rows() > 0:
2317 (model, paths) = selection.get_selected_rows()
2319 for path in paths:
2320 try:
2321 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2322 except TypeError, te:
2323 log('Invalid episode at path %s', str(path), sender=self)
2324 continue
2326 if episode.file_type() not in ('audio', 'video'):
2327 open_instead_of_play = True
2329 if episode.was_downloaded():
2330 can_play = episode.was_downloaded(and_exists=True)
2331 is_played = episode.is_played
2332 is_locked = episode.is_locked
2333 if not can_play:
2334 can_download = True
2335 else:
2336 if self.episode_is_downloading(episode):
2337 can_cancel = True
2338 else:
2339 can_download = True
2341 can_download = can_download and not can_cancel
2342 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
2343 can_transfer = can_play and self.config.device_type != 'none' and not can_cancel and not can_download and not open_instead_of_play
2344 can_delete = not can_cancel
2346 if gpodder.ui.desktop:
2347 if open_instead_of_play:
2348 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
2349 else:
2350 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
2351 self.toolPlay.set_sensitive( can_play)
2352 self.toolDownload.set_sensitive( can_download)
2353 self.toolTransfer.set_sensitive( can_transfer)
2354 self.toolCancel.set_sensitive( can_cancel)
2356 if not gpodder.ui.fremantle:
2357 self.item_cancel_download.set_sensitive(can_cancel)
2358 self.itemDownloadSelected.set_sensitive(can_download)
2359 self.itemOpenSelected.set_sensitive(can_play)
2360 self.itemPlaySelected.set_sensitive(can_play)
2361 self.itemDeleteSelected.set_sensitive(can_delete)
2362 self.item_toggle_played.set_sensitive(can_play)
2363 self.item_toggle_lock.set_sensitive(can_play)
2364 self.itemOpenSelected.set_visible(open_instead_of_play)
2365 self.itemPlaySelected.set_visible(not open_instead_of_play)
2367 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
2369 def on_cbMaxDownloads_toggled(self, widget, *args):
2370 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2372 def on_cbLimitDownloads_toggled(self, widget, *args):
2373 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2375 def episode_new_status_changed(self, urls):
2376 self.update_podcast_list_model()
2377 self.update_episode_list_icons(urls)
2379 def update_podcast_list_model(self, urls=None, selected=False, select_url=None):
2380 """Update the podcast list treeview model
2382 If urls is given, it should list the URLs of each
2383 podcast that has to be updated in the list.
2385 If selected is True, only update the model contents
2386 for the currently-selected podcast - nothing more.
2388 The caller can optionally specify "select_url",
2389 which is the URL of the podcast that is to be
2390 selected in the list after the update is complete.
2391 This only works if the podcast list has to be
2392 reloaded; i.e. something has been added or removed
2393 since the last update of the podcast list).
2395 selection = self.treeChannels.get_selection()
2396 model, iter = selection.get_selected()
2398 if self.config.podcast_list_view_all and not self.channel_list_changed:
2399 # Update "all episodes" view in any case (if enabled)
2400 self.podcast_list_model.update_first_row()
2402 if selected:
2403 # very cheap! only update selected channel
2404 if iter is not None:
2405 # If we have selected the "all episodes" view, we have
2406 # to update all channels for selected episodes:
2407 if self.config.podcast_list_view_all and \
2408 self.podcast_list_model.iter_is_first_row(iter):
2409 urls = self.get_podcast_urls_from_selected_episodes()
2410 self.podcast_list_model.update_by_urls(urls)
2411 else:
2412 # Otherwise just update the selected row (a podcast)
2413 self.podcast_list_model.update_by_filter_iter(iter)
2414 elif not self.channel_list_changed:
2415 # we can keep the model, but have to update some
2416 if urls is None:
2417 # still cheaper than reloading the whole list
2418 self.podcast_list_model.update_all()
2419 else:
2420 # ok, we got a bunch of urls to update
2421 self.podcast_list_model.update_by_urls(urls)
2422 else:
2423 if model and iter and select_url is None:
2424 # Get the URL of the currently-selected podcast
2425 select_url = model.get_value(iter, PodcastListModel.C_URL)
2427 # Update the podcast list model with new channels
2428 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2430 try:
2431 selected_iter = model.get_iter_first()
2432 # Find the previously-selected URL in the new
2433 # model if we have an URL (else select first)
2434 if select_url is not None:
2435 pos = model.get_iter_first()
2436 while pos is not None:
2437 url = model.get_value(pos, PodcastListModel.C_URL)
2438 if url == select_url:
2439 selected_iter = pos
2440 break
2441 pos = model.iter_next(pos)
2443 if not gpodder.ui.fremantle:
2444 if selected_iter is not None:
2445 selection.select_iter(selected_iter)
2446 self.on_treeChannels_cursor_changed(self.treeChannels)
2447 except:
2448 log('Cannot select podcast in list', traceback=True, sender=self)
2449 self.channel_list_changed = False
2451 def episode_is_downloading(self, episode):
2452 """Returns True if the given episode is being downloaded at the moment"""
2453 if episode is None:
2454 return False
2456 return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
2458 def update_episode_list_model(self):
2459 if self.channels and self.active_channel is not None:
2460 if gpodder.ui.fremantle:
2461 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2463 self.currently_updating = True
2464 self.episode_list_model.clear()
2465 self.episode_list_model.reset_update_progress()
2466 self.treeAvailable.set_model(self.empty_episode_list_model)
2467 def do_update_episode_list_model():
2468 additional_args = (self.episode_is_downloading, \
2469 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2470 self.config.episode_list_thumbnails and gpodder.ui.desktop, \
2471 self.treeAvailable)
2472 self.episode_list_model.add_from_channel(self.active_channel, *additional_args)
2474 def on_episode_list_model_updated():
2475 if gpodder.ui.fremantle:
2476 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2477 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
2478 self.treeAvailable.columns_autosize()
2479 self.currently_updating = False
2480 self.play_or_download()
2481 util.idle_add(on_episode_list_model_updated)
2482 threading.Thread(target=do_update_episode_list_model).start()
2483 else:
2484 self.episode_list_model.clear()
2486 @dbus.service.method(gpodder.dbus_interface)
2487 def offer_new_episodes(self, channels=None):
2488 if gpodder.ui.fremantle:
2489 # Assume that when this function is called that the
2490 # notification is not shown anymore (Maemo bug 11345)
2491 self._fremantle_notification_visible = False
2493 new_episodes = self.get_new_episodes(channels)
2494 if new_episodes:
2495 self.new_episodes_show(new_episodes)
2496 return True
2497 return False
2499 def add_podcast_list(self, urls, auth_tokens=None):
2500 """Subscribe to a list of podcast given their URLs
2502 If auth_tokens is given, it should be a dictionary
2503 mapping URLs to (username, password) tuples."""
2505 if auth_tokens is None:
2506 auth_tokens = {}
2508 # Sort and split the URL list into five buckets
2509 queued, failed, existing, worked, authreq = [], [], [], [], []
2510 for input_url in urls:
2511 url = util.normalize_feed_url(input_url)
2512 if url is None:
2513 # Fail this one because the URL is not valid
2514 failed.append(input_url)
2515 elif self.podcast_list_model.get_filter_path_from_url(url) is not None:
2516 # A podcast already exists in the list for this URL
2517 existing.append(url)
2518 else:
2519 # This URL has survived the first round - queue for add
2520 queued.append(url)
2521 if url != input_url and input_url in auth_tokens:
2522 auth_tokens[url] = auth_tokens[input_url]
2524 error_messages = {}
2525 redirections = {}
2527 progress = ProgressIndicator(_('Adding podcasts'), \
2528 _('Please wait while episode information is downloaded.'), \
2529 parent=self.get_dialog_parent())
2531 def on_after_update():
2532 progress.on_finished()
2533 # Report already-existing subscriptions to the user
2534 if existing:
2535 title = _('Existing subscriptions skipped')
2536 message = _('You are already subscribed to these podcasts:') \
2537 + '\n\n' + '\n'.join(saxutils.escape(url) for url in existing)
2538 self.show_message(message, title, widget=self.treeChannels)
2540 # Report subscriptions that require authentication
2541 if authreq:
2542 retry_podcasts = {}
2543 for url in authreq:
2544 title = _('Podcast requires authentication')
2545 message = _('Please login to %s:') % (saxutils.escape(url),)
2546 success, auth_tokens = self.show_login_dialog(title, message)
2547 if success:
2548 retry_podcasts[url] = auth_tokens
2549 else:
2550 # Stop asking the user for more login data
2551 retry_podcasts = {}
2552 for url in authreq:
2553 error_messages[url] = _('Authentication failed')
2554 failed.append(url)
2555 break
2557 # If we have authentication data to retry, do so here
2558 if retry_podcasts:
2559 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
2561 # Report website redirections
2562 for url in redirections:
2563 title = _('Website redirection detected')
2564 message = _('The URL %(url)s redirects to %(target)s.') \
2565 + '\n\n' + _('Do you want to visit the website now?')
2566 message = message % {'url': url, 'target': redirections[url]}
2567 if self.show_confirmation(message, title):
2568 util.open_website(url)
2569 else:
2570 break
2572 # Report failed subscriptions to the user
2573 if failed:
2574 title = _('Could not add some podcasts')
2575 message = _('Some podcasts could not be added to your list:') \
2576 + '\n\n' + '\n'.join(saxutils.escape('%s: %s' % (url, \
2577 error_messages.get(url, _('Unknown')))) for url in failed)
2578 self.show_message(message, title, important=True)
2580 # Upload subscription changes to gpodder.net
2581 self.mygpo_client.on_subscribe(worked)
2583 # If at least one podcast has been added, save and update all
2584 if self.channel_list_changed:
2585 # Fix URLs if mygpo has rewritten them
2586 self.rewrite_urls_mygpo()
2588 self.save_channels_opml()
2590 # If only one podcast was added, select it after the update
2591 if len(worked) == 1:
2592 url = worked[0]
2593 else:
2594 url = None
2596 # Update the list of subscribed podcasts
2597 self.update_feed_cache(force_update=False, select_url_afterwards=url)
2598 self.update_podcasts_tab()
2600 # Offer to download new episodes
2601 episodes = []
2602 for podcast in self.channels:
2603 if podcast.url in worked:
2604 episodes.extend(podcast.get_all_episodes())
2606 if episodes:
2607 episodes = list(PodcastEpisode.sort_by_pubdate(episodes, \
2608 reverse=True))
2609 self.new_episodes_show(episodes, \
2610 selected=[e.check_is_new() for e in episodes])
2613 def thread_proc():
2614 # After the initial sorting and splitting, try all queued podcasts
2615 length = len(queued)
2616 for index, url in enumerate(queued):
2617 progress.on_progress(float(index)/float(length))
2618 progress.on_message(url)
2619 log('QUEUE RUNNER: %s', url, sender=self)
2620 try:
2621 # The URL is valid and does not exist already - subscribe!
2622 channel = PodcastChannel.load(self.db, url=url, create=True, \
2623 authentication_tokens=auth_tokens.get(url, None), \
2624 max_episodes=self.config.max_episodes_per_feed, \
2625 download_dir=self.config.download_dir, \
2626 allow_empty_feeds=self.config.allow_empty_feeds, \
2627 mimetype_prefs=self.config.mimetype_prefs)
2629 try:
2630 username, password = util.username_password_from_url(url)
2631 except ValueError, ve:
2632 username, password = (None, None)
2634 if username is not None and channel.username is None and \
2635 password is not None and channel.password is None:
2636 channel.username = username
2637 channel.password = password
2638 channel.save()
2640 self._update_cover(channel)
2641 except feedcore.AuthenticationRequired:
2642 if url in auth_tokens:
2643 # Fail for wrong authentication data
2644 error_messages[url] = _('Authentication failed')
2645 failed.append(url)
2646 else:
2647 # Queue for login dialog later
2648 authreq.append(url)
2649 continue
2650 except feedcore.WifiLogin, error:
2651 redirections[url] = error.data
2652 failed.append(url)
2653 error_messages[url] = _('Redirection detected')
2654 continue
2655 except Exception, e:
2656 log('Subscription error: %s', e, traceback=True, sender=self)
2657 error_messages[url] = str(e)
2658 failed.append(url)
2659 continue
2661 assert channel is not None
2662 worked.append(channel.url)
2663 self.channels.append(channel)
2664 self.channel_list_changed = True
2665 util.idle_add(on_after_update)
2666 threading.Thread(target=thread_proc).start()
2668 def save_channels_opml(self):
2669 exporter = opml.Exporter(gpodder.subscription_file)
2670 return exporter.write(self.channels)
2672 def find_episode(self, podcast_url, episode_url):
2673 """Find an episode given its podcast and episode URL
2675 The function will return a PodcastEpisode object if
2676 the episode is found, or None if it's not found.
2678 for podcast in self.channels:
2679 if podcast_url == podcast.url:
2680 for episode in podcast.get_all_episodes():
2681 if episode_url == episode.url:
2682 return episode
2684 return None
2686 def process_received_episode_actions(self, updated_urls):
2687 """Process/merge episode actions from gpodder.net
2689 This function will merge all changes received from
2690 the server to the local database and update the
2691 status of the affected episodes as necessary.
2693 indicator = ProgressIndicator(_('Merging episode actions'), \
2694 _('Episode actions from gpodder.net are merged.'), \
2695 False, self.get_dialog_parent())
2697 for idx, action in enumerate(self.mygpo_client.get_episode_actions(updated_urls)):
2698 if action.action == 'play':
2699 episode = self.find_episode(action.podcast_url, \
2700 action.episode_url)
2702 if episode is not None:
2703 log('Play action for %s', episode.url, sender=self)
2704 episode.mark(is_played=True)
2706 if action.timestamp > episode.current_position_updated:
2707 log('Updating position for %s', episode.url, sender=self)
2708 episode.current_position = action.position
2709 episode.current_position_updated = action.timestamp
2711 if action.total:
2712 log('Updating total time for %s', episode.url, sender=self)
2713 episode.total_time = action.total
2715 episode.save()
2716 elif action.action == 'delete':
2717 episode = self.find_episode(action.podcast_url, \
2718 action.episode_url)
2720 if episode is not None:
2721 if not episode.was_downloaded(and_exists=True):
2722 # Set the episode to a "deleted" state
2723 log('Marking as deleted: %s', episode.url, sender=self)
2724 episode.delete_from_disk()
2725 episode.save()
2727 indicator.on_message(N_('%d action processed', '%d actions processed', idx) % idx)
2728 gtk.main_iteration(False)
2730 indicator.on_finished()
2731 self.db.commit()
2734 def update_feed_cache_finish_callback(self, updated_urls=None, select_url_afterwards=None):
2735 self.db.commit()
2736 self.updating_feed_cache = False
2738 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2740 # Process received episode actions for all updated URLs
2741 self.process_received_episode_actions(updated_urls)
2743 self.channel_list_changed = True
2744 self.update_podcast_list_model(select_url=select_url_afterwards)
2746 # Only search for new episodes in podcasts that have been
2747 # updated, not in other podcasts (for single-feed updates)
2748 episodes = self.get_new_episodes([c for c in self.channels if c.url in updated_urls])
2750 if gpodder.ui.fremantle:
2751 self.fancy_progress_bar.hide()
2752 self.button_subscribe.set_sensitive(True)
2753 self.button_refresh.set_sensitive(True)
2754 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
2755 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2756 self.update_podcasts_tab()
2757 self.update_episode_list_model()
2758 if self.feed_cache_update_cancelled:
2759 return
2761 def application_in_foreground():
2762 try:
2763 return any(w.get_property('is-topmost') for w in hildon.WindowStack.get_default().get_windows())
2764 except Exception, e:
2765 log('Could not determine is-topmost', traceback=True)
2766 # When in doubt, assume not in foreground
2767 return False
2769 if episodes:
2770 if self.config.auto_download == 'quiet' and not self.config.auto_update_feeds:
2771 # New episodes found, but we should do nothing
2772 self.show_message(_('New episodes are available.'))
2773 elif self.config.auto_download == 'always':
2774 count = len(episodes)
2775 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2776 self.show_message(title)
2777 self.download_episode_list(episodes)
2778 elif self.config.auto_download == 'queue':
2779 self.show_message(_('New episodes have been added to the download list.'))
2780 self.download_episode_list_paused(episodes)
2781 elif application_in_foreground():
2782 if not self._fremantle_notification_visible:
2783 self.new_episodes_show(episodes)
2784 elif not self._fremantle_notification_visible:
2785 try:
2786 import pynotify
2787 pynotify.init('gPodder')
2788 n = pynotify.Notification('gPodder', _('New episodes available'), 'gpodder')
2789 n.set_urgency(pynotify.URGENCY_CRITICAL)
2790 n.set_hint('dbus-callback-default', ' '.join([
2791 gpodder.dbus_bus_name,
2792 gpodder.dbus_gui_object_path,
2793 gpodder.dbus_interface,
2794 'offer_new_episodes',
2796 n.set_category('gpodder-new-episodes')
2797 n.show()
2798 self._fremantle_notification_visible = True
2799 except Exception, e:
2800 log('Error: %s', str(e), sender=self, traceback=True)
2801 self.new_episodes_show(episodes)
2802 self._fremantle_notification_visible = False
2803 elif not self.config.auto_update_feeds:
2804 self.show_message(_('No new episodes. Please check for new episodes later.'))
2805 return
2807 if self.tray_icon:
2808 self.tray_icon.set_status()
2810 if self.feed_cache_update_cancelled:
2811 # The user decided to abort the feed update
2812 self.show_update_feeds_buttons()
2813 elif not episodes:
2814 # Nothing new here - but inform the user
2815 self.pbFeedUpdate.set_fraction(1.0)
2816 self.pbFeedUpdate.set_text(_('No new episodes'))
2817 self.feed_cache_update_cancelled = True
2818 self.btnCancelFeedUpdate.show()
2819 self.btnCancelFeedUpdate.set_sensitive(True)
2820 if gpodder.ui.maemo:
2821 # btnCancelFeedUpdate is a ToolButton on Maemo
2822 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_APPLY)
2823 else:
2824 # btnCancelFeedUpdate is a normal gtk.Button
2825 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
2826 else:
2827 count = len(episodes)
2828 # New episodes are available
2829 self.pbFeedUpdate.set_fraction(1.0)
2830 # Are we minimized and should we auto download?
2831 if (self.is_iconified() and (self.config.auto_download == 'minimized')) or (self.config.auto_download == 'always'):
2832 self.download_episode_list(episodes)
2833 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2834 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2835 self.show_update_feeds_buttons()
2836 elif self.config.auto_download == 'queue':
2837 self.download_episode_list_paused(episodes)
2838 title = N_('%d new episode added to download list.', '%d new episodes added to download list.', count) % count
2839 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2840 self.show_update_feeds_buttons()
2841 else:
2842 self.show_update_feeds_buttons()
2843 # New episodes are available and we are not minimized
2844 if not self.config.do_not_show_new_episodes_dialog:
2845 self.new_episodes_show(episodes, notification=True)
2846 else:
2847 message = N_('%d new episode available', '%d new episodes available', count) % count
2848 self.pbFeedUpdate.set_text(message)
2850 def _update_cover(self, channel):
2851 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
2852 self.cover_downloader.request_cover(channel)
2854 def update_feed_cache_proc(self, channels, select_url_afterwards):
2855 total = len(channels)
2857 for updated, channel in enumerate(channels):
2858 if not self.feed_cache_update_cancelled:
2859 try:
2860 channel.update(max_episodes=self.config.max_episodes_per_feed, \
2861 mimetype_prefs=self.config.mimetype_prefs)
2862 self._update_cover(channel)
2863 except Exception, e:
2864 d = {'url': saxutils.escape(channel.url), 'message': saxutils.escape(str(e))}
2865 if d['message']:
2866 message = _('Error while updating %(url)s: %(message)s')
2867 else:
2868 message = _('The feed at %(url)s could not be updated.')
2869 self.notification(message % d, _('Error while updating feed'), widget=self.treeChannels)
2870 log('Error: %s', str(e), sender=self, traceback=True)
2872 if self.feed_cache_update_cancelled:
2873 break
2875 # By the time we get here the update may have already been cancelled
2876 if not self.feed_cache_update_cancelled:
2877 def update_progress():
2878 d = {'podcast': channel.title, 'position': updated+1, 'total': total}
2879 progression = _('Updated %(podcast)s (%(position)d/%(total)d)') % d
2880 self.pbFeedUpdate.set_text(progression)
2881 if self.tray_icon:
2882 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression)
2883 self.pbFeedUpdate.set_fraction(float(updated)/float(total))
2884 util.idle_add(update_progress)
2886 updated_urls = [c.url for c in channels]
2887 util.idle_add(self.update_feed_cache_finish_callback, updated_urls, select_url_afterwards)
2889 def show_update_feeds_buttons(self):
2890 # Make sure that the buttons for updating feeds
2891 # appear - this should happen after a feed update
2892 if gpodder.ui.maemo:
2893 self.btnUpdateSelectedFeed.show()
2894 self.toolFeedUpdateProgress.hide()
2895 self.btnCancelFeedUpdate.hide()
2896 self.btnCancelFeedUpdate.set_is_important(False)
2897 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_CLOSE)
2898 self.toolbarSpacer.set_expand(True)
2899 self.toolbarSpacer.set_draw(False)
2900 else:
2901 self.hboxUpdateFeeds.hide()
2902 self.btnUpdateFeeds.show()
2903 self.itemUpdate.set_sensitive(True)
2904 self.itemUpdateChannel.set_sensitive(True)
2906 def on_btnCancelFeedUpdate_clicked(self, widget):
2907 if not self.feed_cache_update_cancelled:
2908 self.pbFeedUpdate.set_text(_('Cancelling...'))
2909 self.feed_cache_update_cancelled = True
2910 self.btnCancelFeedUpdate.set_sensitive(False)
2911 else:
2912 self.show_update_feeds_buttons()
2914 def update_feed_cache(self, channels=None, force_update=True, select_url_afterwards=None):
2915 if self.updating_feed_cache:
2916 if gpodder.ui.fremantle:
2917 self.feed_cache_update_cancelled = True
2918 return
2920 if not force_update:
2921 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2922 self.channel_list_changed = True
2923 self.update_podcast_list_model(select_url=select_url_afterwards)
2924 return
2926 # Fix URLs if mygpo has rewritten them
2927 self.rewrite_urls_mygpo()
2929 self.updating_feed_cache = True
2931 if channels is None:
2932 # Only update podcasts for which updates are enabled
2933 channels = [c for c in self.channels if c.feed_update_enabled]
2935 if gpodder.ui.fremantle:
2936 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
2937 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2938 self.fancy_progress_bar.show()
2939 self.button_subscribe.set_sensitive(False)
2940 self.button_refresh.set_sensitive(False)
2941 self.feed_cache_update_cancelled = False
2942 else:
2943 self.itemUpdate.set_sensitive(False)
2944 self.itemUpdateChannel.set_sensitive(False)
2946 if self.tray_icon:
2947 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
2949 self.feed_cache_update_cancelled = False
2950 self.btnCancelFeedUpdate.show()
2951 self.btnCancelFeedUpdate.set_sensitive(True)
2952 if gpodder.ui.maemo:
2953 self.toolbarSpacer.set_expand(False)
2954 self.toolbarSpacer.set_draw(True)
2955 self.btnUpdateSelectedFeed.hide()
2956 self.toolFeedUpdateProgress.show_all()
2957 else:
2958 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
2959 self.hboxUpdateFeeds.show_all()
2960 self.btnUpdateFeeds.hide()
2962 if len(channels) == 1:
2963 text = _('Updating "%s"...') % channels[0].title
2964 else:
2965 count = len(channels)
2966 text = N_('Updating %d feed...', 'Updating %d feeds...', count) % count
2967 self.pbFeedUpdate.set_text(text)
2968 self.pbFeedUpdate.set_fraction(0)
2970 args = (channels, select_url_afterwards)
2971 threading.Thread(target=self.update_feed_cache_proc, args=args).start()
2973 def on_gPodder_delete_event(self, widget, *args):
2974 """Called when the GUI wants to close the window
2975 Displays a confirmation dialog (and closes/hides gPodder)
2978 downloading = self.download_status_model.are_downloads_in_progress()
2980 # Only iconify if we are using the window's "X" button,
2981 # but not when we are using "Quit" in the menu or toolbar
2982 if self.config.on_quit_systray and self.tray_icon and widget.get_name() not in ('toolQuit', 'itemQuit'):
2983 self.iconify_main_window()
2984 elif self.config.on_quit_ask or downloading:
2985 if gpodder.ui.fremantle:
2986 self.close_gpodder()
2987 elif gpodder.ui.diablo:
2988 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
2989 if result:
2990 self.close_gpodder()
2991 else:
2992 return True
2993 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2994 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2995 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
2997 title = _('Quit gPodder')
2998 if downloading:
2999 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
3000 else:
3001 message = _('Do you really want to quit gPodder now?')
3003 dialog.set_title(title)
3004 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
3005 if not downloading:
3006 cb_ask = gtk.CheckButton(_("Don't ask me again"))
3007 dialog.vbox.pack_start(cb_ask)
3008 cb_ask.show_all()
3010 quit_button.grab_focus()
3011 result = dialog.run()
3012 dialog.destroy()
3014 if result == gtk.RESPONSE_CLOSE:
3015 if not downloading and cb_ask.get_active() == True:
3016 self.config.on_quit_ask = False
3017 self.close_gpodder()
3018 else:
3019 self.close_gpodder()
3021 return True
3023 def close_gpodder(self):
3024 """ clean everything and exit properly
3026 if self.channels:
3027 if self.save_channels_opml():
3028 pass # FIXME: Add mygpo synchronization here
3029 else:
3030 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'), important=True)
3032 self.gPodder.hide()
3034 if self.tray_icon is not None:
3035 self.tray_icon.set_visible(False)
3037 # Notify all tasks to to carry out any clean-up actions
3038 self.download_status_model.tell_all_tasks_to_quit()
3040 while gtk.events_pending():
3041 gtk.main_iteration(False)
3043 self.db.close()
3045 self.quit()
3046 sys.exit(0)
3048 def get_expired_episodes(self):
3049 for channel in self.channels:
3050 for episode in channel.get_downloaded_episodes():
3051 # Never consider locked episodes as old
3052 if episode.is_locked:
3053 continue
3055 # Never consider fresh episodes as old
3056 if episode.age_in_days() < self.config.episode_old_age:
3057 continue
3059 # Do not delete played episodes (except if configured)
3060 if episode.is_played:
3061 if not self.config.auto_remove_played_episodes:
3062 continue
3064 # Do not delete unplayed episodes (except if configured)
3065 if not episode.is_played:
3066 if not self.config.auto_remove_unplayed_episodes:
3067 continue
3069 yield episode
3071 def delete_episode_list(self, episodes, confirm=True, skip_locked=True):
3072 if not episodes:
3073 return False
3075 if skip_locked:
3076 episodes = [e for e in episodes if not e.is_locked]
3078 if not episodes:
3079 title = _('Episodes are locked')
3080 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
3081 self.notification(message, title, widget=self.treeAvailable)
3082 return False
3084 count = len(episodes)
3085 title = N_('Delete %d episode?', 'Delete %d episodes?', count) % count
3086 message = _('Deleting episodes removes downloaded files.')
3088 if gpodder.ui.fremantle:
3089 message = '\n'.join([title, message])
3091 if confirm and not self.show_confirmation(message, title):
3092 return False
3094 progress = ProgressIndicator(_('Deleting episodes'), \
3095 _('Please wait while episodes are deleted'), \
3096 parent=self.get_dialog_parent())
3098 def finish_deletion(episode_urls, channel_urls):
3099 progress.on_finished()
3101 # Episodes have been deleted - persist the database
3102 self.db.commit()
3104 self.update_episode_list_icons(episode_urls)
3105 self.update_podcast_list_model(channel_urls)
3106 self.play_or_download()
3108 def thread_proc():
3109 episode_urls = set()
3110 channel_urls = set()
3112 episodes_status_update = []
3113 for idx, episode in enumerate(episodes):
3114 progress.on_progress(float(idx)/float(len(episodes)))
3115 if episode.is_locked and skip_locked:
3116 log('Not deleting episode (is locked): %s', episode.title)
3117 else:
3118 log('Deleting episode: %s', episode.title)
3119 progress.on_message(episode.title)
3120 episode.delete_from_disk()
3121 episode_urls.add(episode.url)
3122 channel_urls.add(episode.channel.url)
3123 episodes_status_update.append(episode)
3125 # Tell the shownotes window that we have removed the episode
3126 if self.episode_shownotes_window is not None and \
3127 self.episode_shownotes_window.episode is not None and \
3128 self.episode_shownotes_window.episode.url == episode.url:
3129 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
3131 # Notify the web service about the status update + upload
3132 self.mygpo_client.on_delete(episodes_status_update)
3133 self.mygpo_client.flush()
3135 util.idle_add(finish_deletion, episode_urls, channel_urls)
3137 threading.Thread(target=thread_proc).start()
3139 return True
3141 def on_itemRemoveOldEpisodes_activate( self, widget):
3142 if gpodder.ui.maemo:
3143 columns = (
3144 ('maemo_remove_markup', None, None, _('Episode')),
3146 else:
3147 columns = (
3148 ('title_markup', None, None, _('Episode')),
3149 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3150 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3151 ('played_prop', None, None, _('Status')),
3152 ('age_prop', 'age_int_prop', gobject.TYPE_INT, _('Downloaded')),
3155 msg_older_than = N_('Select older than %d day', 'Select older than %d days', self.config.episode_old_age)
3156 selection_buttons = {
3157 _('Select played'): lambda episode: episode.is_played,
3158 msg_older_than % self.config.episode_old_age: lambda episode: episode.age_in_days() > self.config.episode_old_age,
3161 instructions = _('Select the episodes you want to delete:')
3163 episodes = []
3164 selected = []
3165 for channel in self.channels:
3166 for episode in channel.get_downloaded_episodes():
3167 # Disallow deletion of locked episodes that still exist
3168 if not episode.is_locked or not episode.file_exists():
3169 episodes.append(episode)
3170 # Automatically select played and file-less episodes
3171 selected.append(episode.is_played or \
3172 not episode.file_exists())
3174 gPodderEpisodeSelector(self.gPodder, title = _('Delete episodes'), instructions = instructions, \
3175 episodes = episodes, selected = selected, columns = columns, \
3176 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
3177 selection_buttons = selection_buttons, _config=self.config, \
3178 show_episode_shownotes=self.show_episode_shownotes)
3180 def on_selected_episodes_status_changed(self):
3181 # The order of the updates here is important! When "All episodes" is
3182 # selected, the update of the podcast list model depends on the episode
3183 # list selection to determine which podcasts are affected. Updating
3184 # the episode list could remove the selection if a filter is active.
3185 self.update_podcast_list_model(selected=True)
3186 self.update_episode_list_icons(selected=True)
3187 self.db.commit()
3189 def mark_selected_episodes_new(self):
3190 for episode in self.get_selected_episodes():
3191 episode.mark_new()
3192 self.on_selected_episodes_status_changed()
3194 def mark_selected_episodes_old(self):
3195 for episode in self.get_selected_episodes():
3196 episode.mark_old()
3197 self.on_selected_episodes_status_changed()
3199 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
3200 for episode in self.get_selected_episodes():
3201 if toggle:
3202 episode.mark(is_played=not episode.is_played)
3203 else:
3204 episode.mark(is_played=new_value)
3205 self.on_selected_episodes_status_changed()
3207 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3208 for episode in self.get_selected_episodes():
3209 if toggle:
3210 episode.mark(is_locked=not episode.is_locked)
3211 else:
3212 episode.mark(is_locked=new_value)
3213 self.on_selected_episodes_status_changed()
3215 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3216 if self.active_channel is None:
3217 return
3219 self.active_channel.channel_is_locked = not self.active_channel.channel_is_locked
3220 self.active_channel.update_channel_lock()
3222 for episode in self.active_channel.get_all_episodes():
3223 episode.mark(is_locked=self.active_channel.channel_is_locked)
3225 self.update_podcast_list_model(selected=True)
3226 self.update_episode_list_icons(all=True)
3228 def on_itemUpdateChannel_activate(self, widget=None):
3229 if self.active_channel is None:
3230 title = _('No podcast selected')
3231 message = _('Please select a podcast in the podcasts list to update.')
3232 self.show_message( message, title, widget=self.treeChannels)
3233 return
3235 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3236 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3237 self.update_feed_cache()
3238 else:
3239 self.update_feed_cache(channels=[self.active_channel])
3241 def on_itemUpdate_activate(self, widget=None):
3242 # Check if we have outstanding subscribe/unsubscribe actions
3243 if self.on_add_remove_podcasts_mygpo():
3244 log('Update cancelled (received server changes)', sender=self)
3245 return
3247 if self.channels:
3248 self.update_feed_cache()
3249 else:
3250 gPodderWelcome(self.gPodder,
3251 center_on_widget=self.gPodder,
3252 show_example_podcasts_callback=self.on_itemImportChannels_activate,
3253 setup_my_gpodder_callback=self.on_download_subscriptions_from_mygpo)
3255 def download_episode_list_paused(self, episodes):
3256 self.download_episode_list(episodes, True)
3258 def download_episode_list(self, episodes, add_paused=False, force_start=False):
3259 enable_update = False
3261 for episode in episodes:
3262 log('Downloading episode: %s', episode.title, sender = self)
3263 if not episode.was_downloaded(and_exists=True):
3264 task_exists = False
3265 for task in self.download_tasks_seen:
3266 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
3267 self.download_queue_manager.add_task(task, force_start)
3268 enable_update = True
3269 task_exists = True
3270 continue
3272 if task_exists:
3273 continue
3275 try:
3276 task = download.DownloadTask(episode, self.config)
3277 except Exception, e:
3278 d = {'episode': episode.title, 'message': str(e)}
3279 message = _('Download error while downloading %(episode)s: %(message)s')
3280 self.show_message(message % d, _('Download error'), important=True)
3281 log('Download error while downloading %s', episode.title, sender=self, traceback=True)
3282 continue
3284 if add_paused:
3285 task.status = task.PAUSED
3286 else:
3287 self.mygpo_client.on_download([task.episode])
3288 self.download_queue_manager.add_task(task, force_start)
3290 self.download_status_model.register_task(task)
3291 enable_update = True
3293 if enable_update:
3294 self.enable_download_list_update()
3296 # Flush updated episode status
3297 self.mygpo_client.flush()
3299 def cancel_task_list(self, tasks):
3300 if not tasks:
3301 return
3303 for task in tasks:
3304 if task.status in (task.QUEUED, task.DOWNLOADING):
3305 task.status = task.CANCELLED
3306 elif task.status == task.PAUSED:
3307 task.status = task.CANCELLED
3308 # Call run, so the partial file gets deleted
3309 task.run()
3311 self.update_episode_list_icons([task.url for task in tasks])
3312 self.play_or_download()
3314 # Update the tab title and downloads list
3315 self.update_downloads_list()
3317 def new_episodes_show(self, episodes, notification=False, selected=None):
3318 if gpodder.ui.maemo:
3319 columns = (
3320 ('maemo_markup', None, None, _('Episode')),
3322 show_notification = notification
3323 else:
3324 columns = (
3325 ('title_markup', None, None, _('Episode')),
3326 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3327 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3329 show_notification = False
3331 instructions = _('Select the episodes you want to download:')
3333 if self.new_episodes_window is not None:
3334 self.new_episodes_window.main_window.destroy()
3335 self.new_episodes_window = None
3337 def download_episodes_callback(episodes):
3338 self.new_episodes_window = None
3339 self.download_episode_list(episodes)
3341 if selected is None:
3342 # Select all by default
3343 selected = [True]*len(episodes)
3345 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
3346 title=_('New episodes available'), \
3347 instructions=instructions, \
3348 episodes=episodes, \
3349 columns=columns, \
3350 selected=selected, \
3351 stock_ok_button = 'gpodder-download', \
3352 callback=download_episodes_callback, \
3353 remove_callback=lambda e: e.mark_old(), \
3354 remove_action=_('Mark as old'), \
3355 remove_finished=self.episode_new_status_changed, \
3356 _config=self.config, \
3357 show_notification=show_notification, \
3358 show_episode_shownotes=self.show_episode_shownotes)
3360 def on_itemDownloadAllNew_activate(self, widget, *args):
3361 if not self.offer_new_episodes():
3362 self.show_message(_('Please check for new episodes later.'), \
3363 _('No new episodes available'), widget=self.btnUpdateFeeds)
3365 def get_new_episodes(self, channels=None):
3366 if channels is None:
3367 channels = self.channels
3368 episodes = []
3369 for channel in channels:
3370 for episode in channel.get_new_episodes(downloading=self.episode_is_downloading):
3371 episodes.append(episode)
3373 return episodes
3375 def on_sync_to_ipod_activate(self, widget, episodes=None):
3376 self.sync_ui.on_synchronize_episodes(self.channels, episodes)
3378 def commit_changes_to_database(self):
3379 """This will be called after the sync process is finished"""
3380 self.db.commit()
3382 def on_cleanup_ipod_activate(self, widget, *args):
3383 self.sync_ui.on_cleanup_device()
3385 def on_manage_device_playlist(self, widget):
3386 self.sync_ui.on_manage_device_playlist()
3388 def show_hide_tray_icon(self):
3389 if self.config.display_tray_icon and have_trayicon and self.tray_icon is None:
3390 self.tray_icon = GPodderStatusIcon(self, gpodder.icon_file, self.config)
3391 elif not self.config.display_tray_icon and self.tray_icon is not None:
3392 self.tray_icon.set_visible(False)
3393 del self.tray_icon
3394 self.tray_icon = None
3396 if self.config.minimize_to_tray and self.tray_icon:
3397 self.tray_icon.set_visible(self.is_iconified())
3398 elif self.tray_icon:
3399 self.tray_icon.set_visible(True)
3401 def on_itemShowAllEpisodes_activate(self, widget):
3402 self.config.podcast_list_view_all = widget.get_active()
3404 def on_itemShowToolbar_activate(self, widget):
3405 self.config.show_toolbar = self.itemShowToolbar.get_active()
3407 def on_itemShowDescription_activate(self, widget):
3408 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
3410 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
3411 self.config.podcast_list_hide_boring = toggleaction.get_active()
3412 if self.config.podcast_list_hide_boring:
3413 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3414 else:
3415 self.podcast_list_model.set_view_mode(-1)
3417 def on_item_view_podcasts_changed(self, radioaction, current):
3418 # Only on Fremantle
3419 if current == self.item_view_podcasts_all:
3420 self.podcast_list_model.set_view_mode(-1)
3421 elif current == self.item_view_podcasts_downloaded:
3422 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3423 elif current == self.item_view_podcasts_unplayed:
3424 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3426 self.config.podcast_list_view_mode = self.podcast_list_model.get_view_mode()
3428 def on_item_view_episodes_changed(self, radioaction, current):
3429 if current == self.item_view_episodes_all:
3430 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_ALL)
3431 elif current == self.item_view_episodes_undeleted:
3432 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNDELETED)
3433 elif current == self.item_view_episodes_downloaded:
3434 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3435 elif current == self.item_view_episodes_unplayed:
3436 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3438 self.config.episode_list_view_mode = self.episode_list_model.get_view_mode()
3440 if self.config.podcast_list_hide_boring and not gpodder.ui.fremantle:
3441 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3443 def update_item_device( self):
3444 if not gpodder.ui.fremantle:
3445 if self.config.device_type != 'none':
3446 self.itemDevice.set_visible(True)
3447 self.itemDevice.label = self.get_device_name()
3448 else:
3449 self.itemDevice.set_visible(False)
3451 def properties_closed( self):
3452 self.preferences_dialog = None
3453 self.show_hide_tray_icon()
3454 self.update_item_device()
3455 if gpodder.ui.maemo:
3456 selection = self.treeAvailable.get_selection()
3457 if self.config.maemo_enable_gestures or \
3458 self.config.enable_fingerscroll:
3459 selection.set_mode(gtk.SELECTION_SINGLE)
3460 else:
3461 selection.set_mode(gtk.SELECTION_MULTIPLE)
3463 def on_itemPreferences_activate(self, widget, *args):
3464 self.preferences_dialog = gPodderPreferences(self.main_window, \
3465 _config=self.config, \
3466 callback_finished=self.properties_closed, \
3467 user_apps_reader=self.user_apps_reader, \
3468 parent_window=self.main_window, \
3469 mygpo_client=self.mygpo_client, \
3470 on_send_full_subscriptions=self.on_send_full_subscriptions)
3472 # Initial message to relayout window (in case it's opened in portrait mode
3473 self.preferences_dialog.on_window_orientation_changed(self._last_orientation)
3475 def on_itemDependencies_activate(self, widget):
3476 gPodderDependencyManager(self.gPodder)
3478 def on_goto_mygpo(self, widget):
3479 self.mygpo_client.open_website()
3481 def on_download_subscriptions_from_mygpo(self, action=None):
3482 title = _('Login to gpodder.net')
3483 message = _('Please login to download your subscriptions.')
3484 success, (username, password) = self.show_login_dialog(title, message, \
3485 self.config.mygpo_username, self.config.mygpo_password)
3486 if not success:
3487 return
3489 self.config.mygpo_username = username
3490 self.config.mygpo_password = password
3492 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3493 custom_title=_('Subscriptions on gpodder.net'), \
3494 add_urls_callback=self.add_podcast_list, \
3495 hide_url_entry=True)
3497 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
3498 # we do not have to hardcode the URL here
3499 OPML_URL = 'http://gpodder.net/subscriptions/%s.opml' % self.config.mygpo_username
3500 url = util.url_add_authentication(OPML_URL, \
3501 self.config.mygpo_username, \
3502 self.config.mygpo_password)
3503 dir.download_opml_file(url)
3505 def on_mygpo_settings_activate(self, action=None):
3506 # This dialog is only used for Maemo 4
3507 if not gpodder.ui.diablo:
3508 return
3510 settings = MygPodderSettings(self.main_window, \
3511 config=self.config, \
3512 mygpo_client=self.mygpo_client, \
3513 on_send_full_subscriptions=self.on_send_full_subscriptions)
3515 def on_itemAddChannel_activate(self, widget=None):
3516 gPodderAddPodcast(self.gPodder, \
3517 add_urls_callback=self.add_podcast_list)
3519 def on_itemEditChannel_activate(self, widget, *args):
3520 if self.active_channel is None:
3521 title = _('No podcast selected')
3522 message = _('Please select a podcast in the podcasts list to edit.')
3523 self.show_message( message, title, widget=self.treeChannels)
3524 return
3526 callback_closed = lambda: self.update_podcast_list_model(selected=True)
3527 gPodderChannel(self.main_window, \
3528 channel=self.active_channel, \
3529 callback_closed=callback_closed, \
3530 cover_downloader=self.cover_downloader)
3532 def on_itemMassUnsubscribe_activate(self, item=None):
3533 columns = (
3534 ('title', None, None, _('Podcast')),
3537 # We're abusing the Episode Selector for selecting Podcasts here,
3538 # but it works and looks good, so why not? -- thp
3539 gPodderEpisodeSelector(self.main_window, \
3540 title=_('Remove podcasts'), \
3541 instructions=_('Select the podcast you want to remove.'), \
3542 episodes=self.channels, \
3543 columns=columns, \
3544 size_attribute=None, \
3545 stock_ok_button=_('Remove'), \
3546 callback=self.remove_podcast_list, \
3547 _config=self.config)
3549 def remove_podcast_list(self, channels, confirm=True):
3550 if not channels:
3551 log('No podcasts selected for deletion', sender=self)
3552 return
3554 if len(channels) == 1:
3555 title = _('Removing podcast')
3556 info = _('Please wait while the podcast is removed')
3557 message = _('Do you really want to remove this podcast and its episodes?')
3558 else:
3559 title = _('Removing podcasts')
3560 info = _('Please wait while the podcasts are removed')
3561 message = _('Do you really want to remove the selected podcasts and their episodes?')
3563 if confirm and not self.show_confirmation(message, title):
3564 return
3566 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3568 def finish_deletion(select_url):
3569 # Upload subscription list changes to the web service
3570 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3572 # Re-load the channels and select the desired new channel
3573 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
3574 progress.on_finished()
3575 self.update_podcasts_tab()
3577 def thread_proc():
3578 select_url = None
3580 for idx, channel in enumerate(channels):
3581 # Update the UI for correct status messages
3582 progress.on_progress(float(idx)/float(len(channels)))
3583 progress.on_message(channel.title)
3585 # Delete downloaded episodes
3586 channel.remove_downloaded()
3588 # cancel any active downloads from this channel
3589 for episode in channel.get_all_episodes():
3590 util.idle_add(self.download_status_model.cancel_by_url,
3591 episode.url)
3593 if len(channels) == 1:
3594 # get the URL of the podcast we want to select next
3595 if channel in self.channels:
3596 position = self.channels.index(channel)
3597 else:
3598 position = -1
3600 if position == len(self.channels)-1:
3601 # this is the last podcast, so select the URL
3602 # of the item before this one (i.e. the "new last")
3603 select_url = self.channels[position-1].url
3604 else:
3605 # there is a podcast after the deleted one, so
3606 # we simply select the one that comes after it
3607 select_url = self.channels[position+1].url
3609 # Remove the channel and clean the database entries
3610 channel.delete()
3611 self.channels.remove(channel)
3613 # Clean up downloads and download directories
3614 self.clean_up_downloads()
3616 self.channel_list_changed = True
3617 self.save_channels_opml()
3619 # The remaining stuff is to be done in the GTK main thread
3620 util.idle_add(finish_deletion, select_url)
3622 threading.Thread(target=thread_proc).start()
3624 def on_itemRemoveChannel_activate(self, widget, *args):
3625 if self.active_channel is None:
3626 title = _('No podcast selected')
3627 message = _('Please select a podcast in the podcasts list to remove.')
3628 self.show_message( message, title, widget=self.treeChannels)
3629 return
3631 self.remove_podcast_list([self.active_channel])
3633 def get_opml_filter(self):
3634 filter = gtk.FileFilter()
3635 filter.add_pattern('*.opml')
3636 filter.add_pattern('*.xml')
3637 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
3638 return filter
3640 def on_item_import_from_file_activate(self, widget, filename=None):
3641 if filename is None:
3642 if gpodder.ui.desktop or gpodder.ui.fremantle:
3643 # FIXME: Hildonization on Fremantle
3644 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
3645 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3646 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3647 elif gpodder.ui.diablo:
3648 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
3649 dlg.set_filter(self.get_opml_filter())
3650 response = dlg.run()
3651 filename = None
3652 if response == gtk.RESPONSE_OK:
3653 filename = dlg.get_filename()
3654 dlg.destroy()
3656 if filename is not None:
3657 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3658 custom_title=_('Import podcasts from OPML file'), \
3659 add_urls_callback=self.add_podcast_list, \
3660 hide_url_entry=True)
3661 dir.download_opml_file(filename)
3663 def on_itemExportChannels_activate(self, widget, *args):
3664 if not self.channels:
3665 title = _('Nothing to export')
3666 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3667 self.show_message(message, title, widget=self.treeChannels)
3668 return
3670 if gpodder.ui.desktop or gpodder.ui.fremantle:
3671 # FIXME: Hildonization on Fremantle
3672 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3673 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3674 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3675 elif gpodder.ui.diablo:
3676 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
3677 dlg.set_filter(self.get_opml_filter())
3678 response = dlg.run()
3679 if response == gtk.RESPONSE_OK:
3680 filename = dlg.get_filename()
3681 dlg.destroy()
3682 exporter = opml.Exporter( filename)
3683 if exporter.write(self.channels):
3684 count = len(self.channels)
3685 title = N_('%d subscription exported', '%d subscriptions exported', count) % count
3686 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3687 else:
3688 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3689 else:
3690 dlg.destroy()
3692 def on_itemImportChannels_activate(self, widget, *args):
3693 if gpodder.ui.fremantle:
3694 gPodderPodcastDirectory.show_add_podcast_picker(self.main_window, \
3695 self.config.toplist_url, \
3696 self.config.opml_url, \
3697 self.add_podcast_list, \
3698 self.on_itemAddChannel_activate, \
3699 self.on_download_subscriptions_from_mygpo, \
3700 self.show_text_edit_dialog)
3701 else:
3702 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3703 add_urls_callback=self.add_podcast_list)
3704 util.idle_add(dir.download_opml_file, self.config.opml_url)
3706 def on_homepage_activate(self, widget, *args):
3707 util.open_website(gpodder.__url__)
3709 def on_wiki_activate(self, widget, *args):
3710 util.open_website('http://gpodder.org/wiki/User_Manual')
3712 def on_bug_tracker_activate(self, widget, *args):
3713 if gpodder.ui.maemo:
3714 util.open_website('http://bugs.maemo.org/enter_bug.cgi?product=gPodder')
3715 else:
3716 util.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder')
3718 def on_item_support_activate(self, widget):
3719 util.open_website('http://gpodder.org/donate')
3721 def on_itemAbout_activate(self, widget, *args):
3722 if gpodder.ui.fremantle:
3723 from gpodder.gtkui.frmntl.about import HeAboutDialog
3724 HeAboutDialog.present(self.main_window,
3725 'gPodder',
3726 'gpodder',
3727 gpodder.__version__,
3728 _('A podcast client with focus on usability'),
3729 gpodder.__copyright__,
3730 gpodder.__url__,
3731 'http://bugs.maemo.org/enter_bug.cgi?product=gPodder',
3732 'http://gpodder.org/donate')
3733 return
3735 dlg = gtk.AboutDialog()
3736 dlg.set_transient_for(self.main_window)
3737 dlg.set_name('gPodder')
3738 dlg.set_version(gpodder.__version__)
3739 dlg.set_copyright(gpodder.__copyright__)
3740 dlg.set_comments(_('A podcast client with focus on usability'))
3741 dlg.set_website(gpodder.__url__)
3742 dlg.set_translator_credits( _('translator-credits'))
3743 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
3745 if gpodder.ui.desktop:
3746 # For the "GUI" version, we add some more
3747 # items to the about dialog (credits and logo)
3748 app_authors = [
3749 _('Maintainer:'),
3750 'Thomas Perl <thpinfo.com>',
3753 if os.path.exists(gpodder.credits_file):
3754 credits = open(gpodder.credits_file).read().strip().split('\n')
3755 app_authors += ['', _('Patches, bug reports and donations by:')]
3756 app_authors += credits
3758 dlg.set_authors(app_authors)
3759 try:
3760 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(gpodder.icon_file))
3761 except:
3762 dlg.set_logo_icon_name('gpodder')
3764 dlg.run()
3766 def on_wNotebook_switch_page(self, widget, *args):
3767 page_num = args[1]
3768 if gpodder.ui.maemo:
3769 self.tool_downloads.set_active(page_num == 1)
3770 page = self.wNotebook.get_nth_page(page_num)
3771 tab_label = self.wNotebook.get_tab_label(page).get_text()
3772 if page_num == 0 and self.active_channel is not None:
3773 self.set_title(self.active_channel.title)
3774 else:
3775 self.set_title(tab_label)
3776 if page_num == 0:
3777 self.play_or_download()
3778 self.menuChannels.set_sensitive(True)
3779 self.menuSubscriptions.set_sensitive(True)
3780 # The message area in the downloads tab should be hidden
3781 # when the user switches away from the downloads tab
3782 if self.message_area is not None:
3783 self.message_area.hide()
3784 self.message_area = None
3785 else:
3786 self.menuChannels.set_sensitive(False)
3787 self.menuSubscriptions.set_sensitive(False)
3788 if gpodder.ui.desktop:
3789 self.toolDownload.set_sensitive(False)
3790 self.toolPlay.set_sensitive(False)
3791 self.toolTransfer.set_sensitive(False)
3792 self.toolCancel.set_sensitive(False)
3794 def on_treeChannels_row_activated(self, widget, path, *args):
3795 # double-click action of the podcast list or enter
3796 self.treeChannels.set_cursor(path)
3798 def on_treeChannels_cursor_changed(self, widget, *args):
3799 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3801 if model is not None and iter is not None:
3802 old_active_channel = self.active_channel
3803 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3805 if self.active_channel == old_active_channel:
3806 return
3808 if gpodder.ui.maemo:
3809 self.set_title(self.active_channel.title)
3811 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3812 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3813 self.itemEditChannel.set_visible(False)
3814 self.itemRemoveChannel.set_visible(False)
3815 else:
3816 self.itemEditChannel.set_visible(True)
3817 self.itemRemoveChannel.set_visible(True)
3818 else:
3819 self.active_channel = None
3820 self.itemEditChannel.set_visible(False)
3821 self.itemRemoveChannel.set_visible(False)
3823 self.update_episode_list_model()
3825 def on_btnEditChannel_clicked(self, widget, *args):
3826 self.on_itemEditChannel_activate( widget, args)
3828 def get_podcast_urls_from_selected_episodes(self):
3829 """Get a set of podcast URLs based on the selected episodes"""
3830 return set(episode.channel.url for episode in \
3831 self.get_selected_episodes())
3833 def get_selected_episodes(self):
3834 """Get a list of selected episodes from treeAvailable"""
3835 selection = self.treeAvailable.get_selection()
3836 model, paths = selection.get_selected_rows()
3838 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3839 return episodes
3841 def on_transfer_selected_episodes(self, widget):
3842 self.on_sync_to_ipod_activate(widget, self.get_selected_episodes())
3844 def on_playback_selected_episodes(self, widget):
3845 self.playback_episodes(self.get_selected_episodes())
3847 def on_shownotes_selected_episodes(self, widget):
3848 episodes = self.get_selected_episodes()
3849 if episodes:
3850 episode = episodes.pop(0)
3851 self.show_episode_shownotes(episode)
3852 else:
3853 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3855 def on_download_selected_episodes(self, widget):
3856 episodes = self.get_selected_episodes()
3857 self.download_episode_list(episodes)
3858 self.update_episode_list_icons([episode.url for episode in episodes])
3859 self.play_or_download()
3861 def on_treeAvailable_row_activated(self, widget, path, view_column):
3862 """Double-click/enter action handler for treeAvailable"""
3863 # We should only have one one selected as it was double clicked!
3864 e = self.get_selected_episodes()[0]
3866 if (self.config.double_click_episode_action == 'download'):
3867 # If the episode has already been downloaded and exists then play it
3868 if e.was_downloaded(and_exists=True):
3869 self.playback_episodes(self.get_selected_episodes())
3870 # else download it if it is not already downloading
3871 elif not self.episode_is_downloading(e):
3872 self.download_episode_list([e])
3873 self.update_episode_list_icons([e.url])
3874 self.play_or_download()
3875 elif (self.config.double_click_episode_action == 'stream'):
3876 # If we happen to have downloaded this episode simple play it
3877 if e.was_downloaded(and_exists=True):
3878 self.playback_episodes(self.get_selected_episodes())
3879 # else if streaming is possible stream it
3880 elif self.streaming_possible():
3881 self.playback_episodes(self.get_selected_episodes())
3882 else:
3883 log('Unable to stream episode - default media player selected!', sender=self, traceback=True)
3884 self.show_message(_('Please check your media player settings in the preferences dialog.'), _('Unable to stream episode'), widget=self.toolPreferences)
3885 else:
3886 # default action is to display show notes
3887 self.on_shownotes_selected_episodes(widget)
3889 def show_episode_shownotes(self, episode):
3890 if self.episode_shownotes_window is None:
3891 log('First-time use of episode window --- creating', sender=self)
3892 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3893 _download_episode_list=self.download_episode_list, \
3894 _playback_episodes=self.playback_episodes, \
3895 _delete_episode_list=self.delete_episode_list, \
3896 _episode_list_status_changed=self.episode_list_status_changed, \
3897 _cancel_task_list=self.cancel_task_list, \
3898 _episode_is_downloading=self.episode_is_downloading, \
3899 _streaming_possible=self.streaming_possible())
3900 self.episode_shownotes_window.show(episode)
3901 if self.episode_is_downloading(episode):
3902 self.update_downloads_list()
3904 def restart_auto_update_timer(self):
3905 if self._auto_update_timer_source_id is not None:
3906 log('Removing existing auto update timer.', sender=self)
3907 gobject.source_remove(self._auto_update_timer_source_id)
3908 self._auto_update_timer_source_id = None
3910 if self.config.auto_update_feeds and \
3911 self.config.auto_update_frequency:
3912 interval = 60*1000*self.config.auto_update_frequency
3913 log('Setting up auto update timer with interval %d.', \
3914 self.config.auto_update_frequency, sender=self)
3915 self._auto_update_timer_source_id = gobject.timeout_add(\
3916 interval, self._on_auto_update_timer)
3918 def _on_auto_update_timer(self):
3919 log('Auto update timer fired.', sender=self)
3920 self.update_feed_cache(force_update=True)
3922 # Ask web service for sub changes (if enabled)
3923 self.mygpo_client.flush()
3925 return True
3927 def on_treeDownloads_row_activated(self, widget, *args):
3928 # Use the standard way of working on the treeview
3929 selection = self.treeDownloads.get_selection()
3930 (model, paths) = selection.get_selected_rows()
3931 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3933 for tree_row_reference, task in selected_tasks:
3934 if task.status in (task.DOWNLOADING, task.QUEUED):
3935 task.status = task.PAUSED
3936 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3937 self.download_queue_manager.add_task(task)
3938 self.enable_download_list_update()
3939 elif task.status == task.DONE:
3940 model.remove(model.get_iter(tree_row_reference.get_path()))
3942 self.play_or_download()
3944 # Update the tab title and downloads list
3945 self.update_downloads_list()
3947 def on_item_cancel_download_activate(self, widget):
3948 if self.wNotebook.get_current_page() == 0:
3949 selection = self.treeAvailable.get_selection()
3950 (model, paths) = selection.get_selected_rows()
3951 urls = [model.get_value(model.get_iter(path), \
3952 self.episode_list_model.C_URL) for path in paths]
3953 selected_tasks = [task for task in self.download_tasks_seen \
3954 if task.url in urls]
3955 else:
3956 selection = self.treeDownloads.get_selection()
3957 (model, paths) = selection.get_selected_rows()
3958 selected_tasks = [model.get_value(model.get_iter(path), \
3959 self.download_status_model.C_TASK) for path in paths]
3960 self.cancel_task_list(selected_tasks)
3962 def on_btnCancelAll_clicked(self, widget, *args):
3963 self.cancel_task_list(self.download_tasks_seen)
3965 def on_btnDownloadedDelete_clicked(self, widget, *args):
3966 episodes = self.get_selected_episodes()
3967 if len(episodes) == 1:
3968 self.delete_episode_list(episodes, skip_locked=False)
3969 else:
3970 self.delete_episode_list(episodes)
3972 def on_key_press(self, widget, event):
3973 # Allow tab switching with Ctrl + PgUp/PgDown
3974 if event.state & gtk.gdk.CONTROL_MASK:
3975 if event.keyval == gtk.keysyms.Page_Up:
3976 self.wNotebook.prev_page()
3977 return True
3978 elif event.keyval == gtk.keysyms.Page_Down:
3979 self.wNotebook.next_page()
3980 return True
3982 # After this code we only handle Maemo hardware keys,
3983 # so if we are not a Maemo app, we don't do anything
3984 if not gpodder.ui.maemo:
3985 return False
3987 diff = 0
3988 if event.keyval == gtk.keysyms.F7: #plus
3989 diff = 1
3990 elif event.keyval == gtk.keysyms.F8: #minus
3991 diff = -1
3993 if diff != 0 and not self.currently_updating:
3994 selection = self.treeChannels.get_selection()
3995 (model, iter) = selection.get_selected()
3996 new_path = ((model.get_path(iter)[0]+diff)%len(model),)
3997 selection.select_path(new_path)
3998 self.treeChannels.set_cursor(new_path)
3999 return True
4001 return False
4003 def on_iconify(self):
4004 if self.tray_icon:
4005 self.gPodder.set_skip_taskbar_hint(True)
4006 if self.config.minimize_to_tray:
4007 self.tray_icon.set_visible(True)
4008 else:
4009 self.gPodder.set_skip_taskbar_hint(False)
4011 def on_uniconify(self):
4012 if self.tray_icon:
4013 self.gPodder.set_skip_taskbar_hint(False)
4014 if self.config.minimize_to_tray:
4015 self.tray_icon.set_visible(False)
4016 else:
4017 self.gPodder.set_skip_taskbar_hint(False)
4019 def uniconify_main_window(self):
4020 if self.is_iconified():
4021 # We need to hide and then show the window in WMs like Metacity
4022 # or KWin4 to move the window to the active workspace
4023 # (see http://gpodder.org/bug/1125)
4024 self.gPodder.hide()
4025 self.gPodder.show()
4026 self.gPodder.present()
4028 def iconify_main_window(self):
4029 if not self.is_iconified():
4030 self.gPodder.iconify()
4032 def update_podcasts_tab(self):
4033 if len(self.channels):
4034 if gpodder.ui.fremantle:
4035 self.button_refresh.set_title(_('Check for new episodes'))
4036 self.button_refresh.show()
4037 else:
4038 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
4039 else:
4040 if gpodder.ui.fremantle:
4041 self.button_refresh.hide()
4042 else:
4043 self.label2.set_text(_('Podcasts'))
4045 @dbus.service.method(gpodder.dbus_interface)
4046 def show_gui_window(self):
4047 parent = self.get_dialog_parent()
4048 parent.present()
4050 @dbus.service.method(gpodder.dbus_interface)
4051 def subscribe_to_url(self, url):
4052 gPodderAddPodcast(self.gPodder,
4053 add_urls_callback=self.add_podcast_list,
4054 preset_url=url)
4056 @dbus.service.method(gpodder.dbus_interface)
4057 def mark_episode_played(self, filename):
4058 if filename is None:
4059 return False
4061 for channel in self.channels:
4062 for episode in channel.get_all_episodes():
4063 fn = episode.local_filename(create=False, check_only=True)
4064 if fn == filename:
4065 episode.mark(is_played=True)
4066 self.db.commit()
4067 self.update_episode_list_icons([episode.url])
4068 self.update_podcast_list_model([episode.channel.url])
4069 return True
4071 return False
4074 def main(options=None):
4075 gobject.threads_init()
4076 gobject.set_application_name('gPodder')
4078 if gpodder.ui.maemo:
4079 # Try to enable the custom icon theme for gPodder on Maemo
4080 settings = gtk.settings_get_default()
4081 settings.set_string_property('gtk-icon-theme-name', \
4082 'gpodder', __file__)
4083 # Extend the search path for the optified icon theme (Maemo 5)
4084 icon_theme = gtk.icon_theme_get_default()
4085 icon_theme.prepend_search_path('/opt/gpodder-icon-theme/')
4087 gtk.window_set_default_icon_name('gpodder')
4088 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
4090 try:
4091 dbus_main_loop = dbus.glib.DBusGMainLoop(set_as_default=True)
4092 gpodder.dbus_session_bus = dbus.SessionBus(dbus_main_loop)
4094 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=gpodder.dbus_session_bus)
4095 except dbus.exceptions.DBusException, dbe:
4096 log('Warning: Cannot get "on the bus".', traceback=True)
4097 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
4098 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
4099 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
4100 dlg.set_title('gPodder')
4101 dlg.run()
4102 dlg.destroy()
4103 sys.exit(0)
4105 util.make_directory(gpodder.home)
4106 gpodder.load_plugins()
4108 config = UIConfig(gpodder.config_file)
4110 # Load hook modules and install the hook manager globally
4111 # if modules have been found an instantiated by the manager
4112 user_hooks = hooks.HookManager()
4113 if user_hooks.has_modules():
4114 gpodder.user_hooks = user_hooks
4116 if gpodder.ui.diablo:
4117 # Detect changing of SD cards between mmc1/mmc2 if a gpodder
4118 # folder exists there (allow moving "gpodder" between SD cards or USB)
4119 # Also allow moving "gpodder" to home folder (e.g. rootfs on SD)
4120 if not os.path.exists(config.download_dir):
4121 log('Downloads might have been moved. Trying to locate them...')
4122 for basedir in ['/media/mmc1', '/media/mmc2']+glob.glob('/media/usb/*')+['/home/user/MyDocs']:
4123 dir = os.path.join(basedir, 'gpodder')
4124 if os.path.exists(dir):
4125 log('Downloads found in: %s', dir)
4126 config.download_dir = dir
4127 break
4128 else:
4129 log('Downloads NOT FOUND in %s', dir)
4131 if config.enable_fingerscroll:
4132 BuilderWidget.use_fingerscroll = True
4133 elif gpodder.ui.fremantle:
4134 config.on_quit_ask = False
4136 config.mygpo_device_type = util.detect_device_type()
4138 gp = gPodder(bus_name, config)
4140 # Handle options
4141 if options.subscribe:
4142 util.idle_add(gp.subscribe_to_url, options.subscribe)
4144 # mac OS X stuff :
4145 # handle "subscribe to podcast" events from firefox
4146 if platform.system() == 'Darwin':
4147 from gpodder import gpodderosx
4148 gpodderosx.register_handlers(gp)
4149 # end mac OS X stuff
4151 gp.run()