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