Initial work on custom tree models
[gpodder.git] / src / gpodder / gui.py
blobe0e6c6edca7d5cb540583018154234e6854715a7
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 random
27 import sys
28 import shutil
29 import subprocess
30 import glob
31 import time
32 import tempfile
33 import collections
34 import threading
36 from xml.sax import saxutils
38 import gpodder
40 try:
41 import dbus
42 import dbus.service
43 import dbus.mainloop
44 import dbus.glib
45 except ImportError:
46 # Mock the required D-Bus interfaces with no-ops (ugly? maybe.)
47 class dbus:
48 class SessionBus:
49 def __init__(self, *args, **kwargs):
50 pass
51 def add_signal_receiver(self, *args, **kwargs):
52 pass
53 class glib:
54 class DBusGMainLoop:
55 def __init__(self, *args, **kwargs):
56 pass
57 class service:
58 @staticmethod
59 def method(*args, **kwargs):
60 return lambda x: x
61 class BusName:
62 def __init__(self, *args, **kwargs):
63 pass
64 class Object:
65 def __init__(self, *args, **kwargs):
66 pass
69 from gpodder import feedcore
70 from gpodder import util
71 from gpodder import opml
72 from gpodder import download
73 from gpodder import my
74 from gpodder import youtube
75 from gpodder import player
76 from gpodder.liblogger import log
78 _ = gpodder.gettext
79 N_ = gpodder.ngettext
81 from gpodder.model import PodcastChannel
82 from gpodder.model import PodcastEpisode
83 from gpodder.dbsqlite import Database
85 from gpodder.gtkui.model import PodcastListModel
86 from gpodder.gtkui.model import EpisodeListModel
87 from gpodder.gtkui.config import UIConfig
88 from gpodder.gtkui.services import CoverDownloader
89 from gpodder.gtkui.widgets import SimpleMessageArea
90 from gpodder.gtkui.desktopfile import UserAppsReader
92 from gpodder.gtkui.draw import draw_text_box_centered
94 from gpodder.gtkui.interface.common import BuilderWidget
95 from gpodder.gtkui.interface.common import TreeViewHelper
96 from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
98 if gpodder.ui.desktop:
99 from gpodder.gtkui.download import DownloadStatusModel
101 from gpodder.gtkui.desktop.sync import gPodderSyncUI
103 from gpodder.gtkui.desktop.channel import gPodderChannel
104 from gpodder.gtkui.desktop.preferences import gPodderPreferences
105 from gpodder.gtkui.desktop.shownotes import gPodderShownotes
106 from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
107 from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
108 from gpodder.gtkui.desktop.dependencymanager import gPodderDependencyManager
109 from gpodder.gtkui.interface.progress import ProgressIndicator
110 try:
111 from gpodder.gtkui.desktop.trayicon import GPodderStatusIcon
112 have_trayicon = True
113 except Exception, exc:
114 log('Warning: Could not import gpodder.trayicon.', traceback=True)
115 log('Warning: This probably means your PyGTK installation is too old!')
116 have_trayicon = False
117 elif gpodder.ui.diablo:
118 from gpodder.gtkui.download import DownloadStatusModel
120 from gpodder.gtkui.maemo.channel import gPodderChannel
121 from gpodder.gtkui.maemo.preferences import gPodderPreferences
122 from gpodder.gtkui.maemo.shownotes import gPodderShownotes
123 from gpodder.gtkui.maemo.episodeselector import gPodderEpisodeSelector
124 from gpodder.gtkui.maemo.podcastdirectory import gPodderPodcastDirectory
125 from gpodder.gtkui.maemo.mygpodder import MygPodderSettings
126 from gpodder.gtkui.interface.progress import ProgressIndicator
127 have_trayicon = False
128 elif gpodder.ui.fremantle:
129 from gpodder.gtkui.frmntl.model import DownloadStatusModel
130 from gpodder.gtkui.frmntl.model import EpisodeListModel
131 from gpodder.gtkui.frmntl.model import PodcastListModel
133 from gpodder.gtkui.maemo.channel import gPodderChannel
134 from gpodder.gtkui.frmntl.preferences import gPodderPreferences
135 from gpodder.gtkui.frmntl.shownotes import gPodderShownotes
136 from gpodder.gtkui.frmntl.episodeselector import gPodderEpisodeSelector
137 from gpodder.gtkui.frmntl.podcastdirectory import gPodderPodcastDirectory
138 from gpodder.gtkui.frmntl.episodes import gPodderEpisodes
139 from gpodder.gtkui.frmntl.downloads import gPodderDownloads
140 from gpodder.gtkui.frmntl.progress import ProgressIndicator
141 from gpodder.gtkui.frmntl.widgets import FancyProgressBar
142 have_trayicon = False
144 from gpodder.gtkui.frmntl.portrait import FremantleRotation
145 from gpodder.gtkui.frmntl.mafw import MafwPlaybackMonitor
146 from gpodder.gtkui.frmntl.hints import HINT_STRINGS
148 from gpodder.gtkui.interface.common import Orientation
150 from gpodder.gtkui.interface.welcome import gPodderWelcome
152 if gpodder.ui.maemo:
153 import hildon
155 from gpodder.dbusproxy import DBusPodcastsProxy
156 from gpodder import hooks
158 class gPodder(BuilderWidget, dbus.service.Object):
159 finger_friendly_widgets = ['btnCleanUpDownloads', 'button_search_episodes_clear', 'label2', 'labelDownloads', 'btnUpdateFeeds']
161 ICON_GENERAL_ADD = 'general_add'
162 ICON_GENERAL_REFRESH = 'general_refresh'
164 def __init__(self, bus_name, config):
165 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
166 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels, \
167 self.on_itemUpdate_activate, \
168 self.playback_episodes, \
169 self.download_episode_list, \
170 self.episode_object_by_uri, \
171 bus_name)
172 self.db = Database(gpodder.database_file)
173 self.config = config
174 BuilderWidget.__init__(self, None)
176 def new(self):
177 if gpodder.ui.diablo:
178 import hildon
179 self.app = hildon.Program()
180 self.app.add_window(self.main_window)
181 self.main_window.add_toolbar(self.toolbar)
182 menu = gtk.Menu()
183 for child in self.main_menu.get_children():
184 child.reparent(menu)
185 self.main_window.set_menu(self.set_finger_friendly(menu))
186 self._last_orientation = Orientation.LANDSCAPE
187 elif gpodder.ui.fremantle:
188 import hildon
189 self.app = hildon.Program()
190 self.app.add_window(self.main_window)
192 appmenu = hildon.AppMenu()
194 for filter in (self.item_view_podcasts_all, \
195 self.item_view_podcasts_downloaded, \
196 self.item_view_podcasts_unplayed):
197 button = gtk.ToggleButton()
198 filter.connect_proxy(button)
199 appmenu.add_filter(button)
201 for action in (self.itemPreferences, \
202 self.item_downloads, \
203 self.itemRemoveOldEpisodes, \
204 self.item_unsubscribe, \
205 self.itemAbout):
206 button = hildon.Button(gtk.HILDON_SIZE_AUTO,\
207 hildon.BUTTON_ARRANGEMENT_HORIZONTAL)
208 action.connect_proxy(button)
209 if action == self.item_downloads:
210 button.set_title(_('Downloads'))
211 button.set_value(_('Idle'))
212 self.button_downloads = button
213 appmenu.append(button)
215 def show_hint(button):
216 self.show_message(random.choice(HINT_STRINGS), important=True)
218 button = hildon.Button(gtk.HILDON_SIZE_AUTO,\
219 hildon.BUTTON_ARRANGEMENT_HORIZONTAL)
220 button.set_title(_('Hint of the day'))
221 button.connect('clicked', show_hint)
222 appmenu.append(button)
224 appmenu.show_all()
225 self.main_window.set_app_menu(appmenu)
227 # Initialize portrait mode / rotation manager
228 self._fremantle_rotation = FremantleRotation('gPodder', \
229 self.main_window, \
230 gpodder.__version__, \
231 self.config.rotation_mode)
233 if self.config.rotation_mode == FremantleRotation.ALWAYS:
234 util.idle_add(self.on_window_orientation_changed, \
235 Orientation.PORTRAIT)
236 self._last_orientation = Orientation.PORTRAIT
237 else:
238 self._last_orientation = Orientation.LANDSCAPE
240 # Flag set when a notification is being shown (Maemo bug 11235)
241 self._fremantle_notification_visible = False
242 else:
243 self._last_orientation = Orientation.LANDSCAPE
244 self.toolbar.set_property('visible', self.config.show_toolbar)
246 self.bluetooth_available = util.bluetooth_available()
248 self.config.connect_gtk_window(self.gPodder, 'main_window')
249 if not gpodder.ui.fremantle:
250 self.config.connect_gtk_paned('paned_position', self.channelPaned)
251 self.main_window.show()
253 self.player_receiver = player.MediaPlayerDBusReceiver(self.on_played)
255 if gpodder.ui.fremantle:
256 # Create a D-Bus monitoring object that takes care of
257 # tracking MAFW (Nokia Media Player) playback events
258 # and sends episode playback status events via D-Bus
259 self.mafw_monitor = MafwPlaybackMonitor(gpodder.dbus_session_bus)
261 self.gPodder.connect('key-press-event', self.on_key_press)
263 self.preferences_dialog = None
264 self.config.add_observer(self.on_config_changed)
266 self.tray_icon = None
267 self.episode_shownotes_window = None
268 self.new_episodes_window = None
270 if gpodder.ui.desktop:
271 # Mac OS X-specific UI tweaks: Native main menu integration
272 # http://sourceforge.net/apps/trac/gtk-osx/wiki/Integrate
273 if getattr(gtk.gdk, 'WINDOWING', 'x11') == 'quartz':
274 try:
275 import igemacintegration as igemi
277 # Move the menu bar from the window to the Mac menu bar
278 self.mainMenu.hide()
279 igemi.ige_mac_menu_set_menu_bar(self.mainMenu)
281 # Reparent some items to the "Application" menu
282 for widget in ('/mainMenu/menuHelp/itemAbout', \
283 '/mainMenu/menuPodcasts/itemPreferences'):
284 item = self.uimanager1.get_widget(widget)
285 group = igemi.ige_mac_menu_add_app_menu_group()
286 igemi.ige_mac_menu_add_app_menu_item(group, item, None)
288 quit_widget = '/mainMenu/menuPodcasts/itemQuit'
289 quit_item = self.uimanager1.get_widget(quit_widget)
290 igemi.ige_mac_menu_set_quit_menu_item(quit_item)
291 except ImportError:
292 print >>sys.stderr, """
293 Warning: ige-mac-integration not found - no native menus.
296 self.sync_ui = gPodderSyncUI(self.config, self.notification, \
297 self.main_window, self.show_confirmation, \
298 self.update_episode_list_icons, \
299 self.update_podcast_list_model, self.toolPreferences, \
300 gPodderEpisodeSelector, \
301 self.commit_changes_to_database)
302 else:
303 self.sync_ui = None
305 self.download_status_model = DownloadStatusModel()
306 self.download_queue_manager = download.DownloadQueueManager(self.config)
308 if gpodder.ui.desktop:
309 self.show_hide_tray_icon()
310 self.itemShowAllEpisodes.set_active(self.config.podcast_list_view_all)
311 self.itemShowToolbar.set_active(self.config.show_toolbar)
312 self.itemShowDescription.set_active(self.config.episode_list_descriptions)
314 if not gpodder.ui.fremantle:
315 self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
316 self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
317 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
318 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
320 # When the amount of maximum downloads changes, notify the queue manager
321 changed_cb = lambda spinbutton: self.download_queue_manager.spawn_threads()
322 self.spinMaxDownloads.connect('value-changed', changed_cb)
324 self.default_title = 'gPodder'
325 if gpodder.__version__.rfind('git') != -1:
326 self.set_title('gPodder %s' % gpodder.__version__)
327 else:
328 title = self.gPodder.get_title()
329 if title is not None:
330 self.set_title(title)
331 else:
332 self.set_title(_('gPodder'))
334 self.cover_downloader = CoverDownloader()
336 # Generate list models for podcasts and their episodes
337 self.podcast_list_model = PodcastListModel(self.cover_downloader)
339 self.cover_downloader.register('cover-available', self.cover_download_finished)
340 self.cover_downloader.register('cover-removed', self.cover_file_removed)
342 if gpodder.ui.fremantle:
343 # Work around Maemo bug #4718
344 self.button_refresh.set_name('HildonButton-finger')
345 self.button_subscribe.set_name('HildonButton-finger')
347 self.button_refresh.set_sensitive(False)
348 self.button_subscribe.set_sensitive(False)
350 self.button_subscribe.set_image(gtk.image_new_from_icon_name(\
351 self.ICON_GENERAL_ADD, gtk.ICON_SIZE_BUTTON))
352 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
353 self.ICON_GENERAL_REFRESH, gtk.ICON_SIZE_BUTTON))
355 # Make the button scroll together with the TreeView contents
356 action_area_box = self.treeChannels.get_action_area_box()
357 for child in self.buttonbox:
358 child.reparent(action_area_box)
359 self.vbox.remove(self.buttonbox)
360 action_area_box.set_spacing(2)
361 action_area_box.set_border_width(3)
362 self.treeChannels.set_action_area_visible(True)
364 # Set up a very nice progress bar setup
365 self.fancy_progress_bar = FancyProgressBar(self.main_window, \
366 self.on_btnCancelFeedUpdate_clicked)
367 self.pbFeedUpdate = self.fancy_progress_bar.progress_bar
368 self.pbFeedUpdate.set_ellipsize(pango.ELLIPSIZE_MIDDLE)
369 self.vbox.pack_start(self.fancy_progress_bar.event_box, False)
371 from gpodder.gtkui.frmntl import style
372 sub_font = style.get_font_desc('SmallSystemFont')
373 sub_color = style.get_color('SecondaryTextColor')
374 sub = (sub_font.to_string(), sub_color.to_string())
375 sub = '<span font_desc="%s" foreground="%s">%%s</span>' % sub
376 self.label_footer.set_markup(sub % gpodder.__copyright__)
378 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
379 while gtk.events_pending():
380 gtk.main_iteration(False)
382 try:
383 # Try to get the real package version from dpkg
384 p = subprocess.Popen(['dpkg-query', '-W', '-f=${Version}', 'gpodder'], stdout=subprocess.PIPE)
385 version, _stderr = p.communicate()
386 del _stderr
387 del p
388 except:
389 version = gpodder.__version__
390 self.label_footer.set_markup(sub % ('v %s' % version))
391 self.label_footer.hide()
393 self.episodes_window = gPodderEpisodes(self.main_window, \
394 on_treeview_expose_event=self.on_treeview_expose_event, \
395 show_episode_shownotes=self.show_episode_shownotes, \
396 update_podcast_list_model=self.update_podcast_list_model, \
397 on_itemRemoveChannel_activate=self.on_itemRemoveChannel_activate, \
398 item_view_episodes_all=self.item_view_episodes_all, \
399 item_view_episodes_unplayed=self.item_view_episodes_unplayed, \
400 item_view_episodes_downloaded=self.item_view_episodes_downloaded, \
401 item_view_episodes_undeleted=self.item_view_episodes_undeleted, \
402 on_entry_search_episodes_changed=self.on_entry_search_episodes_changed, \
403 on_entry_search_episodes_key_press=self.on_entry_search_episodes_key_press, \
404 hide_episode_search=self.hide_episode_search, \
405 on_itemUpdateChannel_activate=self.on_itemUpdateChannel_activate, \
406 playback_episodes=self.playback_episodes, \
407 delete_episode_list=self.delete_episode_list, \
408 episode_list_status_changed=self.episode_list_status_changed, \
409 download_episode_list=self.download_episode_list, \
410 episode_is_downloading=self.episode_is_downloading, \
411 show_episode_in_download_manager=self.show_episode_in_download_manager, \
412 add_download_task_monitor=self.add_download_task_monitor, \
413 remove_download_task_monitor=self.remove_download_task_monitor, \
414 for_each_episode_set_task_status=self.for_each_episode_set_task_status, \
415 on_delete_episodes_button_clicked=self.on_itemRemoveOldEpisodes_activate, \
416 on_itemUpdate_activate=self.on_itemUpdate_activate)
418 # Expose objects for episode list type-ahead find
419 self.hbox_search_episodes = self.episodes_window.hbox_search_episodes
420 self.entry_search_episodes = self.episodes_window.entry_search_episodes
421 self.button_search_episodes_clear = self.episodes_window.button_search_episodes_clear
423 self.downloads_window = gPodderDownloads(self.main_window, \
424 on_treeview_expose_event=self.on_treeview_expose_event, \
425 cleanup_downloads=self.cleanup_downloads, \
426 _for_each_task_set_status=self._for_each_task_set_status, \
427 downloads_list_get_selection=self.downloads_list_get_selection, \
428 _config=self.config)
430 self.treeAvailable = self.episodes_window.treeview
431 self.treeDownloads = self.downloads_window.treeview
433 # Init the treeviews that we use
434 self.init_podcast_list_treeview()
435 self.init_episode_list_treeview()
436 self.init_download_list_treeview()
438 if self.config.podcast_list_hide_boring:
439 self.item_view_hide_boring_podcasts.set_active(True)
441 self.currently_updating = False
443 if gpodder.ui.maemo or self.config.enable_fingerscroll:
444 self.context_menu_mouse_button = 1
445 else:
446 self.context_menu_mouse_button = 3
448 if self.config.start_iconified:
449 self.iconify_main_window()
451 self.download_tasks_seen = set()
452 self.download_list_update_enabled = False
453 self.download_task_monitors = set()
455 # Subscribed channels
456 self.active_channel = None
457 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
458 self.channel_list_changed = True
459 self.update_podcasts_tab()
461 # load list of user applications for audio playback
462 self.user_apps_reader = UserAppsReader(['audio', 'video'])
463 threading.Thread(target=self.user_apps_reader.read).start()
465 # Set the "Device" menu item for the first time
466 if gpodder.ui.desktop:
467 self.update_item_device()
469 # Set up the first instance of MygPoClient
470 self.mygpo_client = my.MygPoClient(self.config)
472 # Now, update the feed cache, when everything's in place
473 if not gpodder.ui.fremantle:
474 self.btnUpdateFeeds.show()
475 self.updating_feed_cache = False
476 self.feed_cache_update_cancelled = False
477 self.update_feed_cache(force_update=self.config.update_on_startup)
479 self.message_area = None
481 def find_partial_downloads():
482 # Look for partial file downloads
483 partial_files = glob.glob(os.path.join(self.config.download_dir, '*', '*.partial'))
484 count = len(partial_files)
485 resumable_episodes = []
486 if count:
487 if not gpodder.ui.fremantle:
488 util.idle_add(self.wNotebook.set_current_page, 1)
489 indicator = ProgressIndicator(_('Loading incomplete downloads'), \
490 _('Some episodes have not finished downloading in a previous session.'), \
491 False, self.get_dialog_parent())
492 indicator.on_message(N_('%d partial file', '%d partial files', count) % count)
494 candidates = [f[:-len('.partial')] for f in partial_files]
495 found = 0
497 for c in self.channels:
498 for e in c.get_all_episodes():
499 filename = e.local_filename(create=False, check_only=True)
500 if filename in candidates:
501 log('Found episode: %s', e.title, sender=self)
502 found += 1
503 indicator.on_message(e.title)
504 indicator.on_progress(float(found)/count)
505 candidates.remove(filename)
506 partial_files.remove(filename+'.partial')
507 resumable_episodes.append(e)
509 if not candidates:
510 break
512 if not candidates:
513 break
515 for f in partial_files:
516 log('Partial file without episode: %s', f, sender=self)
517 util.delete_file(f)
519 util.idle_add(indicator.on_finished)
521 if len(resumable_episodes):
522 def offer_resuming():
523 self.download_episode_list_paused(resumable_episodes)
524 if not gpodder.ui.fremantle:
525 resume_all = gtk.Button(_('Resume all'))
526 #resume_all.set_border_width(0)
527 def on_resume_all(button):
528 selection = self.treeDownloads.get_selection()
529 selection.select_all()
530 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = self.downloads_list_get_selection()
531 selection.unselect_all()
532 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
533 self.message_area.hide()
534 resume_all.connect('clicked', on_resume_all)
536 self.message_area = SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all,))
537 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
538 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
539 self.message_area.show_all()
540 self.clean_up_downloads(delete_partial=False)
541 util.idle_add(offer_resuming)
542 elif not gpodder.ui.fremantle:
543 util.idle_add(self.wNotebook.set_current_page, 0)
544 else:
545 util.idle_add(self.clean_up_downloads, True)
546 threading.Thread(target=find_partial_downloads).start()
548 # Start the auto-update procedure
549 self._auto_update_timer_source_id = None
550 if self.config.auto_update_feeds:
551 self.restart_auto_update_timer()
553 # Delete old episodes if the user wishes to
554 if self.config.auto_remove_played_episodes and \
555 self.config.episode_old_age > 0:
556 old_episodes = list(self.get_expired_episodes())
557 if len(old_episodes) > 0:
558 self.delete_episode_list(old_episodes, confirm=False)
559 self.update_podcast_list_model(set(e.channel.url for e in old_episodes))
561 if gpodder.ui.fremantle:
562 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
563 self.button_refresh.set_sensitive(True)
564 self.button_subscribe.set_sensitive(True)
565 self.main_window.set_title(_('gPodder'))
566 hildon.hildon_gtk_window_take_screenshot(self.main_window, True)
568 # Do the initial sync with the web service
569 util.idle_add(self.mygpo_client.flush, True)
571 # First-time users should be asked if they want to see the OPML
572 if not self.channels and not gpodder.ui.fremantle:
573 util.idle_add(self.on_itemUpdate_activate)
575 def episode_object_by_uri(self, uri):
576 """Get an episode object given a local or remote URI
578 This can be used to quickly access an episode object
579 when all we have is its download filename or episode
580 URL (e.g. from external D-Bus calls / signals, etc..)
582 if uri.startswith('/'):
583 uri = 'file://' + uri
585 prefix = 'file://' + self.config.download_dir
587 if uri.startswith(prefix):
588 # File is on the local filesystem in the download folder
589 filename = uri[len(prefix):]
590 file_parts = [x for x in filename.split(os.sep) if x]
592 if len(file_parts) == 2:
593 dir_name, filename = file_parts
594 channels = [c for c in self.channels if c.foldername == dir_name]
595 if len(channels) == 1:
596 channel = channels[0]
597 return channel.get_episode_by_filename(filename)
598 else:
599 # Possibly remote file - search the database for a podcast
600 channel_id = self.db.get_channel_id_from_episode_url(uri)
602 if channel_id is not None:
603 channels = [c for c in self.channels if c.id == channel_id]
604 if len(channels) == 1:
605 channel = channels[0]
606 return channel.get_episode_by_url(uri)
608 return None
610 def on_played(self, start, end, total, file_uri):
611 """Handle the "played" signal from a media player"""
612 if start == 0 and end == 0 and total == 0:
613 # Ignore bogus play event
614 return
615 elif end < start + 5:
616 # Ignore "less than five seconds" segments,
617 # as they can happen with seeking, etc...
618 return
620 log('Received play action: %s (%d, %d, %d)', file_uri, start, end, total, sender=self)
621 episode = self.episode_object_by_uri(file_uri)
623 if episode is not None:
624 file_type = episode.file_type()
625 # Automatically enable D-Bus played status mode
626 if file_type == 'audio':
627 self.config.audio_played_dbus = True
628 elif file_type == 'video':
629 self.config.video_played_dbus = True
631 now = time.time()
632 if total > 0:
633 episode.total_time = total
634 elif total == 0:
635 # Assume the episode's total time for the action
636 total = episode.total_time
637 if episode.current_position_updated is None or \
638 now > episode.current_position_updated:
639 episode.current_position = end
640 episode.current_position_updated = now
641 episode.mark(is_played=True)
642 episode.save()
643 self.db.commit()
644 self.update_episode_list_icons([episode.url])
645 self.update_podcast_list_model([episode.channel.url])
647 # Submit this action to the webservice
648 self.mygpo_client.on_playback_full(episode, \
649 start, end, total)
651 def on_add_remove_podcasts_mygpo(self):
652 actions = self.mygpo_client.get_received_actions()
653 if not actions:
654 return False
656 existing_urls = [c.url for c in self.channels]
658 # Columns for the episode selector window - just one...
659 columns = (
660 ('description', None, None, _('Action')),
663 # A list of actions that have to be chosen from
664 changes = []
666 # Actions that are ignored (already carried out)
667 ignored = []
669 for action in actions:
670 if action.is_add and action.url not in existing_urls:
671 changes.append(my.Change(action))
672 elif action.is_remove and action.url in existing_urls:
673 podcast_object = None
674 for podcast in self.channels:
675 if podcast.url == action.url:
676 podcast_object = podcast
677 break
678 changes.append(my.Change(action, podcast_object))
679 else:
680 log('Ignoring action: %s', action, sender=self)
681 ignored.append(action)
683 # Confirm all ignored changes
684 self.mygpo_client.confirm_received_actions(ignored)
686 def execute_podcast_actions(selected):
687 add_list = [c.action.url for c in selected if c.action.is_add]
688 remove_list = [c.podcast for c in selected if c.action.is_remove]
690 # Apply the accepted changes locally
691 self.add_podcast_list(add_list)
692 self.remove_podcast_list(remove_list, confirm=False)
694 # All selected items are now confirmed
695 self.mygpo_client.confirm_received_actions(c.action for c in selected)
697 # Revert the changes on the server
698 rejected = [c.action for c in changes if c not in selected]
699 self.mygpo_client.reject_received_actions(rejected)
701 def ask():
702 # We're abusing the Episode Selector again ;) -- thp
703 gPodderEpisodeSelector(self.main_window, \
704 title=_('Confirm changes from gpodder.net'), \
705 instructions=_('Select the actions you want to carry out.'), \
706 episodes=changes, \
707 columns=columns, \
708 size_attribute=None, \
709 stock_ok_button=gtk.STOCK_APPLY, \
710 callback=execute_podcast_actions, \
711 _config=self.config)
713 # There are some actions that need the user's attention
714 if changes:
715 util.idle_add(ask)
716 return True
718 # We have no remaining actions - no selection happens
719 return False
721 def rewrite_urls_mygpo(self):
722 # Check if we have to rewrite URLs since the last add
723 rewritten_urls = self.mygpo_client.get_rewritten_urls()
725 for rewritten_url in rewritten_urls:
726 if not rewritten_url.new_url:
727 continue
729 for channel in self.channels:
730 if channel.url == rewritten_url.old_url:
731 log('Updating URL of %s to %s', channel, \
732 rewritten_url.new_url, sender=self)
733 channel.url = rewritten_url.new_url
734 channel.save()
735 self.channel_list_changed = True
736 util.idle_add(self.update_episode_list_model)
737 break
739 def on_send_full_subscriptions(self):
740 # Send the full subscription list to the gpodder.net client
741 # (this will overwrite the subscription list on the server)
742 indicator = ProgressIndicator(_('Uploading subscriptions'), \
743 _('Your subscriptions are being uploaded to the server.'), \
744 False, self.get_dialog_parent())
746 try:
747 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
748 util.idle_add(self.show_message, _('List uploaded successfully.'))
749 except Exception, e:
750 def show_error(e):
751 message = str(e)
752 if not message:
753 message = e.__class__.__name__
754 self.show_message(message, \
755 _('Error while uploading'), \
756 important=True)
757 util.idle_add(show_error, e)
759 util.idle_add(indicator.on_finished)
761 def on_podcast_selected(self, treeview, path, column):
762 # for Maemo 5's UI
763 model = treeview.get_model()
764 channel = model.get_value(model.get_iter(path), \
765 PodcastListModel.C_CHANNEL)
766 self.active_channel = channel
767 self.update_episode_list_model()
768 self.episodes_window.channel = self.active_channel
769 self.episodes_window.show()
771 def on_button_subscribe_clicked(self, button):
772 self.on_itemImportChannels_activate(button)
774 def on_button_downloads_clicked(self, widget):
775 self.downloads_window.show()
777 def show_episode_in_download_manager(self, episode):
778 self.downloads_window.show()
779 model = self.treeDownloads.get_model()
780 selection = self.treeDownloads.get_selection()
781 selection.unselect_all()
782 it = model.get_iter_first()
783 while it is not None:
784 task = model.get_value(it, DownloadStatusModel.C_TASK)
785 if task.episode.url == episode.url:
786 selection.select_iter(it)
787 # FIXME: Scroll to selection in pannable area
788 break
789 it = model.iter_next(it)
791 def for_each_episode_set_task_status(self, episodes, status):
792 episode_urls = set(episode.url for episode in episodes)
793 model = self.treeDownloads.get_model()
794 selected_tasks = [(gtk.TreeRowReference(model, row.path), \
795 model.get_value(row.iter, \
796 DownloadStatusModel.C_TASK)) for row in model \
797 if model.get_value(row.iter, DownloadStatusModel.C_TASK).url \
798 in episode_urls]
799 self._for_each_task_set_status(selected_tasks, status)
801 def on_window_orientation_changed(self, orientation):
802 self._last_orientation = orientation
803 if self.preferences_dialog is not None:
804 self.preferences_dialog.on_window_orientation_changed(orientation)
806 treeview = self.treeChannels
807 if orientation == Orientation.PORTRAIT:
808 treeview.set_action_area_orientation(gtk.ORIENTATION_VERTICAL)
809 # Work around Maemo bug #4718
810 self.button_subscribe.set_name('HildonButton-thumb')
811 self.button_refresh.set_name('HildonButton-thumb')
812 else:
813 treeview.set_action_area_orientation(gtk.ORIENTATION_HORIZONTAL)
814 # Work around Maemo bug #4718
815 self.button_subscribe.set_name('HildonButton-finger')
816 self.button_refresh.set_name('HildonButton-finger')
818 if gpodder.ui.fremantle:
819 self.fancy_progress_bar.relayout()
821 def on_treeview_podcasts_selection_changed(self, selection):
822 model, iter = selection.get_selected()
823 if iter is None:
824 self.active_channel = None
825 self.episode_list_model.clear()
827 def on_treeview_button_pressed(self, treeview, event):
828 if event.window != treeview.get_bin_window():
829 return False
831 TreeViewHelper.save_button_press_event(treeview, event)
833 if getattr(treeview, TreeViewHelper.ROLE) == \
834 TreeViewHelper.ROLE_PODCASTS:
835 return self.currently_updating
837 return event.button == self.context_menu_mouse_button and \
838 gpodder.ui.desktop
840 def on_treeview_podcasts_button_released(self, treeview, event):
841 if event.window != treeview.get_bin_window():
842 return False
844 if gpodder.ui.maemo:
845 return self.treeview_channels_handle_gestures(treeview, event)
846 return self.treeview_channels_show_context_menu(treeview, event)
848 def on_treeview_episodes_button_released(self, treeview, event):
849 if event.window != treeview.get_bin_window():
850 return False
852 if self.config.enable_fingerscroll or self.config.maemo_enable_gestures:
853 return self.treeview_available_handle_gestures(treeview, event)
855 return self.treeview_available_show_context_menu(treeview, event)
857 def on_treeview_downloads_button_released(self, treeview, event):
858 if event.window != treeview.get_bin_window():
859 return False
861 return self.treeview_downloads_show_context_menu(treeview, event)
863 def on_entry_search_podcasts_changed(self, editable):
864 if self.hbox_search_podcasts.get_property('visible'):
865 self.podcast_list_model.set_search_term(editable.get_chars(0, -1))
867 def on_entry_search_podcasts_key_press(self, editable, event):
868 if event.keyval == gtk.keysyms.Escape:
869 self.hide_podcast_search()
870 return True
872 def hide_podcast_search(self, *args):
873 self.hbox_search_podcasts.hide()
874 self.entry_search_podcasts.set_text('')
875 self.podcast_list_model.set_search_term(None)
876 self.treeChannels.grab_focus()
878 def show_podcast_search(self, input_char):
879 self.hbox_search_podcasts.show()
880 self.entry_search_podcasts.insert_text(input_char, -1)
881 self.entry_search_podcasts.grab_focus()
882 self.entry_search_podcasts.set_position(-1)
884 def init_podcast_list_treeview(self):
885 # Set up podcast channel tree view widget
886 if gpodder.ui.fremantle:
887 if self.config.podcast_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
888 self.item_view_podcasts_downloaded.set_active(True)
889 elif self.config.podcast_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
890 self.item_view_podcasts_unplayed.set_active(True)
891 else:
892 self.item_view_podcasts_all.set_active(True)
893 self.podcast_list_model.set_view_mode(self.config.podcast_list_view_mode)
895 iconcolumn = gtk.TreeViewColumn('')
896 iconcell = gtk.CellRendererPixbuf()
897 iconcolumn.pack_start(iconcell, False)
898 iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
899 self.treeChannels.append_column(iconcolumn)
901 namecolumn = gtk.TreeViewColumn('')
902 namecell = gtk.CellRendererText()
903 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
904 namecolumn.pack_start(namecell, True)
905 namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
907 if gpodder.ui.fremantle:
908 countcell = gtk.CellRendererText()
909 from gpodder.gtkui.frmntl import style
910 countcell.set_property('font-desc', style.get_font_desc('EmpSystemFont'))
911 countcell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
912 countcell.set_property('alignment', pango.ALIGN_RIGHT)
913 countcell.set_property('xalign', 1.)
914 countcell.set_property('xpad', 5)
915 namecolumn.pack_start(countcell, False)
916 namecolumn.add_attribute(countcell, 'text', PodcastListModel.C_DOWNLOADS)
917 namecolumn.add_attribute(countcell, 'visible', PodcastListModel.C_DOWNLOADS)
918 else:
919 iconcell = gtk.CellRendererPixbuf()
920 iconcell.set_property('xalign', 1.0)
921 namecolumn.pack_start(iconcell, False)
922 namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
923 namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
925 self.treeChannels.append_column(namecolumn)
927 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
929 # When no podcast is selected, clear the episode list model
930 selection = self.treeChannels.get_selection()
931 selection.connect('changed', self.on_treeview_podcasts_selection_changed)
933 # Set up type-ahead find for the podcast list
934 def on_key_press(treeview, event):
935 if event.keyval == gtk.keysyms.Escape:
936 self.hide_podcast_search()
937 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
938 self.hide_podcast_search()
939 elif event.state & gtk.gdk.CONTROL_MASK:
940 # Don't handle type-ahead when control is pressed (so shortcuts
941 # with the Ctrl key still work, e.g. Ctrl+A, ...)
942 return True
943 else:
944 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
945 if unicode_char_id == 0:
946 return False
947 input_char = unichr(unicode_char_id)
948 self.show_podcast_search(input_char)
949 return True
950 self.treeChannels.connect('key-press-event', on_key_press)
952 # Enable separators to the podcast list to separate special podcasts
953 # from others (this is used for the "all episodes" view)
954 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
956 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
958 def on_entry_search_episodes_changed(self, editable):
959 if self.hbox_search_episodes.get_property('visible'):
960 self.episode_list_model.set_search_term(editable.get_chars(0, -1))
962 def on_entry_search_episodes_key_press(self, editable, event):
963 if event.keyval == gtk.keysyms.Escape:
964 self.hide_episode_search()
965 return True
967 def hide_episode_search(self, *args):
968 self.hbox_search_episodes.hide()
969 self.entry_search_episodes.set_text('')
970 self.episode_list_model.set_search_term(None)
971 self.treeAvailable.grab_focus()
973 def show_episode_search(self, input_char):
974 self.hbox_search_episodes.show()
975 self.entry_search_episodes.insert_text(input_char, -1)
976 self.entry_search_episodes.grab_focus()
977 self.entry_search_episodes.set_position(-1)
979 def init_episode_list_treeview(self):
980 # For loading the list model
981 self.episode_list_model = EpisodeListModel()
983 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
984 self.item_view_episodes_undeleted.set_active(True)
985 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
986 self.item_view_episodes_downloaded.set_active(True)
987 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
988 self.item_view_episodes_unplayed.set_active(True)
989 else:
990 self.item_view_episodes_all.set_active(True)
992 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
994 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
996 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
998 iconcell = gtk.CellRendererPixbuf()
999 iconcell.set_property('stock-size', gtk.ICON_SIZE_BUTTON)
1000 if gpodder.ui.maemo:
1001 iconcell.set_fixed_size(50, 50)
1002 else:
1003 iconcell.set_fixed_size(40, -1)
1005 namecell = gtk.CellRendererText()
1006 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
1007 namecolumn = gtk.TreeViewColumn(_('Episode'))
1008 namecolumn.pack_start(iconcell, False)
1009 namecolumn.add_attribute(iconcell, 'icon-name', EpisodeListModel.C_STATUS_ICON)
1010 namecolumn.pack_start(namecell, True)
1011 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
1012 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
1013 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1014 namecolumn.set_resizable(True)
1015 namecolumn.set_expand(True)
1017 if gpodder.ui.fremantle:
1018 from gpodder.gtkui.frmntl import style
1019 timecell = gtk.CellRendererText()
1020 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1021 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1022 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1023 timecell.set_property('xalign', 1.)
1024 timecell.set_property('xpad', 5)
1025 namecolumn.pack_start(timecell, False)
1026 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1027 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME1_VISIBLE)
1029 # Add another cell renderer to fix a sizing issue (one renderer
1030 # only renders short text and the other one longer text to avoid
1031 # having titles of episodes unnecessarily cut off)
1032 timecell = gtk.CellRendererText()
1033 timecell.set_property('font-desc', style.get_font_desc('SystemFont'))
1034 timecell.set_property('foreground-gdk', style.get_color('SecondaryTextColor'))
1035 timecell.set_property('alignment', pango.ALIGN_RIGHT)
1036 timecell.set_property('xalign', 1.)
1037 timecell.set_property('xpad', 5)
1038 namecolumn.pack_start(timecell, False)
1039 namecolumn.add_attribute(timecell, 'text', EpisodeListModel.C_TIME)
1040 namecolumn.add_attribute(timecell, 'visible', EpisodeListModel.C_TIME2_VISIBLE)
1042 lockcell = gtk.CellRendererPixbuf()
1043 lockcell.set_property('stock-size', gtk.ICON_SIZE_MENU)
1044 if gpodder.ui.fremantle:
1045 lockcell.set_property('icon-name', 'general_locked')
1046 else:
1047 lockcell.set_property('icon-name', 'emblem-readonly')
1049 namecolumn.pack_start(lockcell, False)
1050 namecolumn.add_attribute(lockcell, 'visible', EpisodeListModel.C_LOCKED)
1052 sizecell = gtk.CellRendererText()
1053 sizecell.set_property('xalign', 1)
1054 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
1055 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
1057 releasecell = gtk.CellRendererText()
1058 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
1059 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
1061 for itemcolumn in (namecolumn, sizecolumn, releasecolumn):
1062 itemcolumn.set_reorderable(True)
1063 self.treeAvailable.append_column(itemcolumn)
1065 if gpodder.ui.maemo:
1066 sizecolumn.set_visible(False)
1067 releasecolumn.set_visible(False)
1069 # Set up type-ahead find for the episode list
1070 def on_key_press(treeview, event):
1071 if event.keyval == gtk.keysyms.Escape:
1072 self.hide_episode_search()
1073 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
1074 self.hide_episode_search()
1075 elif event.state & gtk.gdk.CONTROL_MASK:
1076 # Don't handle type-ahead when control is pressed (so shortcuts
1077 # with the Ctrl key still work, e.g. Ctrl+A, ...)
1078 return False
1079 else:
1080 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
1081 if unicode_char_id == 0:
1082 return False
1083 input_char = unichr(unicode_char_id)
1084 self.show_episode_search(input_char)
1085 return True
1086 self.treeAvailable.connect('key-press-event', on_key_press)
1088 if gpodder.ui.desktop and not self.config.enable_fingerscroll:
1089 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
1090 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
1091 def drag_data_get(tree, context, selection_data, info, timestamp):
1092 if self.config.on_drag_mark_played:
1093 for episode in self.get_selected_episodes():
1094 episode.mark(is_played=True)
1095 self.on_selected_episodes_status_changed()
1096 uris = ['file://'+e.local_filename(create=False) \
1097 for e in self.get_selected_episodes() \
1098 if e.was_downloaded(and_exists=True)]
1099 uris.append('') # for the trailing '\r\n'
1100 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
1101 self.treeAvailable.connect('drag-data-get', drag_data_get)
1103 selection = self.treeAvailable.get_selection()
1104 if self.config.maemo_enable_gestures or self.config.enable_fingerscroll:
1105 selection.set_mode(gtk.SELECTION_SINGLE)
1106 elif gpodder.ui.fremantle:
1107 selection.set_mode(gtk.SELECTION_SINGLE)
1108 else:
1109 selection.set_mode(gtk.SELECTION_MULTIPLE)
1110 # Update the sensitivity of the toolbar buttons on the Desktop
1111 selection.connect('changed', lambda s: self.play_or_download())
1113 if gpodder.ui.diablo:
1114 # Set up the tap-and-hold context menu for podcasts
1115 menu = gtk.Menu()
1116 menu.append(self.itemUpdateChannel.create_menu_item())
1117 menu.append(self.itemEditChannel.create_menu_item())
1118 menu.append(gtk.SeparatorMenuItem())
1119 menu.append(self.itemRemoveChannel.create_menu_item())
1120 menu.append(gtk.SeparatorMenuItem())
1121 item = gtk.ImageMenuItem(_('Close this menu'))
1122 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, \
1123 gtk.ICON_SIZE_MENU))
1124 menu.append(item)
1125 menu.show_all()
1126 menu = self.set_finger_friendly(menu)
1127 self.treeChannels.tap_and_hold_setup(menu)
1130 def init_download_list_treeview(self):
1131 # enable multiple selection support
1132 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
1133 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
1135 # columns and renderers for "download progress" tab
1136 # First column: [ICON] Episodename
1137 column = gtk.TreeViewColumn(_('Episode'))
1139 cell = gtk.CellRendererPixbuf()
1140 if gpodder.ui.maemo:
1141 cell.set_fixed_size(50, 50)
1142 cell.set_property('stock-size', gtk.ICON_SIZE_BUTTON)
1143 column.pack_start(cell, expand=False)
1144 column.add_attribute(cell, 'icon-name', \
1145 DownloadStatusModel.C_ICON_NAME)
1147 cell = gtk.CellRendererText()
1148 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
1149 column.pack_start(cell, expand=True)
1150 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
1151 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1152 column.set_expand(True)
1153 self.treeDownloads.append_column(column)
1155 # Second column: Progress
1156 cell = gtk.CellRendererProgress()
1157 cell.set_property('yalign', .5)
1158 cell.set_property('ypad', 6)
1159 column = gtk.TreeViewColumn(_('Progress'), cell,
1160 value=DownloadStatusModel.C_PROGRESS, \
1161 text=DownloadStatusModel.C_PROGRESS_TEXT)
1162 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
1163 column.set_expand(False)
1164 self.treeDownloads.append_column(column)
1165 if gpodder.ui.maemo:
1166 column.set_property('min-width', 200)
1167 column.set_property('max-width', 200)
1168 else:
1169 column.set_property('min-width', 150)
1170 column.set_property('max-width', 150)
1172 self.treeDownloads.set_model(self.download_status_model)
1173 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
1175 def on_treeview_expose_event(self, treeview, event):
1176 if event.window == treeview.get_bin_window():
1177 model = treeview.get_model()
1178 if (model is not None and model.get_iter_first() is not None):
1179 return False
1181 role = getattr(treeview, TreeViewHelper.ROLE, None)
1182 if role is None:
1183 return False
1185 ctx = event.window.cairo_create()
1186 ctx.rectangle(event.area.x, event.area.y,
1187 event.area.width, event.area.height)
1188 ctx.clip()
1190 x, y, width, height, depth = event.window.get_geometry()
1191 progress = None
1193 if role == TreeViewHelper.ROLE_EPISODES:
1194 if self.currently_updating:
1195 text = _('Loading episodes')
1196 elif self.config.episode_list_view_mode != \
1197 EpisodeListModel.VIEW_ALL:
1198 text = _('No episodes in current view')
1199 else:
1200 text = _('No episodes available')
1201 elif role == TreeViewHelper.ROLE_PODCASTS:
1202 if self.config.episode_list_view_mode != \
1203 EpisodeListModel.VIEW_ALL and \
1204 self.config.podcast_list_hide_boring and \
1205 len(self.channels) > 0:
1206 text = _('No podcasts in this view')
1207 else:
1208 text = _('No subscriptions')
1209 elif role == TreeViewHelper.ROLE_DOWNLOADS:
1210 text = _('No active downloads')
1211 else:
1212 raise Exception('on_treeview_expose_event: unknown role')
1214 if gpodder.ui.fremantle:
1215 from gpodder.gtkui.frmntl import style
1216 font_desc = style.get_font_desc('LargeSystemFont')
1217 else:
1218 font_desc = None
1220 draw_text_box_centered(ctx, treeview, width, height, text, font_desc, progress)
1222 if role == TreeViewHelper.ROLE_EPISODES and \
1223 self.currently_updating:
1224 return True
1226 return False
1228 def enable_download_list_update(self):
1229 if not self.download_list_update_enabled:
1230 self.update_downloads_list()
1231 gobject.timeout_add(1500, self.update_downloads_list)
1232 self.download_list_update_enabled = True
1234 def cleanup_downloads(self):
1235 model = self.download_status_model
1237 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
1238 changed_episode_urls = set()
1239 for row_reference, task in all_tasks:
1240 if task.status in (task.DONE, task.CANCELLED):
1241 model.remove(model.get_iter(row_reference.get_path()))
1242 try:
1243 # We don't "see" this task anymore - remove it;
1244 # this is needed, so update_episode_list_icons()
1245 # below gets the correct list of "seen" tasks
1246 self.download_tasks_seen.remove(task)
1247 except KeyError, key_error:
1248 log('Cannot remove task from "seen" list: %s', task, sender=self)
1249 changed_episode_urls.add(task.url)
1250 # Tell the task that it has been removed (so it can clean up)
1251 task.removed_from_list()
1253 # Tell the podcasts tab to update icons for our removed podcasts
1254 self.update_episode_list_icons(changed_episode_urls)
1256 # Tell the shownotes window that we have removed the episode
1257 if self.episode_shownotes_window is not None and \
1258 self.episode_shownotes_window.episode is not None and \
1259 self.episode_shownotes_window.episode.url in changed_episode_urls:
1260 self.episode_shownotes_window._download_status_changed(None)
1262 # Update the downloads list one more time
1263 self.update_downloads_list(can_call_cleanup=False)
1265 def on_tool_downloads_toggled(self, toolbutton):
1266 if toolbutton.get_active():
1267 self.wNotebook.set_current_page(1)
1268 else:
1269 self.wNotebook.set_current_page(0)
1271 def add_download_task_monitor(self, monitor):
1272 self.download_task_monitors.add(monitor)
1273 model = self.download_status_model
1274 if model is None:
1275 model = ()
1276 for row in model:
1277 task = row[self.download_status_model.C_TASK]
1278 monitor.task_updated(task)
1280 def remove_download_task_monitor(self, monitor):
1281 self.download_task_monitors.remove(monitor)
1283 def update_downloads_list(self, can_call_cleanup=True):
1284 try:
1285 model = self.download_status_model
1287 downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
1288 total_speed, total_size, done_size = 0, 0, 0
1290 # Keep a list of all download tasks that we've seen
1291 download_tasks_seen = set()
1293 # Remember the DownloadTask object for the episode that
1294 # has been opened in the episode shownotes dialog (if any)
1295 if self.episode_shownotes_window is not None:
1296 shownotes_episode = self.episode_shownotes_window.episode
1297 shownotes_task = None
1298 else:
1299 shownotes_episode = None
1300 shownotes_task = None
1302 # Do not go through the list of the model is not (yet) available
1303 if model is None:
1304 model = ()
1306 failed_downloads = []
1307 for row in model:
1308 self.download_status_model.request_update(row.iter)
1310 task = row[self.download_status_model.C_TASK]
1311 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
1313 # Let the download task monitors know of changes
1314 for monitor in self.download_task_monitors:
1315 monitor.task_updated(task)
1317 total_size += size
1318 done_size += size*progress
1320 if shownotes_episode is not None and \
1321 shownotes_episode.url == task.episode.url:
1322 shownotes_task = task
1324 download_tasks_seen.add(task)
1326 if status == download.DownloadTask.DOWNLOADING:
1327 downloading += 1
1328 total_speed += speed
1329 elif status == download.DownloadTask.FAILED:
1330 failed_downloads.append(task)
1331 failed += 1
1332 elif status == download.DownloadTask.DONE:
1333 finished += 1
1334 elif status == download.DownloadTask.QUEUED:
1335 queued += 1
1336 elif status == download.DownloadTask.PAUSED:
1337 paused += 1
1338 else:
1339 others += 1
1341 # Remember which tasks we have seen after this run
1342 self.download_tasks_seen = download_tasks_seen
1344 if gpodder.ui.desktop:
1345 text = [_('Downloads')]
1346 if downloading + failed + queued > 0:
1347 s = []
1348 if downloading > 0:
1349 s.append(N_('%d active', '%d active', downloading) % downloading)
1350 if failed > 0:
1351 s.append(N_('%d failed', '%d failed', failed) % failed)
1352 if queued > 0:
1353 s.append(N_('%d queued', '%d queued', queued) % queued)
1354 text.append(' (' + ', '.join(s)+')')
1355 self.labelDownloads.set_text(''.join(text))
1356 elif gpodder.ui.diablo:
1357 sum = downloading + failed + finished + queued + paused + others
1358 if sum:
1359 self.tool_downloads.set_label(_('Downloads (%d)') % sum)
1360 else:
1361 self.tool_downloads.set_label(_('Downloads'))
1362 elif gpodder.ui.fremantle:
1363 if downloading + queued > 0:
1364 self.button_downloads.set_value(N_('%d active', '%d active', downloading+queued) % (downloading+queued))
1365 elif failed > 0:
1366 self.button_downloads.set_value(N_('%d failed', '%d failed', failed) % failed)
1367 elif paused > 0:
1368 self.button_downloads.set_value(N_('%d paused', '%d paused', paused) % paused)
1369 else:
1370 self.button_downloads.set_value(_('Idle'))
1372 title = [self.default_title]
1374 # We have to update all episodes/channels for which the status has
1375 # changed. Accessing task.status_changed has the side effect of
1376 # re-setting the changed flag, so we need to get the "changed" list
1377 # of tuples first and split it into two lists afterwards
1378 changed = [(task.url, task.podcast_url) for task in \
1379 self.download_tasks_seen if task.status_changed]
1380 episode_urls = [episode_url for episode_url, channel_url in changed]
1381 channel_urls = [channel_url for episode_url, channel_url in changed]
1383 count = downloading + queued
1384 if count > 0:
1385 title.append(N_('downloading %d file', 'downloading %d files', count) % count)
1387 if total_size > 0:
1388 percentage = 100.0*done_size/total_size
1389 else:
1390 percentage = 0.0
1391 total_speed = util.format_filesize(total_speed)
1392 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1393 if self.tray_icon is not None:
1394 # Update the tray icon status and progress bar
1395 self.tray_icon.set_status(self.tray_icon.STATUS_DOWNLOAD_IN_PROGRESS, title[1])
1396 self.tray_icon.draw_progress_bar(percentage/100.)
1397 else:
1398 if self.tray_icon is not None:
1399 # Update the tray icon status
1400 self.tray_icon.set_status()
1401 if gpodder.ui.desktop:
1402 self.downloads_finished(self.download_tasks_seen)
1403 if gpodder.ui.diablo:
1404 hildon.hildon_banner_show_information(self.gPodder, '', 'gPodder: %s' % _('All downloads finished'))
1405 log('All downloads have finished.', sender=self)
1406 if self.config.cmd_all_downloads_complete:
1407 util.run_external_command(self.config.cmd_all_downloads_complete)
1409 if gpodder.ui.fremantle and failed:
1410 message = '\n'.join(['%s: %s' % (str(task), \
1411 task.error_message) for task in failed_downloads])
1412 self.show_message(message, _('Downloads failed'), important=True)
1414 # Remove finished episodes
1415 if self.config.auto_cleanup_downloads and can_call_cleanup:
1416 self.cleanup_downloads()
1418 # Stop updating the download list here
1419 self.download_list_update_enabled = False
1421 if not gpodder.ui.fremantle:
1422 self.gPodder.set_title(' - '.join(title))
1424 self.update_episode_list_icons(episode_urls)
1425 if self.episode_shownotes_window is not None:
1426 if (shownotes_task and shownotes_task.url in episode_urls) or \
1427 shownotes_task != self.episode_shownotes_window.task:
1428 self.episode_shownotes_window._download_status_changed(shownotes_task)
1429 self.episode_shownotes_window._download_status_progress()
1430 self.play_or_download()
1431 if channel_urls:
1432 self.update_podcast_list_model(channel_urls)
1434 return self.download_list_update_enabled
1435 except Exception, e:
1436 log('Exception happened while updating download list.', sender=self, traceback=True)
1437 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
1438 # We return False here, so the update loop won't be called again,
1439 # that's why we require the restart of gPodder in the message.
1440 return False
1442 def on_config_changed(self, *args):
1443 util.idle_add(self._on_config_changed, *args)
1445 def _on_config_changed(self, name, old_value, new_value):
1446 if name == 'show_toolbar' and gpodder.ui.desktop:
1447 self.toolbar.set_property('visible', new_value)
1448 elif name == 'videoplayer':
1449 self.config.video_played_dbus = False
1450 elif name == 'player':
1451 self.config.audio_played_dbus = False
1452 elif name == 'episode_list_descriptions':
1453 self.update_episode_list_model()
1454 elif name == 'episode_list_thumbnails':
1455 self.update_episode_list_icons(all=True)
1456 elif name == 'rotation_mode':
1457 self._fremantle_rotation.set_mode(new_value)
1458 elif name in ('auto_update_feeds', 'auto_update_frequency'):
1459 self.restart_auto_update_timer()
1460 elif name == 'podcast_list_view_all':
1461 # Force a update of the podcast list model
1462 self.channel_list_changed = True
1463 if gpodder.ui.fremantle:
1464 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
1465 while gtk.events_pending():
1466 gtk.main_iteration(False)
1467 self.update_podcast_list_model()
1468 if gpodder.ui.fremantle:
1469 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
1471 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1472 # With get_bin_window, we get the window that contains the rows without
1473 # the header. The Y coordinate of this window will be the height of the
1474 # treeview header. This is the amount we have to subtract from the
1475 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1476 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1477 y -= x_bin
1478 y -= y_bin
1479 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
1481 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or (column is not None and column != treeview.get_columns()[0]):
1482 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1483 return False
1485 if path is not None:
1486 model = treeview.get_model()
1487 iter = model.get_iter(path)
1488 role = getattr(treeview, TreeViewHelper.ROLE)
1490 if role == TreeViewHelper.ROLE_EPISODES:
1491 id = model.get_value(iter, EpisodeListModel.C_URL)
1492 elif role == TreeViewHelper.ROLE_PODCASTS:
1493 id = model.get_value(iter, PodcastListModel.C_URL)
1495 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1496 if last_tooltip is not None and last_tooltip != id:
1497 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1498 return False
1499 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1501 if role == TreeViewHelper.ROLE_EPISODES:
1502 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1503 if description:
1504 tooltip.set_text(description)
1505 else:
1506 return False
1507 elif role == TreeViewHelper.ROLE_PODCASTS:
1508 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1509 if channel is None:
1510 return False
1511 channel.request_save_dir_size()
1512 diskspace_str = util.format_filesize(channel.save_dir_size, 0)
1513 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1514 if error_str:
1515 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
1516 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1517 table = gtk.Table(rows=3, columns=3)
1518 table.set_row_spacings(5)
1519 table.set_col_spacings(5)
1520 table.set_border_width(5)
1522 heading = gtk.Label()
1523 heading.set_alignment(0, 1)
1524 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
1525 table.attach(heading, 0, 1, 0, 1)
1526 size_info = gtk.Label()
1527 size_info.set_alignment(1, 1)
1528 size_info.set_justify(gtk.JUSTIFY_RIGHT)
1529 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
1530 table.attach(size_info, 2, 3, 0, 1)
1532 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
1534 if len(channel.description) < 500:
1535 description = channel.description
1536 else:
1537 pos = channel.description.find('\n\n')
1538 if pos == -1 or pos > 500:
1539 description = channel.description[:498]+'[...]'
1540 else:
1541 description = channel.description[:pos]
1543 description = gtk.Label(description)
1544 if error_str:
1545 description.set_markup(error_str)
1546 description.set_alignment(0, 0)
1547 description.set_line_wrap(True)
1548 table.attach(description, 0, 3, 2, 3)
1550 table.show_all()
1551 tooltip.set_custom(table)
1553 return True
1555 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1556 return False
1558 def treeview_allow_tooltips(self, treeview, allow):
1559 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1561 def update_m3u_playlist_clicked(self, widget):
1562 if self.active_channel is not None:
1563 self.active_channel.update_m3u_playlist()
1564 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'), widget=self.treeChannels)
1566 def treeview_handle_context_menu_click(self, treeview, event):
1567 x, y = int(event.x), int(event.y)
1568 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
1570 selection = treeview.get_selection()
1571 model, paths = selection.get_selected_rows()
1573 if path is None or (path not in paths and \
1574 event.button == self.context_menu_mouse_button):
1575 # We have right-clicked, but not into the selection,
1576 # assume we don't want to operate on the selection
1577 paths = []
1579 if path is not None and not paths and \
1580 event.button == self.context_menu_mouse_button:
1581 # No selection or clicked outside selection;
1582 # select the single item where we clicked
1583 treeview.grab_focus()
1584 treeview.set_cursor(path, column, 0)
1585 paths = [path]
1587 if not paths:
1588 # Unselect any remaining items (clicked elsewhere)
1589 if hasattr(treeview, 'is_rubber_banding_active'):
1590 if not treeview.is_rubber_banding_active():
1591 selection.unselect_all()
1592 else:
1593 selection.unselect_all()
1595 return model, paths
1597 def downloads_list_get_selection(self, model=None, paths=None):
1598 if model is None and paths is None:
1599 selection = self.treeDownloads.get_selection()
1600 model, paths = selection.get_selected_rows()
1602 can_queue, can_cancel, can_pause, can_remove, can_force = (True,)*5
1603 selected_tasks = [(gtk.TreeRowReference(model, path), \
1604 model.get_value(model.get_iter(path), \
1605 DownloadStatusModel.C_TASK)) for path in paths]
1607 for row_reference, task in selected_tasks:
1608 if task.status != download.DownloadTask.QUEUED:
1609 can_force = False
1610 if task.status not in (download.DownloadTask.PAUSED, \
1611 download.DownloadTask.FAILED, \
1612 download.DownloadTask.CANCELLED):
1613 can_queue = False
1614 if task.status not in (download.DownloadTask.PAUSED, \
1615 download.DownloadTask.QUEUED, \
1616 download.DownloadTask.DOWNLOADING, \
1617 download.DownloadTask.FAILED):
1618 can_cancel = False
1619 if task.status not in (download.DownloadTask.QUEUED, \
1620 download.DownloadTask.DOWNLOADING):
1621 can_pause = False
1622 if task.status not in (download.DownloadTask.CANCELLED, \
1623 download.DownloadTask.FAILED, \
1624 download.DownloadTask.DONE):
1625 can_remove = False
1627 return selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force
1629 def downloads_finished(self, download_tasks_seen):
1630 # FIXME: Filter all tasks that have already been reported
1631 finished_downloads = [str(task) for task in download_tasks_seen if task.status == task.DONE]
1632 failed_downloads = [str(task)+' ('+task.error_message+')' for task in download_tasks_seen if task.status == task.FAILED]
1634 if finished_downloads and failed_downloads:
1635 message = self.format_episode_list(finished_downloads, 5)
1636 message += '\n\n<i>%s</i>\n' % _('These downloads failed:')
1637 message += self.format_episode_list(failed_downloads, 5)
1638 self.show_message(message, _('Downloads finished'), True, widget=self.labelDownloads)
1639 elif finished_downloads:
1640 message = self.format_episode_list(finished_downloads)
1641 self.show_message(message, _('Downloads finished'), widget=self.labelDownloads)
1642 elif failed_downloads:
1643 message = self.format_episode_list(failed_downloads)
1644 self.show_message(message, _('Downloads failed'), True, widget=self.labelDownloads)
1646 # Open torrent files right after download (bug 1029)
1647 if self.config.open_torrent_after_download:
1648 for task in download_tasks_seen:
1649 if task.status != task.DONE:
1650 continue
1652 episode = task.episode
1653 if episode.mimetype != 'application/x-bittorrent':
1654 continue
1656 self.playback_episodes([episode])
1659 def format_episode_list(self, episode_list, max_episodes=10):
1661 Format a list of episode names for notifications
1663 Will truncate long episode names and limit the amount of
1664 episodes displayed (max_episodes=10).
1666 The episode_list parameter should be a list of strings.
1668 MAX_TITLE_LENGTH = 100
1670 result = []
1671 for title in episode_list[:min(len(episode_list), max_episodes)]:
1672 if len(title) > MAX_TITLE_LENGTH:
1673 middle = (MAX_TITLE_LENGTH/2)-2
1674 title = '%s...%s' % (title[0:middle], title[-middle:])
1675 result.append(saxutils.escape(title))
1676 result.append('\n')
1678 more_episodes = len(episode_list) - max_episodes
1679 if more_episodes > 0:
1680 result.append('(...')
1681 result.append(N_('%d more episode', '%d more episodes', more_episodes) % more_episodes)
1682 result.append('...)')
1684 return (''.join(result)).strip()
1686 def _for_each_task_set_status(self, tasks, status, force_start=False):
1687 episode_urls = set()
1688 model = self.treeDownloads.get_model()
1689 for row_reference, task in tasks:
1690 if status == download.DownloadTask.QUEUED:
1691 # Only queue task when its paused/failed/cancelled (or forced)
1692 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED) or force_start:
1693 self.download_queue_manager.add_task(task, force_start)
1694 self.enable_download_list_update()
1695 elif status == download.DownloadTask.CANCELLED:
1696 # Cancelling a download allowed when downloading/queued
1697 if task.status in (task.QUEUED, task.DOWNLOADING):
1698 task.status = status
1699 # Cancelling paused/failed downloads requires a call to .run()
1700 elif task.status in (task.PAUSED, task.FAILED):
1701 task.status = status
1702 # Call run, so the partial file gets deleted
1703 task.run()
1704 elif status == download.DownloadTask.PAUSED:
1705 # Pausing a download only when queued/downloading
1706 if task.status in (task.DOWNLOADING, task.QUEUED):
1707 task.status = status
1708 elif status is None:
1709 # Remove the selected task - cancel downloading/queued tasks
1710 if task.status in (task.QUEUED, task.DOWNLOADING):
1711 task.status = task.CANCELLED
1712 model.remove(model.get_iter(row_reference.get_path()))
1713 # Remember the URL, so we can tell the UI to update
1714 try:
1715 # We don't "see" this task anymore - remove it;
1716 # this is needed, so update_episode_list_icons()
1717 # below gets the correct list of "seen" tasks
1718 self.download_tasks_seen.remove(task)
1719 except KeyError, key_error:
1720 log('Cannot remove task from "seen" list: %s', task, sender=self)
1721 episode_urls.add(task.url)
1722 # Tell the task that it has been removed (so it can clean up)
1723 task.removed_from_list()
1724 else:
1725 # We can (hopefully) simply set the task status here
1726 task.status = status
1727 # Tell the podcasts tab to update icons for our removed podcasts
1728 self.update_episode_list_icons(episode_urls)
1729 # Update the tab title and downloads list
1730 self.update_downloads_list()
1732 def treeview_downloads_show_context_menu(self, treeview, event):
1733 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1734 if not paths:
1735 if not hasattr(treeview, 'is_rubber_banding_active'):
1736 return True
1737 else:
1738 return not treeview.is_rubber_banding_active()
1740 if event.button == self.context_menu_mouse_button:
1741 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = \
1742 self.downloads_list_get_selection(model, paths)
1744 def make_menu_item(label, stock_id, tasks, status, sensitive, force_start=False):
1745 # This creates a menu item for selection-wide actions
1746 item = gtk.ImageMenuItem(label)
1747 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
1748 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status, force_start))
1749 item.set_sensitive(sensitive)
1750 return self.set_finger_friendly(item)
1752 menu = gtk.Menu()
1754 item = gtk.ImageMenuItem(_('Episode details'))
1755 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1756 if len(selected_tasks) == 1:
1757 row_reference, task = selected_tasks[0]
1758 episode = task.episode
1759 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
1760 else:
1761 item.set_sensitive(False)
1762 menu.append(self.set_finger_friendly(item))
1763 menu.append(gtk.SeparatorMenuItem())
1764 if can_force:
1765 menu.append(make_menu_item(_('Start download now'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, True, True))
1766 else:
1767 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, can_queue, False))
1768 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED, can_cancel))
1769 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED, can_pause))
1770 menu.append(gtk.SeparatorMenuItem())
1771 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None, can_remove))
1773 if gpodder.ui.maemo or self.config.enable_fingerscroll:
1774 # Because we open the popup on left-click for Maemo,
1775 # we also include a non-action to close the menu
1776 menu.append(gtk.SeparatorMenuItem())
1777 item = gtk.ImageMenuItem(_('Close this menu'))
1778 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1780 menu.append(self.set_finger_friendly(item))
1782 menu.show_all()
1783 menu.popup(None, None, None, event.button, event.time)
1784 return True
1786 def treeview_channels_show_context_menu(self, treeview, event):
1787 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1788 if not paths:
1789 return True
1791 # Check for valid channel id, if there's no id then
1792 # assume that it is a proxy channel or equivalent
1793 # and cannot be operated with right click
1794 if self.active_channel.id is None:
1795 return True
1797 if event.button == 3:
1798 menu = gtk.Menu()
1800 ICON = lambda x: x
1802 item = gtk.ImageMenuItem( _('Update podcast'))
1803 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1804 item.connect('activate', self.on_itemUpdateChannel_activate)
1805 item.set_sensitive(not self.updating_feed_cache)
1806 menu.append(item)
1808 menu.append(gtk.SeparatorMenuItem())
1810 item = gtk.CheckMenuItem(_('Keep episodes'))
1811 item.set_active(self.active_channel.channel_is_locked)
1812 item.connect('activate', self.on_channel_toggle_lock_activate)
1813 menu.append(self.set_finger_friendly(item))
1815 item = gtk.ImageMenuItem(_('Remove podcast'))
1816 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1817 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1818 menu.append( item)
1820 if self.config.device_type != 'none':
1821 item = gtk.MenuItem(_('Synchronize to device'))
1822 item.connect('activate', lambda item: self.on_sync_to_ipod_activate(item, self.active_channel.get_downloaded_episodes()))
1823 menu.append(item)
1825 menu.append( gtk.SeparatorMenuItem())
1827 item = gtk.ImageMenuItem(_('Podcast details'))
1828 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1829 item.connect('activate', self.on_itemEditChannel_activate)
1830 menu.append(item)
1832 menu.show_all()
1833 # Disable tooltips while we are showing the menu, so
1834 # the tooltip will not appear over the menu
1835 self.treeview_allow_tooltips(self.treeChannels, False)
1836 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1837 menu.popup( None, None, None, event.button, event.time)
1839 return True
1841 def on_itemClose_activate(self, widget):
1842 if self.tray_icon is not None:
1843 self.iconify_main_window()
1844 else:
1845 self.on_gPodder_delete_event(widget)
1847 def cover_file_removed(self, channel_url):
1849 The Cover Downloader calls this when a previously-
1850 available cover has been removed from the disk. We
1851 have to update our model to reflect this change.
1853 self.podcast_list_model.delete_cover_by_url(channel_url)
1855 def cover_download_finished(self, channel, pixbuf):
1857 The Cover Downloader calls this when it has finished
1858 downloading (or registering, if already downloaded)
1859 a new channel cover, which is ready for displaying.
1861 self.podcast_list_model.add_cover_by_channel(channel, pixbuf)
1863 def save_episodes_as_file(self, episodes):
1864 for episode in episodes:
1865 self.save_episode_as_file(episode)
1867 def save_episode_as_file(self, episode):
1868 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1869 if episode.was_downloaded(and_exists=True):
1870 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1871 copy_from = episode.local_filename(create=False)
1872 assert copy_from is not None
1873 copy_to = episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)
1874 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1875 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1877 def copy_episodes_bluetooth(self, episodes):
1878 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1880 if gpodder.ui.maemo:
1881 util.bluetooth_send_files_maemo([e.local_filename(create=False) \
1882 for e in episodes_to_copy])
1883 return True
1885 def convert_and_send_thread(episode):
1886 for episode in episodes:
1887 filename = episode.local_filename(create=False)
1888 assert filename is not None
1889 destfile = os.path.join(tempfile.gettempdir(), \
1890 util.sanitize_filename(episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)))
1891 (base, ext) = os.path.splitext(filename)
1892 if not destfile.endswith(ext):
1893 destfile += ext
1895 try:
1896 shutil.copyfile(filename, destfile)
1897 util.bluetooth_send_file(destfile)
1898 except:
1899 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1900 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1902 util.delete_file(destfile)
1904 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1906 def get_device_name(self):
1907 if self.config.device_type == 'ipod':
1908 return _('iPod')
1909 elif self.config.device_type in ('filesystem', 'mtp'):
1910 return _('MP3 player')
1911 else:
1912 return '(unknown device)'
1914 def _treeview_button_released(self, treeview, event):
1915 xpos, ypos = TreeViewHelper.get_button_press_event(treeview)
1916 dy = int(abs(event.y-ypos))
1917 dx = int(event.x-xpos)
1919 selection = treeview.get_selection()
1920 path = treeview.get_path_at_pos(int(event.x), int(event.y))
1921 if path is None or dy > 30:
1922 return (False, dx, dy)
1924 path, column, x, y = path
1925 selection.select_path(path)
1926 treeview.set_cursor(path)
1927 treeview.grab_focus()
1929 return (True, dx, dy)
1931 def treeview_channels_handle_gestures(self, treeview, event):
1932 if self.currently_updating:
1933 return False
1935 selected, dx, dy = self._treeview_button_released(treeview, event)
1937 if selected:
1938 if self.config.maemo_enable_gestures:
1939 if dx > 70:
1940 self.on_itemUpdateChannel_activate()
1941 elif dx < -70:
1942 self.on_itemEditChannel_activate(treeview)
1944 return False
1946 def treeview_available_handle_gestures(self, treeview, event):
1947 selected, dx, dy = self._treeview_button_released(treeview, event)
1949 if selected:
1950 if self.config.maemo_enable_gestures:
1951 if dx > 70:
1952 self.on_playback_selected_episodes(None)
1953 return True
1954 elif dx < -70:
1955 self.on_shownotes_selected_episodes(None)
1956 return True
1958 # Pass the event to the context menu handler for treeAvailable
1959 self.treeview_available_show_context_menu(treeview, event)
1961 return True
1963 def treeview_available_show_context_menu(self, treeview, event):
1964 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1965 if not paths:
1966 if not hasattr(treeview, 'is_rubber_banding_active'):
1967 return True
1968 else:
1969 return not treeview.is_rubber_banding_active()
1971 if event.button == self.context_menu_mouse_button:
1972 episodes = self.get_selected_episodes()
1973 any_locked = any(e.is_locked for e in episodes)
1974 any_played = any(e.is_played for e in episodes)
1975 one_is_new = any(e.state == gpodder.STATE_NORMAL and not e.is_played for e in episodes)
1976 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1977 downloading = any(self.episode_is_downloading(e) for e in episodes)
1979 menu = gtk.Menu()
1981 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1983 if open_instead_of_play:
1984 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1985 elif downloaded:
1986 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1987 else:
1988 item = gtk.ImageMenuItem(_('Stream'))
1989 item.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PLAY, gtk.ICON_SIZE_MENU))
1991 item.set_sensitive(can_play and not downloading)
1992 item.connect('activate', self.on_playback_selected_episodes)
1993 menu.append(self.set_finger_friendly(item))
1995 if not can_cancel:
1996 item = gtk.ImageMenuItem(_('Download'))
1997 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1998 item.set_sensitive(can_download)
1999 item.connect('activate', self.on_download_selected_episodes)
2000 menu.append(self.set_finger_friendly(item))
2001 else:
2002 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
2003 item.connect('activate', self.on_item_cancel_download_activate)
2004 menu.append(self.set_finger_friendly(item))
2006 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
2007 item.set_sensitive(can_delete)
2008 item.connect('activate', self.on_btnDownloadedDelete_clicked)
2009 menu.append(self.set_finger_friendly(item))
2011 ICON = lambda x: x
2013 # Ok, this probably makes sense to only display for downloaded files
2014 if downloaded:
2015 menu.append(gtk.SeparatorMenuItem())
2016 share_item = gtk.MenuItem(_('Send to'))
2017 menu.append(self.set_finger_friendly(share_item))
2018 share_menu = gtk.Menu()
2020 item = gtk.ImageMenuItem(_('Local folder'))
2021 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU))
2022 item.connect('button-press-event', lambda w, ee: self.save_episodes_as_file(episodes))
2023 share_menu.append(self.set_finger_friendly(item))
2024 if self.bluetooth_available:
2025 item = gtk.ImageMenuItem(_('Bluetooth device'))
2026 if gpodder.ui.maemo:
2027 icon_name = ICON('qgn_list_filesys_bluetooth')
2028 else:
2029 icon_name = ICON('bluetooth')
2030 item.set_image(gtk.image_new_from_icon_name(icon_name, gtk.ICON_SIZE_MENU))
2031 item.connect('button-press-event', lambda w, ee: self.copy_episodes_bluetooth(episodes))
2032 share_menu.append(self.set_finger_friendly(item))
2033 if can_transfer:
2034 item = gtk.ImageMenuItem(self.get_device_name())
2035 item.set_image(gtk.image_new_from_icon_name(ICON('multimedia-player'), gtk.ICON_SIZE_MENU))
2036 item.connect('button-press-event', lambda w, ee: self.on_sync_to_ipod_activate(w, episodes))
2037 share_menu.append(self.set_finger_friendly(item))
2039 share_item.set_submenu(share_menu)
2041 if (downloaded or one_is_new or can_download) and not downloading:
2042 menu.append(gtk.SeparatorMenuItem())
2043 if one_is_new:
2044 item = gtk.CheckMenuItem(_('New'))
2045 item.set_active(True)
2046 item.connect('activate', lambda w: self.mark_selected_episodes_old())
2047 menu.append(self.set_finger_friendly(item))
2048 elif can_download:
2049 item = gtk.CheckMenuItem(_('New'))
2050 item.set_active(False)
2051 item.connect('activate', lambda w: self.mark_selected_episodes_new())
2052 menu.append(self.set_finger_friendly(item))
2054 if downloaded:
2055 item = gtk.CheckMenuItem(_('Played'))
2056 item.set_active(any_played)
2057 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, not any_played))
2058 menu.append(self.set_finger_friendly(item))
2060 item = gtk.CheckMenuItem(_('Keep episode'))
2061 item.set_active(any_locked)
2062 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, not any_locked))
2063 menu.append(self.set_finger_friendly(item))
2065 menu.append(gtk.SeparatorMenuItem())
2066 # Single item, add episode information menu item
2067 item = gtk.ImageMenuItem(_('Episode details'))
2068 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
2069 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
2070 menu.append(self.set_finger_friendly(item))
2072 if gpodder.ui.maemo or self.config.enable_fingerscroll:
2073 # Because we open the popup on left-click for Maemo,
2074 # we also include a non-action to close the menu
2075 menu.append(gtk.SeparatorMenuItem())
2076 item = gtk.ImageMenuItem(_('Close this menu'))
2077 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
2078 menu.append(self.set_finger_friendly(item))
2080 menu.show_all()
2081 # Disable tooltips while we are showing the menu, so
2082 # the tooltip will not appear over the menu
2083 self.treeview_allow_tooltips(self.treeAvailable, False)
2084 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
2085 menu.popup( None, None, None, event.button, event.time)
2087 return True
2089 def set_title(self, new_title):
2090 if not gpodder.ui.fremantle:
2091 self.default_title = new_title
2092 self.gPodder.set_title(new_title)
2094 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2096 Updates the status icons in the episode list.
2098 If urls is given, it should be a list of URLs
2099 of episodes that should be updated.
2101 If urls is None, set ONE OF selected, all to
2102 True (the former updates just the selected
2103 episodes and the latter updates all episodes).
2105 additional_args = (self.episode_is_downloading, \
2106 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2107 self.config.episode_list_thumbnails and gpodder.ui.desktop)
2109 if urls is not None:
2110 # We have a list of URLs to walk through
2111 self.episode_list_model.update_by_urls(urls, *additional_args)
2112 elif selected and not all:
2113 # We should update all selected episodes
2114 selection = self.treeAvailable.get_selection()
2115 model, paths = selection.get_selected_rows()
2116 for path in reversed(paths):
2117 iter = model.get_iter(path)
2118 self.episode_list_model.update_by_filter_iter(iter, \
2119 *additional_args)
2120 elif all and not selected:
2121 # We update all (even the filter-hidden) episodes
2122 self.episode_list_model.update_all(*additional_args)
2123 else:
2124 # Wrong/invalid call - have to specify at least one parameter
2125 raise ValueError('Invalid call to update_episode_list_icons')
2127 def episode_list_status_changed(self, episodes):
2128 self.update_episode_list_icons(set(e.url for e in episodes))
2129 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2130 self.db.commit()
2132 def clean_up_downloads(self, delete_partial=False):
2133 # Clean up temporary files left behind by old gPodder versions
2134 temporary_files = glob.glob('%s/*/.tmp-*' % self.config.download_dir)
2136 if delete_partial:
2137 temporary_files += glob.glob('%s/*/*.partial' % self.config.download_dir)
2139 for tempfile in temporary_files:
2140 util.delete_file(tempfile)
2142 # Clean up empty download folders and abandoned download folders
2143 download_dirs = glob.glob(os.path.join(self.config.download_dir, '*'))
2144 for ddir in download_dirs:
2145 if os.path.isdir(ddir) and False: # FIXME not db.channel_foldername_exists(os.path.basename(ddir)):
2146 globr = glob.glob(os.path.join(ddir, '*'))
2147 if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
2148 log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
2149 shutil.rmtree(ddir, ignore_errors=True)
2151 def streaming_possible(self):
2152 if gpodder.ui.desktop:
2153 # User has to have a media player set on the Desktop, or else we
2154 # would probably open the browser when giving a URL to xdg-open..
2155 return (self.config.player and self.config.player != 'default')
2156 elif gpodder.ui.maemo:
2157 # On Maemo, the default is to use the Nokia Media Player, which is
2158 # already able to deal with HTTP URLs the right way, so we
2159 # unconditionally enable streaming always on Maemo
2160 return True
2162 return False
2164 def playback_episodes_for_real(self, episodes):
2165 groups = collections.defaultdict(list)
2166 for episode in episodes:
2167 file_type = episode.file_type()
2168 if file_type == 'video' and self.config.videoplayer and \
2169 self.config.videoplayer != 'default':
2170 player = self.config.videoplayer
2171 if gpodder.ui.diablo:
2172 # Use the wrapper script if it's installed to crop 3GP YouTube
2173 # videos to fit the screen (looks much nicer than w/ black border)
2174 if player == 'mplayer' and util.find_command('gpodder-mplayer'):
2175 player = 'gpodder-mplayer'
2176 elif gpodder.ui.fremantle and player == 'mplayer':
2177 player = 'mplayer -fs %F'
2178 elif file_type == 'audio' and self.config.player and \
2179 self.config.player != 'default':
2180 player = self.config.player
2181 else:
2182 player = 'default'
2184 if file_type not in ('audio', 'video') or \
2185 (file_type == 'audio' and not self.config.audio_played_dbus) or \
2186 (file_type == 'video' and not self.config.video_played_dbus):
2187 # Mark episode as played in the database
2188 episode.mark(is_played=True)
2189 self.mygpo_client.on_playback([episode])
2191 filename = episode.local_filename(create=False)
2192 if filename is None or not os.path.exists(filename):
2193 filename = episode.url
2194 if youtube.is_video_link(filename):
2195 fmt_id = self.config.youtube_preferred_fmt_id
2196 if gpodder.ui.fremantle:
2197 fmt_id = 5
2198 filename = youtube.get_real_download_url(filename, fmt_id)
2200 # Determine the playback resume position - if the file
2201 # was played 100%, we simply start from the beginning
2202 resume_position = episode.current_position
2203 if resume_position == episode.total_time:
2204 resume_position = 0
2206 if gpodder.ui.fremantle:
2207 self.mafw_monitor.set_resume_point(filename, resume_position)
2209 # If Panucci is configured, use D-Bus on Maemo to call it
2210 if player == 'panucci':
2211 try:
2212 PANUCCI_NAME = 'org.panucci.panucciInterface'
2213 PANUCCI_PATH = '/panucciInterface'
2214 PANUCCI_INTF = 'org.panucci.panucciInterface'
2215 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2216 i = dbus.Interface(o, PANUCCI_INTF)
2218 def on_reply(*args):
2219 pass
2221 def error_handler(filename, err):
2222 log('Exception in D-Bus call: %s', str(err), \
2223 sender=self)
2225 # Fallback: use the command line client
2226 for command in util.format_desktop_command('panucci', \
2227 [filename]):
2228 log('Executing: %s', repr(command), sender=self)
2229 subprocess.Popen(command)
2231 on_error = lambda err: error_handler(filename, err)
2233 # This method only exists in Panucci > 0.9 ('new Panucci')
2234 i.playback_from(filename, resume_position, \
2235 reply_handler=on_reply, error_handler=on_error)
2237 continue # This file was handled by the D-Bus call
2238 except Exception, e:
2239 log('Error calling Panucci using D-Bus', sender=self, traceback=True)
2240 elif player == 'MediaBox' and gpodder.ui.maemo:
2241 try:
2242 MEDIABOX_NAME = 'de.pycage.mediabox'
2243 MEDIABOX_PATH = '/de/pycage/mediabox/control'
2244 MEDIABOX_INTF = 'de.pycage.mediabox.control'
2245 o = gpodder.dbus_session_bus.get_object(MEDIABOX_NAME, MEDIABOX_PATH)
2246 i = dbus.Interface(o, MEDIABOX_INTF)
2248 def on_reply(*args):
2249 pass
2251 def on_error(err):
2252 log('Exception in D-Bus call: %s', str(err), \
2253 sender=self)
2255 i.load(filename, '%s/x-unknown' % file_type, \
2256 reply_handler=on_reply, error_handler=on_error)
2258 continue # This file was handled by the D-Bus call
2259 except Exception, e:
2260 log('Error calling MediaBox using D-Bus', sender=self, traceback=True)
2262 groups[player].append(filename)
2264 # Open episodes with system default player
2265 if 'default' in groups:
2266 if gpodder.ui.maemo and len(groups['default']) > 1:
2267 # The Nokia Media Player app does not support receiving multiple
2268 # file names via D-Bus, so we simply place all file names into a
2269 # temporary M3U playlist and open that with the Media Player.
2270 m3u_filename = os.path.join(gpodder.home, 'gpodder_open_with.m3u')
2271 util.write_m3u_playlist(m3u_filename, groups['default'], extm3u=False)
2272 util.gui_open(m3u_filename)
2273 else:
2274 for filename in groups['default']:
2275 log('Opening with system default: %s', filename, sender=self)
2276 util.gui_open(filename)
2277 del groups['default']
2278 elif gpodder.ui.maemo and groups:
2279 # When on Maemo and not opening with default, show a notification
2280 # (no startup notification for Panucci / MPlayer yet...)
2281 if len(episodes) == 1:
2282 text = _('Opening %s') % episodes[0].title
2283 else:
2284 count = len(episodes)
2285 text = N_('Opening %d episode', 'Opening %d episodes', count) % count
2287 banner = hildon.hildon_banner_show_animation(self.gPodder, '', text)
2289 def destroy_banner_later(banner):
2290 banner.destroy()
2291 return False
2292 gobject.timeout_add(5000, destroy_banner_later, banner)
2294 # For each type now, go and create play commands
2295 for group in groups:
2296 for command in util.format_desktop_command(group, groups[group]):
2297 log('Executing: %s', repr(command), sender=self)
2298 subprocess.Popen(command)
2300 # Persist episode status changes to the database
2301 self.db.commit()
2303 # Flush updated episode status
2304 self.mygpo_client.flush()
2306 def playback_episodes(self, episodes):
2307 # We need to create a list, because we run through it more than once
2308 episodes = list(PodcastEpisode.sort_by_pubdate(e for e in episodes if \
2309 e.was_downloaded(and_exists=True) or self.streaming_possible()))
2311 try:
2312 self.playback_episodes_for_real(episodes)
2313 except Exception, e:
2314 log('Error in playback!', sender=self, traceback=True)
2315 if gpodder.ui.desktop:
2316 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
2317 _('Error opening player'), widget=self.toolPreferences)
2318 else:
2319 self.show_message(_('Please check your media player settings in the preferences dialog.'))
2321 channel_urls = set()
2322 episode_urls = set()
2323 for episode in episodes:
2324 channel_urls.add(episode.channel.url)
2325 episode_urls.add(episode.url)
2326 self.update_episode_list_icons(episode_urls)
2327 self.update_podcast_list_model(channel_urls)
2329 def play_or_download(self):
2330 if not gpodder.ui.fremantle:
2331 if self.wNotebook.get_current_page() > 0:
2332 if gpodder.ui.desktop:
2333 self.toolCancel.set_sensitive(True)
2334 return
2336 if self.currently_updating:
2337 return (False, False, False, False, False, False)
2339 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
2340 ( is_played, is_locked ) = (False,)*2
2342 open_instead_of_play = False
2344 selection = self.treeAvailable.get_selection()
2345 if selection.count_selected_rows() > 0:
2346 (model, paths) = selection.get_selected_rows()
2348 for path in paths:
2349 try:
2350 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2351 except TypeError, te:
2352 log('Invalid episode at path %s', str(path), sender=self)
2353 continue
2355 if episode.file_type() not in ('audio', 'video'):
2356 open_instead_of_play = True
2358 if episode.was_downloaded():
2359 can_play = episode.was_downloaded(and_exists=True)
2360 is_played = episode.is_played
2361 is_locked = episode.is_locked
2362 if not can_play:
2363 can_download = True
2364 else:
2365 if self.episode_is_downloading(episode):
2366 can_cancel = True
2367 else:
2368 can_download = True
2370 can_download = can_download and not can_cancel
2371 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
2372 can_transfer = can_play and self.config.device_type != 'none' and not can_cancel and not can_download and not open_instead_of_play
2373 can_delete = not can_cancel
2375 if gpodder.ui.desktop:
2376 if open_instead_of_play:
2377 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
2378 else:
2379 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
2380 self.toolPlay.set_sensitive( can_play)
2381 self.toolDownload.set_sensitive( can_download)
2382 self.toolTransfer.set_sensitive( can_transfer)
2383 self.toolCancel.set_sensitive( can_cancel)
2385 if not gpodder.ui.fremantle:
2386 self.item_cancel_download.set_sensitive(can_cancel)
2387 self.itemDownloadSelected.set_sensitive(can_download)
2388 self.itemOpenSelected.set_sensitive(can_play)
2389 self.itemPlaySelected.set_sensitive(can_play)
2390 self.itemDeleteSelected.set_sensitive(can_delete)
2391 self.item_toggle_played.set_sensitive(can_play)
2392 self.item_toggle_lock.set_sensitive(can_play)
2393 self.itemOpenSelected.set_visible(open_instead_of_play)
2394 self.itemPlaySelected.set_visible(not open_instead_of_play)
2396 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
2398 def on_cbMaxDownloads_toggled(self, widget, *args):
2399 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2401 def on_cbLimitDownloads_toggled(self, widget, *args):
2402 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2404 def episode_new_status_changed(self, urls):
2405 self.update_podcast_list_model()
2406 self.update_episode_list_icons(urls)
2408 def update_podcast_list_model(self, urls=None, selected=False, select_url=None):
2409 """Update the podcast list treeview model
2411 If urls is given, it should list the URLs of each
2412 podcast that has to be updated in the list.
2414 If selected is True, only update the model contents
2415 for the currently-selected podcast - nothing more.
2417 The caller can optionally specify "select_url",
2418 which is the URL of the podcast that is to be
2419 selected in the list after the update is complete.
2420 This only works if the podcast list has to be
2421 reloaded; i.e. something has been added or removed
2422 since the last update of the podcast list).
2424 selection = self.treeChannels.get_selection()
2425 model, iter = selection.get_selected()
2427 if self.config.podcast_list_view_all and not self.channel_list_changed:
2428 # Update "all episodes" view in any case (if enabled)
2429 self.podcast_list_model.update_first_row()
2431 if selected:
2432 # very cheap! only update selected channel
2433 if iter is not None:
2434 # If we have selected the "all episodes" view, we have
2435 # to update all channels for selected episodes:
2436 if self.config.podcast_list_view_all and \
2437 self.podcast_list_model.iter_is_first_row(iter):
2438 urls = self.get_podcast_urls_from_selected_episodes()
2439 self.podcast_list_model.update_by_urls(urls)
2440 else:
2441 # Otherwise just update the selected row (a podcast)
2442 self.podcast_list_model.update_by_filter_iter(iter)
2443 elif not self.channel_list_changed:
2444 # we can keep the model, but have to update some
2445 if urls is None:
2446 # still cheaper than reloading the whole list
2447 self.podcast_list_model.update_all()
2448 else:
2449 # ok, we got a bunch of urls to update
2450 self.podcast_list_model.update_by_urls(urls)
2451 else:
2452 if model and iter and select_url is None:
2453 # Get the URL of the currently-selected podcast
2454 select_url = model.get_value(iter, PodcastListModel.C_URL)
2456 # Update the podcast list model with new channels
2457 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2459 try:
2460 selected_iter = model.get_iter_first()
2461 # Find the previously-selected URL in the new
2462 # model if we have an URL (else select first)
2463 if select_url is not None:
2464 pos = model.get_iter_first()
2465 while pos is not None:
2466 url = model.get_value(pos, PodcastListModel.C_URL)
2467 if url == select_url:
2468 selected_iter = pos
2469 break
2470 pos = model.iter_next(pos)
2472 if not gpodder.ui.fremantle:
2473 if selected_iter is not None:
2474 selection.select_iter(selected_iter)
2475 self.on_treeChannels_cursor_changed(self.treeChannels)
2476 except:
2477 log('Cannot select podcast in list', traceback=True, sender=self)
2478 self.channel_list_changed = False
2480 def episode_is_downloading(self, episode):
2481 """Returns True if the given episode is being downloaded at the moment"""
2482 if episode is None:
2483 return False
2485 return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
2487 def update_episode_list_model(self):
2488 if self.channels and self.active_channel is not None:
2489 if gpodder.ui.fremantle:
2490 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2492 self.currently_updating = True
2493 self.treeAvailable.hide()
2495 def update():
2496 additional_args = (self.episode_is_downloading, \
2497 self.config.episode_list_descriptions and gpodder.ui.desktop, \
2498 self.config.episode_list_thumbnails and gpodder.ui.desktop)
2499 self.episode_list_model.replace_from_channel(self.active_channel, *additional_args)
2501 self.treeAvailable.get_selection().unselect_all()
2502 self.treeAvailable.show()
2503 util.idle_add(self.treeAvailable.scroll_to_point, 0, 0)
2504 self.currently_updating = False
2505 self.play_or_download()
2507 if gpodder.ui.fremantle:
2508 util.idle_add(hildon.hildon_gtk_window_set_progress_indicator,
2509 self.episodes_window.main_window, False)
2511 util.idle_add(update)
2512 else:
2513 self.episode_list_model.clear()
2515 @dbus.service.method(gpodder.dbus_interface)
2516 def offer_new_episodes(self, channels=None):
2517 if gpodder.ui.fremantle:
2518 # Assume that when this function is called that the
2519 # notification is not shown anymore (Maemo bug 11345)
2520 self._fremantle_notification_visible = False
2522 new_episodes = self.get_new_episodes(channels)
2523 if new_episodes:
2524 self.new_episodes_show(new_episodes)
2525 return True
2526 return False
2528 def add_podcast_list(self, urls, auth_tokens=None):
2529 """Subscribe to a list of podcast given their URLs
2531 If auth_tokens is given, it should be a dictionary
2532 mapping URLs to (username, password) tuples."""
2534 if auth_tokens is None:
2535 auth_tokens = {}
2537 # Sort and split the URL list into five buckets
2538 queued, failed, existing, worked, authreq = [], [], [], [], []
2539 for input_url in urls:
2540 url = util.normalize_feed_url(input_url)
2541 if url is None:
2542 # Fail this one because the URL is not valid
2543 failed.append(input_url)
2544 elif self.podcast_list_model.get_filter_path_from_url(url) is not None:
2545 # A podcast already exists in the list for this URL
2546 existing.append(url)
2547 else:
2548 # This URL has survived the first round - queue for add
2549 queued.append(url)
2550 if url != input_url and input_url in auth_tokens:
2551 auth_tokens[url] = auth_tokens[input_url]
2553 error_messages = {}
2554 redirections = {}
2556 progress = ProgressIndicator(_('Adding podcasts'), \
2557 _('Please wait while episode information is downloaded.'), \
2558 parent=self.get_dialog_parent())
2560 def on_after_update():
2561 progress.on_finished()
2562 # Report already-existing subscriptions to the user
2563 if existing:
2564 title = _('Existing subscriptions skipped')
2565 message = _('You are already subscribed to these podcasts:') \
2566 + '\n\n' + '\n'.join(saxutils.escape(url) for url in existing)
2567 self.show_message(message, title, widget=self.treeChannels)
2569 # Report subscriptions that require authentication
2570 if authreq:
2571 retry_podcasts = {}
2572 for url in authreq:
2573 title = _('Podcast requires authentication')
2574 message = _('Please login to %s:') % (saxutils.escape(url),)
2575 success, auth_tokens = self.show_login_dialog(title, message)
2576 if success:
2577 retry_podcasts[url] = auth_tokens
2578 else:
2579 # Stop asking the user for more login data
2580 retry_podcasts = {}
2581 for url in authreq:
2582 error_messages[url] = _('Authentication failed')
2583 failed.append(url)
2584 break
2586 # If we have authentication data to retry, do so here
2587 if retry_podcasts:
2588 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
2590 # Report website redirections
2591 for url in redirections:
2592 title = _('Website redirection detected')
2593 message = _('The URL %(url)s redirects to %(target)s.') \
2594 + '\n\n' + _('Do you want to visit the website now?')
2595 message = message % {'url': url, 'target': redirections[url]}
2596 if self.show_confirmation(message, title):
2597 util.open_website(url)
2598 else:
2599 break
2601 # Report failed subscriptions to the user
2602 if failed:
2603 title = _('Could not add some podcasts')
2604 message = _('Some podcasts could not be added to your list:') \
2605 + '\n\n' + '\n'.join(saxutils.escape('%s: %s' % (url, \
2606 error_messages.get(url, _('Unknown')))) for url in failed)
2607 self.show_message(message, title, important=True)
2609 # Upload subscription changes to gpodder.net
2610 self.mygpo_client.on_subscribe(worked)
2612 # If at least one podcast has been added, save and update all
2613 if self.channel_list_changed:
2614 # Fix URLs if mygpo has rewritten them
2615 self.rewrite_urls_mygpo()
2617 self.save_channels_opml()
2619 # If only one podcast was added, select it after the update
2620 if len(worked) == 1:
2621 url = worked[0]
2622 else:
2623 url = None
2625 # Update the list of subscribed podcasts
2626 self.update_feed_cache(force_update=False, select_url_afterwards=url)
2627 self.update_podcasts_tab()
2629 # Offer to download new episodes
2630 episodes = []
2631 for podcast in self.channels:
2632 if podcast.url in worked:
2633 episodes.extend(podcast.get_all_episodes())
2635 if episodes:
2636 episodes = list(PodcastEpisode.sort_by_pubdate(episodes, \
2637 reverse=True))
2638 self.new_episodes_show(episodes, \
2639 selected=[e.check_is_new() for e in episodes])
2642 def thread_proc():
2643 # After the initial sorting and splitting, try all queued podcasts
2644 length = len(queued)
2645 for index, url in enumerate(queued):
2646 progress.on_progress(float(index)/float(length))
2647 progress.on_message(url)
2648 log('QUEUE RUNNER: %s', url, sender=self)
2649 try:
2650 # The URL is valid and does not exist already - subscribe!
2651 channel = PodcastChannel.load(self.db, url=url, create=True, \
2652 authentication_tokens=auth_tokens.get(url, None), \
2653 max_episodes=self.config.max_episodes_per_feed, \
2654 download_dir=self.config.download_dir, \
2655 allow_empty_feeds=self.config.allow_empty_feeds, \
2656 mimetype_prefs=self.config.mimetype_prefs)
2658 try:
2659 username, password = util.username_password_from_url(url)
2660 except ValueError, ve:
2661 username, password = (None, None)
2663 if username is not None and channel.username is None and \
2664 password is not None and channel.password is None:
2665 channel.username = username
2666 channel.password = password
2667 channel.save()
2669 self._update_cover(channel)
2670 except feedcore.AuthenticationRequired:
2671 if url in auth_tokens:
2672 # Fail for wrong authentication data
2673 error_messages[url] = _('Authentication failed')
2674 failed.append(url)
2675 else:
2676 # Queue for login dialog later
2677 authreq.append(url)
2678 continue
2679 except feedcore.WifiLogin, error:
2680 redirections[url] = error.data
2681 failed.append(url)
2682 error_messages[url] = _('Redirection detected')
2683 continue
2684 except Exception, e:
2685 log('Subscription error: %s', e, traceback=True, sender=self)
2686 error_messages[url] = str(e)
2687 failed.append(url)
2688 continue
2690 assert channel is not None
2691 worked.append(channel.url)
2692 self.channels.append(channel)
2693 self.channel_list_changed = True
2694 util.idle_add(on_after_update)
2695 threading.Thread(target=thread_proc).start()
2697 def save_channels_opml(self):
2698 exporter = opml.Exporter(gpodder.subscription_file)
2699 return exporter.write(self.channels)
2701 def find_episode(self, podcast_url, episode_url):
2702 """Find an episode given its podcast and episode URL
2704 The function will return a PodcastEpisode object if
2705 the episode is found, or None if it's not found.
2707 for podcast in self.channels:
2708 if podcast_url == podcast.url:
2709 for episode in podcast.get_all_episodes():
2710 if episode_url == episode.url:
2711 return episode
2713 return None
2715 def process_received_episode_actions(self, updated_urls):
2716 """Process/merge episode actions from gpodder.net
2718 This function will merge all changes received from
2719 the server to the local database and update the
2720 status of the affected episodes as necessary.
2722 indicator = ProgressIndicator(_('Merging episode actions'), \
2723 _('Episode actions from gpodder.net are merged.'), \
2724 False, self.get_dialog_parent())
2726 for idx, action in enumerate(self.mygpo_client.get_episode_actions(updated_urls)):
2727 if action.action == 'play':
2728 episode = self.find_episode(action.podcast_url, \
2729 action.episode_url)
2731 if episode is not None:
2732 log('Play action for %s', episode.url, sender=self)
2733 episode.mark(is_played=True)
2735 if action.timestamp > episode.current_position_updated and \
2736 action.position is not None:
2737 log('Updating position for %s', episode.url, sender=self)
2738 episode.current_position = action.position
2739 episode.current_position_updated = action.timestamp
2741 if action.total:
2742 log('Updating total time for %s', episode.url, sender=self)
2743 episode.total_time = action.total
2745 episode.save()
2746 elif action.action == 'delete':
2747 episode = self.find_episode(action.podcast_url, \
2748 action.episode_url)
2750 if episode is not None:
2751 if not episode.was_downloaded(and_exists=True):
2752 # Set the episode to a "deleted" state
2753 log('Marking as deleted: %s', episode.url, sender=self)
2754 episode.delete_from_disk()
2755 episode.save()
2757 indicator.on_message(N_('%d action processed', '%d actions processed', idx) % idx)
2758 gtk.main_iteration(False)
2760 indicator.on_finished()
2761 self.db.commit()
2764 def update_feed_cache_finish_callback(self, updated_urls=None, select_url_afterwards=None):
2765 self.db.commit()
2766 self.updating_feed_cache = False
2768 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2770 # Process received episode actions for all updated URLs
2771 self.process_received_episode_actions(updated_urls)
2773 self.channel_list_changed = True
2774 self.update_podcast_list_model(select_url=select_url_afterwards)
2776 # Only search for new episodes in podcasts that have been
2777 # updated, not in other podcasts (for single-feed updates)
2778 episodes = self.get_new_episodes([c for c in self.channels if c.url in updated_urls])
2780 if gpodder.ui.fremantle:
2781 self.fancy_progress_bar.hide()
2782 self.button_subscribe.set_sensitive(True)
2783 self.button_refresh.set_sensitive(True)
2784 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
2785 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
2786 self.update_podcasts_tab()
2787 self.update_episode_list_model()
2788 if self.feed_cache_update_cancelled:
2789 return
2791 def application_in_foreground():
2792 try:
2793 return any(w.get_property('is-topmost') for w in hildon.WindowStack.get_default().get_windows())
2794 except Exception, e:
2795 log('Could not determine is-topmost', traceback=True)
2796 # When in doubt, assume not in foreground
2797 return False
2799 if episodes:
2800 if self.config.auto_download == 'quiet' and not self.config.auto_update_feeds:
2801 # New episodes found, but we should do nothing
2802 self.show_message(_('New episodes are available.'))
2803 elif self.config.auto_download == 'always':
2804 count = len(episodes)
2805 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2806 self.show_message(title)
2807 self.download_episode_list(episodes)
2808 elif self.config.auto_download == 'queue':
2809 self.show_message(_('New episodes have been added to the download list.'))
2810 self.download_episode_list_paused(episodes)
2811 elif application_in_foreground():
2812 if not self._fremantle_notification_visible:
2813 self.new_episodes_show(episodes)
2814 elif not self._fremantle_notification_visible:
2815 try:
2816 import pynotify
2817 pynotify.init('gPodder')
2818 n = pynotify.Notification('gPodder', _('New episodes available'), 'gpodder')
2819 n.set_urgency(pynotify.URGENCY_CRITICAL)
2820 n.set_hint('dbus-callback-default', ' '.join([
2821 gpodder.dbus_bus_name,
2822 gpodder.dbus_gui_object_path,
2823 gpodder.dbus_interface,
2824 'offer_new_episodes',
2826 n.set_category('gpodder-new-episodes')
2827 n.show()
2828 self._fremantle_notification_visible = True
2829 except Exception, e:
2830 log('Error: %s', str(e), sender=self, traceback=True)
2831 self.new_episodes_show(episodes)
2832 self._fremantle_notification_visible = False
2833 elif not self.config.auto_update_feeds:
2834 self.show_message(_('No new episodes. Please check for new episodes later.'))
2835 return
2837 if self.tray_icon:
2838 self.tray_icon.set_status()
2840 if self.feed_cache_update_cancelled:
2841 # The user decided to abort the feed update
2842 self.show_update_feeds_buttons()
2843 elif not episodes:
2844 # Nothing new here - but inform the user
2845 self.pbFeedUpdate.set_fraction(1.0)
2846 self.pbFeedUpdate.set_text(_('No new episodes'))
2847 self.feed_cache_update_cancelled = True
2848 self.btnCancelFeedUpdate.show()
2849 self.btnCancelFeedUpdate.set_sensitive(True)
2850 self.itemUpdate.set_sensitive(True)
2851 if gpodder.ui.maemo:
2852 # btnCancelFeedUpdate is a ToolButton on Maemo
2853 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_APPLY)
2854 else:
2855 # btnCancelFeedUpdate is a normal gtk.Button
2856 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
2857 else:
2858 count = len(episodes)
2859 # New episodes are available
2860 self.pbFeedUpdate.set_fraction(1.0)
2861 # Are we minimized and should we auto download?
2862 if (self.is_iconified() and (self.config.auto_download == 'minimized')) or (self.config.auto_download == 'always'):
2863 self.download_episode_list(episodes)
2864 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2865 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2866 self.show_update_feeds_buttons()
2867 elif self.config.auto_download == 'queue':
2868 self.download_episode_list_paused(episodes)
2869 title = N_('%d new episode added to download list.', '%d new episodes added to download list.', count) % count
2870 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2871 self.show_update_feeds_buttons()
2872 else:
2873 self.show_update_feeds_buttons()
2874 # New episodes are available and we are not minimized
2875 if not self.config.do_not_show_new_episodes_dialog:
2876 self.new_episodes_show(episodes, notification=True)
2877 else:
2878 message = N_('%d new episode available', '%d new episodes available', count) % count
2879 self.pbFeedUpdate.set_text(message)
2881 def _update_cover(self, channel):
2882 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
2883 self.cover_downloader.request_cover(channel)
2885 def update_feed_cache_proc(self, channels, select_url_afterwards):
2886 total = len(channels)
2888 for updated, channel in enumerate(channels):
2889 if not self.feed_cache_update_cancelled:
2890 try:
2891 channel.update(max_episodes=self.config.max_episodes_per_feed, \
2892 mimetype_prefs=self.config.mimetype_prefs)
2893 self._update_cover(channel)
2894 except Exception, e:
2895 d = {'url': saxutils.escape(channel.url), 'message': saxutils.escape(str(e))}
2896 if d['message']:
2897 message = _('Error while updating %(url)s: %(message)s')
2898 else:
2899 message = _('The feed at %(url)s could not be updated.')
2900 self.notification(message % d, _('Error while updating feed'), widget=self.treeChannels)
2901 log('Error: %s', str(e), sender=self, traceback=True)
2903 if self.feed_cache_update_cancelled:
2904 break
2906 # By the time we get here the update may have already been cancelled
2907 if not self.feed_cache_update_cancelled:
2908 def update_progress():
2909 d = {'podcast': channel.title, 'position': updated+1, 'total': total}
2910 progression = _('Updated %(podcast)s (%(position)d/%(total)d)') % d
2911 self.pbFeedUpdate.set_text(progression)
2912 if self.tray_icon:
2913 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression)
2914 self.pbFeedUpdate.set_fraction(float(updated+1)/float(total))
2915 util.idle_add(update_progress)
2917 updated_urls = [c.url for c in channels]
2918 util.idle_add(self.update_feed_cache_finish_callback, updated_urls, select_url_afterwards)
2920 def show_update_feeds_buttons(self):
2921 # Make sure that the buttons for updating feeds
2922 # appear - this should happen after a feed update
2923 if gpodder.ui.maemo:
2924 self.btnUpdateSelectedFeed.show()
2925 self.toolFeedUpdateProgress.hide()
2926 self.btnCancelFeedUpdate.hide()
2927 self.btnCancelFeedUpdate.set_is_important(False)
2928 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_CLOSE)
2929 self.toolbarSpacer.set_expand(True)
2930 self.toolbarSpacer.set_draw(False)
2931 else:
2932 self.hboxUpdateFeeds.hide()
2933 self.btnUpdateFeeds.show()
2934 self.itemUpdate.set_sensitive(True)
2935 self.itemUpdateChannel.set_sensitive(True)
2937 def on_btnCancelFeedUpdate_clicked(self, widget):
2938 if not self.feed_cache_update_cancelled:
2939 self.pbFeedUpdate.set_text(_('Cancelling...'))
2940 self.feed_cache_update_cancelled = True
2941 if not gpodder.ui.fremantle:
2942 self.btnCancelFeedUpdate.set_sensitive(False)
2943 elif not gpodder.ui.fremantle:
2944 self.show_update_feeds_buttons()
2946 def update_feed_cache(self, channels=None, force_update=True, select_url_afterwards=None):
2947 if self.updating_feed_cache:
2948 if gpodder.ui.fremantle:
2949 self.feed_cache_update_cancelled = True
2950 return
2952 if not force_update:
2953 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2954 self.channel_list_changed = True
2955 self.update_podcast_list_model(select_url=select_url_afterwards)
2956 return
2958 # Fix URLs if mygpo has rewritten them
2959 self.rewrite_urls_mygpo()
2961 self.updating_feed_cache = True
2963 if channels is None:
2964 # Only update podcasts for which updates are enabled
2965 channels = [c for c in self.channels if c.feed_update_enabled]
2967 if gpodder.ui.fremantle:
2968 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
2969 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
2970 self.fancy_progress_bar.show()
2971 self.button_subscribe.set_sensitive(False)
2972 self.button_refresh.set_sensitive(False)
2973 self.feed_cache_update_cancelled = False
2974 else:
2975 self.itemUpdate.set_sensitive(False)
2976 self.itemUpdateChannel.set_sensitive(False)
2978 if self.tray_icon:
2979 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
2981 self.feed_cache_update_cancelled = False
2982 self.btnCancelFeedUpdate.show()
2983 self.btnCancelFeedUpdate.set_sensitive(True)
2984 if gpodder.ui.maemo:
2985 self.toolbarSpacer.set_expand(False)
2986 self.toolbarSpacer.set_draw(True)
2987 self.btnUpdateSelectedFeed.hide()
2988 self.toolFeedUpdateProgress.show_all()
2989 else:
2990 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
2991 self.hboxUpdateFeeds.show_all()
2992 self.btnUpdateFeeds.hide()
2994 if len(channels) == 1:
2995 text = _('Updating "%s"...') % channels[0].title
2996 else:
2997 count = len(channels)
2998 text = N_('Updating %d feed...', 'Updating %d feeds...', count) % count
2999 self.pbFeedUpdate.set_text(text)
3000 self.pbFeedUpdate.set_fraction(0)
3002 args = (channels, select_url_afterwards)
3003 threading.Thread(target=self.update_feed_cache_proc, args=args).start()
3005 def on_gPodder_delete_event(self, widget, *args):
3006 """Called when the GUI wants to close the window
3007 Displays a confirmation dialog (and closes/hides gPodder)
3010 downloading = self.download_status_model.are_downloads_in_progress()
3012 # Only iconify if we are using the window's "X" button,
3013 # but not when we are using "Quit" in the menu or toolbar
3014 if self.config.on_quit_systray and self.tray_icon and widget.get_name() not in ('toolQuit', 'itemQuit'):
3015 self.iconify_main_window()
3016 elif self.config.on_quit_ask or downloading:
3017 if gpodder.ui.fremantle:
3018 self.close_gpodder()
3019 elif gpodder.ui.diablo:
3020 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
3021 if result:
3022 self.close_gpodder()
3023 else:
3024 return True
3025 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
3026 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3027 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
3029 title = _('Quit gPodder')
3030 if downloading:
3031 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
3032 else:
3033 message = _('Do you really want to quit gPodder now?')
3035 dialog.set_title(title)
3036 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
3037 if not downloading:
3038 cb_ask = gtk.CheckButton(_("Don't ask me again"))
3039 dialog.vbox.pack_start(cb_ask)
3040 cb_ask.show_all()
3042 quit_button.grab_focus()
3043 result = dialog.run()
3044 dialog.destroy()
3046 if result == gtk.RESPONSE_CLOSE:
3047 if not downloading and cb_ask.get_active() == True:
3048 self.config.on_quit_ask = False
3049 self.close_gpodder()
3050 else:
3051 self.close_gpodder()
3053 return True
3055 def close_gpodder(self):
3056 """ clean everything and exit properly
3058 if self.channels:
3059 if self.save_channels_opml():
3060 pass # FIXME: Add mygpo synchronization here
3061 else:
3062 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'), important=True)
3064 self.gPodder.hide()
3066 if self.tray_icon is not None:
3067 self.tray_icon.set_visible(False)
3069 # Notify all tasks to to carry out any clean-up actions
3070 self.download_status_model.tell_all_tasks_to_quit()
3072 while gtk.events_pending():
3073 gtk.main_iteration(False)
3075 self.db.close()
3077 self.quit()
3078 sys.exit(0)
3080 def get_expired_episodes(self):
3081 for channel in self.channels:
3082 for episode in channel.get_downloaded_episodes():
3083 # Never consider locked episodes as old
3084 if episode.is_locked:
3085 continue
3087 # Never consider fresh episodes as old
3088 if episode.age_in_days() < self.config.episode_old_age:
3089 continue
3091 # Do not delete played episodes (except if configured)
3092 if episode.is_played:
3093 if not self.config.auto_remove_played_episodes:
3094 continue
3096 # Do not delete unplayed episodes (except if configured)
3097 if not episode.is_played:
3098 if not self.config.auto_remove_unplayed_episodes:
3099 continue
3101 yield episode
3103 def delete_episode_list(self, episodes, confirm=True, skip_locked=True):
3104 if not episodes:
3105 return False
3107 if skip_locked:
3108 episodes = [e for e in episodes if not e.is_locked]
3110 if not episodes:
3111 title = _('Episodes are locked')
3112 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
3113 self.notification(message, title, widget=self.treeAvailable)
3114 return False
3116 count = len(episodes)
3117 title = N_('Delete %d episode?', 'Delete %d episodes?', count) % count
3118 message = _('Deleting episodes removes downloaded files.')
3120 if gpodder.ui.fremantle:
3121 message = '\n'.join([title, message])
3123 if confirm and not self.show_confirmation(message, title):
3124 return False
3126 progress = ProgressIndicator(_('Deleting episodes'), \
3127 _('Please wait while episodes are deleted'), \
3128 parent=self.get_dialog_parent())
3130 def finish_deletion(episode_urls, channel_urls):
3131 progress.on_finished()
3133 # Episodes have been deleted - persist the database
3134 self.db.commit()
3136 self.update_episode_list_icons(episode_urls)
3137 self.update_podcast_list_model(channel_urls)
3138 self.play_or_download()
3140 def thread_proc():
3141 episode_urls = set()
3142 channel_urls = set()
3144 episodes_status_update = []
3145 for idx, episode in enumerate(episodes):
3146 progress.on_progress(float(idx)/float(len(episodes)))
3147 if episode.is_locked and skip_locked:
3148 log('Not deleting episode (is locked): %s', episode.title)
3149 else:
3150 log('Deleting episode: %s', episode.title)
3151 progress.on_message(episode.title)
3152 episode.delete_from_disk()
3153 episode_urls.add(episode.url)
3154 channel_urls.add(episode.channel.url)
3155 episodes_status_update.append(episode)
3157 # Tell the shownotes window that we have removed the episode
3158 if self.episode_shownotes_window is not None and \
3159 self.episode_shownotes_window.episode is not None and \
3160 self.episode_shownotes_window.episode.url == episode.url:
3161 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
3163 # Notify the web service about the status update + upload
3164 self.mygpo_client.on_delete(episodes_status_update)
3165 self.mygpo_client.flush()
3167 util.idle_add(finish_deletion, episode_urls, channel_urls)
3169 threading.Thread(target=thread_proc).start()
3171 return True
3173 def on_itemRemoveOldEpisodes_activate( self, widget):
3174 if gpodder.ui.maemo:
3175 columns = (
3176 ('maemo_remove_markup', None, None, _('Episode')),
3178 else:
3179 columns = (
3180 ('title_markup', None, None, _('Episode')),
3181 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3182 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3183 ('played_prop', None, None, _('Status')),
3184 ('age_prop', 'age_int_prop', gobject.TYPE_INT, _('Downloaded')),
3187 msg_older_than = N_('Select older than %d day', 'Select older than %d days', self.config.episode_old_age)
3188 selection_buttons = {
3189 _('Select played'): lambda episode: episode.is_played,
3190 _('Select finished'): lambda episode: episode.is_finished(),
3191 msg_older_than % self.config.episode_old_age: lambda episode: episode.age_in_days() > self.config.episode_old_age,
3194 instructions = _('Select the episodes you want to delete:')
3196 episodes = []
3197 selected = []
3198 for channel in self.channels:
3199 for episode in channel.get_downloaded_episodes():
3200 # Disallow deletion of locked episodes that still exist
3201 if not episode.is_locked or not episode.file_exists():
3202 episodes.append(episode)
3203 # Automatically select played and file-less episodes
3204 selected.append(episode.is_played or \
3205 not episode.file_exists())
3207 gPodderEpisodeSelector(self.gPodder, title = _('Delete episodes'), instructions = instructions, \
3208 episodes = episodes, selected = selected, columns = columns, \
3209 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
3210 selection_buttons = selection_buttons, _config=self.config, \
3211 show_episode_shownotes=self.show_episode_shownotes)
3213 def on_selected_episodes_status_changed(self):
3214 # The order of the updates here is important! When "All episodes" is
3215 # selected, the update of the podcast list model depends on the episode
3216 # list selection to determine which podcasts are affected. Updating
3217 # the episode list could remove the selection if a filter is active.
3218 self.update_podcast_list_model(selected=True)
3219 self.update_episode_list_icons(selected=True)
3220 self.db.commit()
3222 def mark_selected_episodes_new(self):
3223 for episode in self.get_selected_episodes():
3224 episode.mark_new()
3225 self.on_selected_episodes_status_changed()
3227 def mark_selected_episodes_old(self):
3228 for episode in self.get_selected_episodes():
3229 episode.mark_old()
3230 self.on_selected_episodes_status_changed()
3232 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
3233 for episode in self.get_selected_episodes():
3234 if toggle:
3235 episode.mark(is_played=not episode.is_played)
3236 else:
3237 episode.mark(is_played=new_value)
3238 self.on_selected_episodes_status_changed()
3240 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3241 for episode in self.get_selected_episodes():
3242 if toggle:
3243 episode.mark(is_locked=not episode.is_locked)
3244 else:
3245 episode.mark(is_locked=new_value)
3246 self.on_selected_episodes_status_changed()
3248 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3249 if self.active_channel is None:
3250 return
3252 self.active_channel.channel_is_locked = not self.active_channel.channel_is_locked
3253 self.active_channel.update_channel_lock()
3255 for episode in self.active_channel.get_all_episodes():
3256 episode.mark(is_locked=self.active_channel.channel_is_locked)
3258 self.update_podcast_list_model(selected=True)
3259 self.update_episode_list_icons(all=True)
3261 def on_itemUpdateChannel_activate(self, widget=None):
3262 if self.active_channel is None:
3263 title = _('No podcast selected')
3264 message = _('Please select a podcast in the podcasts list to update.')
3265 self.show_message( message, title, widget=self.treeChannels)
3266 return
3268 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3269 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3270 self.update_feed_cache()
3271 else:
3272 self.update_feed_cache(channels=[self.active_channel])
3274 def on_itemUpdate_activate(self, widget=None):
3275 # Check if we have outstanding subscribe/unsubscribe actions
3276 if self.on_add_remove_podcasts_mygpo():
3277 log('Update cancelled (received server changes)', sender=self)
3278 return
3280 if self.channels:
3281 self.update_feed_cache()
3282 else:
3283 gPodderWelcome(self.gPodder,
3284 center_on_widget=self.gPodder,
3285 show_example_podcasts_callback=self.on_itemImportChannels_activate,
3286 setup_my_gpodder_callback=self.on_download_subscriptions_from_mygpo)
3288 def download_episode_list_paused(self, episodes):
3289 self.download_episode_list(episodes, True)
3291 def download_episode_list(self, episodes, add_paused=False, force_start=False):
3292 enable_update = False
3294 for episode in episodes:
3295 log('Downloading episode: %s', episode.title, sender = self)
3296 if not episode.was_downloaded(and_exists=True):
3297 task_exists = False
3298 for task in self.download_tasks_seen:
3299 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
3300 self.download_queue_manager.add_task(task, force_start)
3301 enable_update = True
3302 task_exists = True
3303 continue
3305 if task_exists:
3306 continue
3308 try:
3309 task = download.DownloadTask(episode, self.config)
3310 except Exception, e:
3311 d = {'episode': episode.title, 'message': str(e)}
3312 message = _('Download error while downloading %(episode)s: %(message)s')
3313 self.show_message(message % d, _('Download error'), important=True)
3314 log('Download error while downloading %s', episode.title, sender=self, traceback=True)
3315 continue
3317 if add_paused:
3318 task.status = task.PAUSED
3319 else:
3320 self.mygpo_client.on_download([task.episode])
3321 self.download_queue_manager.add_task(task, force_start)
3323 self.download_status_model.register_task(task)
3324 enable_update = True
3326 if enable_update:
3327 self.enable_download_list_update()
3329 # Flush updated episode status
3330 self.mygpo_client.flush()
3332 def cancel_task_list(self, tasks):
3333 if not tasks:
3334 return
3336 for task in tasks:
3337 if task.status in (task.QUEUED, task.DOWNLOADING):
3338 task.status = task.CANCELLED
3339 elif task.status == task.PAUSED:
3340 task.status = task.CANCELLED
3341 # Call run, so the partial file gets deleted
3342 task.run()
3344 self.update_episode_list_icons([task.url for task in tasks])
3345 self.play_or_download()
3347 # Update the tab title and downloads list
3348 self.update_downloads_list()
3350 def new_episodes_show(self, episodes, notification=False, selected=None):
3351 if gpodder.ui.maemo:
3352 columns = (
3353 ('maemo_markup', None, None, _('Episode')),
3355 show_notification = notification
3356 else:
3357 columns = (
3358 ('title_markup', None, None, _('Episode')),
3359 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
3360 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
3362 show_notification = False
3364 instructions = _('Select the episodes you want to download:')
3366 if self.new_episodes_window is not None:
3367 self.new_episodes_window.main_window.destroy()
3368 self.new_episodes_window = None
3370 def download_episodes_callback(episodes):
3371 self.new_episodes_window = None
3372 self.download_episode_list(episodes)
3374 if selected is None:
3375 # Select all by default
3376 selected = [True]*len(episodes)
3378 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
3379 title=_('New episodes available'), \
3380 instructions=instructions, \
3381 episodes=episodes, \
3382 columns=columns, \
3383 selected=selected, \
3384 stock_ok_button = 'gpodder-download', \
3385 callback=download_episodes_callback, \
3386 remove_callback=lambda e: e.mark_old(), \
3387 remove_action=_('Mark as old'), \
3388 remove_finished=self.episode_new_status_changed, \
3389 _config=self.config, \
3390 show_notification=show_notification, \
3391 show_episode_shownotes=self.show_episode_shownotes)
3393 def on_itemDownloadAllNew_activate(self, widget, *args):
3394 if not self.offer_new_episodes():
3395 self.show_message(_('Please check for new episodes later.'), \
3396 _('No new episodes available'), widget=self.btnUpdateFeeds)
3398 def get_new_episodes(self, channels=None):
3399 if channels is None:
3400 channels = self.channels
3401 episodes = []
3402 for channel in channels:
3403 for episode in channel.get_new_episodes(downloading=self.episode_is_downloading):
3404 episodes.append(episode)
3406 return episodes
3408 @dbus.service.method(gpodder.dbus_interface)
3409 def start_device_synchronization(self):
3410 """Public D-Bus API for starting Device sync (Desktop only)
3412 This method can be called to initiate a synchronization with
3413 a configured protable media player. This only works for the
3414 Desktop version of gPodder and does nothing on Maemo.
3416 if gpodder.ui.desktop:
3417 self.on_sync_to_ipod_activate(None)
3418 return True
3420 return False
3422 def on_sync_to_ipod_activate(self, widget, episodes=None):
3423 self.sync_ui.on_synchronize_episodes(self.channels, episodes)
3425 def commit_changes_to_database(self):
3426 """This will be called after the sync process is finished"""
3427 self.db.commit()
3429 def on_cleanup_ipod_activate(self, widget, *args):
3430 self.sync_ui.on_cleanup_device()
3432 def on_manage_device_playlist(self, widget):
3433 self.sync_ui.on_manage_device_playlist()
3435 def show_hide_tray_icon(self):
3436 if self.config.display_tray_icon and have_trayicon and self.tray_icon is None:
3437 self.tray_icon = GPodderStatusIcon(self, gpodder.icon_file, self.config)
3438 elif not self.config.display_tray_icon and self.tray_icon is not None:
3439 self.tray_icon.set_visible(False)
3440 del self.tray_icon
3441 self.tray_icon = None
3443 if self.config.minimize_to_tray and self.tray_icon:
3444 self.tray_icon.set_visible(self.is_iconified())
3445 elif self.tray_icon:
3446 self.tray_icon.set_visible(True)
3448 def on_itemShowAllEpisodes_activate(self, widget):
3449 self.config.podcast_list_view_all = widget.get_active()
3451 def on_itemShowToolbar_activate(self, widget):
3452 self.config.show_toolbar = self.itemShowToolbar.get_active()
3454 def on_itemShowDescription_activate(self, widget):
3455 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
3457 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
3458 self.config.podcast_list_hide_boring = toggleaction.get_active()
3459 if self.config.podcast_list_hide_boring:
3460 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3461 else:
3462 self.podcast_list_model.set_view_mode(-1)
3464 def on_item_view_podcasts_changed(self, radioaction, current):
3465 # Only on Fremantle
3466 if current == self.item_view_podcasts_all:
3467 self.podcast_list_model.set_view_mode(-1)
3468 elif current == self.item_view_podcasts_downloaded:
3469 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3470 elif current == self.item_view_podcasts_unplayed:
3471 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3473 self.config.podcast_list_view_mode = self.podcast_list_model.get_view_mode()
3475 def on_item_view_episodes_changed(self, radioaction, current):
3476 if current == self.item_view_episodes_all:
3477 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_ALL)
3478 elif current == self.item_view_episodes_undeleted:
3479 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNDELETED)
3480 elif current == self.item_view_episodes_downloaded:
3481 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
3482 elif current == self.item_view_episodes_unplayed:
3483 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
3485 self.config.episode_list_view_mode = self.episode_list_model.get_view_mode()
3487 if self.config.podcast_list_hide_boring and not gpodder.ui.fremantle:
3488 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3490 def update_item_device( self):
3491 if not gpodder.ui.fremantle:
3492 if self.config.device_type != 'none':
3493 self.itemDevice.set_visible(True)
3494 self.itemDevice.label = self.get_device_name()
3495 else:
3496 self.itemDevice.set_visible(False)
3498 def properties_closed( self):
3499 self.preferences_dialog = None
3500 self.show_hide_tray_icon()
3501 self.update_item_device()
3502 if gpodder.ui.maemo:
3503 selection = self.treeAvailable.get_selection()
3504 if self.config.maemo_enable_gestures or \
3505 self.config.enable_fingerscroll:
3506 selection.set_mode(gtk.SELECTION_SINGLE)
3507 else:
3508 selection.set_mode(gtk.SELECTION_MULTIPLE)
3510 def on_itemPreferences_activate(self, widget, *args):
3511 self.preferences_dialog = gPodderPreferences(self.main_window, \
3512 _config=self.config, \
3513 callback_finished=self.properties_closed, \
3514 user_apps_reader=self.user_apps_reader, \
3515 parent_window=self.main_window, \
3516 mygpo_client=self.mygpo_client, \
3517 on_send_full_subscriptions=self.on_send_full_subscriptions)
3519 # Initial message to relayout window (in case it's opened in portrait mode
3520 self.preferences_dialog.on_window_orientation_changed(self._last_orientation)
3522 def on_itemDependencies_activate(self, widget):
3523 gPodderDependencyManager(self.gPodder)
3525 def on_goto_mygpo(self, widget):
3526 self.mygpo_client.open_website()
3528 def on_download_subscriptions_from_mygpo(self, action=None):
3529 title = _('Login to gpodder.net')
3530 message = _('Please login to download your subscriptions.')
3531 success, (username, password) = self.show_login_dialog(title, message, \
3532 self.config.mygpo_username, self.config.mygpo_password)
3533 if not success:
3534 return
3536 self.config.mygpo_username = username
3537 self.config.mygpo_password = password
3539 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3540 custom_title=_('Subscriptions on gpodder.net'), \
3541 add_urls_callback=self.add_podcast_list, \
3542 hide_url_entry=True)
3544 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
3545 # we do not have to hardcode the URL here
3546 OPML_URL = 'http://gpodder.net/subscriptions/%s.opml' % self.config.mygpo_username
3547 url = util.url_add_authentication(OPML_URL, \
3548 self.config.mygpo_username, \
3549 self.config.mygpo_password)
3550 dir.download_opml_file(url)
3552 def on_mygpo_settings_activate(self, action=None):
3553 # This dialog is only used for Maemo 4
3554 if not gpodder.ui.diablo:
3555 return
3557 settings = MygPodderSettings(self.main_window, \
3558 config=self.config, \
3559 mygpo_client=self.mygpo_client, \
3560 on_send_full_subscriptions=self.on_send_full_subscriptions)
3562 def on_itemAddChannel_activate(self, widget=None):
3563 gPodderAddPodcast(self.gPodder, \
3564 add_urls_callback=self.add_podcast_list)
3566 def on_itemEditChannel_activate(self, widget, *args):
3567 if self.active_channel is None:
3568 title = _('No podcast selected')
3569 message = _('Please select a podcast in the podcasts list to edit.')
3570 self.show_message( message, title, widget=self.treeChannels)
3571 return
3573 callback_closed = lambda: self.update_podcast_list_model(selected=True)
3574 gPodderChannel(self.main_window, \
3575 channel=self.active_channel, \
3576 callback_closed=callback_closed, \
3577 cover_downloader=self.cover_downloader)
3579 def on_itemMassUnsubscribe_activate(self, item=None):
3580 columns = (
3581 ('title', None, None, _('Podcast')),
3584 # We're abusing the Episode Selector for selecting Podcasts here,
3585 # but it works and looks good, so why not? -- thp
3586 gPodderEpisodeSelector(self.main_window, \
3587 title=_('Remove podcasts'), \
3588 instructions=_('Select the podcast you want to remove.'), \
3589 episodes=self.channels, \
3590 columns=columns, \
3591 size_attribute=None, \
3592 stock_ok_button=_('Remove'), \
3593 callback=self.remove_podcast_list, \
3594 _config=self.config)
3596 def remove_podcast_list(self, channels, confirm=True):
3597 if not channels:
3598 log('No podcasts selected for deletion', sender=self)
3599 return
3601 if len(channels) == 1:
3602 title = _('Removing podcast')
3603 info = _('Please wait while the podcast is removed')
3604 message = _('Do you really want to remove this podcast and its episodes?')
3605 else:
3606 title = _('Removing podcasts')
3607 info = _('Please wait while the podcasts are removed')
3608 message = _('Do you really want to remove the selected podcasts and their episodes?')
3610 if confirm and not self.show_confirmation(message, title):
3611 return
3613 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3615 def finish_deletion(select_url):
3616 # Upload subscription list changes to the web service
3617 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3619 # Re-load the channels and select the desired new channel
3620 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
3621 progress.on_finished()
3622 self.update_podcasts_tab()
3624 def thread_proc():
3625 select_url = None
3627 for idx, channel in enumerate(channels):
3628 # Update the UI for correct status messages
3629 progress.on_progress(float(idx)/float(len(channels)))
3630 progress.on_message(channel.title)
3632 # Delete downloaded episodes
3633 channel.remove_downloaded()
3635 # cancel any active downloads from this channel
3636 for episode in channel.get_all_episodes():
3637 util.idle_add(self.download_status_model.cancel_by_url,
3638 episode.url)
3640 if len(channels) == 1:
3641 # get the URL of the podcast we want to select next
3642 if channel in self.channels:
3643 position = self.channels.index(channel)
3644 else:
3645 position = -1
3647 if position == len(self.channels)-1:
3648 # this is the last podcast, so select the URL
3649 # of the item before this one (i.e. the "new last")
3650 select_url = self.channels[position-1].url
3651 else:
3652 # there is a podcast after the deleted one, so
3653 # we simply select the one that comes after it
3654 select_url = self.channels[position+1].url
3656 # Remove the channel and clean the database entries
3657 channel.delete()
3658 self.channels.remove(channel)
3660 # Clean up downloads and download directories
3661 self.clean_up_downloads()
3663 self.channel_list_changed = True
3664 self.save_channels_opml()
3666 # The remaining stuff is to be done in the GTK main thread
3667 util.idle_add(finish_deletion, select_url)
3669 threading.Thread(target=thread_proc).start()
3671 def on_itemRemoveChannel_activate(self, widget, *args):
3672 if self.active_channel is None:
3673 title = _('No podcast selected')
3674 message = _('Please select a podcast in the podcasts list to remove.')
3675 self.show_message( message, title, widget=self.treeChannels)
3676 return
3678 self.remove_podcast_list([self.active_channel])
3680 def get_opml_filter(self):
3681 filter = gtk.FileFilter()
3682 filter.add_pattern('*.opml')
3683 filter.add_pattern('*.xml')
3684 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
3685 return filter
3687 def on_item_import_from_file_activate(self, widget, filename=None):
3688 if filename is None:
3689 if gpodder.ui.desktop or gpodder.ui.fremantle:
3690 # FIXME: Hildonization on Fremantle
3691 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
3692 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3693 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3694 elif gpodder.ui.diablo:
3695 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
3696 dlg.set_filter(self.get_opml_filter())
3697 response = dlg.run()
3698 filename = None
3699 if response == gtk.RESPONSE_OK:
3700 filename = dlg.get_filename()
3701 dlg.destroy()
3703 if filename is not None:
3704 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3705 custom_title=_('Import podcasts from OPML file'), \
3706 add_urls_callback=self.add_podcast_list, \
3707 hide_url_entry=True)
3708 dir.download_opml_file(filename)
3710 def on_itemExportChannels_activate(self, widget, *args):
3711 if not self.channels:
3712 title = _('Nothing to export')
3713 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3714 self.show_message(message, title, widget=self.treeChannels)
3715 return
3717 if gpodder.ui.desktop or gpodder.ui.fremantle:
3718 # FIXME: Hildonization on Fremantle
3719 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3720 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3721 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3722 elif gpodder.ui.diablo:
3723 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
3724 dlg.set_filter(self.get_opml_filter())
3725 response = dlg.run()
3726 if response == gtk.RESPONSE_OK:
3727 filename = dlg.get_filename()
3728 dlg.destroy()
3729 exporter = opml.Exporter( filename)
3730 if exporter.write(self.channels):
3731 count = len(self.channels)
3732 title = N_('%d subscription exported', '%d subscriptions exported', count) % count
3733 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3734 else:
3735 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3736 else:
3737 dlg.destroy()
3739 def on_itemImportChannels_activate(self, widget, *args):
3740 if gpodder.ui.fremantle:
3741 gPodderPodcastDirectory.show_add_podcast_picker(self.main_window, \
3742 self.config.toplist_url, \
3743 self.config.opml_url, \
3744 self.add_podcast_list, \
3745 self.on_itemAddChannel_activate, \
3746 self.on_download_subscriptions_from_mygpo, \
3747 self.show_text_edit_dialog)
3748 else:
3749 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3750 add_urls_callback=self.add_podcast_list)
3751 util.idle_add(dir.download_opml_file, self.config.opml_url)
3753 def on_homepage_activate(self, widget, *args):
3754 util.open_website(gpodder.__url__)
3756 def on_wiki_activate(self, widget, *args):
3757 util.open_website('http://gpodder.org/wiki/User_Manual')
3759 def on_bug_tracker_activate(self, widget, *args):
3760 if gpodder.ui.maemo:
3761 util.open_website('http://bugs.maemo.org/enter_bug.cgi?product=gPodder')
3762 else:
3763 util.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder')
3765 def on_item_support_activate(self, widget):
3766 util.open_website('http://gpodder.org/donate')
3768 def on_itemAbout_activate(self, widget, *args):
3769 if gpodder.ui.fremantle:
3770 from gpodder.gtkui.frmntl.about import HeAboutDialog
3771 HeAboutDialog.present(self.main_window,
3772 'gPodder',
3773 'gpodder',
3774 gpodder.__version__,
3775 _('A podcast client with focus on usability'),
3776 gpodder.__copyright__,
3777 gpodder.__url__,
3778 'http://bugs.maemo.org/enter_bug.cgi?product=gPodder',
3779 'http://gpodder.org/donate')
3780 return
3782 dlg = gtk.AboutDialog()
3783 dlg.set_transient_for(self.main_window)
3784 dlg.set_name('gPodder')
3785 dlg.set_version(gpodder.__version__)
3786 dlg.set_copyright(gpodder.__copyright__)
3787 dlg.set_comments(_('A podcast client with focus on usability'))
3788 dlg.set_website(gpodder.__url__)
3789 dlg.set_translator_credits( _('translator-credits'))
3790 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
3792 if gpodder.ui.desktop:
3793 # For the "GUI" version, we add some more
3794 # items to the about dialog (credits and logo)
3795 app_authors = [
3796 _('Maintainer:'),
3797 'Thomas Perl <thpinfo.com>',
3800 if os.path.exists(gpodder.credits_file):
3801 credits = open(gpodder.credits_file).read().strip().split('\n')
3802 app_authors += ['', _('Patches, bug reports and donations by:')]
3803 app_authors += credits
3805 dlg.set_authors(app_authors)
3806 try:
3807 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(gpodder.icon_file))
3808 except:
3809 dlg.set_logo_icon_name('gpodder')
3811 dlg.run()
3813 def on_wNotebook_switch_page(self, widget, *args):
3814 page_num = args[1]
3815 if gpodder.ui.maemo:
3816 self.tool_downloads.set_active(page_num == 1)
3817 page = self.wNotebook.get_nth_page(page_num)
3818 tab_label = self.wNotebook.get_tab_label(page).get_text()
3819 if page_num == 0 and self.active_channel is not None:
3820 self.set_title(self.active_channel.title)
3821 else:
3822 self.set_title(tab_label)
3823 if page_num == 0:
3824 self.play_or_download()
3825 self.menuChannels.set_sensitive(True)
3826 self.menuSubscriptions.set_sensitive(True)
3827 # The message area in the downloads tab should be hidden
3828 # when the user switches away from the downloads tab
3829 if self.message_area is not None:
3830 self.message_area.hide()
3831 self.message_area = None
3832 else:
3833 self.menuChannels.set_sensitive(False)
3834 self.menuSubscriptions.set_sensitive(False)
3835 if gpodder.ui.desktop:
3836 self.toolDownload.set_sensitive(False)
3837 self.toolPlay.set_sensitive(False)
3838 self.toolTransfer.set_sensitive(False)
3839 self.toolCancel.set_sensitive(False)
3841 def on_treeChannels_row_activated(self, widget, path, *args):
3842 # double-click action of the podcast list or enter
3843 self.treeChannels.set_cursor(path)
3845 def on_treeChannels_cursor_changed(self, widget, *args):
3846 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3848 if model is not None and iter is not None:
3849 old_active_channel = self.active_channel
3850 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3852 if self.active_channel == old_active_channel:
3853 return
3855 if gpodder.ui.maemo:
3856 self.set_title(self.active_channel.title)
3858 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3859 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3860 self.itemEditChannel.set_visible(False)
3861 self.itemRemoveChannel.set_visible(False)
3862 else:
3863 self.itemEditChannel.set_visible(True)
3864 self.itemRemoveChannel.set_visible(True)
3865 else:
3866 self.active_channel = None
3867 self.itemEditChannel.set_visible(False)
3868 self.itemRemoveChannel.set_visible(False)
3870 self.update_episode_list_model()
3872 def on_btnEditChannel_clicked(self, widget, *args):
3873 self.on_itemEditChannel_activate( widget, args)
3875 def get_podcast_urls_from_selected_episodes(self):
3876 """Get a set of podcast URLs based on the selected episodes"""
3877 return set(episode.channel.url for episode in \
3878 self.get_selected_episodes())
3880 def get_selected_episodes(self):
3881 """Get a list of selected episodes from treeAvailable"""
3882 selection = self.treeAvailable.get_selection()
3883 model, paths = selection.get_selected_rows()
3885 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3886 return episodes
3888 def on_transfer_selected_episodes(self, widget):
3889 self.on_sync_to_ipod_activate(widget, self.get_selected_episodes())
3891 def on_playback_selected_episodes(self, widget):
3892 self.playback_episodes(self.get_selected_episodes())
3894 def on_shownotes_selected_episodes(self, widget):
3895 episodes = self.get_selected_episodes()
3896 if episodes:
3897 episode = episodes.pop(0)
3898 self.show_episode_shownotes(episode)
3899 else:
3900 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3902 def on_download_selected_episodes(self, widget):
3903 episodes = self.get_selected_episodes()
3904 self.download_episode_list(episodes)
3905 self.update_episode_list_icons([episode.url for episode in episodes])
3906 self.play_or_download()
3908 def on_treeAvailable_row_activated(self, widget, path, view_column):
3909 """Double-click/enter action handler for treeAvailable"""
3910 # We should only have one one selected as it was double clicked!
3911 e = self.get_selected_episodes()[0]
3913 if (self.config.double_click_episode_action == 'download'):
3914 # If the episode has already been downloaded and exists then play it
3915 if e.was_downloaded(and_exists=True):
3916 self.playback_episodes(self.get_selected_episodes())
3917 # else download it if it is not already downloading
3918 elif not self.episode_is_downloading(e):
3919 self.download_episode_list([e])
3920 self.update_episode_list_icons([e.url])
3921 self.play_or_download()
3922 elif (self.config.double_click_episode_action == 'stream'):
3923 # If we happen to have downloaded this episode simple play it
3924 if e.was_downloaded(and_exists=True):
3925 self.playback_episodes(self.get_selected_episodes())
3926 # else if streaming is possible stream it
3927 elif self.streaming_possible():
3928 self.playback_episodes(self.get_selected_episodes())
3929 else:
3930 log('Unable to stream episode - default media player selected!', sender=self, traceback=True)
3931 self.show_message(_('Please check your media player settings in the preferences dialog.'), _('Unable to stream episode'), widget=self.toolPreferences)
3932 else:
3933 # default action is to display show notes
3934 self.on_shownotes_selected_episodes(widget)
3936 def show_episode_shownotes(self, episode):
3937 if self.episode_shownotes_window is None:
3938 log('First-time use of episode window --- creating', sender=self)
3939 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3940 _download_episode_list=self.download_episode_list, \
3941 _playback_episodes=self.playback_episodes, \
3942 _delete_episode_list=self.delete_episode_list, \
3943 _episode_list_status_changed=self.episode_list_status_changed, \
3944 _cancel_task_list=self.cancel_task_list, \
3945 _episode_is_downloading=self.episode_is_downloading, \
3946 _streaming_possible=self.streaming_possible())
3947 self.episode_shownotes_window.show(episode)
3948 if self.episode_is_downloading(episode):
3949 self.update_downloads_list()
3951 def restart_auto_update_timer(self):
3952 if self._auto_update_timer_source_id is not None:
3953 log('Removing existing auto update timer.', sender=self)
3954 gobject.source_remove(self._auto_update_timer_source_id)
3955 self._auto_update_timer_source_id = None
3957 if self.config.auto_update_feeds and \
3958 self.config.auto_update_frequency:
3959 interval = 60*1000*self.config.auto_update_frequency
3960 log('Setting up auto update timer with interval %d.', \
3961 self.config.auto_update_frequency, sender=self)
3962 self._auto_update_timer_source_id = gobject.timeout_add(\
3963 interval, self._on_auto_update_timer)
3965 def _on_auto_update_timer(self):
3966 log('Auto update timer fired.', sender=self)
3967 self.update_feed_cache(force_update=True)
3969 # Ask web service for sub changes (if enabled)
3970 self.mygpo_client.flush()
3972 return True
3974 def on_treeDownloads_row_activated(self, widget, *args):
3975 # Use the standard way of working on the treeview
3976 selection = self.treeDownloads.get_selection()
3977 (model, paths) = selection.get_selected_rows()
3978 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3980 for tree_row_reference, task in selected_tasks:
3981 if task.status in (task.DOWNLOADING, task.QUEUED):
3982 task.status = task.PAUSED
3983 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3984 self.download_queue_manager.add_task(task)
3985 self.enable_download_list_update()
3986 elif task.status == task.DONE:
3987 model.remove(model.get_iter(tree_row_reference.get_path()))
3989 self.play_or_download()
3991 # Update the tab title and downloads list
3992 self.update_downloads_list()
3994 def on_item_cancel_download_activate(self, widget):
3995 if self.wNotebook.get_current_page() == 0:
3996 selection = self.treeAvailable.get_selection()
3997 (model, paths) = selection.get_selected_rows()
3998 urls = [model.get_value(model.get_iter(path), \
3999 self.episode_list_model.C_URL) for path in paths]
4000 selected_tasks = [task for task in self.download_tasks_seen \
4001 if task.url in urls]
4002 else:
4003 selection = self.treeDownloads.get_selection()
4004 (model, paths) = selection.get_selected_rows()
4005 selected_tasks = [model.get_value(model.get_iter(path), \
4006 self.download_status_model.C_TASK) for path in paths]
4007 self.cancel_task_list(selected_tasks)
4009 def on_btnCancelAll_clicked(self, widget, *args):
4010 self.cancel_task_list(self.download_tasks_seen)
4012 def on_btnDownloadedDelete_clicked(self, widget, *args):
4013 episodes = self.get_selected_episodes()
4014 if len(episodes) == 1:
4015 self.delete_episode_list(episodes, skip_locked=False)
4016 else:
4017 self.delete_episode_list(episodes)
4019 def on_key_press(self, widget, event):
4020 # Allow tab switching with Ctrl + PgUp/PgDown
4021 if event.state & gtk.gdk.CONTROL_MASK:
4022 if event.keyval == gtk.keysyms.Page_Up:
4023 self.wNotebook.prev_page()
4024 return True
4025 elif event.keyval == gtk.keysyms.Page_Down:
4026 self.wNotebook.next_page()
4027 return True
4029 # After this code we only handle Maemo hardware keys,
4030 # so if we are not a Maemo app, we don't do anything
4031 if not gpodder.ui.maemo:
4032 return False
4034 diff = 0
4035 if event.keyval == gtk.keysyms.F7: #plus
4036 diff = 1
4037 elif event.keyval == gtk.keysyms.F8: #minus
4038 diff = -1
4040 if diff != 0 and not self.currently_updating:
4041 selection = self.treeChannels.get_selection()
4042 (model, iter) = selection.get_selected()
4043 new_path = ((model.get_path(iter)[0]+diff)%len(model),)
4044 selection.select_path(new_path)
4045 self.treeChannels.set_cursor(new_path)
4046 return True
4048 return False
4050 def on_iconify(self):
4051 if self.tray_icon:
4052 self.gPodder.set_skip_taskbar_hint(True)
4053 if self.config.minimize_to_tray:
4054 self.tray_icon.set_visible(True)
4055 else:
4056 self.gPodder.set_skip_taskbar_hint(False)
4058 def on_uniconify(self):
4059 if self.tray_icon:
4060 self.gPodder.set_skip_taskbar_hint(False)
4061 if self.config.minimize_to_tray:
4062 self.tray_icon.set_visible(False)
4063 else:
4064 self.gPodder.set_skip_taskbar_hint(False)
4066 def uniconify_main_window(self):
4067 if self.is_iconified():
4068 # We need to hide and then show the window in WMs like Metacity
4069 # or KWin4 to move the window to the active workspace
4070 # (see http://gpodder.org/bug/1125)
4071 self.gPodder.hide()
4072 self.gPodder.show()
4073 self.gPodder.present()
4075 def iconify_main_window(self):
4076 if not self.is_iconified():
4077 self.gPodder.iconify()
4079 def update_podcasts_tab(self):
4080 if len(self.channels):
4081 if gpodder.ui.fremantle:
4082 self.button_refresh.set_title(_('Check for new episodes'))
4083 self.button_refresh.show()
4084 else:
4085 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
4086 else:
4087 if gpodder.ui.fremantle:
4088 self.button_refresh.hide()
4089 else:
4090 self.label2.set_text(_('Podcasts'))
4092 @dbus.service.method(gpodder.dbus_interface)
4093 def show_gui_window(self):
4094 parent = self.get_dialog_parent()
4095 parent.present()
4097 @dbus.service.method(gpodder.dbus_interface)
4098 def subscribe_to_url(self, url):
4099 gPodderAddPodcast(self.gPodder,
4100 add_urls_callback=self.add_podcast_list,
4101 preset_url=url)
4103 @dbus.service.method(gpodder.dbus_interface)
4104 def mark_episode_played(self, filename):
4105 if filename is None:
4106 return False
4108 for channel in self.channels:
4109 for episode in channel.get_all_episodes():
4110 fn = episode.local_filename(create=False, check_only=True)
4111 if fn == filename:
4112 episode.mark(is_played=True)
4113 self.db.commit()
4114 self.update_episode_list_icons([episode.url])
4115 self.update_podcast_list_model([episode.channel.url])
4116 return True
4118 return False
4121 def main(options=None):
4122 gobject.threads_init()
4123 gobject.set_application_name('gPodder')
4125 if gpodder.ui.maemo:
4126 # Try to enable the custom icon theme for gPodder on Maemo
4127 settings = gtk.settings_get_default()
4128 settings.set_string_property('gtk-icon-theme-name', \
4129 'gpodder', __file__)
4130 # Extend the search path for the optified icon theme (Maemo 5)
4131 icon_theme = gtk.icon_theme_get_default()
4132 icon_theme.prepend_search_path('/opt/gpodder-icon-theme/')
4134 gtk.window_set_default_icon_name('gpodder')
4135 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
4137 try:
4138 dbus_main_loop = dbus.glib.DBusGMainLoop(set_as_default=True)
4139 gpodder.dbus_session_bus = dbus.SessionBus(dbus_main_loop)
4141 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=gpodder.dbus_session_bus)
4142 except dbus.exceptions.DBusException, dbe:
4143 log('Warning: Cannot get "on the bus".', traceback=True)
4144 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
4145 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
4146 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
4147 dlg.set_title('gPodder')
4148 dlg.run()
4149 dlg.destroy()
4150 sys.exit(0)
4152 util.make_directory(gpodder.home)
4153 gpodder.load_plugins()
4155 config = UIConfig(gpodder.config_file)
4157 # Load hook modules and install the hook manager globally
4158 # if modules have been found an instantiated by the manager
4159 user_hooks = hooks.HookManager()
4160 if user_hooks.has_modules():
4161 gpodder.user_hooks = user_hooks
4163 if gpodder.ui.diablo:
4164 # Detect changing of SD cards between mmc1/mmc2 if a gpodder
4165 # folder exists there (allow moving "gpodder" between SD cards or USB)
4166 # Also allow moving "gpodder" to home folder (e.g. rootfs on SD)
4167 if not os.path.exists(config.download_dir):
4168 log('Downloads might have been moved. Trying to locate them...')
4169 for basedir in ['/media/mmc1', '/media/mmc2']+glob.glob('/media/usb/*')+['/home/user/MyDocs']:
4170 dir = os.path.join(basedir, 'gpodder')
4171 if os.path.exists(dir):
4172 log('Downloads found in: %s', dir)
4173 config.download_dir = dir
4174 break
4175 else:
4176 log('Downloads NOT FOUND in %s', dir)
4177 elif gpodder.ui.fremantle:
4178 config.on_quit_ask = False
4180 if config.enable_fingerscroll:
4181 BuilderWidget.use_fingerscroll = True
4183 config.mygpo_device_type = util.detect_device_type()
4185 gp = gPodder(bus_name, config)
4187 # Handle options
4188 if options.subscribe:
4189 util.idle_add(gp.subscribe_to_url, options.subscribe)
4191 # mac OS X stuff :
4192 # handle "subscribe to podcast" events from firefox
4193 if platform.system() == 'Darwin':
4194 from gpodder import gpodderosx
4195 gpodderosx.register_handlers(gp)
4196 # end mac OS X stuff
4198 gp.run()