Maemo 5: Avoid multiple notifications (Maemo bug 11235)
[gpodder.git] / src / gpodder / gui.py
blob9a34abfd07eb7f81fe26f90c2c99f0c591e17e82
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 have_trayicon = False
142 from gpodder.gtkui.frmntl.portrait import FremantleRotation
143 from gpodder.gtkui.frmntl.mafw import MafwPlaybackMonitor
145 from gpodder.gtkui.interface.common import Orientation
147 from gpodder.gtkui.interface.welcome import gPodderWelcome
149 if gpodder.ui.maemo:
150 import hildon
152 from gpodder.dbusproxy import DBusPodcastsProxy
153 from gpodder import hooks
155 class gPodder(BuilderWidget, dbus.service.Object):
156 finger_friendly_widgets = ['btnCleanUpDownloads', 'button_search_episodes_clear']
158 ICON_GENERAL_ADD = 'general_add'
159 ICON_GENERAL_REFRESH = 'general_refresh'
160 ICON_GENERAL_CLOSE = 'general_close'
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 from gpodder.gtkui.frmntl import style
353 sub_font = style.get_font_desc('SmallSystemFont')
354 sub_color = style.get_color('SecondaryTextColor')
355 sub = (sub_font.to_string(), sub_color.to_string())
356 sub = '<span font_desc="%s" foreground="%s">%%s</span>' % sub
357 self.label_footer.set_markup(sub % gpodder.__copyright__)
359 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
360 while gtk.events_pending():
361 gtk.main_iteration(False)
363 try:
364 # Try to get the real package version from dpkg
365 p = subprocess.Popen(['dpkg-query', '-W', '-f=${Version}', 'gpodder'], stdout=subprocess.PIPE)
366 version, _stderr = p.communicate()
367 del _stderr
368 del p
369 except:
370 version = gpodder.__version__
371 self.label_footer.set_markup(sub % ('v %s' % version))
372 self.label_footer.hide()
374 self.episodes_window = gPodderEpisodes(self.main_window, \
375 on_treeview_expose_event=self.on_treeview_expose_event, \
376 show_episode_shownotes=self.show_episode_shownotes, \
377 update_podcast_list_model=self.update_podcast_list_model, \
378 on_itemRemoveChannel_activate=self.on_itemRemoveChannel_activate, \
379 item_view_episodes_all=self.item_view_episodes_all, \
380 item_view_episodes_unplayed=self.item_view_episodes_unplayed, \
381 item_view_episodes_downloaded=self.item_view_episodes_downloaded, \
382 item_view_episodes_undeleted=self.item_view_episodes_undeleted, \
383 on_entry_search_episodes_changed=self.on_entry_search_episodes_changed, \
384 on_entry_search_episodes_key_press=self.on_entry_search_episodes_key_press, \
385 hide_episode_search=self.hide_episode_search, \
386 on_itemUpdateChannel_activate=self.on_itemUpdateChannel_activate, \
387 playback_episodes=self.playback_episodes, \
388 delete_episode_list=self.delete_episode_list, \
389 episode_list_status_changed=self.episode_list_status_changed, \
390 download_episode_list=self.download_episode_list, \
391 episode_is_downloading=self.episode_is_downloading, \
392 show_episode_in_download_manager=self.show_episode_in_download_manager, \
393 add_download_task_monitor=self.add_download_task_monitor, \
394 remove_download_task_monitor=self.remove_download_task_monitor, \
395 for_each_episode_set_task_status=self.for_each_episode_set_task_status, \
396 on_delete_episodes_button_clicked=self.on_itemRemoveOldEpisodes_activate, \
397 on_itemUpdate_activate=self.on_itemUpdate_activate)
399 # Expose objects for episode list type-ahead find
400 self.hbox_search_episodes = self.episodes_window.hbox_search_episodes
401 self.entry_search_episodes = self.episodes_window.entry_search_episodes
402 self.button_search_episodes_clear = self.episodes_window.button_search_episodes_clear
404 self.downloads_window = gPodderDownloads(self.main_window, \
405 on_treeview_expose_event=self.on_treeview_expose_event, \
406 cleanup_downloads=self.cleanup_downloads, \
407 _for_each_task_set_status=self._for_each_task_set_status, \
408 downloads_list_get_selection=self.downloads_list_get_selection, \
409 _config=self.config)
411 self.treeAvailable = self.episodes_window.treeview
412 self.treeDownloads = self.downloads_window.treeview
414 # Init the treeviews that we use
415 self.init_podcast_list_treeview()
416 self.init_episode_list_treeview()
417 self.init_download_list_treeview()
419 if self.config.podcast_list_hide_boring:
420 self.item_view_hide_boring_podcasts.set_active(True)
422 self.currently_updating = False
424 if gpodder.ui.maemo:
425 self.context_menu_mouse_button = 1
426 else:
427 self.context_menu_mouse_button = 3
429 if self.config.start_iconified:
430 self.iconify_main_window()
432 self.download_tasks_seen = set()
433 self.download_list_update_enabled = False
434 self.download_task_monitors = set()
436 # Subscribed channels
437 self.active_channel = None
438 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
439 self.channel_list_changed = True
440 self.update_podcasts_tab()
442 # load list of user applications for audio playback
443 self.user_apps_reader = UserAppsReader(['audio', 'video'])
444 threading.Thread(target=self.user_apps_reader.read).start()
446 # Set the "Device" menu item for the first time
447 if gpodder.ui.desktop:
448 self.update_item_device()
450 # Set up the first instance of MygPoClient
451 self.mygpo_client = my.MygPoClient(self.config)
453 # Now, update the feed cache, when everything's in place
454 if not gpodder.ui.fremantle:
455 self.btnUpdateFeeds.show()
456 self.updating_feed_cache = False
457 self.feed_cache_update_cancelled = False
458 self.update_feed_cache(force_update=self.config.update_on_startup)
460 self.message_area = None
462 def find_partial_downloads():
463 # Look for partial file downloads
464 partial_files = glob.glob(os.path.join(self.config.download_dir, '*', '*.partial'))
465 count = len(partial_files)
466 resumable_episodes = []
467 if count:
468 if not gpodder.ui.fremantle:
469 util.idle_add(self.wNotebook.set_current_page, 1)
470 indicator = ProgressIndicator(_('Loading incomplete downloads'), \
471 _('Some episodes have not finished downloading in a previous session.'), \
472 False, self.get_dialog_parent())
473 indicator.on_message(N_('%d partial file', '%d partial files', count) % count)
475 candidates = [f[:-len('.partial')] for f in partial_files]
476 found = 0
478 for c in self.channels:
479 for e in c.get_all_episodes():
480 filename = e.local_filename(create=False, check_only=True)
481 if filename in candidates:
482 log('Found episode: %s', e.title, sender=self)
483 found += 1
484 indicator.on_message(e.title)
485 indicator.on_progress(float(found)/count)
486 candidates.remove(filename)
487 partial_files.remove(filename+'.partial')
488 resumable_episodes.append(e)
490 if not candidates:
491 break
493 if not candidates:
494 break
496 for f in partial_files:
497 log('Partial file without episode: %s', f, sender=self)
498 util.delete_file(f)
500 util.idle_add(indicator.on_finished)
502 if len(resumable_episodes):
503 def offer_resuming():
504 self.download_episode_list_paused(resumable_episodes)
505 if not gpodder.ui.fremantle:
506 resume_all = gtk.Button(_('Resume all'))
507 #resume_all.set_border_width(0)
508 def on_resume_all(button):
509 selection = self.treeDownloads.get_selection()
510 selection.select_all()
511 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = self.downloads_list_get_selection()
512 selection.unselect_all()
513 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
514 self.message_area.hide()
515 resume_all.connect('clicked', on_resume_all)
517 self.message_area = SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all,))
518 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
519 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
520 self.message_area.show_all()
521 self.clean_up_downloads(delete_partial=False)
522 util.idle_add(offer_resuming)
523 elif not gpodder.ui.fremantle:
524 util.idle_add(self.wNotebook.set_current_page, 0)
525 else:
526 util.idle_add(self.clean_up_downloads, True)
527 threading.Thread(target=find_partial_downloads).start()
529 # Start the auto-update procedure
530 self._auto_update_timer_source_id = None
531 if self.config.auto_update_feeds:
532 self.restart_auto_update_timer()
534 # Delete old episodes if the user wishes to
535 if self.config.auto_remove_played_episodes and \
536 self.config.episode_old_age > 0:
537 old_episodes = list(self.get_expired_episodes())
538 if len(old_episodes) > 0:
539 self.delete_episode_list(old_episodes, confirm=False)
540 self.update_podcast_list_model(set(e.channel.url for e in old_episodes))
542 if gpodder.ui.fremantle:
543 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
544 self.button_refresh.set_sensitive(True)
545 self.button_subscribe.set_sensitive(True)
546 self.main_window.set_title(_('gPodder'))
547 hildon.hildon_gtk_window_take_screenshot(self.main_window, True)
549 # Do the initial sync with the web service
550 util.idle_add(self.mygpo_client.flush, True)
552 # First-time users should be asked if they want to see the OPML
553 if not self.channels and not gpodder.ui.fremantle:
554 util.idle_add(self.on_itemUpdate_activate)
556 def episode_object_by_uri(self, uri):
557 """Get an episode object given a local or remote URI
559 This can be used to quickly access an episode object
560 when all we have is its download filename or episode
561 URL (e.g. from external D-Bus calls / signals, etc..)
563 if uri.startswith('/'):
564 uri = 'file://' + uri
566 prefix = 'file://' + self.config.download_dir
568 if uri.startswith(prefix):
569 # File is on the local filesystem in the download folder
570 filename = uri[len(prefix):]
571 file_parts = [x for x in filename.split(os.sep) if x]
573 if len(file_parts) == 2:
574 dir_name, filename = file_parts
575 channels = [c for c in self.channels if c.foldername == dir_name]
576 if len(channels) == 1:
577 channel = channels[0]
578 return channel.get_episode_by_filename(filename)
579 else:
580 # Possibly remote file - search the database for a podcast
581 channel_id = self.db.get_channel_id_from_episode_url(uri)
583 if channel_id is not None:
584 channels = [c for c in self.channels if c.id == channel_id]
585 if len(channels) == 1:
586 channel = channels[0]
587 return channel.get_episode_by_url(uri)
589 return None
591 def on_played(self, start, end, total, file_uri):
592 """Handle the "played" signal from a media player"""
593 if start == 0 and end == 0 and total == 0:
594 # Ignore bogus play event
595 return
596 elif end < start + 5:
597 # Ignore "less than five seconds" segments,
598 # as they can happen with seeking, etc...
599 return
601 log('Received play action: %s (%d, %d, %d)', file_uri, start, end, total, sender=self)
602 episode = self.episode_object_by_uri(file_uri)
604 if episode is not None:
605 file_type = episode.file_type()
606 # Automatically enable D-Bus played status mode
607 if file_type == 'audio':
608 self.config.audio_played_dbus = True
609 elif file_type == 'video':
610 self.config.video_played_dbus = True
612 now = time.time()
613 if total > 0:
614 episode.total_time = total
615 elif total == 0:
616 # Assume the episode's total time for the action
617 total = episode.total_time
618 if episode.current_position_updated is None or \
619 now > episode.current_position_updated:
620 episode.current_position = end
621 episode.current_position_updated = now
622 episode.mark(is_played=True)
623 episode.save()
624 self.db.commit()
625 self.update_episode_list_icons([episode.url])
626 self.update_podcast_list_model([episode.channel.url])
628 # Submit this action to the webservice
629 self.mygpo_client.on_playback_full(episode, \
630 start, end, total)
632 def on_add_remove_podcasts_mygpo(self):
633 actions = self.mygpo_client.get_received_actions()
634 if not actions:
635 return False
637 existing_urls = [c.url for c in self.channels]
639 # Columns for the episode selector window - just one...
640 columns = (
641 ('description', None, None, _('Action')),
644 # A list of actions that have to be chosen from
645 changes = []
647 # Actions that are ignored (already carried out)
648 ignored = []
650 for action in actions:
651 if action.is_add and action.url not in existing_urls:
652 changes.append(my.Change(action))
653 elif action.is_remove and action.url in existing_urls:
654 podcast_object = None
655 for podcast in self.channels:
656 if podcast.url == action.url:
657 podcast_object = podcast
658 break
659 changes.append(my.Change(action, podcast_object))
660 else:
661 log('Ignoring action: %s', action, sender=self)
662 ignored.append(action)
664 # Confirm all ignored changes
665 self.mygpo_client.confirm_received_actions(ignored)
667 def execute_podcast_actions(selected):
668 add_list = [c.action.url for c in selected if c.action.is_add]
669 remove_list = [c.podcast for c in selected if c.action.is_remove]
671 # Apply the accepted changes locally
672 self.add_podcast_list(add_list)
673 self.remove_podcast_list(remove_list, confirm=False)
675 # All selected items are now confirmed
676 self.mygpo_client.confirm_received_actions(c.action for c in selected)
678 # Revert the changes on the server
679 rejected = [c.action for c in changes if c not in selected]
680 self.mygpo_client.reject_received_actions(rejected)
682 def ask():
683 # We're abusing the Episode Selector again ;) -- thp
684 gPodderEpisodeSelector(self.main_window, \
685 title=_('Confirm changes from gpodder.net'), \
686 instructions=_('Select the actions you want to carry out.'), \
687 episodes=changes, \
688 columns=columns, \
689 size_attribute=None, \
690 stock_ok_button=gtk.STOCK_APPLY, \
691 callback=execute_podcast_actions, \
692 _config=self.config)
694 # There are some actions that need the user's attention
695 if changes:
696 util.idle_add(ask)
697 return True
699 # We have no remaining actions - no selection happens
700 return False
702 def rewrite_urls_mygpo(self):
703 # Check if we have to rewrite URLs since the last add
704 rewritten_urls = self.mygpo_client.get_rewritten_urls()
706 for rewritten_url in rewritten_urls:
707 if not rewritten_url.new_url:
708 continue
710 for channel in self.channels:
711 if channel.url == rewritten_url.old_url:
712 log('Updating URL of %s to %s', channel, \
713 rewritten_url.new_url, sender=self)
714 channel.url = rewritten_url.new_url
715 channel.save()
716 self.channel_list_changed = True
717 util.idle_add(self.update_episode_list_model)
718 break
720 def on_send_full_subscriptions(self):
721 # Send the full subscription list to the gpodder.net client
722 # (this will overwrite the subscription list on the server)
723 indicator = ProgressIndicator(_('Uploading subscriptions'), \
724 _('Your subscriptions are being uploaded to the server.'), \
725 False, self.get_dialog_parent())
727 try:
728 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
729 util.idle_add(self.show_message, _('List uploaded successfully.'))
730 except Exception, e:
731 def show_error(e):
732 message = str(e)
733 if not message:
734 message = e.__class__.__name__
735 self.show_message(message, \
736 _('Error while uploading'), \
737 important=True)
738 util.idle_add(show_error, e)
740 util.idle_add(indicator.on_finished)
742 def on_podcast_selected(self, treeview, path, column):
743 # for Maemo 5's UI
744 model = treeview.get_model()
745 channel = model.get_value(model.get_iter(path), \
746 PodcastListModel.C_CHANNEL)
747 self.active_channel = channel
748 self.update_episode_list_model()
749 self.episodes_window.channel = self.active_channel
750 self.episodes_window.show()
752 def on_button_subscribe_clicked(self, button):
753 self.on_itemImportChannels_activate(button)
755 def on_button_downloads_clicked(self, widget):
756 self.downloads_window.show()
758 def show_episode_in_download_manager(self, episode):
759 self.downloads_window.show()
760 model = self.treeDownloads.get_model()
761 selection = self.treeDownloads.get_selection()
762 selection.unselect_all()
763 it = model.get_iter_first()
764 while it is not None:
765 task = model.get_value(it, DownloadStatusModel.C_TASK)
766 if task.episode.url == episode.url:
767 selection.select_iter(it)
768 # FIXME: Scroll to selection in pannable area
769 break
770 it = model.iter_next(it)
772 def for_each_episode_set_task_status(self, episodes, status):
773 episode_urls = set(episode.url for episode in episodes)
774 model = self.treeDownloads.get_model()
775 selected_tasks = [(gtk.TreeRowReference(model, row.path), \
776 model.get_value(row.iter, \
777 DownloadStatusModel.C_TASK)) for row in model \
778 if model.get_value(row.iter, DownloadStatusModel.C_TASK).url \
779 in episode_urls]
780 self._for_each_task_set_status(selected_tasks, status)
782 def on_window_orientation_changed(self, orientation):
783 self._last_orientation = orientation
784 if self.preferences_dialog is not None:
785 self.preferences_dialog.on_window_orientation_changed(orientation)
787 treeview = self.treeChannels
788 if orientation == Orientation.PORTRAIT:
789 treeview.set_action_area_orientation(gtk.ORIENTATION_VERTICAL)
790 # Work around Maemo bug #4718
791 self.button_subscribe.set_name('HildonButton-thumb')
792 self.button_refresh.set_name('HildonButton-thumb')
793 else:
794 treeview.set_action_area_orientation(gtk.ORIENTATION_HORIZONTAL)
795 # Work around Maemo bug #4718
796 self.button_subscribe.set_name('HildonButton-finger')
797 self.button_refresh.set_name('HildonButton-finger')
799 def on_treeview_podcasts_selection_changed(self, selection):
800 model, iter = selection.get_selected()
801 if iter is None:
802 self.active_channel = None
803 self.episode_list_model.clear()
805 def on_treeview_button_pressed(self, treeview, event):
806 if event.window != treeview.get_bin_window():
807 return False
809 TreeViewHelper.save_button_press_event(treeview, event)
811 if getattr(treeview, TreeViewHelper.ROLE) == \
812 TreeViewHelper.ROLE_PODCASTS:
813 return self.currently_updating
815 return event.button == self.context_menu_mouse_button and \
816 gpodder.ui.desktop
818 def on_treeview_podcasts_button_released(self, treeview, event):
819 if event.window != treeview.get_bin_window():
820 return False
822 if gpodder.ui.maemo:
823 return self.treeview_channels_handle_gestures(treeview, event)
824 return self.treeview_channels_show_context_menu(treeview, event)
826 def on_treeview_episodes_button_released(self, treeview, event):
827 if event.window != treeview.get_bin_window():
828 return False
830 if gpodder.ui.maemo:
831 if self.config.enable_fingerscroll or self.config.maemo_enable_gestures:
832 return self.treeview_available_handle_gestures(treeview, event)
834 return self.treeview_available_show_context_menu(treeview, event)
836 def on_treeview_downloads_button_released(self, treeview, event):
837 if event.window != treeview.get_bin_window():
838 return False
840 return self.treeview_downloads_show_context_menu(treeview, event)
842 def on_entry_search_podcasts_changed(self, editable):
843 if self.hbox_search_podcasts.get_property('visible'):
844 self.podcast_list_model.set_search_term(editable.get_chars(0, -1))
846 def on_entry_search_podcasts_key_press(self, editable, event):
847 if event.keyval == gtk.keysyms.Escape:
848 self.hide_podcast_search()
849 return True
851 def hide_podcast_search(self, *args):
852 self.hbox_search_podcasts.hide()
853 self.entry_search_podcasts.set_text('')
854 self.podcast_list_model.set_search_term(None)
855 self.treeChannels.grab_focus()
857 def show_podcast_search(self, input_char):
858 self.hbox_search_podcasts.show()
859 self.entry_search_podcasts.insert_text(input_char, -1)
860 self.entry_search_podcasts.grab_focus()
861 self.entry_search_podcasts.set_position(-1)
863 def init_podcast_list_treeview(self):
864 # Set up podcast channel tree view widget
865 if gpodder.ui.fremantle:
866 if self.config.podcast_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
867 self.item_view_podcasts_downloaded.set_active(True)
868 elif self.config.podcast_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
869 self.item_view_podcasts_unplayed.set_active(True)
870 else:
871 self.item_view_podcasts_all.set_active(True)
872 self.podcast_list_model.set_view_mode(self.config.podcast_list_view_mode)
874 iconcolumn = gtk.TreeViewColumn('')
875 iconcell = gtk.CellRendererPixbuf()
876 iconcolumn.pack_start(iconcell, False)
877 iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
878 self.treeChannels.append_column(iconcolumn)
880 namecolumn = gtk.TreeViewColumn('')
881 namecell = gtk.CellRendererText()
882 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
883 namecolumn.pack_start(namecell, True)
884 namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
886 if gpodder.ui.fremantle:
887 countcell = gtk.CellRendererText()
888 from gpodder.gtkui.frmntl import style
889 countcell.set_property('font-desc', style.get_font_desc('EmpSystemFont'))
890 countcell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
891 countcell.set_property('alignment', pango.ALIGN_RIGHT)
892 countcell.set_property('xalign', 1.)
893 countcell.set_property('xpad', 5)
894 namecolumn.pack_start(countcell, False)
895 namecolumn.add_attribute(countcell, 'text', PodcastListModel.C_DOWNLOADS)
896 namecolumn.add_attribute(countcell, 'visible', PodcastListModel.C_DOWNLOADS)
897 else:
898 iconcell = gtk.CellRendererPixbuf()
899 iconcell.set_property('xalign', 1.0)
900 namecolumn.pack_start(iconcell, False)
901 namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
902 namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
904 self.treeChannels.append_column(namecolumn)
906 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
908 # When no podcast is selected, clear the episode list model
909 selection = self.treeChannels.get_selection()
910 selection.connect('changed', self.on_treeview_podcasts_selection_changed)
912 # Set up type-ahead find for the podcast list
913 def on_key_press(treeview, event):
914 if event.keyval == gtk.keysyms.Escape:
915 self.hide_podcast_search()
916 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
917 self.hide_podcast_search()
918 elif event.state & gtk.gdk.CONTROL_MASK:
919 # Don't handle type-ahead when control is pressed (so shortcuts
920 # with the Ctrl key still work, e.g. Ctrl+A, ...)
921 return True
922 else:
923 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
924 if unicode_char_id == 0:
925 return False
926 input_char = unichr(unicode_char_id)
927 self.show_podcast_search(input_char)
928 return True
929 self.treeChannels.connect('key-press-event', on_key_press)
931 # Enable separators to the podcast list to separate special podcasts
932 # from others (this is used for the "all episodes" view)
933 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
935 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
937 def on_entry_search_episodes_changed(self, editable):
938 if self.hbox_search_episodes.get_property('visible'):
939 self.episode_list_model.set_search_term(editable.get_chars(0, -1))
941 def on_entry_search_episodes_key_press(self, editable, event):
942 if event.keyval == gtk.keysyms.Escape:
943 self.hide_episode_search()
944 return True
946 def hide_episode_search(self, *args):
947 self.hbox_search_episodes.hide()
948 self.entry_search_episodes.set_text('')
949 self.episode_list_model.set_search_term(None)
950 self.treeAvailable.grab_focus()
952 def show_episode_search(self, input_char):
953 self.hbox_search_episodes.show()
954 self.entry_search_episodes.insert_text(input_char, -1)
955 self.entry_search_episodes.grab_focus()
956 self.entry_search_episodes.set_position(-1)
958 def init_episode_list_treeview(self):
959 # For loading the list model
960 self.empty_episode_list_model = EpisodeListModel()
961 self.episode_list_model = EpisodeListModel()
963 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
964 self.item_view_episodes_undeleted.set_active(True)
965 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
966 self.item_view_episodes_downloaded.set_active(True)
967 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
968 self.item_view_episodes_unplayed.set_active(True)
969 else:
970 self.item_view_episodes_all.set_active(True)
972 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
974 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
976 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
978 iconcell = gtk.CellRendererPixbuf()
979 if gpodder.ui.maemo:
980 iconcell.set_fixed_size(50, 50)
981 status_column_label = ''
982 else:
983 status_column_label = _('Status')
984 iconcolumn = gtk.TreeViewColumn(status_column_label, iconcell, pixbuf=EpisodeListModel.C_STATUS_ICON)
986 namecell = gtk.CellRendererText()
987 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
988 namecolumn = gtk.TreeViewColumn(_('Episode'))
989 namecolumn.pack_start(namecell, True)
990 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
991 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
992 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
993 namecolumn.set_resizable(True)
994 namecolumn.set_expand(True)
996 if gpodder.ui.fremantle:
997 from gpodder.gtkui.frmntl import style
998 timecell = gtk.CellRendererText()
999 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1000 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1001 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1002 timecell.set_property('xalign', 1.)
1003 timecell.set_property('xpad', 5)
1004 namecolumn.pack_start(timecell, False)
1005 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1006 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME1_VISIBLE)
1008 # Add another cell renderer to fix a sizing issue (one renderer
1009 # only renders short text and the other one longer text to avoid
1010 # having titles of episodes unnecessarily cut off)
1011 timecell = gtk.CellRendererText()
1012 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1013 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1014 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1015 timecell.set_property('xalign', 1.)
1016 timecell.set_property('xpad', 5)
1017 namecolumn.pack_start(timecell, False)
1018 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1019 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME2_VISIBLE)
1021 sizecell = gtk.CellRendererText()
1022 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
1023 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
1025 releasecell = gtk.CellRendererText()
1026 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
1027 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
1029 for itemcolumn in (iconcolumn, namecolumn, sizecolumn, releasecolumn):
1030 itemcolumn.set_reorderable(True)
1031 self.treeAvailable.append_column(itemcolumn)
1033 if gpodder.ui.maemo:
1034 sizecolumn.set_visible(False)
1035 releasecolumn.set_visible(False)
1037 # Set up type-ahead find for the episode list
1038 def on_key_press(treeview, event):
1039 if event.keyval == gtk.keysyms.Escape:
1040 self.hide_episode_search()
1041 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
1042 self.hide_episode_search()
1043 elif event.state & gtk.gdk.CONTROL_MASK:
1044 # Don't handle type-ahead when control is pressed (so shortcuts
1045 # with the Ctrl key still work, e.g. Ctrl+A, ...)
1046 return False
1047 else:
1048 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
1049 if unicode_char_id == 0:
1050 return False
1051 input_char = unichr(unicode_char_id)
1052 self.show_episode_search(input_char)
1053 return True
1054 self.treeAvailable.connect('key-press-event', on_key_press)
1056 if gpodder.ui.desktop:
1057 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
1058 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
1059 def drag_data_get(tree, context, selection_data, info, timestamp):
1060 if self.config.on_drag_mark_played:
1061 for episode in self.get_selected_episodes():
1062 episode.mark(is_played=True)
1063 self.on_selected_episodes_status_changed()
1064 uris = ['file://'+e.local_filename(create=False) \
1065 for e in self.get_selected_episodes() \
1066 if e.was_downloaded(and_exists=True)]
1067 uris.append('') # for the trailing '\r\n'
1068 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
1069 self.treeAvailable.connect('drag-data-get', drag_data_get)
1071 selection = self.treeAvailable.get_selection()
1072 if gpodder.ui.diablo:
1073 if self.config.maemo_enable_gestures or self.config.enable_fingerscroll:
1074 selection.set_mode(gtk.SELECTION_SINGLE)
1075 else:
1076 selection.set_mode(gtk.SELECTION_MULTIPLE)
1077 elif gpodder.ui.fremantle:
1078 selection.set_mode(gtk.SELECTION_SINGLE)
1079 else:
1080 selection.set_mode(gtk.SELECTION_MULTIPLE)
1081 # Update the sensitivity of the toolbar buttons on the Desktop
1082 selection.connect('changed', lambda s: self.play_or_download())
1084 if gpodder.ui.diablo:
1085 # Set up the tap-and-hold context menu for podcasts
1086 menu = gtk.Menu()
1087 menu.append(self.itemUpdateChannel.create_menu_item())
1088 menu.append(self.itemEditChannel.create_menu_item())
1089 menu.append(gtk.SeparatorMenuItem())
1090 menu.append(self.itemRemoveChannel.create_menu_item())
1091 menu.append(gtk.SeparatorMenuItem())
1092 item = gtk.ImageMenuItem(_('Close this menu'))
1093 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, \
1094 gtk.ICON_SIZE_MENU))
1095 menu.append(item)
1096 menu.show_all()
1097 menu = self.set_finger_friendly(menu)
1098 self.treeChannels.tap_and_hold_setup(menu)
1101 def init_download_list_treeview(self):
1102 # enable multiple selection support
1103 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
1104 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
1106 # columns and renderers for "download progress" tab
1107 # First column: [ICON] Episodename
1108 column = gtk.TreeViewColumn(_('Episode'))
1110 cell = gtk.CellRendererPixbuf()
1111 if gpodder.ui.maemo:
1112 cell.set_fixed_size(50, 50)
1113 cell.set_property('stock-size', gtk.ICON_SIZE_MENU)
1114 column.pack_start(cell, expand=False)
1115 column.add_attribute(cell, 'stock-id', \
1116 DownloadStatusModel.C_ICON_NAME)
1118 cell = gtk.CellRendererText()
1119 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
1120 column.pack_start(cell, expand=True)
1121 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
1122 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1123 column.set_expand(True)
1124 self.treeDownloads.append_column(column)
1126 # Second column: Progress
1127 cell = gtk.CellRendererProgress()
1128 cell.set_property('yalign', .5)
1129 cell.set_property('ypad', 6)
1130 column = gtk.TreeViewColumn(_('Progress'), cell,
1131 value=DownloadStatusModel.C_PROGRESS, \
1132 text=DownloadStatusModel.C_PROGRESS_TEXT)
1133 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1134 column.set_expand(False)
1135 self.treeDownloads.append_column(column)
1136 column.set_property('min-width', 150)
1137 column.set_property('max-width', 150)
1139 self.treeDownloads.set_model(self.download_status_model)
1140 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
1142 def on_treeview_expose_event(self, treeview, event):
1143 if event.window == treeview.get_bin_window():
1144 model = treeview.get_model()
1145 if (model is not None and model.get_iter_first() is not None):
1146 return False
1148 role = getattr(treeview, TreeViewHelper.ROLE, None)
1149 if role is None:
1150 return False
1152 ctx = event.window.cairo_create()
1153 ctx.rectangle(event.area.x, event.area.y,
1154 event.area.width, event.area.height)
1155 ctx.clip()
1157 x, y, width, height, depth = event.window.get_geometry()
1158 progress = None
1160 if role == TreeViewHelper.ROLE_EPISODES:
1161 if self.currently_updating:
1162 text = _('Loading episodes')
1163 progress = self.episode_list_model.get_update_progress()
1164 elif self.config.episode_list_view_mode != \
1165 EpisodeListModel.VIEW_ALL:
1166 text = _('No episodes in current view')
1167 else:
1168 text = _('No episodes available')
1169 elif role == TreeViewHelper.ROLE_PODCASTS:
1170 if self.config.episode_list_view_mode != \
1171 EpisodeListModel.VIEW_ALL and \
1172 self.config.podcast_list_hide_boring and \
1173 len(self.channels) > 0:
1174 text = _('No podcasts in this view')
1175 else:
1176 text = _('No subscriptions')
1177 elif role == TreeViewHelper.ROLE_DOWNLOADS:
1178 text = _('No active downloads')
1179 else:
1180 raise Exception('on_treeview_expose_event: unknown role')
1182 if gpodder.ui.fremantle:
1183 from gpodder.gtkui.frmntl import style
1184 font_desc = style.get_font_desc('LargeSystemFont')
1185 else:
1186 font_desc = None
1188 draw_text_box_centered(ctx, treeview, width, height, text, font_desc, progress)
1190 return False
1192 def enable_download_list_update(self):
1193 if not self.download_list_update_enabled:
1194 self.update_downloads_list()
1195 gobject.timeout_add(1500, self.update_downloads_list)
1196 self.download_list_update_enabled = True
1198 def cleanup_downloads(self):
1199 model = self.download_status_model
1201 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
1202 changed_episode_urls = set()
1203 for row_reference, task in all_tasks:
1204 if task.status in (task.DONE, task.CANCELLED):
1205 model.remove(model.get_iter(row_reference.get_path()))
1206 try:
1207 # We don't "see" this task anymore - remove it;
1208 # this is needed, so update_episode_list_icons()
1209 # below gets the correct list of "seen" tasks
1210 self.download_tasks_seen.remove(task)
1211 except KeyError, key_error:
1212 log('Cannot remove task from "seen" list: %s', task, sender=self)
1213 changed_episode_urls.add(task.url)
1214 # Tell the task that it has been removed (so it can clean up)
1215 task.removed_from_list()
1217 # Tell the podcasts tab to update icons for our removed podcasts
1218 self.update_episode_list_icons(changed_episode_urls)
1220 # Tell the shownotes window that we have removed the episode
1221 if self.episode_shownotes_window is not None and \
1222 self.episode_shownotes_window.episode is not None and \
1223 self.episode_shownotes_window.episode.url in changed_episode_urls:
1224 self.episode_shownotes_window._download_status_changed(None)
1226 # Update the downloads list one more time
1227 self.update_downloads_list(can_call_cleanup=False)
1229 def on_tool_downloads_toggled(self, toolbutton):
1230 if toolbutton.get_active():
1231 self.wNotebook.set_current_page(1)
1232 else:
1233 self.wNotebook.set_current_page(0)
1235 def add_download_task_monitor(self, monitor):
1236 self.download_task_monitors.add(monitor)
1237 model = self.download_status_model
1238 if model is None:
1239 model = ()
1240 for row in model:
1241 task = row[self.download_status_model.C_TASK]
1242 monitor.task_updated(task)
1244 def remove_download_task_monitor(self, monitor):
1245 self.download_task_monitors.remove(monitor)
1247 def update_downloads_list(self, can_call_cleanup=True):
1248 try:
1249 model = self.download_status_model
1251 downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
1252 total_speed, total_size, done_size = 0, 0, 0
1254 # Keep a list of all download tasks that we've seen
1255 download_tasks_seen = set()
1257 # Remember the DownloadTask object for the episode that
1258 # has been opened in the episode shownotes dialog (if any)
1259 if self.episode_shownotes_window is not None:
1260 shownotes_episode = self.episode_shownotes_window.episode
1261 shownotes_task = None
1262 else:
1263 shownotes_episode = None
1264 shownotes_task = None
1266 # Do not go through the list of the model is not (yet) available
1267 if model is None:
1268 model = ()
1270 failed_downloads = []
1271 for row in model:
1272 self.download_status_model.request_update(row.iter)
1274 task = row[self.download_status_model.C_TASK]
1275 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
1277 # Let the download task monitors know of changes
1278 for monitor in self.download_task_monitors:
1279 monitor.task_updated(task)
1281 total_size += size
1282 done_size += size*progress
1284 if shownotes_episode is not None and \
1285 shownotes_episode.url == task.episode.url:
1286 shownotes_task = task
1288 download_tasks_seen.add(task)
1290 if status == download.DownloadTask.DOWNLOADING:
1291 downloading += 1
1292 total_speed += speed
1293 elif status == download.DownloadTask.FAILED:
1294 failed_downloads.append(task)
1295 failed += 1
1296 elif status == download.DownloadTask.DONE:
1297 finished += 1
1298 elif status == download.DownloadTask.QUEUED:
1299 queued += 1
1300 elif status == download.DownloadTask.PAUSED:
1301 paused += 1
1302 else:
1303 others += 1
1305 # Remember which tasks we have seen after this run
1306 self.download_tasks_seen = download_tasks_seen
1308 if gpodder.ui.desktop:
1309 text = [_('Downloads')]
1310 if downloading + failed + queued > 0:
1311 s = []
1312 if downloading > 0:
1313 s.append(N_('%d active', '%d active', downloading) % downloading)
1314 if failed > 0:
1315 s.append(N_('%d failed', '%d failed', failed) % failed)
1316 if queued > 0:
1317 s.append(N_('%d queued', '%d queued', queued) % queued)
1318 text.append(' (' + ', '.join(s)+')')
1319 self.labelDownloads.set_text(''.join(text))
1320 elif gpodder.ui.diablo:
1321 sum = downloading + failed + finished + queued + paused + others
1322 if sum:
1323 self.tool_downloads.set_label(_('Downloads (%d)') % sum)
1324 else:
1325 self.tool_downloads.set_label(_('Downloads'))
1326 elif gpodder.ui.fremantle:
1327 if downloading + queued > 0:
1328 self.button_downloads.set_value(N_('%d active', '%d active', downloading+queued) % (downloading+queued))
1329 elif failed > 0:
1330 self.button_downloads.set_value(N_('%d failed', '%d failed', failed) % failed)
1331 elif paused > 0:
1332 self.button_downloads.set_value(N_('%d paused', '%d paused', paused) % paused)
1333 else:
1334 self.button_downloads.set_value(_('Idle'))
1336 title = [self.default_title]
1338 # We have to update all episodes/channels for which the status has
1339 # changed. Accessing task.status_changed has the side effect of
1340 # re-setting the changed flag, so we need to get the "changed" list
1341 # of tuples first and split it into two lists afterwards
1342 changed = [(task.url, task.podcast_url) for task in \
1343 self.download_tasks_seen if task.status_changed]
1344 episode_urls = [episode_url for episode_url, channel_url in changed]
1345 channel_urls = [channel_url for episode_url, channel_url in changed]
1347 count = downloading + queued
1348 if count > 0:
1349 title.append(N_('downloading %d file', 'downloading %d files', count) % count)
1351 if total_size > 0:
1352 percentage = 100.0*done_size/total_size
1353 else:
1354 percentage = 0.0
1355 total_speed = util.format_filesize(total_speed)
1356 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1357 if self.tray_icon is not None:
1358 # Update the tray icon status and progress bar
1359 self.tray_icon.set_status(self.tray_icon.STATUS_DOWNLOAD_IN_PROGRESS, title[1])
1360 self.tray_icon.draw_progress_bar(percentage/100.)
1361 else:
1362 if self.tray_icon is not None:
1363 # Update the tray icon status
1364 self.tray_icon.set_status()
1365 if gpodder.ui.desktop:
1366 self.downloads_finished(self.download_tasks_seen)
1367 if gpodder.ui.diablo:
1368 hildon.hildon_banner_show_information(self.gPodder, '', 'gPodder: %s' % _('All downloads finished'))
1369 log('All downloads have finished.', sender=self)
1370 if self.config.cmd_all_downloads_complete:
1371 util.run_external_command(self.config.cmd_all_downloads_complete)
1373 if gpodder.ui.fremantle and failed:
1374 message = '\n'.join(['%s: %s' % (str(task), \
1375 task.error_message) for task in failed_downloads])
1376 self.show_message(message, _('Downloads failed'), important=True)
1378 # Remove finished episodes
1379 if self.config.auto_cleanup_downloads and can_call_cleanup:
1380 self.cleanup_downloads()
1382 # Stop updating the download list here
1383 self.download_list_update_enabled = False
1385 if not gpodder.ui.fremantle:
1386 self.gPodder.set_title(' - '.join(title))
1388 self.update_episode_list_icons(episode_urls)
1389 if self.episode_shownotes_window is not None:
1390 if (shownotes_task and shownotes_task.url in episode_urls) or \
1391 shownotes_task != self.episode_shownotes_window.task:
1392 self.episode_shownotes_window._download_status_changed(shownotes_task)
1393 self.episode_shownotes_window._download_status_progress()
1394 self.play_or_download()
1395 if channel_urls:
1396 self.update_podcast_list_model(channel_urls)
1398 return self.download_list_update_enabled
1399 except Exception, e:
1400 log('Exception happened while updating download list.', sender=self, traceback=True)
1401 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
1402 # We return False here, so the update loop won't be called again,
1403 # that's why we require the restart of gPodder in the message.
1404 return False
1406 def on_config_changed(self, *args):
1407 util.idle_add(self._on_config_changed, *args)
1409 def _on_config_changed(self, name, old_value, new_value):
1410 if name == 'show_toolbar' and gpodder.ui.desktop:
1411 self.toolbar.set_property('visible', new_value)
1412 elif name == 'videoplayer':
1413 self.config.video_played_dbus = False
1414 elif name == 'player':
1415 self.config.audio_played_dbus = False
1416 elif name == 'episode_list_descriptions':
1417 self.update_episode_list_model()
1418 elif name == 'episode_list_thumbnails':
1419 self.update_episode_list_icons(all=True)
1420 elif name == 'rotation_mode':
1421 self._fremantle_rotation.set_mode(new_value)
1422 elif name in ('auto_update_feeds', 'auto_update_frequency'):
1423 self.restart_auto_update_timer()
1424 elif name == 'podcast_list_view_all':
1425 # Force a update of the podcast list model
1426 self.channel_list_changed = True
1427 if gpodder.ui.fremantle:
1428 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
1429 while gtk.events_pending():
1430 gtk.main_iteration(False)
1431 self.update_podcast_list_model()
1432 if gpodder.ui.fremantle:
1433 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
1435 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1436 # With get_bin_window, we get the window that contains the rows without
1437 # the header. The Y coordinate of this window will be the height of the
1438 # treeview header. This is the amount we have to subtract from the
1439 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1440 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1441 y -= x_bin
1442 y -= y_bin
1443 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
1445 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or (column is not None and column != treeview.get_columns()[0]):
1446 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1447 return False
1449 if path is not None:
1450 model = treeview.get_model()
1451 iter = model.get_iter(path)
1452 role = getattr(treeview, TreeViewHelper.ROLE)
1454 if role == TreeViewHelper.ROLE_EPISODES:
1455 id = model.get_value(iter, EpisodeListModel.C_URL)
1456 elif role == TreeViewHelper.ROLE_PODCASTS:
1457 id = model.get_value(iter, PodcastListModel.C_URL)
1459 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1460 if last_tooltip is not None and last_tooltip != id:
1461 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1462 return False
1463 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1465 if role == TreeViewHelper.ROLE_EPISODES:
1466 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1467 if description:
1468 tooltip.set_text(description)
1469 else:
1470 return False
1471 elif role == TreeViewHelper.ROLE_PODCASTS:
1472 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1473 if channel is None:
1474 return False
1475 channel.request_save_dir_size()
1476 diskspace_str = util.format_filesize(channel.save_dir_size, 0)
1477 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1478 if error_str:
1479 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
1480 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1481 table = gtk.Table(rows=3, columns=3)
1482 table.set_row_spacings(5)
1483 table.set_col_spacings(5)
1484 table.set_border_width(5)
1486 heading = gtk.Label()
1487 heading.set_alignment(0, 1)
1488 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
1489 table.attach(heading, 0, 1, 0, 1)
1490 size_info = gtk.Label()
1491 size_info.set_alignment(1, 1)
1492 size_info.set_justify(gtk.JUSTIFY_RIGHT)
1493 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
1494 table.attach(size_info, 2, 3, 0, 1)
1496 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
1498 if len(channel.description) < 500:
1499 description = channel.description
1500 else:
1501 pos = channel.description.find('\n\n')
1502 if pos == -1 or pos > 500:
1503 description = channel.description[:498]+'[...]'
1504 else:
1505 description = channel.description[:pos]
1507 description = gtk.Label(description)
1508 if error_str:
1509 description.set_markup(error_str)
1510 description.set_alignment(0, 0)
1511 description.set_line_wrap(True)
1512 table.attach(description, 0, 3, 2, 3)
1514 table.show_all()
1515 tooltip.set_custom(table)
1517 return True
1519 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1520 return False
1522 def treeview_allow_tooltips(self, treeview, allow):
1523 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1525 def update_m3u_playlist_clicked(self, widget):
1526 if self.active_channel is not None:
1527 self.active_channel.update_m3u_playlist()
1528 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'), widget=self.treeChannels)
1530 def treeview_handle_context_menu_click(self, treeview, event):
1531 x, y = int(event.x), int(event.y)
1532 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
1534 selection = treeview.get_selection()
1535 model, paths = selection.get_selected_rows()
1537 if path is None or (path not in paths and \
1538 event.button == self.context_menu_mouse_button):
1539 # We have right-clicked, but not into the selection,
1540 # assume we don't want to operate on the selection
1541 paths = []
1543 if path is not None and not paths and \
1544 event.button == self.context_menu_mouse_button:
1545 # No selection or clicked outside selection;
1546 # select the single item where we clicked
1547 treeview.grab_focus()
1548 treeview.set_cursor(path, column, 0)
1549 paths = [path]
1551 if not paths:
1552 # Unselect any remaining items (clicked elsewhere)
1553 if hasattr(treeview, 'is_rubber_banding_active'):
1554 if not treeview.is_rubber_banding_active():
1555 selection.unselect_all()
1556 else:
1557 selection.unselect_all()
1559 return model, paths
1561 def downloads_list_get_selection(self, model=None, paths=None):
1562 if model is None and paths is None:
1563 selection = self.treeDownloads.get_selection()
1564 model, paths = selection.get_selected_rows()
1566 can_queue, can_cancel, can_pause, can_remove, can_force = (True,)*5
1567 selected_tasks = [(gtk.TreeRowReference(model, path), \
1568 model.get_value(model.get_iter(path), \
1569 DownloadStatusModel.C_TASK)) for path in paths]
1571 for row_reference, task in selected_tasks:
1572 if task.status != download.DownloadTask.QUEUED:
1573 can_force = False
1574 if task.status not in (download.DownloadTask.PAUSED, \
1575 download.DownloadTask.FAILED, \
1576 download.DownloadTask.CANCELLED):
1577 can_queue = False
1578 if task.status not in (download.DownloadTask.PAUSED, \
1579 download.DownloadTask.QUEUED, \
1580 download.DownloadTask.DOWNLOADING):
1581 can_cancel = False
1582 if task.status not in (download.DownloadTask.QUEUED, \
1583 download.DownloadTask.DOWNLOADING):
1584 can_pause = False
1585 if task.status not in (download.DownloadTask.CANCELLED, \
1586 download.DownloadTask.FAILED, \
1587 download.DownloadTask.DONE):
1588 can_remove = False
1590 return selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force
1592 def downloads_finished(self, download_tasks_seen):
1593 # FIXME: Filter all tasks that have already been reported
1594 finished_downloads = [str(task) for task in download_tasks_seen if task.status == task.DONE]
1595 failed_downloads = [str(task)+' ('+task.error_message+')' for task in download_tasks_seen if task.status == task.FAILED]
1597 if finished_downloads and failed_downloads:
1598 message = self.format_episode_list(finished_downloads, 5)
1599 message += '\n\n<i>%s</i>\n' % _('These downloads failed:')
1600 message += self.format_episode_list(failed_downloads, 5)
1601 self.show_message(message, _('Downloads finished'), True, widget=self.labelDownloads)
1602 elif finished_downloads:
1603 message = self.format_episode_list(finished_downloads)
1604 self.show_message(message, _('Downloads finished'), widget=self.labelDownloads)
1605 elif failed_downloads:
1606 message = self.format_episode_list(failed_downloads)
1607 self.show_message(message, _('Downloads failed'), True, widget=self.labelDownloads)
1609 # Open torrent files right after download (bug 1029)
1610 if self.config.open_torrent_after_download:
1611 for task in download_tasks_seen:
1612 if task.status != task.DONE:
1613 continue
1615 episode = task.episode
1616 if episode.mimetype != 'application/x-bittorrent':
1617 continue
1619 self.playback_episodes([episode])
1622 def format_episode_list(self, episode_list, max_episodes=10):
1624 Format a list of episode names for notifications
1626 Will truncate long episode names and limit the amount of
1627 episodes displayed (max_episodes=10).
1629 The episode_list parameter should be a list of strings.
1631 MAX_TITLE_LENGTH = 100
1633 result = []
1634 for title in episode_list[:min(len(episode_list), max_episodes)]:
1635 if len(title) > MAX_TITLE_LENGTH:
1636 middle = (MAX_TITLE_LENGTH/2)-2
1637 title = '%s...%s' % (title[0:middle], title[-middle:])
1638 result.append(saxutils.escape(title))
1639 result.append('\n')
1641 more_episodes = len(episode_list) - max_episodes
1642 if more_episodes > 0:
1643 result.append('(...')
1644 result.append(N_('%d more episode', '%d more episodes', more_episodes) % more_episodes)
1645 result.append('...)')
1647 return (''.join(result)).strip()
1649 def _for_each_task_set_status(self, tasks, status, force_start=False):
1650 episode_urls = set()
1651 model = self.treeDownloads.get_model()
1652 for row_reference, task in tasks:
1653 if status == download.DownloadTask.QUEUED:
1654 # Only queue task when its paused/failed/cancelled (or forced)
1655 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED) or force_start:
1656 self.download_queue_manager.add_task(task, force_start)
1657 self.enable_download_list_update()
1658 elif status == download.DownloadTask.CANCELLED:
1659 # Cancelling a download allowed when downloading/queued
1660 if task.status in (task.QUEUED, task.DOWNLOADING):
1661 task.status = status
1662 # Cancelling paused downloads requires a call to .run()
1663 elif task.status == task.PAUSED:
1664 task.status = status
1665 # Call run, so the partial file gets deleted
1666 task.run()
1667 elif status == download.DownloadTask.PAUSED:
1668 # Pausing a download only when queued/downloading
1669 if task.status in (task.DOWNLOADING, task.QUEUED):
1670 task.status = status
1671 elif status is None:
1672 # Remove the selected task - cancel downloading/queued tasks
1673 if task.status in (task.QUEUED, task.DOWNLOADING):
1674 task.status = task.CANCELLED
1675 model.remove(model.get_iter(row_reference.get_path()))
1676 # Remember the URL, so we can tell the UI to update
1677 try:
1678 # We don't "see" this task anymore - remove it;
1679 # this is needed, so update_episode_list_icons()
1680 # below gets the correct list of "seen" tasks
1681 self.download_tasks_seen.remove(task)
1682 except KeyError, key_error:
1683 log('Cannot remove task from "seen" list: %s', task, sender=self)
1684 episode_urls.add(task.url)
1685 # Tell the task that it has been removed (so it can clean up)
1686 task.removed_from_list()
1687 else:
1688 # We can (hopefully) simply set the task status here
1689 task.status = status
1690 # Tell the podcasts tab to update icons for our removed podcasts
1691 self.update_episode_list_icons(episode_urls)
1692 # Update the tab title and downloads list
1693 self.update_downloads_list()
1695 def treeview_downloads_show_context_menu(self, treeview, event):
1696 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1697 if not paths:
1698 if not hasattr(treeview, 'is_rubber_banding_active'):
1699 return True
1700 else:
1701 return not treeview.is_rubber_banding_active()
1703 if event.button == self.context_menu_mouse_button:
1704 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = \
1705 self.downloads_list_get_selection(model, paths)
1707 def make_menu_item(label, stock_id, tasks, status, sensitive, force_start=False):
1708 # This creates a menu item for selection-wide actions
1709 item = gtk.ImageMenuItem(label)
1710 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
1711 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status, force_start))
1712 item.set_sensitive(sensitive)
1713 return self.set_finger_friendly(item)
1715 menu = gtk.Menu()
1717 item = gtk.ImageMenuItem(_('Episode details'))
1718 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1719 if len(selected_tasks) == 1:
1720 row_reference, task = selected_tasks[0]
1721 episode = task.episode
1722 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
1723 else:
1724 item.set_sensitive(False)
1725 menu.append(self.set_finger_friendly(item))
1726 menu.append(gtk.SeparatorMenuItem())
1727 if can_force:
1728 menu.append(make_menu_item(_('Start download now'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, True, True))
1729 else:
1730 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, can_queue, False))
1731 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED, can_cancel))
1732 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED, can_pause))
1733 menu.append(gtk.SeparatorMenuItem())
1734 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None, can_remove))
1736 if gpodder.ui.maemo:
1737 # Because we open the popup on left-click for Maemo,
1738 # we also include a non-action to close the menu
1739 menu.append(gtk.SeparatorMenuItem())
1740 item = gtk.ImageMenuItem(_('Close this menu'))
1741 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1743 menu.append(self.set_finger_friendly(item))
1745 menu.show_all()
1746 menu.popup(None, None, None, event.button, event.time)
1747 return True
1749 def treeview_channels_show_context_menu(self, treeview, event):
1750 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1751 if not paths:
1752 return True
1754 # Check for valid channel id, if there's no id then
1755 # assume that it is a proxy channel or equivalent
1756 # and cannot be operated with right click
1757 if self.active_channel.id is None:
1758 return True
1760 if event.button == 3:
1761 menu = gtk.Menu()
1763 ICON = lambda x: x
1765 item = gtk.ImageMenuItem( _('Update podcast'))
1766 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1767 item.connect('activate', self.on_itemUpdateChannel_activate)
1768 item.set_sensitive(not self.updating_feed_cache)
1769 menu.append(item)
1771 menu.append(gtk.SeparatorMenuItem())
1773 item = gtk.CheckMenuItem(_('Keep episodes'))
1774 item.set_active(self.active_channel.channel_is_locked)
1775 item.connect('activate', self.on_channel_toggle_lock_activate)
1776 menu.append(self.set_finger_friendly(item))
1778 item = gtk.ImageMenuItem(_('Remove podcast'))
1779 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1780 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1781 menu.append( item)
1783 if self.config.device_type != 'none':
1784 item = gtk.MenuItem(_('Synchronize to device'))
1785 item.connect('activate', lambda item: self.on_sync_to_ipod_activate(item, self.active_channel.get_downloaded_episodes()))
1786 menu.append(item)
1788 menu.append( gtk.SeparatorMenuItem())
1790 item = gtk.ImageMenuItem(_('Podcast details'))
1791 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1792 item.connect('activate', self.on_itemEditChannel_activate)
1793 menu.append(item)
1795 menu.show_all()
1796 # Disable tooltips while we are showing the menu, so
1797 # the tooltip will not appear over the menu
1798 self.treeview_allow_tooltips(self.treeChannels, False)
1799 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1800 menu.popup( None, None, None, event.button, event.time)
1802 return True
1804 def on_itemClose_activate(self, widget):
1805 if self.tray_icon is not None:
1806 self.iconify_main_window()
1807 else:
1808 self.on_gPodder_delete_event(widget)
1810 def cover_file_removed(self, channel_url):
1812 The Cover Downloader calls this when a previously-
1813 available cover has been removed from the disk. We
1814 have to update our model to reflect this change.
1816 self.podcast_list_model.delete_cover_by_url(channel_url)
1818 def cover_download_finished(self, channel, pixbuf):
1820 The Cover Downloader calls this when it has finished
1821 downloading (or registering, if already downloaded)
1822 a new channel cover, which is ready for displaying.
1824 self.podcast_list_model.add_cover_by_channel(channel, pixbuf)
1826 def save_episodes_as_file(self, episodes):
1827 for episode in episodes:
1828 self.save_episode_as_file(episode)
1830 def save_episode_as_file(self, episode):
1831 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1832 if episode.was_downloaded(and_exists=True):
1833 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1834 copy_from = episode.local_filename(create=False)
1835 assert copy_from is not None
1836 copy_to = episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)
1837 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1838 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1840 def copy_episodes_bluetooth(self, episodes):
1841 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1843 if gpodder.ui.maemo:
1844 util.bluetooth_send_files_maemo([e.local_filename(create=False) \
1845 for e in episodes_to_copy])
1846 return True
1848 def convert_and_send_thread(episode):
1849 for episode in episodes:
1850 filename = episode.local_filename(create=False)
1851 assert filename is not None
1852 destfile = os.path.join(tempfile.gettempdir(), \
1853 util.sanitize_filename(episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)))
1854 (base, ext) = os.path.splitext(filename)
1855 if not destfile.endswith(ext):
1856 destfile += ext
1858 try:
1859 shutil.copyfile(filename, destfile)
1860 util.bluetooth_send_file(destfile)
1861 except:
1862 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1863 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1865 util.delete_file(destfile)
1867 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1869 def get_device_name(self):
1870 if self.config.device_type == 'ipod':
1871 return _('iPod')
1872 elif self.config.device_type in ('filesystem', 'mtp'):
1873 return _('MP3 player')
1874 else:
1875 return '(unknown device)'
1877 def _treeview_button_released(self, treeview, event):
1878 xpos, ypos = TreeViewHelper.get_button_press_event(treeview)
1879 dy = int(abs(event.y-ypos))
1880 dx = int(event.x-xpos)
1882 selection = treeview.get_selection()
1883 path = treeview.get_path_at_pos(int(event.x), int(event.y))
1884 if path is None or dy > 30:
1885 return (False, dx, dy)
1887 path, column, x, y = path
1888 selection.select_path(path)
1889 treeview.set_cursor(path)
1890 treeview.grab_focus()
1892 return (True, dx, dy)
1894 def treeview_channels_handle_gestures(self, treeview, event):
1895 if self.currently_updating:
1896 return False
1898 selected, dx, dy = self._treeview_button_released(treeview, event)
1900 if selected:
1901 if self.config.maemo_enable_gestures:
1902 if dx > 70:
1903 self.on_itemUpdateChannel_activate()
1904 elif dx < -70:
1905 self.on_itemEditChannel_activate(treeview)
1907 return False
1909 def treeview_available_handle_gestures(self, treeview, event):
1910 selected, dx, dy = self._treeview_button_released(treeview, event)
1912 if selected:
1913 if self.config.maemo_enable_gestures:
1914 if dx > 70:
1915 self.on_playback_selected_episodes(None)
1916 return True
1917 elif dx < -70:
1918 self.on_shownotes_selected_episodes(None)
1919 return True
1921 # Pass the event to the context menu handler for treeAvailable
1922 self.treeview_available_show_context_menu(treeview, event)
1924 return True
1926 def treeview_available_show_context_menu(self, treeview, event):
1927 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1928 if not paths:
1929 if not hasattr(treeview, 'is_rubber_banding_active'):
1930 return True
1931 else:
1932 return not treeview.is_rubber_banding_active()
1934 if event.button == self.context_menu_mouse_button:
1935 episodes = self.get_selected_episodes()
1936 any_locked = any(e.is_locked for e in episodes)
1937 any_played = any(e.is_played for e in episodes)
1938 one_is_new = any(e.state == gpodder.STATE_NORMAL and not e.is_played for e in episodes)
1939 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1940 downloading = any(self.episode_is_downloading(e) for e in episodes)
1942 menu = gtk.Menu()
1944 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1946 if open_instead_of_play:
1947 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1948 elif downloaded:
1949 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1950 else:
1951 item = gtk.ImageMenuItem(_('Stream'))
1952 item.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PLAY, gtk.ICON_SIZE_MENU))
1954 item.set_sensitive(can_play and not downloading)
1955 item.connect('activate', self.on_playback_selected_episodes)
1956 menu.append(self.set_finger_friendly(item))
1958 if not can_cancel:
1959 item = gtk.ImageMenuItem(_('Download'))
1960 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1961 item.set_sensitive(can_download)
1962 item.connect('activate', self.on_download_selected_episodes)
1963 menu.append(self.set_finger_friendly(item))
1964 else:
1965 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
1966 item.connect('activate', self.on_item_cancel_download_activate)
1967 menu.append(self.set_finger_friendly(item))
1969 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1970 item.set_sensitive(can_delete)
1971 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1972 menu.append(self.set_finger_friendly(item))
1974 ICON = lambda x: x
1976 # Ok, this probably makes sense to only display for downloaded files
1977 if downloaded:
1978 menu.append(gtk.SeparatorMenuItem())
1979 share_item = gtk.MenuItem(_('Send to'))
1980 menu.append(self.set_finger_friendly(share_item))
1981 share_menu = gtk.Menu()
1983 item = gtk.ImageMenuItem(_('Local folder'))
1984 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU))
1985 item.connect('button-press-event', lambda w, ee: self.save_episodes_as_file(episodes))
1986 share_menu.append(self.set_finger_friendly(item))
1987 if self.bluetooth_available:
1988 item = gtk.ImageMenuItem(_('Bluetooth device'))
1989 if gpodder.ui.maemo:
1990 icon_name = ICON('qgn_list_filesys_bluetooth')
1991 else:
1992 icon_name = ICON('bluetooth')
1993 item.set_image(gtk.image_new_from_icon_name(icon_name, gtk.ICON_SIZE_MENU))
1994 item.connect('button-press-event', lambda w, ee: self.copy_episodes_bluetooth(episodes))
1995 share_menu.append(self.set_finger_friendly(item))
1996 if can_transfer:
1997 item = gtk.ImageMenuItem(self.get_device_name())
1998 item.set_image(gtk.image_new_from_icon_name(ICON('multimedia-player'), gtk.ICON_SIZE_MENU))
1999 item.connect('button-press-event', lambda w, ee: self.on_sync_to_ipod_activate(w, episodes))
2000 share_menu.append(self.set_finger_friendly(item))
2002 share_item.set_submenu(share_menu)
2004 if (downloaded or one_is_new or can_download) and not downloading:
2005 menu.append(gtk.SeparatorMenuItem())
2006 if one_is_new:
2007 item = gtk.CheckMenuItem(_('New'))
2008 item.set_active(True)
2009 item.connect('activate', lambda w: self.mark_selected_episodes_old())
2010 menu.append(self.set_finger_friendly(item))
2011 elif can_download:
2012 item = gtk.CheckMenuItem(_('New'))
2013 item.set_active(False)
2014 item.connect('activate', lambda w: self.mark_selected_episodes_new())
2015 menu.append(self.set_finger_friendly(item))
2017 if downloaded:
2018 item = gtk.CheckMenuItem(_('Played'))
2019 item.set_active(any_played)
2020 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, not any_played))
2021 menu.append(self.set_finger_friendly(item))
2023 item = gtk.CheckMenuItem(_('Keep episode'))
2024 item.set_active(any_locked)
2025 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, not any_locked))
2026 menu.append(self.set_finger_friendly(item))
2028 menu.append(gtk.SeparatorMenuItem())
2029 # Single item, add episode information menu item
2030 item = gtk.ImageMenuItem(_('Episode details'))
2031 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
2032 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
2033 menu.append(self.set_finger_friendly(item))
2035 if gpodder.ui.maemo:
2036 # Because we open the popup on left-click for Maemo,
2037 # we also include a non-action to close the menu
2038 menu.append(gtk.SeparatorMenuItem())
2039 item = gtk.ImageMenuItem(_('Close this menu'))
2040 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
2041 menu.append(self.set_finger_friendly(item))
2043 menu.show_all()
2044 # Disable tooltips while we are showing the menu, so
2045 # the tooltip will not appear over the menu
2046 self.treeview_allow_tooltips(self.treeAvailable, False)
2047 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
2048 menu.popup( None, None, None, event.button, event.time)
2050 return True
2052 def set_title(self, new_title):
2053 if not gpodder.ui.fremantle:
2054 self.default_title = new_title
2055 self.gPodder.set_title(new_title)
2057 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2059 Updates the status icons in the episode list.
2061 If urls is given, it should be a list of URLs
2062 of episodes that should be updated.
2064 If urls is None, set ONE OF selected, all to
2065 True (the former updates just the selected
2066 episodes and the latter updates all episodes).
2068 additional_args = (self.episode_is_downloading, \
2069 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2070 self.config.episode_list_thumbnails and gpodder.ui.desktop)
2072 if urls is not None:
2073 # We have a list of URLs to walk through
2074 self.episode_list_model.update_by_urls(urls, *additional_args)
2075 elif selected and not all:
2076 # We should update all selected episodes
2077 selection = self.treeAvailable.get_selection()
2078 model, paths = selection.get_selected_rows()
2079 for path in reversed(paths):
2080 iter = model.get_iter(path)
2081 self.episode_list_model.update_by_filter_iter(iter, \
2082 *additional_args)
2083 elif all and not selected:
2084 # We update all (even the filter-hidden) episodes
2085 self.episode_list_model.update_all(*additional_args)
2086 else:
2087 # Wrong/invalid call - have to specify at least one parameter
2088 raise ValueError('Invalid call to update_episode_list_icons')
2090 def episode_list_status_changed(self, episodes):
2091 self.update_episode_list_icons(set(e.url for e in episodes))
2092 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2093 self.db.commit()
2095 def clean_up_downloads(self, delete_partial=False):
2096 # Clean up temporary files left behind by old gPodder versions
2097 temporary_files = glob.glob('%s/*/.tmp-*' % self.config.download_dir)
2099 if delete_partial:
2100 temporary_files += glob.glob('%s/*/*.partial' % self.config.download_dir)
2102 for tempfile in temporary_files:
2103 util.delete_file(tempfile)
2105 # Clean up empty download folders and abandoned download folders
2106 download_dirs = glob.glob(os.path.join(self.config.download_dir, '*'))
2107 for ddir in download_dirs:
2108 if os.path.isdir(ddir) and False: # FIXME not db.channel_foldername_exists(os.path.basename(ddir)):
2109 globr = glob.glob(os.path.join(ddir, '*'))
2110 if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
2111 log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
2112 shutil.rmtree(ddir, ignore_errors=True)
2114 def streaming_possible(self):
2115 if gpodder.ui.desktop:
2116 # User has to have a media player set on the Desktop, or else we
2117 # would probably open the browser when giving a URL to xdg-open..
2118 return (self.config.player and self.config.player != 'default')
2119 elif gpodder.ui.maemo:
2120 # On Maemo, the default is to use the Nokia Media Player, which is
2121 # already able to deal with HTTP URLs the right way, so we
2122 # unconditionally enable streaming always on Maemo
2123 return True
2125 return False
2127 def playback_episodes_for_real(self, episodes):
2128 groups = collections.defaultdict(list)
2129 for episode in episodes:
2130 file_type = episode.file_type()
2131 if file_type == 'video' and self.config.videoplayer and \
2132 self.config.videoplayer != 'default':
2133 player = self.config.videoplayer
2134 if gpodder.ui.diablo:
2135 # Use the wrapper script if it's installed to crop 3GP YouTube
2136 # videos to fit the screen (looks much nicer than w/ black border)
2137 if player == 'mplayer' and util.find_command('gpodder-mplayer'):
2138 player = 'gpodder-mplayer'
2139 elif gpodder.ui.fremantle and player == 'mplayer':
2140 player = 'mplayer -fs %F'
2141 elif file_type == 'audio' and self.config.player and \
2142 self.config.player != 'default':
2143 player = self.config.player
2144 else:
2145 player = 'default'
2147 if file_type not in ('audio', 'video') or \
2148 (file_type == 'audio' and not self.config.audio_played_dbus) or \
2149 (file_type == 'video' and not self.config.video_played_dbus):
2150 # Mark episode as played in the database
2151 episode.mark(is_played=True)
2152 self.mygpo_client.on_playback([episode])
2154 filename = episode.local_filename(create=False)
2155 if filename is None or not os.path.exists(filename):
2156 filename = episode.url
2157 if youtube.is_video_link(filename):
2158 fmt_id = self.config.youtube_preferred_fmt_id
2159 if gpodder.ui.fremantle:
2160 fmt_id = 5
2161 filename = youtube.get_real_download_url(filename, fmt_id)
2163 # Determine the playback resume position - if the file
2164 # was played 100%, we simply start from the beginning
2165 resume_position = episode.current_position
2166 if resume_position == episode.total_time:
2167 resume_position = 0
2169 if gpodder.ui.fremantle:
2170 self.mafw_monitor.set_resume_point(filename, resume_position)
2172 # If Panucci is configured, use D-Bus on Maemo to call it
2173 if player == 'panucci':
2174 try:
2175 PANUCCI_NAME = 'org.panucci.panucciInterface'
2176 PANUCCI_PATH = '/panucciInterface'
2177 PANUCCI_INTF = 'org.panucci.panucciInterface'
2178 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2179 i = dbus.Interface(o, PANUCCI_INTF)
2181 def on_reply(*args):
2182 pass
2184 def error_handler(filename, err):
2185 log('Exception in D-Bus call: %s', str(err), \
2186 sender=self)
2188 # Fallback: use the command line client
2189 for command in util.format_desktop_command('panucci', \
2190 [filename]):
2191 log('Executing: %s', repr(command), sender=self)
2192 subprocess.Popen(command)
2194 on_error = lambda err: error_handler(filename, err)
2196 # This method only exists in Panucci > 0.9 ('new Panucci')
2197 i.playback_from(filename, resume_position, \
2198 reply_handler=on_reply, error_handler=on_error)
2200 continue # This file was handled by the D-Bus call
2201 except Exception, e:
2202 log('Error calling Panucci using D-Bus', sender=self, traceback=True)
2203 elif player == 'MediaBox' and gpodder.ui.maemo:
2204 try:
2205 MEDIABOX_NAME = 'de.pycage.mediabox'
2206 MEDIABOX_PATH = '/de/pycage/mediabox/control'
2207 MEDIABOX_INTF = 'de.pycage.mediabox.control'
2208 o = gpodder.dbus_session_bus.get_object(MEDIABOX_NAME, MEDIABOX_PATH)
2209 i = dbus.Interface(o, MEDIABOX_INTF)
2211 def on_reply(*args):
2212 pass
2214 def on_error(err):
2215 log('Exception in D-Bus call: %s', str(err), \
2216 sender=self)
2218 i.load(filename, '%s/x-unknown' % file_type, \
2219 reply_handler=on_reply, error_handler=on_error)
2221 continue # This file was handled by the D-Bus call
2222 except Exception, e:
2223 log('Error calling MediaBox using D-Bus', sender=self, traceback=True)
2225 groups[player].append(filename)
2227 # Open episodes with system default player
2228 if 'default' in groups:
2229 if gpodder.ui.maemo:
2230 # The Nokia Media Player app does not support receiving multiple
2231 # file names via D-Bus, so we simply place all file names into a
2232 # temporary M3U playlist and open that with the Media Player.
2233 m3u_filename = os.path.join(gpodder.home, 'gpodder_open_with.m3u')
2234 util.write_m3u_playlist(m3u_filename, groups['default'], extm3u=False)
2235 util.gui_open(m3u_filename)
2236 else:
2237 for filename in groups['default']:
2238 log('Opening with system default: %s', filename, sender=self)
2239 util.gui_open(filename)
2240 del groups['default']
2241 elif gpodder.ui.maemo and groups:
2242 # When on Maemo and not opening with default, show a notification
2243 # (no startup notification for Panucci / MPlayer yet...)
2244 if len(episodes) == 1:
2245 text = _('Opening %s') % episodes[0].title
2246 else:
2247 count = len(episodes)
2248 text = N_('Opening %d episode', 'Opening %d episodes', count) % count
2250 banner = hildon.hildon_banner_show_animation(self.gPodder, '', text)
2252 def destroy_banner_later(banner):
2253 banner.destroy()
2254 return False
2255 gobject.timeout_add(5000, destroy_banner_later, banner)
2257 # For each type now, go and create play commands
2258 for group in groups:
2259 for command in util.format_desktop_command(group, groups[group]):
2260 log('Executing: %s', repr(command), sender=self)
2261 subprocess.Popen(command)
2263 # Persist episode status changes to the database
2264 self.db.commit()
2266 # Flush updated episode status
2267 self.mygpo_client.flush()
2269 def playback_episodes(self, episodes):
2270 # We need to create a list, because we run through it more than once
2271 episodes = list(PodcastEpisode.sort_by_pubdate(e for e in episodes if \
2272 e.was_downloaded(and_exists=True) or self.streaming_possible()))
2274 try:
2275 self.playback_episodes_for_real(episodes)
2276 except Exception, e:
2277 log('Error in playback!', sender=self, traceback=True)
2278 if gpodder.ui.desktop:
2279 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
2280 _('Error opening player'), widget=self.toolPreferences)
2281 else:
2282 self.show_message(_('Please check your media player settings in the preferences dialog.'))
2284 channel_urls = set()
2285 episode_urls = set()
2286 for episode in episodes:
2287 channel_urls.add(episode.channel.url)
2288 episode_urls.add(episode.url)
2289 self.update_episode_list_icons(episode_urls)
2290 self.update_podcast_list_model(channel_urls)
2292 def play_or_download(self):
2293 if not gpodder.ui.fremantle:
2294 if self.wNotebook.get_current_page() > 0:
2295 if gpodder.ui.desktop:
2296 self.toolCancel.set_sensitive(True)
2297 return
2299 if self.currently_updating:
2300 return (False, False, False, False, False, False)
2302 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
2303 ( is_played, is_locked ) = (False,)*2
2305 open_instead_of_play = False
2307 selection = self.treeAvailable.get_selection()
2308 if selection.count_selected_rows() > 0:
2309 (model, paths) = selection.get_selected_rows()
2311 for path in paths:
2312 try:
2313 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2314 except TypeError, te:
2315 log('Invalid episode at path %s', str(path), sender=self)
2316 continue
2318 if episode.file_type() not in ('audio', 'video'):
2319 open_instead_of_play = True
2321 if episode.was_downloaded():
2322 can_play = episode.was_downloaded(and_exists=True)
2323 is_played = episode.is_played
2324 is_locked = episode.is_locked
2325 if not can_play:
2326 can_download = True
2327 else:
2328 if self.episode_is_downloading(episode):
2329 can_cancel = True
2330 else:
2331 can_download = True
2333 can_download = can_download and not can_cancel
2334 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
2335 can_transfer = can_play and self.config.device_type != 'none' and not can_cancel and not can_download and not open_instead_of_play
2336 can_delete = not can_cancel
2338 if gpodder.ui.desktop:
2339 if open_instead_of_play:
2340 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
2341 else:
2342 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
2343 self.toolPlay.set_sensitive( can_play)
2344 self.toolDownload.set_sensitive( can_download)
2345 self.toolTransfer.set_sensitive( can_transfer)
2346 self.toolCancel.set_sensitive( can_cancel)
2348 if not gpodder.ui.fremantle:
2349 self.item_cancel_download.set_sensitive(can_cancel)
2350 self.itemDownloadSelected.set_sensitive(can_download)
2351 self.itemOpenSelected.set_sensitive(can_play)
2352 self.itemPlaySelected.set_sensitive(can_play)
2353 self.itemDeleteSelected.set_sensitive(can_delete)
2354 self.item_toggle_played.set_sensitive(can_play)
2355 self.item_toggle_lock.set_sensitive(can_play)
2356 self.itemOpenSelected.set_visible(open_instead_of_play)
2357 self.itemPlaySelected.set_visible(not open_instead_of_play)
2359 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
2361 def on_cbMaxDownloads_toggled(self, widget, *args):
2362 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2364 def on_cbLimitDownloads_toggled(self, widget, *args):
2365 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2367 def episode_new_status_changed(self, urls):
2368 self.update_podcast_list_model()
2369 self.update_episode_list_icons(urls)
2371 def update_podcast_list_model(self, urls=None, selected=False, select_url=None):
2372 """Update the podcast list treeview model
2374 If urls is given, it should list the URLs of each
2375 podcast that has to be updated in the list.
2377 If selected is True, only update the model contents
2378 for the currently-selected podcast - nothing more.
2380 The caller can optionally specify "select_url",
2381 which is the URL of the podcast that is to be
2382 selected in the list after the update is complete.
2383 This only works if the podcast list has to be
2384 reloaded; i.e. something has been added or removed
2385 since the last update of the podcast list).
2387 selection = self.treeChannels.get_selection()
2388 model, iter = selection.get_selected()
2390 if self.config.podcast_list_view_all and not self.channel_list_changed:
2391 # Update "all episodes" view in any case (if enabled)
2392 self.podcast_list_model.update_first_row()
2394 if selected:
2395 # very cheap! only update selected channel
2396 if iter is not None:
2397 # If we have selected the "all episodes" view, we have
2398 # to update all channels for selected episodes:
2399 if self.config.podcast_list_view_all and \
2400 self.podcast_list_model.iter_is_first_row(iter):
2401 urls = self.get_podcast_urls_from_selected_episodes()
2402 self.podcast_list_model.update_by_urls(urls)
2403 else:
2404 # Otherwise just update the selected row (a podcast)
2405 self.podcast_list_model.update_by_filter_iter(iter)
2406 elif not self.channel_list_changed:
2407 # we can keep the model, but have to update some
2408 if urls is None:
2409 # still cheaper than reloading the whole list
2410 self.podcast_list_model.update_all()
2411 else:
2412 # ok, we got a bunch of urls to update
2413 self.podcast_list_model.update_by_urls(urls)
2414 else:
2415 if model and iter and select_url is None:
2416 # Get the URL of the currently-selected podcast
2417 select_url = model.get_value(iter, PodcastListModel.C_URL)
2419 # Update the podcast list model with new channels
2420 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2422 try:
2423 selected_iter = model.get_iter_first()
2424 # Find the previously-selected URL in the new
2425 # model if we have an URL (else select first)
2426 if select_url is not None:
2427 pos = model.get_iter_first()
2428 while pos is not None:
2429 url = model.get_value(pos, PodcastListModel.C_URL)
2430 if url == select_url:
2431 selected_iter = pos
2432 break
2433 pos = model.iter_next(pos)
2435 if not gpodder.ui.fremantle:
2436 if selected_iter is not None:
2437 selection.select_iter(selected_iter)
2438 self.on_treeChannels_cursor_changed(self.treeChannels)
2439 except:
2440 log('Cannot select podcast in list', traceback=True, sender=self)
2441 self.channel_list_changed = False
2443 def episode_is_downloading(self, episode):
2444 """Returns True if the given episode is being downloaded at the moment"""
2445 if episode is None:
2446 return False
2448 return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
2450 def update_episode_list_model(self):
2451 if self.channels and self.active_channel is not None:
2452 if gpodder.ui.fremantle:
2453 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2455 self.currently_updating = True
2456 self.episode_list_model.clear()
2457 self.episode_list_model.reset_update_progress()
2458 self.treeAvailable.set_model(self.empty_episode_list_model)
2459 def do_update_episode_list_model():
2460 additional_args = (self.episode_is_downloading, \
2461 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2462 self.config.episode_list_thumbnails and gpodder.ui.desktop, \
2463 self.treeAvailable)
2464 self.episode_list_model.add_from_channel(self.active_channel, *additional_args)
2466 def on_episode_list_model_updated():
2467 if gpodder.ui.fremantle:
2468 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2469 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
2470 self.treeAvailable.columns_autosize()
2471 self.currently_updating = False
2472 self.play_or_download()
2473 util.idle_add(on_episode_list_model_updated)
2474 threading.Thread(target=do_update_episode_list_model).start()
2475 else:
2476 self.episode_list_model.clear()
2478 @dbus.service.method(gpodder.dbus_interface)
2479 def offer_new_episodes(self, channels=None):
2480 if gpodder.ui.fremantle:
2481 # Assume that when this function is called that the
2482 # notification is not shown anymore (Maemo bug 11345)
2483 self._fremantle_notification_visible = False
2485 new_episodes = self.get_new_episodes(channels)
2486 if new_episodes:
2487 self.new_episodes_show(new_episodes)
2488 return True
2489 return False
2491 def add_podcast_list(self, urls, auth_tokens=None):
2492 """Subscribe to a list of podcast given their URLs
2494 If auth_tokens is given, it should be a dictionary
2495 mapping URLs to (username, password) tuples."""
2497 if auth_tokens is None:
2498 auth_tokens = {}
2500 # Sort and split the URL list into five buckets
2501 queued, failed, existing, worked, authreq = [], [], [], [], []
2502 for input_url in urls:
2503 url = util.normalize_feed_url(input_url)
2504 if url is None:
2505 # Fail this one because the URL is not valid
2506 failed.append(input_url)
2507 elif self.podcast_list_model.get_filter_path_from_url(url) is not None:
2508 # A podcast already exists in the list for this URL
2509 existing.append(url)
2510 else:
2511 # This URL has survived the first round - queue for add
2512 queued.append(url)
2513 if url != input_url and input_url in auth_tokens:
2514 auth_tokens[url] = auth_tokens[input_url]
2516 error_messages = {}
2517 redirections = {}
2519 progress = ProgressIndicator(_('Adding podcasts'), \
2520 _('Please wait while episode information is downloaded.'), \
2521 parent=self.get_dialog_parent())
2523 def on_after_update():
2524 progress.on_finished()
2525 # Report already-existing subscriptions to the user
2526 if existing:
2527 title = _('Existing subscriptions skipped')
2528 message = _('You are already subscribed to these podcasts:') \
2529 + '\n\n' + '\n'.join(saxutils.escape(url) for url in existing)
2530 self.show_message(message, title, widget=self.treeChannels)
2532 # Report subscriptions that require authentication
2533 if authreq:
2534 retry_podcasts = {}
2535 for url in authreq:
2536 title = _('Podcast requires authentication')
2537 message = _('Please login to %s:') % (saxutils.escape(url),)
2538 success, auth_tokens = self.show_login_dialog(title, message)
2539 if success:
2540 retry_podcasts[url] = auth_tokens
2541 else:
2542 # Stop asking the user for more login data
2543 retry_podcasts = {}
2544 for url in authreq:
2545 error_messages[url] = _('Authentication failed')
2546 failed.append(url)
2547 break
2549 # If we have authentication data to retry, do so here
2550 if retry_podcasts:
2551 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
2553 # Report website redirections
2554 for url in redirections:
2555 title = _('Website redirection detected')
2556 message = _('The URL %(url)s redirects to %(target)s.') \
2557 + '\n\n' + _('Do you want to visit the website now?')
2558 message = message % {'url': url, 'target': redirections[url]}
2559 if self.show_confirmation(message, title):
2560 util.open_website(url)
2561 else:
2562 break
2564 # Report failed subscriptions to the user
2565 if failed:
2566 title = _('Could not add some podcasts')
2567 message = _('Some podcasts could not be added to your list:') \
2568 + '\n\n' + '\n'.join(saxutils.escape('%s: %s' % (url, \
2569 error_messages.get(url, _('Unknown')))) for url in failed)
2570 self.show_message(message, title, important=True)
2572 # Upload subscription changes to gpodder.net
2573 self.mygpo_client.on_subscribe(worked)
2575 # If at least one podcast has been added, save and update all
2576 if self.channel_list_changed:
2577 # Fix URLs if mygpo has rewritten them
2578 self.rewrite_urls_mygpo()
2580 self.save_channels_opml()
2582 # If only one podcast was added, select it after the update
2583 if len(worked) == 1:
2584 url = worked[0]
2585 else:
2586 url = None
2588 # Update the list of subscribed podcasts
2589 self.update_feed_cache(force_update=False, select_url_afterwards=url)
2590 self.update_podcasts_tab()
2592 # Offer to download new episodes
2593 episodes = []
2594 for podcast in self.channels:
2595 if podcast.url in worked:
2596 episodes.extend(podcast.get_all_episodes())
2598 if episodes:
2599 episodes = list(PodcastEpisode.sort_by_pubdate(episodes, \
2600 reverse=True))
2601 self.new_episodes_show(episodes, \
2602 selected=[e.check_is_new() for e in episodes])
2605 def thread_proc():
2606 # After the initial sorting and splitting, try all queued podcasts
2607 length = len(queued)
2608 for index, url in enumerate(queued):
2609 progress.on_progress(float(index)/float(length))
2610 progress.on_message(url)
2611 log('QUEUE RUNNER: %s', url, sender=self)
2612 try:
2613 # The URL is valid and does not exist already - subscribe!
2614 channel = PodcastChannel.load(self.db, url=url, create=True, \
2615 authentication_tokens=auth_tokens.get(url, None), \
2616 max_episodes=self.config.max_episodes_per_feed, \
2617 download_dir=self.config.download_dir, \
2618 allow_empty_feeds=self.config.allow_empty_feeds, \
2619 mimetype_prefs=self.config.mimetype_prefs)
2621 try:
2622 username, password = util.username_password_from_url(url)
2623 except ValueError, ve:
2624 username, password = (None, None)
2626 if username is not None and channel.username is None and \
2627 password is not None and channel.password is None:
2628 channel.username = username
2629 channel.password = password
2630 channel.save()
2632 self._update_cover(channel)
2633 except feedcore.AuthenticationRequired:
2634 if url in auth_tokens:
2635 # Fail for wrong authentication data
2636 error_messages[url] = _('Authentication failed')
2637 failed.append(url)
2638 else:
2639 # Queue for login dialog later
2640 authreq.append(url)
2641 continue
2642 except feedcore.WifiLogin, error:
2643 redirections[url] = error.data
2644 failed.append(url)
2645 error_messages[url] = _('Redirection detected')
2646 continue
2647 except Exception, e:
2648 log('Subscription error: %s', e, traceback=True, sender=self)
2649 error_messages[url] = str(e)
2650 failed.append(url)
2651 continue
2653 assert channel is not None
2654 worked.append(channel.url)
2655 self.channels.append(channel)
2656 self.channel_list_changed = True
2657 util.idle_add(on_after_update)
2658 threading.Thread(target=thread_proc).start()
2660 def save_channels_opml(self):
2661 exporter = opml.Exporter(gpodder.subscription_file)
2662 return exporter.write(self.channels)
2664 def find_episode(self, podcast_url, episode_url):
2665 """Find an episode given its podcast and episode URL
2667 The function will return a PodcastEpisode object if
2668 the episode is found, or None if it's not found.
2670 for podcast in self.channels:
2671 if podcast_url == podcast.url:
2672 for episode in podcast.get_all_episodes():
2673 if episode_url == episode.url:
2674 return episode
2676 return None
2678 def process_received_episode_actions(self, updated_urls):
2679 """Process/merge episode actions from gpodder.net
2681 This function will merge all changes received from
2682 the server to the local database and update the
2683 status of the affected episodes as necessary.
2685 indicator = ProgressIndicator(_('Merging episode actions'), \
2686 _('Episode actions from gpodder.net are merged.'), \
2687 False, self.get_dialog_parent())
2689 for idx, action in enumerate(self.mygpo_client.get_episode_actions(updated_urls)):
2690 if action.action == 'play':
2691 episode = self.find_episode(action.podcast_url, \
2692 action.episode_url)
2694 if episode is not None:
2695 log('Play action for %s', episode.url, sender=self)
2696 episode.mark(is_played=True)
2698 if action.timestamp > episode.current_position_updated:
2699 log('Updating position for %s', episode.url, sender=self)
2700 episode.current_position = action.position
2701 episode.current_position_updated = action.timestamp
2703 if action.total:
2704 log('Updating total time for %s', episode.url, sender=self)
2705 episode.total_time = action.total
2707 episode.save()
2708 elif action.action == 'delete':
2709 episode = self.find_episode(action.podcast_url, \
2710 action.episode_url)
2712 if episode is not None:
2713 if not episode.was_downloaded(and_exists=True):
2714 # Set the episode to a "deleted" state
2715 log('Marking as deleted: %s', episode.url, sender=self)
2716 episode.delete_from_disk()
2717 episode.save()
2719 indicator.on_message(N_('%d action processed', '%d actions processed', idx) % idx)
2720 gtk.main_iteration(False)
2722 indicator.on_finished()
2723 self.db.commit()
2726 def update_feed_cache_finish_callback(self, updated_urls=None, select_url_afterwards=None):
2727 self.db.commit()
2728 self.updating_feed_cache = False
2730 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2732 # Process received episode actions for all updated URLs
2733 self.process_received_episode_actions(updated_urls)
2735 self.channel_list_changed = True
2736 self.update_podcast_list_model(select_url=select_url_afterwards)
2738 # Only search for new episodes in podcasts that have been
2739 # updated, not in other podcasts (for single-feed updates)
2740 episodes = self.get_new_episodes([c for c in self.channels if c.url in updated_urls])
2742 if gpodder.ui.fremantle:
2743 self.button_subscribe.set_sensitive(True)
2744 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
2745 self.ICON_GENERAL_REFRESH, gtk.ICON_SIZE_BUTTON))
2746 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
2747 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2748 self.update_podcasts_tab()
2749 self.update_episode_list_model()
2750 if self.feed_cache_update_cancelled:
2751 return
2753 if episodes:
2754 if self.config.auto_download == 'quiet' and not self.config.auto_update_feeds:
2755 # New episodes found, but we should do nothing
2756 self.show_message(_('New episodes are available.'))
2757 elif self.config.auto_download == 'always':
2758 count = len(episodes)
2759 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2760 self.show_message(title)
2761 self.download_episode_list(episodes)
2762 elif self.config.auto_download == 'queue':
2763 self.show_message(_('New episodes have been added to the download list.'))
2764 self.download_episode_list_paused(episodes)
2765 elif not self._fremantle_notification_visible:
2766 try:
2767 import pynotify
2768 pynotify.init('gPodder')
2769 n = pynotify.Notification('gPodder', _('New episodes available'), 'gpodder')
2770 n.set_urgency(pynotify.URGENCY_CRITICAL)
2771 n.set_hint('dbus-callback-default', ' '.join([
2772 gpodder.dbus_bus_name,
2773 gpodder.dbus_gui_object_path,
2774 gpodder.dbus_interface,
2775 'offer_new_episodes',
2777 n.set_category('gpodder-new-episodes')
2778 n.show()
2779 self._fremantle_notification_visible = True
2780 except Exception, e:
2781 log('Error: %s', str(e), sender=self, traceback=True)
2782 self.new_episodes_show(episodes)
2783 self._fremantle_notification_visible = False
2784 elif not self.config.auto_update_feeds:
2785 self.show_message(_('No new episodes. Please check for new episodes later.'))
2786 return
2788 if self.tray_icon:
2789 self.tray_icon.set_status()
2791 if self.feed_cache_update_cancelled:
2792 # The user decided to abort the feed update
2793 self.show_update_feeds_buttons()
2794 elif not episodes:
2795 # Nothing new here - but inform the user
2796 self.pbFeedUpdate.set_fraction(1.0)
2797 self.pbFeedUpdate.set_text(_('No new episodes'))
2798 self.feed_cache_update_cancelled = True
2799 self.btnCancelFeedUpdate.show()
2800 self.btnCancelFeedUpdate.set_sensitive(True)
2801 if gpodder.ui.maemo:
2802 # btnCancelFeedUpdate is a ToolButton on Maemo
2803 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_APPLY)
2804 else:
2805 # btnCancelFeedUpdate is a normal gtk.Button
2806 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
2807 else:
2808 count = len(episodes)
2809 # New episodes are available
2810 self.pbFeedUpdate.set_fraction(1.0)
2811 # Are we minimized and should we auto download?
2812 if (self.is_iconified() and (self.config.auto_download == 'minimized')) or (self.config.auto_download == 'always'):
2813 self.download_episode_list(episodes)
2814 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2815 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2816 self.show_update_feeds_buttons()
2817 elif self.config.auto_download == 'queue':
2818 self.download_episode_list_paused(episodes)
2819 title = N_('%d new episode added to download list.', '%d new episodes added to download list.', count) % count
2820 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2821 self.show_update_feeds_buttons()
2822 else:
2823 self.show_update_feeds_buttons()
2824 # New episodes are available and we are not minimized
2825 if not self.config.do_not_show_new_episodes_dialog:
2826 self.new_episodes_show(episodes, notification=True)
2827 else:
2828 message = N_('%d new episode available', '%d new episodes available', count) % count
2829 self.pbFeedUpdate.set_text(message)
2831 def _update_cover(self, channel):
2832 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
2833 self.cover_downloader.request_cover(channel)
2835 def update_feed_cache_proc(self, channels, select_url_afterwards):
2836 total = len(channels)
2838 for updated, channel in enumerate(channels):
2839 if not self.feed_cache_update_cancelled:
2840 try:
2841 channel.update(max_episodes=self.config.max_episodes_per_feed, \
2842 mimetype_prefs=self.config.mimetype_prefs)
2843 self._update_cover(channel)
2844 except Exception, e:
2845 d = {'url': saxutils.escape(channel.url), 'message': saxutils.escape(str(e))}
2846 if d['message']:
2847 message = _('Error while updating %(url)s: %(message)s')
2848 else:
2849 message = _('The feed at %(url)s could not be updated.')
2850 self.notification(message % d, _('Error while updating feed'), widget=self.treeChannels)
2851 log('Error: %s', str(e), sender=self, traceback=True)
2853 if self.feed_cache_update_cancelled:
2854 break
2856 if gpodder.ui.fremantle:
2857 util.idle_add(self.button_refresh.set_title, \
2858 _('%(position)d/%(total)d updated') % {'position': updated+1, 'total': total})
2859 continue
2861 # By the time we get here the update may have already been cancelled
2862 if not self.feed_cache_update_cancelled:
2863 def update_progress():
2864 d = {'podcast': channel.title, 'position': updated, 'total': total}
2865 progression = _('Updated %(podcast)s (%(position)d/%(total)d)') % d
2866 self.pbFeedUpdate.set_text(progression)
2867 if self.tray_icon:
2868 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression)
2869 self.pbFeedUpdate.set_fraction(float(updated)/float(total))
2870 util.idle_add(update_progress)
2872 updated_urls = [c.url for c in channels]
2873 util.idle_add(self.update_feed_cache_finish_callback, updated_urls, select_url_afterwards)
2875 def show_update_feeds_buttons(self):
2876 # Make sure that the buttons for updating feeds
2877 # appear - this should happen after a feed update
2878 if gpodder.ui.maemo:
2879 self.btnUpdateSelectedFeed.show()
2880 self.toolFeedUpdateProgress.hide()
2881 self.btnCancelFeedUpdate.hide()
2882 self.btnCancelFeedUpdate.set_is_important(False)
2883 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_CLOSE)
2884 self.toolbarSpacer.set_expand(True)
2885 self.toolbarSpacer.set_draw(False)
2886 else:
2887 self.hboxUpdateFeeds.hide()
2888 self.btnUpdateFeeds.show()
2889 self.itemUpdate.set_sensitive(True)
2890 self.itemUpdateChannel.set_sensitive(True)
2892 def on_btnCancelFeedUpdate_clicked(self, widget):
2893 if not self.feed_cache_update_cancelled:
2894 self.pbFeedUpdate.set_text(_('Cancelling...'))
2895 self.feed_cache_update_cancelled = True
2896 self.btnCancelFeedUpdate.set_sensitive(False)
2897 else:
2898 self.show_update_feeds_buttons()
2900 def update_feed_cache(self, channels=None, force_update=True, select_url_afterwards=None):
2901 if self.updating_feed_cache:
2902 if gpodder.ui.fremantle:
2903 self.feed_cache_update_cancelled = True
2904 return
2906 if not force_update:
2907 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2908 self.channel_list_changed = True
2909 self.update_podcast_list_model(select_url=select_url_afterwards)
2910 return
2912 # Fix URLs if mygpo has rewritten them
2913 self.rewrite_urls_mygpo()
2915 self.updating_feed_cache = True
2917 if channels is None:
2918 # Only update podcasts for which updates are enabled
2919 channels = [c for c in self.channels if c.feed_update_enabled]
2921 if gpodder.ui.fremantle:
2922 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
2923 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2924 self.button_refresh.set_title(_('Updating...'))
2925 self.button_subscribe.set_sensitive(False)
2926 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
2927 self.ICON_GENERAL_CLOSE, gtk.ICON_SIZE_BUTTON))
2928 self.feed_cache_update_cancelled = False
2929 else:
2930 self.itemUpdate.set_sensitive(False)
2931 self.itemUpdateChannel.set_sensitive(False)
2933 if self.tray_icon:
2934 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
2936 if len(channels) == 1:
2937 text = _('Updating "%s"...') % channels[0].title
2938 else:
2939 count = len(channels)
2940 text = N_('Updating %d feed...', 'Updating %d feeds...', count) % count
2941 self.pbFeedUpdate.set_text(text)
2942 self.pbFeedUpdate.set_fraction(0)
2944 self.feed_cache_update_cancelled = False
2945 self.btnCancelFeedUpdate.show()
2946 self.btnCancelFeedUpdate.set_sensitive(True)
2947 if gpodder.ui.maemo:
2948 self.toolbarSpacer.set_expand(False)
2949 self.toolbarSpacer.set_draw(True)
2950 self.btnUpdateSelectedFeed.hide()
2951 self.toolFeedUpdateProgress.show_all()
2952 else:
2953 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
2954 self.hboxUpdateFeeds.show_all()
2955 self.btnUpdateFeeds.hide()
2957 args = (channels, select_url_afterwards)
2958 threading.Thread(target=self.update_feed_cache_proc, args=args).start()
2960 def on_gPodder_delete_event(self, widget, *args):
2961 """Called when the GUI wants to close the window
2962 Displays a confirmation dialog (and closes/hides gPodder)
2965 downloading = self.download_status_model.are_downloads_in_progress()
2967 # Only iconify if we are using the window's "X" button,
2968 # but not when we are using "Quit" in the menu or toolbar
2969 if self.config.on_quit_systray and self.tray_icon and widget.get_name() not in ('toolQuit', 'itemQuit'):
2970 self.iconify_main_window()
2971 elif self.config.on_quit_ask or downloading:
2972 if gpodder.ui.fremantle:
2973 self.close_gpodder()
2974 elif gpodder.ui.diablo:
2975 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
2976 if result:
2977 self.close_gpodder()
2978 else:
2979 return True
2980 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2981 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2982 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
2984 title = _('Quit gPodder')
2985 if downloading:
2986 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
2987 else:
2988 message = _('Do you really want to quit gPodder now?')
2990 dialog.set_title(title)
2991 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
2992 if not downloading:
2993 cb_ask = gtk.CheckButton(_("Don't ask me again"))
2994 dialog.vbox.pack_start(cb_ask)
2995 cb_ask.show_all()
2997 quit_button.grab_focus()
2998 result = dialog.run()
2999 dialog.destroy()
3001 if result == gtk.RESPONSE_CLOSE:
3002 if not downloading and cb_ask.get_active() == True:
3003 self.config.on_quit_ask = False
3004 self.close_gpodder()
3005 else:
3006 self.close_gpodder()
3008 return True
3010 def close_gpodder(self):
3011 """ clean everything and exit properly
3013 if self.channels:
3014 if self.save_channels_opml():
3015 pass # FIXME: Add mygpo synchronization here
3016 else:
3017 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'), important=True)
3019 self.gPodder.hide()
3021 if self.tray_icon is not None:
3022 self.tray_icon.set_visible(False)
3024 # Notify all tasks to to carry out any clean-up actions
3025 self.download_status_model.tell_all_tasks_to_quit()
3027 while gtk.events_pending():
3028 gtk.main_iteration(False)
3030 self.db.close()
3032 self.quit()
3033 sys.exit(0)
3035 def get_expired_episodes(self):
3036 for channel in self.channels:
3037 for episode in channel.get_downloaded_episodes():
3038 # Never consider locked episodes as old
3039 if episode.is_locked:
3040 continue
3042 # Never consider fresh episodes as old
3043 if episode.age_in_days() < self.config.episode_old_age:
3044 continue
3046 # Do not delete played episodes (except if configured)
3047 if episode.is_played:
3048 if not self.config.auto_remove_played_episodes:
3049 continue
3051 # Do not delete unplayed episodes (except if configured)
3052 if not episode.is_played:
3053 if not self.config.auto_remove_unplayed_episodes:
3054 continue
3056 yield episode
3058 def delete_episode_list(self, episodes, confirm=True, skip_locked=True):
3059 if not episodes:
3060 return False
3062 if skip_locked:
3063 episodes = [e for e in episodes if not e.is_locked]
3065 if not episodes:
3066 title = _('Episodes are locked')
3067 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
3068 self.notification(message, title, widget=self.treeAvailable)
3069 return False
3071 count = len(episodes)
3072 title = N_('Delete %d episode?', 'Delete %d episodes?', count) % count
3073 message = _('Deleting episodes removes downloaded files.')
3075 if gpodder.ui.fremantle:
3076 message = '\n'.join([title, message])
3078 if confirm and not self.show_confirmation(message, title):
3079 return False
3081 progress = ProgressIndicator(_('Deleting episodes'), \
3082 _('Please wait while episodes are deleted'), \
3083 parent=self.get_dialog_parent())
3085 def finish_deletion(episode_urls, channel_urls):
3086 progress.on_finished()
3088 # Episodes have been deleted - persist the database
3089 self.db.commit()
3091 self.update_episode_list_icons(episode_urls)
3092 self.update_podcast_list_model(channel_urls)
3093 self.play_or_download()
3095 def thread_proc():
3096 episode_urls = set()
3097 channel_urls = set()
3099 episodes_status_update = []
3100 for idx, episode in enumerate(episodes):
3101 progress.on_progress(float(idx)/float(len(episodes)))
3102 if episode.is_locked and skip_locked:
3103 log('Not deleting episode (is locked): %s', episode.title)
3104 else:
3105 log('Deleting episode: %s', episode.title)
3106 progress.on_message(episode.title)
3107 episode.delete_from_disk()
3108 episode_urls.add(episode.url)
3109 channel_urls.add(episode.channel.url)
3110 episodes_status_update.append(episode)
3112 # Tell the shownotes window that we have removed the episode
3113 if self.episode_shownotes_window is not None and \
3114 self.episode_shownotes_window.episode is not None and \
3115 self.episode_shownotes_window.episode.url == episode.url:
3116 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
3118 # Notify the web service about the status update + upload
3119 self.mygpo_client.on_delete(episodes_status_update)
3120 self.mygpo_client.flush()
3122 util.idle_add(finish_deletion, episode_urls, channel_urls)
3124 threading.Thread(target=thread_proc).start()
3126 return True
3128 def on_itemRemoveOldEpisodes_activate( self, widget):
3129 if gpodder.ui.maemo:
3130 columns = (
3131 ('maemo_remove_markup', None, None, _('Episode')),
3133 else:
3134 columns = (
3135 ('title_markup', None, None, _('Episode')),
3136 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3137 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3138 ('played_prop', None, None, _('Status')),
3139 ('age_prop', 'age_int_prop', gobject.TYPE_INT, _('Downloaded')),
3142 msg_older_than = N_('Select older than %d day', 'Select older than %d days', self.config.episode_old_age)
3143 selection_buttons = {
3144 _('Select played'): lambda episode: episode.is_played,
3145 msg_older_than % self.config.episode_old_age: lambda episode: episode.age_in_days() > self.config.episode_old_age,
3148 instructions = _('Select the episodes you want to delete:')
3150 episodes = []
3151 selected = []
3152 for channel in self.channels:
3153 for episode in channel.get_downloaded_episodes():
3154 # Disallow deletion of locked episodes that still exist
3155 if not episode.is_locked or not episode.file_exists():
3156 episodes.append(episode)
3157 # Automatically select played and file-less episodes
3158 selected.append(episode.is_played or \
3159 not episode.file_exists())
3161 gPodderEpisodeSelector(self.gPodder, title = _('Delete episodes'), instructions = instructions, \
3162 episodes = episodes, selected = selected, columns = columns, \
3163 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
3164 selection_buttons = selection_buttons, _config=self.config, \
3165 show_episode_shownotes=self.show_episode_shownotes)
3167 def on_selected_episodes_status_changed(self):
3168 # The order of the updates here is important! When "All episodes" is
3169 # selected, the update of the podcast list model depends on the episode
3170 # list selection to determine which podcasts are affected. Updating
3171 # the episode list could remove the selection if a filter is active.
3172 self.update_podcast_list_model(selected=True)
3173 self.update_episode_list_icons(selected=True)
3174 self.db.commit()
3176 def mark_selected_episodes_new(self):
3177 for episode in self.get_selected_episodes():
3178 episode.mark_new()
3179 self.on_selected_episodes_status_changed()
3181 def mark_selected_episodes_old(self):
3182 for episode in self.get_selected_episodes():
3183 episode.mark_old()
3184 self.on_selected_episodes_status_changed()
3186 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
3187 for episode in self.get_selected_episodes():
3188 if toggle:
3189 episode.mark(is_played=not episode.is_played)
3190 else:
3191 episode.mark(is_played=new_value)
3192 self.on_selected_episodes_status_changed()
3194 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3195 for episode in self.get_selected_episodes():
3196 if toggle:
3197 episode.mark(is_locked=not episode.is_locked)
3198 else:
3199 episode.mark(is_locked=new_value)
3200 self.on_selected_episodes_status_changed()
3202 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3203 if self.active_channel is None:
3204 return
3206 self.active_channel.channel_is_locked = not self.active_channel.channel_is_locked
3207 self.active_channel.update_channel_lock()
3209 for episode in self.active_channel.get_all_episodes():
3210 episode.mark(is_locked=self.active_channel.channel_is_locked)
3212 self.update_podcast_list_model(selected=True)
3213 self.update_episode_list_icons(all=True)
3215 def on_itemUpdateChannel_activate(self, widget=None):
3216 if self.active_channel is None:
3217 title = _('No podcast selected')
3218 message = _('Please select a podcast in the podcasts list to update.')
3219 self.show_message( message, title, widget=self.treeChannels)
3220 return
3222 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3223 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3224 self.update_feed_cache()
3225 else:
3226 self.update_feed_cache(channels=[self.active_channel])
3228 def on_itemUpdate_activate(self, widget=None):
3229 # Check if we have outstanding subscribe/unsubscribe actions
3230 if self.on_add_remove_podcasts_mygpo():
3231 log('Update cancelled (received server changes)', sender=self)
3232 return
3234 if self.channels:
3235 self.update_feed_cache()
3236 else:
3237 gPodderWelcome(self.gPodder,
3238 center_on_widget=self.gPodder,
3239 show_example_podcasts_callback=self.on_itemImportChannels_activate,
3240 setup_my_gpodder_callback=self.on_download_subscriptions_from_mygpo)
3242 def download_episode_list_paused(self, episodes):
3243 self.download_episode_list(episodes, True)
3245 def download_episode_list(self, episodes, add_paused=False, force_start=False):
3246 enable_update = False
3248 for episode in episodes:
3249 log('Downloading episode: %s', episode.title, sender = self)
3250 if not episode.was_downloaded(and_exists=True):
3251 task_exists = False
3252 for task in self.download_tasks_seen:
3253 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
3254 self.download_queue_manager.add_task(task, force_start)
3255 enable_update = True
3256 task_exists = True
3257 continue
3259 if task_exists:
3260 continue
3262 try:
3263 task = download.DownloadTask(episode, self.config)
3264 except Exception, e:
3265 d = {'episode': episode.title, 'message': str(e)}
3266 message = _('Download error while downloading %(episode)s: %(message)s')
3267 self.show_message(message % d, _('Download error'), important=True)
3268 log('Download error while downloading %s', episode.title, sender=self, traceback=True)
3269 continue
3271 if add_paused:
3272 task.status = task.PAUSED
3273 else:
3274 self.mygpo_client.on_download([task.episode])
3275 self.download_queue_manager.add_task(task, force_start)
3277 self.download_status_model.register_task(task)
3278 enable_update = True
3280 if enable_update:
3281 self.enable_download_list_update()
3283 # Flush updated episode status
3284 self.mygpo_client.flush()
3286 def cancel_task_list(self, tasks):
3287 if not tasks:
3288 return
3290 for task in tasks:
3291 if task.status in (task.QUEUED, task.DOWNLOADING):
3292 task.status = task.CANCELLED
3293 elif task.status == task.PAUSED:
3294 task.status = task.CANCELLED
3295 # Call run, so the partial file gets deleted
3296 task.run()
3298 self.update_episode_list_icons([task.url for task in tasks])
3299 self.play_or_download()
3301 # Update the tab title and downloads list
3302 self.update_downloads_list()
3304 def new_episodes_show(self, episodes, notification=False, selected=None):
3305 if gpodder.ui.maemo:
3306 columns = (
3307 ('maemo_markup', None, None, _('Episode')),
3309 show_notification = notification
3310 else:
3311 columns = (
3312 ('title_markup', None, None, _('Episode')),
3313 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3314 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3316 show_notification = False
3318 instructions = _('Select the episodes you want to download:')
3320 if self.new_episodes_window is not None:
3321 self.new_episodes_window.main_window.destroy()
3322 self.new_episodes_window = None
3324 def download_episodes_callback(episodes):
3325 self.new_episodes_window = None
3326 self.download_episode_list(episodes)
3328 if selected is None:
3329 # Select all by default
3330 selected = [True]*len(episodes)
3332 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
3333 title=_('New episodes available'), \
3334 instructions=instructions, \
3335 episodes=episodes, \
3336 columns=columns, \
3337 selected=selected, \
3338 stock_ok_button = 'gpodder-download', \
3339 callback=download_episodes_callback, \
3340 remove_callback=lambda e: e.mark_old(), \
3341 remove_action=_('Mark as old'), \
3342 remove_finished=self.episode_new_status_changed, \
3343 _config=self.config, \
3344 show_notification=show_notification, \
3345 show_episode_shownotes=self.show_episode_shownotes)
3347 def on_itemDownloadAllNew_activate(self, widget, *args):
3348 if not self.offer_new_episodes():
3349 self.show_message(_('Please check for new episodes later.'), \
3350 _('No new episodes available'), widget=self.btnUpdateFeeds)
3352 def get_new_episodes(self, channels=None):
3353 if channels is None:
3354 channels = self.channels
3355 episodes = []
3356 for channel in channels:
3357 for episode in channel.get_new_episodes(downloading=self.episode_is_downloading):
3358 episodes.append(episode)
3360 return episodes
3362 def on_sync_to_ipod_activate(self, widget, episodes=None):
3363 self.sync_ui.on_synchronize_episodes(self.channels, episodes)
3365 def commit_changes_to_database(self):
3366 """This will be called after the sync process is finished"""
3367 self.db.commit()
3369 def on_cleanup_ipod_activate(self, widget, *args):
3370 self.sync_ui.on_cleanup_device()
3372 def on_manage_device_playlist(self, widget):
3373 self.sync_ui.on_manage_device_playlist()
3375 def show_hide_tray_icon(self):
3376 if self.config.display_tray_icon and have_trayicon and self.tray_icon is None:
3377 self.tray_icon = GPodderStatusIcon(self, gpodder.icon_file, self.config)
3378 elif not self.config.display_tray_icon and self.tray_icon is not None:
3379 self.tray_icon.set_visible(False)
3380 del self.tray_icon
3381 self.tray_icon = None
3383 if self.config.minimize_to_tray and self.tray_icon:
3384 self.tray_icon.set_visible(self.is_iconified())
3385 elif self.tray_icon:
3386 self.tray_icon.set_visible(True)
3388 def on_itemShowAllEpisodes_activate(self, widget):
3389 self.config.podcast_list_view_all = widget.get_active()
3391 def on_itemShowToolbar_activate(self, widget):
3392 self.config.show_toolbar = self.itemShowToolbar.get_active()
3394 def on_itemShowDescription_activate(self, widget):
3395 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
3397 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
3398 self.config.podcast_list_hide_boring = toggleaction.get_active()
3399 if self.config.podcast_list_hide_boring:
3400 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3401 else:
3402 self.podcast_list_model.set_view_mode(-1)
3404 def on_item_view_podcasts_changed(self, radioaction, current):
3405 # Only on Fremantle
3406 if current == self.item_view_podcasts_all:
3407 self.podcast_list_model.set_view_mode(-1)
3408 elif current == self.item_view_podcasts_downloaded:
3409 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3410 elif current == self.item_view_podcasts_unplayed:
3411 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3413 self.config.podcast_list_view_mode = self.podcast_list_model.get_view_mode()
3415 def on_item_view_episodes_changed(self, radioaction, current):
3416 if current == self.item_view_episodes_all:
3417 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_ALL)
3418 elif current == self.item_view_episodes_undeleted:
3419 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNDELETED)
3420 elif current == self.item_view_episodes_downloaded:
3421 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3422 elif current == self.item_view_episodes_unplayed:
3423 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3425 self.config.episode_list_view_mode = self.episode_list_model.get_view_mode()
3427 if self.config.podcast_list_hide_boring and not gpodder.ui.fremantle:
3428 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3430 def update_item_device( self):
3431 if not gpodder.ui.fremantle:
3432 if self.config.device_type != 'none':
3433 self.itemDevice.set_visible(True)
3434 self.itemDevice.label = self.get_device_name()
3435 else:
3436 self.itemDevice.set_visible(False)
3438 def properties_closed( self):
3439 self.preferences_dialog = None
3440 self.show_hide_tray_icon()
3441 self.update_item_device()
3442 if gpodder.ui.maemo:
3443 selection = self.treeAvailable.get_selection()
3444 if self.config.maemo_enable_gestures or \
3445 self.config.enable_fingerscroll:
3446 selection.set_mode(gtk.SELECTION_SINGLE)
3447 else:
3448 selection.set_mode(gtk.SELECTION_MULTIPLE)
3450 def on_itemPreferences_activate(self, widget, *args):
3451 self.preferences_dialog = gPodderPreferences(self.main_window, \
3452 _config=self.config, \
3453 callback_finished=self.properties_closed, \
3454 user_apps_reader=self.user_apps_reader, \
3455 parent_window=self.main_window, \
3456 mygpo_client=self.mygpo_client, \
3457 on_send_full_subscriptions=self.on_send_full_subscriptions)
3459 # Initial message to relayout window (in case it's opened in portrait mode
3460 self.preferences_dialog.on_window_orientation_changed(self._last_orientation)
3462 def on_itemDependencies_activate(self, widget):
3463 gPodderDependencyManager(self.gPodder)
3465 def on_goto_mygpo(self, widget):
3466 self.mygpo_client.open_website()
3468 def on_download_subscriptions_from_mygpo(self, action=None):
3469 title = _('Login to gpodder.net')
3470 message = _('Please login to download your subscriptions.')
3471 success, (username, password) = self.show_login_dialog(title, message, \
3472 self.config.mygpo_username, self.config.mygpo_password)
3473 if not success:
3474 return
3476 self.config.mygpo_username = username
3477 self.config.mygpo_password = password
3479 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3480 custom_title=_('Subscriptions on gpodder.net'), \
3481 add_urls_callback=self.add_podcast_list, \
3482 hide_url_entry=True)
3484 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
3485 # we do not have to hardcode the URL here
3486 OPML_URL = 'http://gpodder.net/subscriptions/%s.opml' % self.config.mygpo_username
3487 url = util.url_add_authentication(OPML_URL, \
3488 self.config.mygpo_username, \
3489 self.config.mygpo_password)
3490 dir.download_opml_file(url)
3492 def on_mygpo_settings_activate(self, action=None):
3493 # This dialog is only used for Maemo 4
3494 if not gpodder.ui.diablo:
3495 return
3497 settings = MygPodderSettings(self.main_window, \
3498 config=self.config, \
3499 mygpo_client=self.mygpo_client, \
3500 on_send_full_subscriptions=self.on_send_full_subscriptions)
3502 def on_itemAddChannel_activate(self, widget=None):
3503 gPodderAddPodcast(self.gPodder, \
3504 add_urls_callback=self.add_podcast_list)
3506 def on_itemEditChannel_activate(self, widget, *args):
3507 if self.active_channel is None:
3508 title = _('No podcast selected')
3509 message = _('Please select a podcast in the podcasts list to edit.')
3510 self.show_message( message, title, widget=self.treeChannels)
3511 return
3513 callback_closed = lambda: self.update_podcast_list_model(selected=True)
3514 gPodderChannel(self.main_window, \
3515 channel=self.active_channel, \
3516 callback_closed=callback_closed, \
3517 cover_downloader=self.cover_downloader)
3519 def on_itemMassUnsubscribe_activate(self, item=None):
3520 columns = (
3521 ('title', None, None, _('Podcast')),
3524 # We're abusing the Episode Selector for selecting Podcasts here,
3525 # but it works and looks good, so why not? -- thp
3526 gPodderEpisodeSelector(self.main_window, \
3527 title=_('Remove podcasts'), \
3528 instructions=_('Select the podcast you want to remove.'), \
3529 episodes=self.channels, \
3530 columns=columns, \
3531 size_attribute=None, \
3532 stock_ok_button=_('Remove'), \
3533 callback=self.remove_podcast_list, \
3534 _config=self.config)
3536 def remove_podcast_list(self, channels, confirm=True):
3537 if not channels:
3538 log('No podcasts selected for deletion', sender=self)
3539 return
3541 if len(channels) == 1:
3542 title = _('Removing podcast')
3543 info = _('Please wait while the podcast is removed')
3544 message = _('Do you really want to remove this podcast and its episodes?')
3545 else:
3546 title = _('Removing podcasts')
3547 info = _('Please wait while the podcasts are removed')
3548 message = _('Do you really want to remove the selected podcasts and their episodes?')
3550 if confirm and not self.show_confirmation(message, title):
3551 return
3553 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3555 def finish_deletion(select_url):
3556 # Upload subscription list changes to the web service
3557 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3559 # Re-load the channels and select the desired new channel
3560 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
3561 progress.on_finished()
3562 self.update_podcasts_tab()
3564 def thread_proc():
3565 select_url = None
3567 for idx, channel in enumerate(channels):
3568 # Update the UI for correct status messages
3569 progress.on_progress(float(idx)/float(len(channels)))
3570 progress.on_message(channel.title)
3572 # Delete downloaded episodes
3573 channel.remove_downloaded()
3575 # cancel any active downloads from this channel
3576 for episode in channel.get_all_episodes():
3577 util.idle_add(self.download_status_model.cancel_by_url,
3578 episode.url)
3580 if len(channels) == 1:
3581 # get the URL of the podcast we want to select next
3582 if channel in self.channels:
3583 position = self.channels.index(channel)
3584 else:
3585 position = -1
3587 if position == len(self.channels)-1:
3588 # this is the last podcast, so select the URL
3589 # of the item before this one (i.e. the "new last")
3590 select_url = self.channels[position-1].url
3591 else:
3592 # there is a podcast after the deleted one, so
3593 # we simply select the one that comes after it
3594 select_url = self.channels[position+1].url
3596 # Remove the channel and clean the database entries
3597 channel.delete()
3598 self.channels.remove(channel)
3600 # Clean up downloads and download directories
3601 self.clean_up_downloads()
3603 self.channel_list_changed = True
3604 self.save_channels_opml()
3606 # The remaining stuff is to be done in the GTK main thread
3607 util.idle_add(finish_deletion, select_url)
3609 threading.Thread(target=thread_proc).start()
3611 def on_itemRemoveChannel_activate(self, widget, *args):
3612 if self.active_channel is None:
3613 title = _('No podcast selected')
3614 message = _('Please select a podcast in the podcasts list to remove.')
3615 self.show_message( message, title, widget=self.treeChannels)
3616 return
3618 self.remove_podcast_list([self.active_channel])
3620 def get_opml_filter(self):
3621 filter = gtk.FileFilter()
3622 filter.add_pattern('*.opml')
3623 filter.add_pattern('*.xml')
3624 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
3625 return filter
3627 def on_item_import_from_file_activate(self, widget, filename=None):
3628 if filename is None:
3629 if gpodder.ui.desktop or gpodder.ui.fremantle:
3630 # FIXME: Hildonization on Fremantle
3631 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
3632 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3633 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3634 elif gpodder.ui.diablo:
3635 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
3636 dlg.set_filter(self.get_opml_filter())
3637 response = dlg.run()
3638 filename = None
3639 if response == gtk.RESPONSE_OK:
3640 filename = dlg.get_filename()
3641 dlg.destroy()
3643 if filename is not None:
3644 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3645 custom_title=_('Import podcasts from OPML file'), \
3646 add_urls_callback=self.add_podcast_list, \
3647 hide_url_entry=True)
3648 dir.download_opml_file(filename)
3650 def on_itemExportChannels_activate(self, widget, *args):
3651 if not self.channels:
3652 title = _('Nothing to export')
3653 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3654 self.show_message(message, title, widget=self.treeChannels)
3655 return
3657 if gpodder.ui.desktop or gpodder.ui.fremantle:
3658 # FIXME: Hildonization on Fremantle
3659 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3660 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3661 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3662 elif gpodder.ui.diablo:
3663 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
3664 dlg.set_filter(self.get_opml_filter())
3665 response = dlg.run()
3666 if response == gtk.RESPONSE_OK:
3667 filename = dlg.get_filename()
3668 dlg.destroy()
3669 exporter = opml.Exporter( filename)
3670 if exporter.write(self.channels):
3671 count = len(self.channels)
3672 title = N_('%d subscription exported', '%d subscriptions exported', count) % count
3673 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3674 else:
3675 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3676 else:
3677 dlg.destroy()
3679 def on_itemImportChannels_activate(self, widget, *args):
3680 if gpodder.ui.fremantle:
3681 gPodderPodcastDirectory.show_add_podcast_picker(self.main_window, \
3682 self.config.toplist_url, \
3683 self.config.opml_url, \
3684 self.add_podcast_list, \
3685 self.on_itemAddChannel_activate, \
3686 self.on_download_subscriptions_from_mygpo, \
3687 self.show_text_edit_dialog)
3688 else:
3689 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3690 add_urls_callback=self.add_podcast_list)
3691 util.idle_add(dir.download_opml_file, self.config.opml_url)
3693 def on_homepage_activate(self, widget, *args):
3694 util.open_website(gpodder.__url__)
3696 def on_wiki_activate(self, widget, *args):
3697 util.open_website('http://gpodder.org/wiki/User_Manual')
3699 def on_bug_tracker_activate(self, widget, *args):
3700 if gpodder.ui.maemo:
3701 util.open_website('http://bugs.maemo.org/enter_bug.cgi?product=gPodder')
3702 else:
3703 util.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder')
3705 def on_item_support_activate(self, widget):
3706 util.open_website('http://gpodder.org/donate')
3708 def on_itemAbout_activate(self, widget, *args):
3709 if gpodder.ui.fremantle:
3710 from gpodder.gtkui.frmntl.about import HeAboutDialog
3711 HeAboutDialog.present(self.main_window,
3712 'gPodder',
3713 'gpodder',
3714 gpodder.__version__,
3715 _('A podcast client with focus on usability'),
3716 gpodder.__copyright__,
3717 gpodder.__url__,
3718 'http://bugs.maemo.org/enter_bug.cgi?product=gPodder',
3719 'http://gpodder.org/donate')
3720 return
3722 dlg = gtk.AboutDialog()
3723 dlg.set_transient_for(self.main_window)
3724 dlg.set_name('gPodder')
3725 dlg.set_version(gpodder.__version__)
3726 dlg.set_copyright(gpodder.__copyright__)
3727 dlg.set_comments(_('A podcast client with focus on usability'))
3728 dlg.set_website(gpodder.__url__)
3729 dlg.set_translator_credits( _('translator-credits'))
3730 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
3732 if gpodder.ui.desktop:
3733 # For the "GUI" version, we add some more
3734 # items to the about dialog (credits and logo)
3735 app_authors = [
3736 _('Maintainer:'),
3737 'Thomas Perl <thpinfo.com>',
3740 if os.path.exists(gpodder.credits_file):
3741 credits = open(gpodder.credits_file).read().strip().split('\n')
3742 app_authors += ['', _('Patches, bug reports and donations by:')]
3743 app_authors += credits
3745 dlg.set_authors(app_authors)
3746 try:
3747 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(gpodder.icon_file))
3748 except:
3749 dlg.set_logo_icon_name('gpodder')
3751 dlg.run()
3753 def on_wNotebook_switch_page(self, widget, *args):
3754 page_num = args[1]
3755 if gpodder.ui.maemo:
3756 self.tool_downloads.set_active(page_num == 1)
3757 page = self.wNotebook.get_nth_page(page_num)
3758 tab_label = self.wNotebook.get_tab_label(page).get_text()
3759 if page_num == 0 and self.active_channel is not None:
3760 self.set_title(self.active_channel.title)
3761 else:
3762 self.set_title(tab_label)
3763 if page_num == 0:
3764 self.play_or_download()
3765 self.menuChannels.set_sensitive(True)
3766 self.menuSubscriptions.set_sensitive(True)
3767 # The message area in the downloads tab should be hidden
3768 # when the user switches away from the downloads tab
3769 if self.message_area is not None:
3770 self.message_area.hide()
3771 self.message_area = None
3772 else:
3773 self.menuChannels.set_sensitive(False)
3774 self.menuSubscriptions.set_sensitive(False)
3775 if gpodder.ui.desktop:
3776 self.toolDownload.set_sensitive(False)
3777 self.toolPlay.set_sensitive(False)
3778 self.toolTransfer.set_sensitive(False)
3779 self.toolCancel.set_sensitive(False)
3781 def on_treeChannels_row_activated(self, widget, path, *args):
3782 # double-click action of the podcast list or enter
3783 self.treeChannels.set_cursor(path)
3785 def on_treeChannels_cursor_changed(self, widget, *args):
3786 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3788 if model is not None and iter is not None:
3789 old_active_channel = self.active_channel
3790 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3792 if self.active_channel == old_active_channel:
3793 return
3795 if gpodder.ui.maemo:
3796 self.set_title(self.active_channel.title)
3798 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3799 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3800 self.itemEditChannel.set_visible(False)
3801 self.itemRemoveChannel.set_visible(False)
3802 else:
3803 self.itemEditChannel.set_visible(True)
3804 self.itemRemoveChannel.set_visible(True)
3805 else:
3806 self.active_channel = None
3807 self.itemEditChannel.set_visible(False)
3808 self.itemRemoveChannel.set_visible(False)
3810 self.update_episode_list_model()
3812 def on_btnEditChannel_clicked(self, widget, *args):
3813 self.on_itemEditChannel_activate( widget, args)
3815 def get_podcast_urls_from_selected_episodes(self):
3816 """Get a set of podcast URLs based on the selected episodes"""
3817 return set(episode.channel.url for episode in \
3818 self.get_selected_episodes())
3820 def get_selected_episodes(self):
3821 """Get a list of selected episodes from treeAvailable"""
3822 selection = self.treeAvailable.get_selection()
3823 model, paths = selection.get_selected_rows()
3825 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3826 return episodes
3828 def on_transfer_selected_episodes(self, widget):
3829 self.on_sync_to_ipod_activate(widget, self.get_selected_episodes())
3831 def on_playback_selected_episodes(self, widget):
3832 self.playback_episodes(self.get_selected_episodes())
3834 def on_shownotes_selected_episodes(self, widget):
3835 episodes = self.get_selected_episodes()
3836 if episodes:
3837 episode = episodes.pop(0)
3838 self.show_episode_shownotes(episode)
3839 else:
3840 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3842 def on_download_selected_episodes(self, widget):
3843 episodes = self.get_selected_episodes()
3844 self.download_episode_list(episodes)
3845 self.update_episode_list_icons([episode.url for episode in episodes])
3846 self.play_or_download()
3848 def on_treeAvailable_row_activated(self, widget, path, view_column):
3849 """Double-click/enter action handler for treeAvailable"""
3850 # We should only have one one selected as it was double clicked!
3851 e = self.get_selected_episodes()[0]
3853 if (self.config.double_click_episode_action == 'download'):
3854 # If the episode has already been downloaded and exists then play it
3855 if e.was_downloaded(and_exists=True):
3856 self.playback_episodes(self.get_selected_episodes())
3857 # else download it if it is not already downloading
3858 elif not self.episode_is_downloading(e):
3859 self.download_episode_list([e])
3860 self.update_episode_list_icons([e.url])
3861 self.play_or_download()
3862 elif (self.config.double_click_episode_action == 'stream'):
3863 # If we happen to have downloaded this episode simple play it
3864 if e.was_downloaded(and_exists=True):
3865 self.playback_episodes(self.get_selected_episodes())
3866 # else if streaming is possible stream it
3867 elif self.streaming_possible():
3868 self.playback_episodes(self.get_selected_episodes())
3869 else:
3870 log('Unable to stream episode - default media player selected!', sender=self, traceback=True)
3871 self.show_message(_('Please check your media player settings in the preferences dialog.'), _('Unable to stream episode'), widget=self.toolPreferences)
3872 else:
3873 # default action is to display show notes
3874 self.on_shownotes_selected_episodes(widget)
3876 def show_episode_shownotes(self, episode):
3877 if self.episode_shownotes_window is None:
3878 log('First-time use of episode window --- creating', sender=self)
3879 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3880 _download_episode_list=self.download_episode_list, \
3881 _playback_episodes=self.playback_episodes, \
3882 _delete_episode_list=self.delete_episode_list, \
3883 _episode_list_status_changed=self.episode_list_status_changed, \
3884 _cancel_task_list=self.cancel_task_list, \
3885 _episode_is_downloading=self.episode_is_downloading, \
3886 _streaming_possible=self.streaming_possible())
3887 self.episode_shownotes_window.show(episode)
3888 if self.episode_is_downloading(episode):
3889 self.update_downloads_list()
3891 def restart_auto_update_timer(self):
3892 if self._auto_update_timer_source_id is not None:
3893 log('Removing existing auto update timer.', sender=self)
3894 gobject.source_remove(self._auto_update_timer_source_id)
3895 self._auto_update_timer_source_id = None
3897 if self.config.auto_update_feeds and \
3898 self.config.auto_update_frequency:
3899 interval = 60*1000*self.config.auto_update_frequency
3900 log('Setting up auto update timer with interval %d.', \
3901 self.config.auto_update_frequency, sender=self)
3902 self._auto_update_timer_source_id = gobject.timeout_add(\
3903 interval, self._on_auto_update_timer)
3905 def _on_auto_update_timer(self):
3906 log('Auto update timer fired.', sender=self)
3907 self.update_feed_cache(force_update=True)
3909 # Ask web service for sub changes (if enabled)
3910 self.mygpo_client.flush()
3912 return True
3914 def on_treeDownloads_row_activated(self, widget, *args):
3915 # Use the standard way of working on the treeview
3916 selection = self.treeDownloads.get_selection()
3917 (model, paths) = selection.get_selected_rows()
3918 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3920 for tree_row_reference, task in selected_tasks:
3921 if task.status in (task.DOWNLOADING, task.QUEUED):
3922 task.status = task.PAUSED
3923 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3924 self.download_queue_manager.add_task(task)
3925 self.enable_download_list_update()
3926 elif task.status == task.DONE:
3927 model.remove(model.get_iter(tree_row_reference.get_path()))
3929 self.play_or_download()
3931 # Update the tab title and downloads list
3932 self.update_downloads_list()
3934 def on_item_cancel_download_activate(self, widget):
3935 if self.wNotebook.get_current_page() == 0:
3936 selection = self.treeAvailable.get_selection()
3937 (model, paths) = selection.get_selected_rows()
3938 urls = [model.get_value(model.get_iter(path), \
3939 self.episode_list_model.C_URL) for path in paths]
3940 selected_tasks = [task for task in self.download_tasks_seen \
3941 if task.url in urls]
3942 else:
3943 selection = self.treeDownloads.get_selection()
3944 (model, paths) = selection.get_selected_rows()
3945 selected_tasks = [model.get_value(model.get_iter(path), \
3946 self.download_status_model.C_TASK) for path in paths]
3947 self.cancel_task_list(selected_tasks)
3949 def on_btnCancelAll_clicked(self, widget, *args):
3950 self.cancel_task_list(self.download_tasks_seen)
3952 def on_btnDownloadedDelete_clicked(self, widget, *args):
3953 episodes = self.get_selected_episodes()
3954 if len(episodes) == 1:
3955 self.delete_episode_list(episodes, skip_locked=False)
3956 else:
3957 self.delete_episode_list(episodes)
3959 def on_key_press(self, widget, event):
3960 # Allow tab switching with Ctrl + PgUp/PgDown
3961 if event.state & gtk.gdk.CONTROL_MASK:
3962 if event.keyval == gtk.keysyms.Page_Up:
3963 self.wNotebook.prev_page()
3964 return True
3965 elif event.keyval == gtk.keysyms.Page_Down:
3966 self.wNotebook.next_page()
3967 return True
3969 # After this code we only handle Maemo hardware keys,
3970 # so if we are not a Maemo app, we don't do anything
3971 if not gpodder.ui.maemo:
3972 return False
3974 diff = 0
3975 if event.keyval == gtk.keysyms.F7: #plus
3976 diff = 1
3977 elif event.keyval == gtk.keysyms.F8: #minus
3978 diff = -1
3980 if diff != 0 and not self.currently_updating:
3981 selection = self.treeChannels.get_selection()
3982 (model, iter) = selection.get_selected()
3983 new_path = ((model.get_path(iter)[0]+diff)%len(model),)
3984 selection.select_path(new_path)
3985 self.treeChannels.set_cursor(new_path)
3986 return True
3988 return False
3990 def on_iconify(self):
3991 if self.tray_icon:
3992 self.gPodder.set_skip_taskbar_hint(True)
3993 if self.config.minimize_to_tray:
3994 self.tray_icon.set_visible(True)
3995 else:
3996 self.gPodder.set_skip_taskbar_hint(False)
3998 def on_uniconify(self):
3999 if self.tray_icon:
4000 self.gPodder.set_skip_taskbar_hint(False)
4001 if self.config.minimize_to_tray:
4002 self.tray_icon.set_visible(False)
4003 else:
4004 self.gPodder.set_skip_taskbar_hint(False)
4006 def uniconify_main_window(self):
4007 if self.is_iconified():
4008 # We need to hide and then show the window in WMs like Metacity
4009 # or KWin4 to move the window to the active workspace
4010 # (see http://gpodder.org/bug/1125)
4011 self.gPodder.hide()
4012 self.gPodder.show()
4013 self.gPodder.present()
4015 def iconify_main_window(self):
4016 if not self.is_iconified():
4017 self.gPodder.iconify()
4019 def update_podcasts_tab(self):
4020 if len(self.channels):
4021 if gpodder.ui.fremantle:
4022 self.button_refresh.set_title(_('Check for new episodes'))
4023 self.button_refresh.show()
4024 else:
4025 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
4026 else:
4027 if gpodder.ui.fremantle:
4028 self.button_refresh.hide()
4029 else:
4030 self.label2.set_text(_('Podcasts'))
4032 @dbus.service.method(gpodder.dbus_interface)
4033 def show_gui_window(self):
4034 parent = self.get_dialog_parent()
4035 parent.present()
4037 @dbus.service.method(gpodder.dbus_interface)
4038 def subscribe_to_url(self, url):
4039 gPodderAddPodcast(self.gPodder,
4040 add_urls_callback=self.add_podcast_list,
4041 preset_url=url)
4043 @dbus.service.method(gpodder.dbus_interface)
4044 def mark_episode_played(self, filename):
4045 if filename is None:
4046 return False
4048 for channel in self.channels:
4049 for episode in channel.get_all_episodes():
4050 fn = episode.local_filename(create=False, check_only=True)
4051 if fn == filename:
4052 episode.mark(is_played=True)
4053 self.db.commit()
4054 self.update_episode_list_icons([episode.url])
4055 self.update_podcast_list_model([episode.channel.url])
4056 return True
4058 return False
4061 def main(options=None):
4062 gobject.threads_init()
4063 gobject.set_application_name('gPodder')
4065 if gpodder.ui.maemo:
4066 # Try to enable the custom icon theme for gPodder on Maemo
4067 settings = gtk.settings_get_default()
4068 settings.set_string_property('gtk-icon-theme-name', \
4069 'gpodder', __file__)
4070 # Extend the search path for the optified icon theme (Maemo 5)
4071 icon_theme = gtk.icon_theme_get_default()
4072 icon_theme.prepend_search_path('/opt/gpodder-icon-theme/')
4074 gtk.window_set_default_icon_name('gpodder')
4075 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
4077 try:
4078 dbus_main_loop = dbus.glib.DBusGMainLoop(set_as_default=True)
4079 gpodder.dbus_session_bus = dbus.SessionBus(dbus_main_loop)
4081 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=gpodder.dbus_session_bus)
4082 except dbus.exceptions.DBusException, dbe:
4083 log('Warning: Cannot get "on the bus".', traceback=True)
4084 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
4085 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
4086 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
4087 dlg.set_title('gPodder')
4088 dlg.run()
4089 dlg.destroy()
4090 sys.exit(0)
4092 util.make_directory(gpodder.home)
4093 gpodder.load_plugins()
4095 config = UIConfig(gpodder.config_file)
4097 # Load hook modules and install the hook manager globally
4098 # if modules have been found an instantiated by the manager
4099 user_hooks = hooks.HookManager()
4100 if user_hooks.has_modules():
4101 gpodder.user_hooks = user_hooks
4103 if gpodder.ui.diablo:
4104 # Detect changing of SD cards between mmc1/mmc2 if a gpodder
4105 # folder exists there (allow moving "gpodder" between SD cards or USB)
4106 # Also allow moving "gpodder" to home folder (e.g. rootfs on SD)
4107 if not os.path.exists(config.download_dir):
4108 log('Downloads might have been moved. Trying to locate them...')
4109 for basedir in ['/media/mmc1', '/media/mmc2']+glob.glob('/media/usb/*')+['/home/user/MyDocs']:
4110 dir = os.path.join(basedir, 'gpodder')
4111 if os.path.exists(dir):
4112 log('Downloads found in: %s', dir)
4113 config.download_dir = dir
4114 break
4115 else:
4116 log('Downloads NOT FOUND in %s', dir)
4118 if config.enable_fingerscroll:
4119 BuilderWidget.use_fingerscroll = True
4120 elif gpodder.ui.fremantle:
4121 config.on_quit_ask = False
4123 config.mygpo_device_type = util.detect_device_type()
4125 gp = gPodder(bus_name, config)
4127 # Handle options
4128 if options.subscribe:
4129 util.idle_add(gp.subscribe_to_url, options.subscribe)
4131 # mac OS X stuff :
4132 # handle "subscribe to podcast" events from firefox
4133 if platform.system() == 'Darwin':
4134 from gpodder import gpodderosx
4135 gpodderosx.register_handlers(gp)
4136 # end mac OS X stuff
4138 gp.run()