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