Add 2010 to the years in copyright notice
[gpodder.git] / src / gpodder / gui.py
blobc9de91102801c9b7805b4429101facac5bbeec1b
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 gtk
22 import gtk.gdk
23 import gobject
24 import pango
25 import sys
26 import shutil
27 import subprocess
28 import glob
29 import time
30 import urllib
31 import urllib2
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 class glib:
52 class DBusGMainLoop:
53 pass
54 class service:
55 @staticmethod
56 def method(interface):
57 return lambda x: x
58 class BusName:
59 def __init__(self, *args, **kwargs):
60 pass
61 class Object:
62 def __init__(self, *args, **kwargs):
63 pass
66 from gpodder import feedcore
67 from gpodder import util
68 from gpodder import opml
69 from gpodder import download
70 from gpodder import my
71 from gpodder.liblogger import log
73 _ = gpodder.gettext
74 N_ = gpodder.ngettext
76 from gpodder.model import PodcastChannel
77 from gpodder.dbsqlite import Database
79 from gpodder.gtkui.model import PodcastListModel
80 from gpodder.gtkui.model import EpisodeListModel
81 from gpodder.gtkui.config import UIConfig
82 from gpodder.gtkui.services import CoverDownloader
83 from gpodder.gtkui.widgets import SimpleMessageArea
84 from gpodder.gtkui.desktopfile import UserAppsReader
86 from gpodder.gtkui.draw import draw_text_box_centered
88 from gpodder.gtkui.interface.common import BuilderWidget
89 from gpodder.gtkui.interface.common import TreeViewHelper
90 from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
92 if gpodder.ui.desktop:
93 from gpodder.gtkui.download import DownloadStatusModel
95 from gpodder.gtkui.desktop.sync import gPodderSyncUI
97 from gpodder.gtkui.desktop.channel import gPodderChannel
98 from gpodder.gtkui.desktop.preferences import gPodderPreferences
99 from gpodder.gtkui.desktop.shownotes import gPodderShownotes
100 from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
101 from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
102 from gpodder.gtkui.desktop.dependencymanager import gPodderDependencyManager
103 try:
104 from gpodder.gtkui.desktop.trayicon import GPodderStatusIcon
105 have_trayicon = True
106 except Exception, exc:
107 log('Warning: Could not import gpodder.trayicon.', traceback=True)
108 log('Warning: This probably means your PyGTK installation is too old!')
109 have_trayicon = False
110 elif gpodder.ui.diablo:
111 from gpodder.gtkui.download import DownloadStatusModel
113 from gpodder.gtkui.maemo.channel import gPodderChannel
114 from gpodder.gtkui.maemo.preferences import gPodderPreferences
115 from gpodder.gtkui.maemo.shownotes import gPodderShownotes
116 from gpodder.gtkui.maemo.episodeselector import gPodderEpisodeSelector
117 from gpodder.gtkui.maemo.podcastdirectory import gPodderPodcastDirectory
118 have_trayicon = False
119 elif gpodder.ui.fremantle:
120 from gpodder.gtkui.frmntl.model import DownloadStatusModel
121 from gpodder.gtkui.frmntl.model import EpisodeListModel
122 from gpodder.gtkui.frmntl.model import PodcastListModel
124 from gpodder.gtkui.maemo.channel import gPodderChannel
125 from gpodder.gtkui.frmntl.preferences import gPodderPreferences
126 from gpodder.gtkui.frmntl.shownotes import gPodderShownotes
127 from gpodder.gtkui.frmntl.episodeselector import gPodderEpisodeSelector
128 from gpodder.gtkui.frmntl.podcastdirectory import gPodderPodcastDirectory
129 from gpodder.gtkui.frmntl.episodes import gPodderEpisodes
130 from gpodder.gtkui.frmntl.downloads import gPodderDownloads
131 from gpodder.gtkui.interface.common import Orientation
132 have_trayicon = False
134 from gpodder.gtkui.frmntl.portrait import FremantleRotation
136 from gpodder.gtkui.interface.welcome import gPodderWelcome
137 from gpodder.gtkui.interface.progress import ProgressIndicator
139 if gpodder.ui.maemo:
140 import hildon
142 class gPodder(BuilderWidget, dbus.service.Object):
143 finger_friendly_widgets = ['btnCleanUpDownloads', 'button_search_episodes_clear']
145 ICON_GENERAL_ADD = 'general_add'
146 ICON_GENERAL_REFRESH = 'general_refresh'
147 ICON_GENERAL_CLOSE = 'general_close'
149 def __init__(self, bus_name, config):
150 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
151 self.db = Database(gpodder.database_file)
152 self.config = config
153 BuilderWidget.__init__(self, None)
155 def new(self):
156 if gpodder.ui.diablo:
157 import hildon
158 self.app = hildon.Program()
159 self.app.add_window(self.main_window)
160 self.main_window.add_toolbar(self.toolbar)
161 menu = gtk.Menu()
162 for child in self.main_menu.get_children():
163 child.reparent(menu)
164 self.main_window.set_menu(self.set_finger_friendly(menu))
165 self.bluetooth_available = False
166 elif gpodder.ui.fremantle:
167 import hildon
168 self.app = hildon.Program()
169 self.app.add_window(self.main_window)
171 appmenu = hildon.AppMenu()
173 for filter in (self.item_view_podcasts_all, \
174 self.item_view_podcasts_downloaded, \
175 self.item_view_podcasts_unplayed):
176 button = gtk.ToggleButton()
177 filter.connect_proxy(button)
178 appmenu.add_filter(button)
180 for action in (self.itemPreferences, \
181 self.item_downloads, \
182 self.itemRemoveOldEpisodes, \
183 self.item_unsubscribe, \
184 self.item_support, \
185 self.item_report_bug):
186 button = hildon.Button(gtk.HILDON_SIZE_AUTO,\
187 hildon.BUTTON_ARRANGEMENT_HORIZONTAL)
188 action.connect_proxy(button)
189 if action == self.item_downloads:
190 button.set_title(_('Downloads'))
191 button.set_value(_('Idle'))
192 self.button_downloads = button
193 appmenu.append(button)
194 appmenu.show_all()
195 self.main_window.set_app_menu(appmenu)
197 # Initialize portrait mode / rotation manager
198 self._fremantle_rotation = FremantleRotation('gPodder', \
199 self.main_window, \
200 gpodder.__version__, \
201 self.config.rotation_mode)
203 if self.config.rotation_mode == FremantleRotation.ALWAYS:
204 util.idle_add(self.on_window_orientation_changed, \
205 Orientation.PORTRAIT)
207 self.bluetooth_available = False
208 else:
209 self.bluetooth_available = util.bluetooth_available()
210 self.toolbar.set_property('visible', self.config.show_toolbar)
212 self.config.connect_gtk_window(self.gPodder, 'main_window')
213 if not gpodder.ui.fremantle:
214 self.config.connect_gtk_paned('paned_position', self.channelPaned)
215 self.main_window.show()
217 self.gPodder.connect('key-press-event', self.on_key_press)
219 self.config.add_observer(self.on_config_changed)
221 self.tray_icon = None
222 self.episode_shownotes_window = None
223 self.new_episodes_window = None
225 if gpodder.ui.desktop:
226 self.sync_ui = gPodderSyncUI(self.config, self.notification, \
227 self.main_window, self.show_confirmation, \
228 self.update_episode_list_icons, \
229 self.update_podcast_list_model, self.toolPreferences, \
230 gPodderEpisodeSelector)
231 else:
232 self.sync_ui = None
234 self.download_status_model = DownloadStatusModel()
235 self.download_queue_manager = download.DownloadQueueManager(self.config)
237 if gpodder.ui.desktop:
238 self.show_hide_tray_icon()
239 self.itemShowToolbar.set_active(self.config.show_toolbar)
240 self.itemShowDescription.set_active(self.config.episode_list_descriptions)
242 if not gpodder.ui.fremantle:
243 self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
244 self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
245 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
246 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
248 # When the amount of maximum downloads changes, notify the queue manager
249 changed_cb = lambda spinbutton: self.download_queue_manager.spawn_threads()
250 self.spinMaxDownloads.connect('value-changed', changed_cb)
252 self.default_title = 'gPodder'
253 if gpodder.__version__.rfind('git') != -1:
254 self.set_title('gPodder %s' % gpodder.__version__)
255 else:
256 title = self.gPodder.get_title()
257 if title is not None:
258 self.set_title(title)
259 else:
260 self.set_title(_('gPodder'))
262 self.cover_downloader = CoverDownloader()
264 # Generate list models for podcasts and their episodes
265 self.podcast_list_model = PodcastListModel(self.config.podcast_list_icon_size, self.cover_downloader)
267 self.cover_downloader.register('cover-available', self.cover_download_finished)
268 self.cover_downloader.register('cover-removed', self.cover_file_removed)
270 if gpodder.ui.fremantle:
271 # Work around Maemo bug #4718
272 self.button_refresh.set_name('HildonButton-finger')
273 self.button_subscribe.set_name('HildonButton-finger')
275 self.button_refresh.set_sensitive(False)
276 self.button_subscribe.set_sensitive(False)
278 self.button_subscribe.set_image(gtk.image_new_from_icon_name(\
279 self.ICON_GENERAL_ADD, gtk.ICON_SIZE_BUTTON))
280 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
281 self.ICON_GENERAL_REFRESH, gtk.ICON_SIZE_BUTTON))
283 # Make the button scroll together with the TreeView contents
284 action_area_box = self.treeChannels.get_action_area_box()
285 for child in self.buttonbox:
286 child.reparent(action_area_box)
287 self.vbox.remove(self.buttonbox)
288 action_area_box.set_spacing(2)
289 action_area_box.set_border_width(3)
290 self.treeChannels.set_action_area_visible(True)
292 from gpodder.gtkui.frmntl import style
293 sub_font = style.get_font_desc('SmallSystemFont')
294 sub_color = style.get_color('SecondaryTextColor')
295 sub = (sub_font.to_string(), sub_color.to_string())
296 sub = '<span font_desc="%s" foreground="%s">%%s</span>' % sub
297 self.label_footer.set_markup(sub % gpodder.__copyright__)
299 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
300 while gtk.events_pending():
301 gtk.main_iteration(False)
303 try:
304 # Try to get the real package version from dpkg
305 p = subprocess.Popen(['dpkg-query', '-W', '-f=${Version}', 'gpodder'], stdout=subprocess.PIPE)
306 version, _stderr = p.communicate()
307 del _stderr
308 del p
309 except:
310 version = gpodder.__version__
311 self.label_footer.set_markup(sub % ('v %s' % version))
312 self.label_footer.hide()
314 self.episodes_window = gPodderEpisodes(self.main_window, \
315 on_treeview_expose_event=self.on_treeview_expose_event, \
316 show_episode_shownotes=self.show_episode_shownotes, \
317 update_podcast_list_model=self.update_podcast_list_model, \
318 on_itemRemoveChannel_activate=self.on_itemRemoveChannel_activate, \
319 item_view_episodes_all=self.item_view_episodes_all, \
320 item_view_episodes_unplayed=self.item_view_episodes_unplayed, \
321 item_view_episodes_downloaded=self.item_view_episodes_downloaded, \
322 item_view_episodes_undeleted=self.item_view_episodes_undeleted, \
323 on_entry_search_episodes_changed=self.on_entry_search_episodes_changed, \
324 on_entry_search_episodes_key_press=self.on_entry_search_episodes_key_press, \
325 hide_episode_search=self.hide_episode_search, \
326 on_itemUpdateChannel_activate=self.on_itemUpdateChannel_activate, \
327 playback_episodes=self.playback_episodes, \
328 delete_episode_list=self.delete_episode_list, \
329 episode_list_status_changed=self.episode_list_status_changed, \
330 download_episode_list=self.download_episode_list, \
331 episode_is_downloading=self.episode_is_downloading, \
332 show_episode_in_download_manager=self.show_episode_in_download_manager, \
333 add_download_task_monitor=self.add_download_task_monitor, \
334 remove_download_task_monitor=self.remove_download_task_monitor, \
335 for_each_episode_set_task_status=self.for_each_episode_set_task_status)
337 # Expose objects for episode list type-ahead find
338 self.hbox_search_episodes = self.episodes_window.hbox_search_episodes
339 self.entry_search_episodes = self.episodes_window.entry_search_episodes
340 self.button_search_episodes_clear = self.episodes_window.button_search_episodes_clear
342 self.downloads_window = gPodderDownloads(self.main_window, \
343 on_treeview_expose_event=self.on_treeview_expose_event, \
344 on_btnCleanUpDownloads_clicked=self.on_btnCleanUpDownloads_clicked, \
345 _for_each_task_set_status=self._for_each_task_set_status, \
346 downloads_list_get_selection=self.downloads_list_get_selection, \
347 _config=self.config)
349 self.treeAvailable = self.episodes_window.treeview
350 self.treeDownloads = self.downloads_window.treeview
352 # Init the treeviews that we use
353 self.init_podcast_list_treeview()
354 self.init_episode_list_treeview()
355 self.init_download_list_treeview()
357 if self.config.podcast_list_hide_boring:
358 self.item_view_hide_boring_podcasts.set_active(True)
360 self.currently_updating = False
362 if gpodder.ui.maemo:
363 self.context_menu_mouse_button = 1
364 else:
365 self.context_menu_mouse_button = 3
367 if self.config.start_iconified:
368 self.iconify_main_window()
370 self.download_tasks_seen = set()
371 self.download_list_update_enabled = False
372 self.last_download_count = 0
373 self.download_task_monitors = set()
375 # Subscribed channels
376 self.active_channel = None
377 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
378 self.channel_list_changed = True
379 self.update_podcasts_tab()
381 # load list of user applications for audio playback
382 self.user_apps_reader = UserAppsReader(['audio', 'video'])
383 def read_apps():
384 time.sleep(3) # give other parts of gpodder a chance to start up
385 self.user_apps_reader.read()
386 util.idle_add(self.user_apps_reader.get_applications_as_model, 'audio', False)
387 util.idle_add(self.user_apps_reader.get_applications_as_model, 'video', False)
388 threading.Thread(target=read_apps).start()
390 # Set the "Device" menu item for the first time
391 if gpodder.ui.desktop:
392 self.update_item_device()
394 # Now, update the feed cache, when everything's in place
395 if not gpodder.ui.fremantle:
396 self.btnUpdateFeeds.show()
397 self.updating_feed_cache = False
398 self.feed_cache_update_cancelled = False
399 self.update_feed_cache(force_update=self.config.update_on_startup)
401 # Look for partial file downloads
402 partial_files = glob.glob(os.path.join(self.config.download_dir, '*', '*.partial'))
404 # Message area
405 self.message_area = None
407 resumable_episodes = []
408 if len(partial_files) > 0:
409 for f in partial_files:
410 correct_name = f[:-len('.partial')] # strip ".partial"
411 log('Searching episode for file: %s', correct_name, sender=self)
412 found_episode = False
413 for c in self.channels:
414 for e in c.get_all_episodes():
415 if e.local_filename(create=False, check_only=True) == correct_name:
416 log('Found episode: %s', e.title, sender=self)
417 resumable_episodes.append(e)
418 found_episode = True
419 if found_episode:
420 break
421 if found_episode:
422 break
423 if not found_episode:
424 log('Partial file without episode: %s', f, sender=self)
425 util.delete_file(f)
427 if len(resumable_episodes):
428 self.download_episode_list_paused(resumable_episodes)
429 if not gpodder.ui.fremantle:
430 self.message_area = SimpleMessageArea(_('There are unfinished downloads from your last session.\nPick the ones you want to continue downloading.'))
431 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
432 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
433 self.message_area.show_all()
434 self.wNotebook.set_current_page(1)
436 self.clean_up_downloads(delete_partial=False)
437 else:
438 self.clean_up_downloads(delete_partial=True)
440 # Start the auto-update procedure
441 self._auto_update_timer_source_id = None
442 if self.config.auto_update_feeds:
443 self.restart_auto_update_timer()
445 # Connect the auto cleanup button to the configuration
446 if gpodder.ui.desktop or gpodder.ui.diablo:
447 self.config.connect_gtk_togglebutton('auto_cleanup_downloads', \
448 self.btnCleanUpDownloads)
450 # Delete old episodes if the user wishes to
451 if self.config.auto_remove_old_episodes:
452 old_episodes = self.get_old_episodes()
453 if len(old_episodes) > 0:
454 self.delete_episode_list(old_episodes, confirm=False)
455 self.update_podcast_list_model(set(e.channel.url for e in old_episodes))
457 if gpodder.ui.fremantle:
458 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
459 self.button_refresh.set_sensitive(True)
460 self.button_subscribe.set_sensitive(True)
461 self.main_window.set_title(_('gPodder'))
462 hildon.hildon_gtk_window_take_screenshot(self.main_window, True)
464 # First-time users should be asked if they want to see the OPML
465 if not self.channels and not gpodder.ui.fremantle:
466 util.idle_add(self.on_itemUpdate_activate)
468 def on_podcast_selected(self, treeview, path, column):
469 # for Maemo 5's UI
470 model = treeview.get_model()
471 channel = model.get_value(model.get_iter(path), \
472 PodcastListModel.C_CHANNEL)
473 self.active_channel = channel
474 self.update_episode_list_model()
475 self.episodes_window.channel = self.active_channel
476 self.episodes_window.show()
478 def on_button_subscribe_clicked(self, button):
479 self.on_itemImportChannels_activate(button)
481 def on_button_downloads_clicked(self, widget):
482 self.downloads_window.show()
484 def show_episode_in_download_manager(self, episode):
485 self.downloads_window.show()
486 model = self.treeDownloads.get_model()
487 selection = self.treeDownloads.get_selection()
488 selection.unselect_all()
489 it = model.get_iter_first()
490 while it is not None:
491 task = model.get_value(it, DownloadStatusModel.C_TASK)
492 if task.episode.url == episode.url:
493 selection.select_iter(it)
494 # FIXME: Scroll to selection in pannable area
495 break
496 it = model.iter_next(it)
498 def for_each_episode_set_task_status(self, episodes, status):
499 episode_urls = set(episode.url for episode in episodes)
500 model = self.treeDownloads.get_model()
501 selected_tasks = [(gtk.TreeRowReference(model, row.path), \
502 model.get_value(row.iter, \
503 DownloadStatusModel.C_TASK)) for row in model \
504 if model.get_value(row.iter, DownloadStatusModel.C_TASK).url \
505 in episode_urls]
506 self._for_each_task_set_status(selected_tasks, status)
508 def on_window_orientation_changed(self, orientation):
509 treeview = self.treeChannels
510 if orientation == Orientation.PORTRAIT:
511 treeview.set_action_area_orientation(gtk.ORIENTATION_VERTICAL)
512 # Work around Maemo bug #4718
513 self.button_subscribe.set_name('HildonButton-thumb')
514 self.button_refresh.set_name('HildonButton-thumb')
515 else:
516 treeview.set_action_area_orientation(gtk.ORIENTATION_HORIZONTAL)
517 # Work around Maemo bug #4718
518 self.button_subscribe.set_name('HildonButton-finger')
519 self.button_refresh.set_name('HildonButton-finger')
521 def on_treeview_podcasts_selection_changed(self, selection):
522 model, iter = selection.get_selected()
523 if iter is None:
524 self.active_channel = None
525 self.episode_list_model.clear()
527 def on_treeview_button_pressed(self, treeview, event):
528 if event.window != treeview.get_bin_window():
529 return False
531 TreeViewHelper.save_button_press_event(treeview, event)
533 if getattr(treeview, TreeViewHelper.ROLE) == \
534 TreeViewHelper.ROLE_PODCASTS:
535 return self.currently_updating
537 return event.button == self.context_menu_mouse_button and \
538 gpodder.ui.desktop
540 def on_treeview_podcasts_button_released(self, treeview, event):
541 if event.window != treeview.get_bin_window():
542 return False
544 if gpodder.ui.maemo:
545 return self.treeview_channels_handle_gestures(treeview, event)
546 return self.treeview_channels_show_context_menu(treeview, event)
548 def on_treeview_episodes_button_released(self, treeview, event):
549 if event.window != treeview.get_bin_window():
550 return False
552 if gpodder.ui.maemo:
553 if self.config.enable_fingerscroll or self.config.maemo_enable_gestures:
554 return self.treeview_available_handle_gestures(treeview, event)
556 return self.treeview_available_show_context_menu(treeview, event)
558 def on_treeview_downloads_button_released(self, treeview, event):
559 if event.window != treeview.get_bin_window():
560 return False
562 return self.treeview_downloads_show_context_menu(treeview, event)
564 def on_entry_search_podcasts_changed(self, editable):
565 if self.hbox_search_podcasts.get_property('visible'):
566 self.podcast_list_model.set_search_term(editable.get_chars(0, -1))
568 def on_entry_search_podcasts_key_press(self, editable, event):
569 if event.keyval == gtk.keysyms.Escape:
570 self.hide_podcast_search()
571 return True
573 def hide_podcast_search(self, *args):
574 self.hbox_search_podcasts.hide()
575 self.entry_search_podcasts.set_text('')
576 self.podcast_list_model.set_search_term(None)
577 self.treeChannels.grab_focus()
579 def show_podcast_search(self, input_char):
580 self.hbox_search_podcasts.show()
581 self.entry_search_podcasts.insert_text(input_char, -1)
582 self.entry_search_podcasts.grab_focus()
583 self.entry_search_podcasts.set_position(-1)
585 def init_podcast_list_treeview(self):
586 # Set up podcast channel tree view widget
587 if gpodder.ui.fremantle:
588 if self.config.podcast_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
589 self.item_view_podcasts_downloaded.set_active(True)
590 elif self.config.podcast_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
591 self.item_view_podcasts_unplayed.set_active(True)
592 else:
593 self.item_view_podcasts_all.set_active(True)
594 self.podcast_list_model.set_view_mode(self.config.podcast_list_view_mode)
596 iconcolumn = gtk.TreeViewColumn('')
597 iconcell = gtk.CellRendererPixbuf()
598 iconcolumn.pack_start(iconcell, False)
599 iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
600 self.treeChannels.append_column(iconcolumn)
602 namecolumn = gtk.TreeViewColumn('')
603 namecell = gtk.CellRendererText()
604 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
605 namecolumn.pack_start(namecell, True)
606 namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
608 iconcell = gtk.CellRendererPixbuf()
609 iconcell.set_property('xalign', 1.0)
610 namecolumn.pack_start(iconcell, False)
611 namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
612 namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
613 self.treeChannels.append_column(namecolumn)
615 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
617 # When no podcast is selected, clear the episode list model
618 selection = self.treeChannels.get_selection()
619 selection.connect('changed', self.on_treeview_podcasts_selection_changed)
621 # Set up type-ahead find for the podcast list
622 def on_key_press(treeview, event):
623 if event.keyval == gtk.keysyms.Escape:
624 self.hide_podcast_search()
625 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
626 self.hide_podcast_search()
627 elif event.state & gtk.gdk.CONTROL_MASK:
628 # Don't handle type-ahead when control is pressed (so shortcuts
629 # with the Ctrl key still work, e.g. Ctrl+A, ...)
630 return True
631 else:
632 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
633 if unicode_char_id == 0:
634 return False
635 input_char = unichr(unicode_char_id)
636 self.show_podcast_search(input_char)
637 return True
638 self.treeChannels.connect('key-press-event', on_key_press)
640 # Enable separators to the podcast list to separate special podcasts
641 # from others (this is used for the "all episodes" view)
642 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
644 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
646 def on_entry_search_episodes_changed(self, editable):
647 if self.hbox_search_episodes.get_property('visible'):
648 self.episode_list_model.set_search_term(editable.get_chars(0, -1))
650 def on_entry_search_episodes_key_press(self, editable, event):
651 if event.keyval == gtk.keysyms.Escape:
652 self.hide_episode_search()
653 return True
655 def hide_episode_search(self, *args):
656 self.hbox_search_episodes.hide()
657 self.entry_search_episodes.set_text('')
658 self.episode_list_model.set_search_term(None)
659 self.treeAvailable.grab_focus()
661 def show_episode_search(self, input_char):
662 self.hbox_search_episodes.show()
663 self.entry_search_episodes.insert_text(input_char, -1)
664 self.entry_search_episodes.grab_focus()
665 self.entry_search_episodes.set_position(-1)
667 def init_episode_list_treeview(self):
668 self.episode_list_model = EpisodeListModel()
670 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
671 self.item_view_episodes_undeleted.set_active(True)
672 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
673 self.item_view_episodes_downloaded.set_active(True)
674 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
675 self.item_view_episodes_unplayed.set_active(True)
676 else:
677 self.item_view_episodes_all.set_active(True)
679 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
681 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
683 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
685 iconcell = gtk.CellRendererPixbuf()
686 if gpodder.ui.maemo:
687 iconcell.set_fixed_size(50, 50)
688 status_column_label = ''
689 else:
690 status_column_label = _('Status')
691 iconcolumn = gtk.TreeViewColumn(status_column_label, iconcell, pixbuf=EpisodeListModel.C_STATUS_ICON)
693 namecell = gtk.CellRendererText()
694 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
695 namecolumn = gtk.TreeViewColumn(_('Episode'), namecell, markup=EpisodeListModel.C_DESCRIPTION)
696 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
697 namecolumn.set_resizable(True)
698 namecolumn.set_expand(True)
700 sizecell = gtk.CellRendererText()
701 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
703 releasecell = gtk.CellRendererText()
704 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
706 for itemcolumn in (iconcolumn, namecolumn, sizecolumn, releasecolumn):
707 itemcolumn.set_reorderable(True)
708 self.treeAvailable.append_column(itemcolumn)
710 if gpodder.ui.maemo:
711 sizecolumn.set_visible(False)
712 releasecolumn.set_visible(False)
714 # Set up type-ahead find for the episode list
715 def on_key_press(treeview, event):
716 if event.keyval == gtk.keysyms.Escape:
717 self.hide_episode_search()
718 elif gpodder.ui.fremantle and event.keyval == gtk.keysyms.BackSpace:
719 self.hide_episode_search()
720 elif event.state & gtk.gdk.CONTROL_MASK:
721 # Don't handle type-ahead when control is pressed (so shortcuts
722 # with the Ctrl key still work, e.g. Ctrl+A, ...)
723 return False
724 else:
725 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
726 if unicode_char_id == 0:
727 return False
728 input_char = unichr(unicode_char_id)
729 self.show_episode_search(input_char)
730 return True
731 self.treeAvailable.connect('key-press-event', on_key_press)
733 if gpodder.ui.desktop:
734 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
735 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
736 def drag_data_get(tree, context, selection_data, info, timestamp):
737 if self.config.on_drag_mark_played:
738 for episode in self.get_selected_episodes():
739 episode.mark(is_played=True)
740 self.on_selected_episodes_status_changed()
741 uris = ['file://'+e.local_filename(create=False) \
742 for e in self.get_selected_episodes() \
743 if e.was_downloaded(and_exists=True)]
744 uris.append('') # for the trailing '\r\n'
745 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
746 self.treeAvailable.connect('drag-data-get', drag_data_get)
748 selection = self.treeAvailable.get_selection()
749 if gpodder.ui.diablo:
750 if self.config.maemo_enable_gestures or self.config.enable_fingerscroll:
751 selection.set_mode(gtk.SELECTION_SINGLE)
752 else:
753 selection.set_mode(gtk.SELECTION_MULTIPLE)
754 elif gpodder.ui.fremantle:
755 selection.set_mode(gtk.SELECTION_SINGLE)
756 else:
757 selection.set_mode(gtk.SELECTION_MULTIPLE)
758 # Update the sensitivity of the toolbar buttons on the Desktop
759 selection.connect('changed', lambda s: self.play_or_download())
761 if gpodder.ui.diablo:
762 # Set up the tap-and-hold context menu for podcasts
763 menu = gtk.Menu()
764 menu.append(self.itemUpdateChannel.create_menu_item())
765 menu.append(self.itemEditChannel.create_menu_item())
766 menu.append(gtk.SeparatorMenuItem())
767 menu.append(self.itemRemoveChannel.create_menu_item())
768 menu.append(gtk.SeparatorMenuItem())
769 item = gtk.ImageMenuItem(_('Close this menu'))
770 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, \
771 gtk.ICON_SIZE_MENU))
772 menu.append(item)
773 menu.show_all()
774 menu = self.set_finger_friendly(menu)
775 self.treeChannels.tap_and_hold_setup(menu)
778 def init_download_list_treeview(self):
779 # enable multiple selection support
780 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
781 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
783 # columns and renderers for "download progress" tab
784 # First column: [ICON] Episodename
785 column = gtk.TreeViewColumn(_('Episode'))
787 cell = gtk.CellRendererPixbuf()
788 if gpodder.ui.maemo:
789 cell.set_fixed_size(50, 50)
790 cell.set_property('stock-size', gtk.ICON_SIZE_MENU)
791 column.pack_start(cell, expand=False)
792 column.add_attribute(cell, 'stock-id', \
793 DownloadStatusModel.C_ICON_NAME)
795 cell = gtk.CellRendererText()
796 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
797 column.pack_start(cell, expand=True)
798 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
799 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
800 column.set_expand(True)
801 self.treeDownloads.append_column(column)
803 # Second column: Progress
804 cell = gtk.CellRendererProgress()
805 cell.set_property('yalign', .5)
806 cell.set_property('ypad', 6)
807 column = gtk.TreeViewColumn(_('Progress'), cell,
808 value=DownloadStatusModel.C_PROGRESS, \
809 text=DownloadStatusModel.C_PROGRESS_TEXT)
810 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
811 column.set_expand(False)
812 self.treeDownloads.append_column(column)
813 column.set_property('min-width', 150)
814 column.set_property('max-width', 150)
816 self.treeDownloads.set_model(self.download_status_model)
817 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
819 def on_treeview_expose_event(self, treeview, event):
820 if event.window == treeview.get_bin_window():
821 model = treeview.get_model()
822 if (model is not None and model.get_iter_first() is not None):
823 return False
825 role = getattr(treeview, TreeViewHelper.ROLE)
826 ctx = event.window.cairo_create()
827 ctx.rectangle(event.area.x, event.area.y,
828 event.area.width, event.area.height)
829 ctx.clip()
831 x, y, width, height, depth = event.window.get_geometry()
833 if role == TreeViewHelper.ROLE_EPISODES:
834 if self.currently_updating:
835 text = _('Loading episodes') + '...'
836 elif self.config.episode_list_view_mode != \
837 EpisodeListModel.VIEW_ALL:
838 text = _('No episodes in current view')
839 else:
840 text = _('No episodes available')
841 elif role == TreeViewHelper.ROLE_PODCASTS:
842 if self.config.episode_list_view_mode != \
843 EpisodeListModel.VIEW_ALL and \
844 self.config.podcast_list_hide_boring and \
845 len(self.channels) > 0:
846 text = _('No podcasts in this view')
847 else:
848 text = _('No subscriptions')
849 elif role == TreeViewHelper.ROLE_DOWNLOADS:
850 text = _('No active downloads')
851 else:
852 raise Exception('on_treeview_expose_event: unknown role')
854 if gpodder.ui.fremantle:
855 from gpodder.gtkui.frmntl import style
856 font_desc = style.get_font_desc('LargeSystemFont')
857 else:
858 font_desc = None
860 draw_text_box_centered(ctx, treeview, width, height, text, font_desc)
862 return False
864 def enable_download_list_update(self):
865 if not self.download_list_update_enabled:
866 gobject.timeout_add(1500, self.update_downloads_list)
867 self.download_list_update_enabled = True
869 def on_btnCleanUpDownloads_clicked(self, button=None):
870 model = self.download_status_model
872 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
873 changed_episode_urls = []
874 for row_reference, task in all_tasks:
875 if task.status in (task.DONE, task.CANCELLED) or \
876 (task.status == task.FAILED and gpodder.ui.fremantle):
877 model.remove(model.get_iter(row_reference.get_path()))
878 try:
879 # We don't "see" this task anymore - remove it;
880 # this is needed, so update_episode_list_icons()
881 # below gets the correct list of "seen" tasks
882 self.download_tasks_seen.remove(task)
883 except KeyError, key_error:
884 log('Cannot remove task from "seen" list: %s', task, sender=self)
885 changed_episode_urls.append(task.url)
886 # Tell the task that it has been removed (so it can clean up)
887 task.removed_from_list()
889 # Tell the podcasts tab to update icons for our removed podcasts
890 self.update_episode_list_icons(changed_episode_urls)
892 # Tell the shownotes window that we have removed the episode
893 if self.episode_shownotes_window is not None and \
894 self.episode_shownotes_window.episode is not None and \
895 self.episode_shownotes_window.episode.url in changed_episode_urls:
896 self.episode_shownotes_window._download_status_changed(None)
898 # Update the tab title and downloads list
899 self.update_downloads_list(from_cleanup=True)
901 def on_tool_downloads_toggled(self, toolbutton):
902 if toolbutton.get_active():
903 self.wNotebook.set_current_page(1)
904 else:
905 self.wNotebook.set_current_page(0)
907 def add_download_task_monitor(self, monitor):
908 self.download_task_monitors.add(monitor)
909 model = self.download_status_model
910 if model is None:
911 model = ()
912 for row in model:
913 task = row[self.download_status_model.C_TASK]
914 monitor.task_updated(task)
916 def remove_download_task_monitor(self, monitor):
917 self.download_task_monitors.remove(monitor)
919 def update_downloads_list(self, from_cleanup=False):
920 try:
921 model = self.download_status_model
923 downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
924 total_speed, total_size, done_size = 0, 0, 0
926 # Keep a list of all download tasks that we've seen
927 download_tasks_seen = set()
929 # Remember the DownloadTask object for the episode that
930 # has been opened in the episode shownotes dialog (if any)
931 if self.episode_shownotes_window is not None:
932 shownotes_episode = self.episode_shownotes_window.episode
933 shownotes_task = None
934 else:
935 shownotes_episode = None
936 shownotes_task = None
938 # Do not go through the list of the model is not (yet) available
939 if model is None:
940 model = ()
942 failed_downloads = []
943 for row in model:
944 self.download_status_model.request_update(row.iter)
946 task = row[self.download_status_model.C_TASK]
947 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
949 # Let the download task monitors know of changes
950 for monitor in self.download_task_monitors:
951 monitor.task_updated(task)
953 total_size += size
954 done_size += size*progress
956 if shownotes_episode is not None and \
957 shownotes_episode.url == task.episode.url:
958 shownotes_task = task
960 download_tasks_seen.add(task)
962 if status == download.DownloadTask.DOWNLOADING:
963 downloading += 1
964 total_speed += speed
965 elif status == download.DownloadTask.FAILED:
966 failed_downloads.append(task)
967 failed += 1
968 elif status == download.DownloadTask.DONE:
969 finished += 1
970 elif status == download.DownloadTask.QUEUED:
971 queued += 1
972 elif status == download.DownloadTask.PAUSED:
973 paused += 1
974 else:
975 others += 1
977 # Remember which tasks we have seen after this run
978 self.download_tasks_seen = download_tasks_seen
980 if gpodder.ui.desktop:
981 text = [_('Downloads')]
982 if downloading + failed + finished + queued > 0:
983 s = []
984 if downloading > 0:
985 s.append(N_('%d active', '%d active', downloading) % downloading)
986 if failed > 0:
987 s.append(N_('%d failed', '%d failed', failed) % failed)
988 if finished > 0:
989 s.append(N_('%d done', '%d done', finished) % finished)
990 if queued > 0:
991 s.append(N_('%d queued', '%d queued', queued) % queued)
992 text.append(' (' + ', '.join(s)+')')
993 self.labelDownloads.set_text(''.join(text))
994 elif gpodder.ui.diablo:
995 sum = downloading + failed + finished + queued + paused + others
996 if sum:
997 self.tool_downloads.set_label(_('Downloads (%d)') % sum)
998 else:
999 self.tool_downloads.set_label(_('Downloads'))
1000 elif gpodder.ui.fremantle:
1001 if downloading + queued > 0:
1002 self.button_downloads.set_value(N_('%d active', '%d active', downloading+queued) % (downloading+queued))
1003 elif failed > 0:
1004 self.button_downloads.set_value(N_('%d failed', '%d failed', failed) % failed)
1005 elif paused > 0:
1006 self.button_downloads.set_value(N_('%d paused', '%d paused', paused) % paused)
1007 else:
1008 self.button_downloads.set_value(_('Idle'))
1010 title = [self.default_title]
1012 # We have to update all episodes/channels for which the status has
1013 # changed. Accessing task.status_changed has the side effect of
1014 # re-setting the changed flag, so we need to get the "changed" list
1015 # of tuples first and split it into two lists afterwards
1016 changed = [(task.url, task.podcast_url) for task in \
1017 self.download_tasks_seen if task.status_changed]
1018 episode_urls = [episode_url for episode_url, channel_url in changed]
1019 channel_urls = [channel_url for episode_url, channel_url in changed]
1021 count = downloading + queued
1022 if count > 0:
1023 title.append(N_('downloading %d file', 'downloading %d files', count) % count)
1025 if total_size > 0:
1026 percentage = 100.0*done_size/total_size
1027 else:
1028 percentage = 0.0
1029 total_speed = util.format_filesize(total_speed)
1030 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1031 if self.tray_icon is not None:
1032 # Update the tray icon status and progress bar
1033 self.tray_icon.set_status(self.tray_icon.STATUS_DOWNLOAD_IN_PROGRESS, title[1])
1034 self.tray_icon.draw_progress_bar(percentage/100.)
1035 elif self.last_download_count > 0 and not from_cleanup:
1036 if self.tray_icon is not None:
1037 # Update the tray icon status
1038 self.tray_icon.set_status()
1039 self.tray_icon.downloads_finished(self.download_tasks_seen)
1040 if gpodder.ui.diablo:
1041 hildon.hildon_banner_show_information(self.gPodder, None, 'gPodder: %s' % _('All downloads finished'))
1042 log('All downloads have finished.', sender=self)
1043 if self.config.cmd_all_downloads_complete:
1044 util.run_external_command(self.config.cmd_all_downloads_complete)
1046 if gpodder.ui.fremantle and failed:
1047 message = '\n'.join(['%s: %s' % (str(task), \
1048 task.error_message) for task in failed_downloads])
1049 self.show_message(message, _('Downloads failed'), important=True)
1051 # Automatically remove finished downloads from the list
1052 if self.config.auto_cleanup_downloads:
1053 self.on_btnCleanUpDownloads_clicked()
1054 self.last_download_count = count
1056 if not gpodder.ui.fremantle:
1057 self.gPodder.set_title(' - '.join(title))
1059 self.update_episode_list_icons(episode_urls)
1060 if self.episode_shownotes_window is not None:
1061 if (shownotes_task and shownotes_task.url in episode_urls) or \
1062 shownotes_task != self.episode_shownotes_window.task:
1063 self.episode_shownotes_window._download_status_changed(shownotes_task)
1064 self.episode_shownotes_window._download_status_progress()
1065 self.play_or_download()
1066 if channel_urls:
1067 self.update_podcast_list_model(channel_urls)
1069 if not self.download_queue_manager.are_queued_or_active_tasks():
1070 self.download_list_update_enabled = False
1072 return self.download_list_update_enabled
1073 except Exception, e:
1074 log('Exception happened while updating download list.', sender=self, traceback=True)
1075 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
1076 # We return False here, so the update loop won't be called again,
1077 # that's why we require the restart of gPodder in the message.
1078 return False
1080 def on_config_changed(self, name, old_value, new_value):
1081 if name == 'show_toolbar' and gpodder.ui.desktop:
1082 self.toolbar.set_property('visible', new_value)
1083 elif name == 'episode_list_descriptions':
1084 self.update_episode_list_model()
1085 elif name == 'rotation_mode':
1086 self._fremantle_rotation.set_mode(new_value)
1087 elif name in ('auto_update_feeds', 'auto_update_frequency'):
1088 self.restart_auto_update_timer()
1089 elif name == 'podcast_list_view_all':
1090 # Force a update of the podcast list model
1091 self.channel_list_changed = True
1092 self.update_podcast_list_model()
1093 elif name == 'auto_cleanup_downloads' and new_value:
1094 # Always cleanup when this option is enabled
1095 self.on_btnCleanUpDownloads_clicked()
1097 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1098 # With get_bin_window, we get the window that contains the rows without
1099 # the header. The Y coordinate of this window will be the height of the
1100 # treeview header. This is the amount we have to subtract from the
1101 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1102 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1103 y -= x_bin
1104 y -= y_bin
1105 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
1107 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or (column is not None and column != treeview.get_columns()[0]):
1108 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1109 return False
1111 if path is not None:
1112 model = treeview.get_model()
1113 iter = model.get_iter(path)
1114 role = getattr(treeview, TreeViewHelper.ROLE)
1116 if role == TreeViewHelper.ROLE_EPISODES:
1117 id = model.get_value(iter, EpisodeListModel.C_URL)
1118 elif role == TreeViewHelper.ROLE_PODCASTS:
1119 id = model.get_value(iter, PodcastListModel.C_URL)
1121 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1122 if last_tooltip is not None and last_tooltip != id:
1123 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1124 return False
1125 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1127 if role == TreeViewHelper.ROLE_EPISODES:
1128 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1129 tooltip.set_text(description)
1130 elif role == TreeViewHelper.ROLE_PODCASTS:
1131 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1132 if channel is None:
1133 return False
1134 channel.request_save_dir_size()
1135 diskspace_str = util.format_filesize(channel.save_dir_size, 0)
1136 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1137 if error_str:
1138 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
1139 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1140 table = gtk.Table(rows=3, columns=3)
1141 table.set_row_spacings(5)
1142 table.set_col_spacings(5)
1143 table.set_border_width(5)
1145 heading = gtk.Label()
1146 heading.set_alignment(0, 1)
1147 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
1148 table.attach(heading, 0, 1, 0, 1)
1149 size_info = gtk.Label()
1150 size_info.set_alignment(1, 1)
1151 size_info.set_justify(gtk.JUSTIFY_RIGHT)
1152 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
1153 table.attach(size_info, 2, 3, 0, 1)
1155 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
1157 if len(channel.description) < 500:
1158 description = channel.description
1159 else:
1160 pos = channel.description.find('\n\n')
1161 if pos == -1 or pos > 500:
1162 description = channel.description[:498]+'[...]'
1163 else:
1164 description = channel.description[:pos]
1166 description = gtk.Label(description)
1167 if error_str:
1168 description.set_markup(error_str)
1169 description.set_alignment(0, 0)
1170 description.set_line_wrap(True)
1171 table.attach(description, 0, 3, 2, 3)
1173 table.show_all()
1174 tooltip.set_custom(table)
1176 return True
1178 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1179 return False
1181 def treeview_allow_tooltips(self, treeview, allow):
1182 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1184 def update_m3u_playlist_clicked(self, widget):
1185 if self.active_channel is not None:
1186 self.active_channel.update_m3u_playlist()
1187 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'), widget=self.treeChannels)
1189 def treeview_handle_context_menu_click(self, treeview, event):
1190 x, y = int(event.x), int(event.y)
1191 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
1193 selection = treeview.get_selection()
1194 model, paths = selection.get_selected_rows()
1196 if path is None or (path not in paths and \
1197 event.button == self.context_menu_mouse_button):
1198 # We have right-clicked, but not into the selection,
1199 # assume we don't want to operate on the selection
1200 paths = []
1202 if path is not None and not paths and \
1203 event.button == self.context_menu_mouse_button:
1204 # No selection or clicked outside selection;
1205 # select the single item where we clicked
1206 treeview.grab_focus()
1207 treeview.set_cursor(path, column, 0)
1208 paths = [path]
1210 if not paths:
1211 # Unselect any remaining items (clicked elsewhere)
1212 if hasattr(treeview, 'is_rubber_banding_active'):
1213 if not treeview.is_rubber_banding_active():
1214 selection.unselect_all()
1215 else:
1216 selection.unselect_all()
1218 return model, paths
1220 def downloads_list_get_selection(self, model=None, paths=None):
1221 if model is None and paths is None:
1222 selection = self.treeDownloads.get_selection()
1223 model, paths = selection.get_selected_rows()
1225 can_queue, can_cancel, can_pause, can_remove = (True,)*4
1226 selected_tasks = [(gtk.TreeRowReference(model, path), \
1227 model.get_value(model.get_iter(path), \
1228 DownloadStatusModel.C_TASK)) for path in paths]
1230 for row_reference, task in selected_tasks:
1231 if task.status not in (download.DownloadTask.PAUSED, \
1232 download.DownloadTask.FAILED, \
1233 download.DownloadTask.CANCELLED):
1234 can_queue = False
1235 if task.status not in (download.DownloadTask.PAUSED, \
1236 download.DownloadTask.QUEUED, \
1237 download.DownloadTask.DOWNLOADING):
1238 can_cancel = False
1239 if task.status not in (download.DownloadTask.QUEUED, \
1240 download.DownloadTask.DOWNLOADING):
1241 can_pause = False
1242 if task.status not in (download.DownloadTask.CANCELLED, \
1243 download.DownloadTask.FAILED, \
1244 download.DownloadTask.DONE):
1245 can_remove = False
1247 return selected_tasks, can_queue, can_cancel, can_pause, can_remove
1249 def _for_each_task_set_status(self, tasks, status):
1250 episode_urls = set()
1251 model = self.treeDownloads.get_model()
1252 for row_reference, task in tasks:
1253 if status == download.DownloadTask.QUEUED:
1254 # Only queue task when its paused/failed/cancelled
1255 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED):
1256 self.download_queue_manager.add_task(task)
1257 self.enable_download_list_update()
1258 elif status == download.DownloadTask.CANCELLED:
1259 # Cancelling a download allowed when downloading/queued
1260 if task.status in (task.QUEUED, task.DOWNLOADING):
1261 task.status = status
1262 # Cancelling paused downloads requires a call to .run()
1263 elif task.status == task.PAUSED:
1264 task.status = status
1265 # Call run, so the partial file gets deleted
1266 task.run()
1267 elif status == download.DownloadTask.PAUSED:
1268 # Pausing a download only when queued/downloading
1269 if task.status in (task.DOWNLOADING, task.QUEUED):
1270 task.status = status
1271 elif status is None:
1272 # Remove the selected task - cancel downloading/queued tasks
1273 if task.status in (task.QUEUED, task.DOWNLOADING):
1274 task.status = task.CANCELLED
1275 model.remove(model.get_iter(row_reference.get_path()))
1276 # Remember the URL, so we can tell the UI to update
1277 try:
1278 # We don't "see" this task anymore - remove it;
1279 # this is needed, so update_episode_list_icons()
1280 # below gets the correct list of "seen" tasks
1281 self.download_tasks_seen.remove(task)
1282 except KeyError, key_error:
1283 log('Cannot remove task from "seen" list: %s', task, sender=self)
1284 episode_urls.add(task.url)
1285 # Tell the task that it has been removed (so it can clean up)
1286 task.removed_from_list()
1287 else:
1288 # We can (hopefully) simply set the task status here
1289 task.status = status
1290 # Tell the podcasts tab to update icons for our removed podcasts
1291 self.update_episode_list_icons(episode_urls)
1292 # Update the tab title and downloads list
1293 self.update_downloads_list()
1295 def treeview_downloads_show_context_menu(self, treeview, event):
1296 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1297 if not paths:
1298 if not hasattr(treeview, 'is_rubber_banding_active'):
1299 return True
1300 else:
1301 return not treeview.is_rubber_banding_active()
1303 if event.button == self.context_menu_mouse_button:
1304 selected_tasks, can_queue, can_cancel, can_pause, can_remove = \
1305 self.downloads_list_get_selection(model, paths)
1307 def make_menu_item(label, stock_id, tasks, status, sensitive):
1308 # This creates a menu item for selection-wide actions
1309 item = gtk.ImageMenuItem(label)
1310 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
1311 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status))
1312 item.set_sensitive(sensitive)
1313 return self.set_finger_friendly(item)
1315 menu = gtk.Menu()
1317 item = gtk.ImageMenuItem(_('Episode details'))
1318 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1319 if len(selected_tasks) == 1:
1320 row_reference, task = selected_tasks[0]
1321 episode = task.episode
1322 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
1323 else:
1324 item.set_sensitive(False)
1325 menu.append(self.set_finger_friendly(item))
1326 menu.append(gtk.SeparatorMenuItem())
1327 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, can_queue))
1328 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED, can_cancel))
1329 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED, can_pause))
1330 menu.append(gtk.SeparatorMenuItem())
1331 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None, can_remove))
1333 if gpodder.ui.maemo:
1334 # Because we open the popup on left-click for Maemo,
1335 # we also include a non-action to close the menu
1336 menu.append(gtk.SeparatorMenuItem())
1337 item = gtk.ImageMenuItem(_('Close this menu'))
1338 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1340 menu.append(self.set_finger_friendly(item))
1342 menu.show_all()
1343 menu.popup(None, None, None, event.button, event.time)
1344 return True
1346 def treeview_channels_show_context_menu(self, treeview, event):
1347 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1348 if not paths:
1349 return True
1351 # Check for valid channel id, if there's no id then
1352 # assume that it is a proxy channel or equivalent
1353 # and cannot be operated with right click
1354 if self.active_channel.id is None:
1355 return True
1357 if event.button == 3:
1358 menu = gtk.Menu()
1360 ICON = lambda x: x
1362 item = gtk.ImageMenuItem( _('Open download folder'))
1363 item.set_image( gtk.image_new_from_icon_name(ICON('folder-open'), gtk.ICON_SIZE_MENU))
1364 item.connect('activate', lambda x: util.gui_open(self.active_channel.save_dir))
1365 menu.append( item)
1367 item = gtk.ImageMenuItem( _('Update Feed'))
1368 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1369 item.connect('activate', self.on_itemUpdateChannel_activate )
1370 item.set_sensitive( not self.updating_feed_cache )
1371 menu.append( item)
1373 item = gtk.ImageMenuItem(_('Update M3U playlist'))
1374 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1375 item.connect('activate', self.update_m3u_playlist_clicked)
1376 menu.append(item)
1378 if self.active_channel.link:
1379 item = gtk.ImageMenuItem(_('Visit website'))
1380 item.set_image(gtk.image_new_from_icon_name(ICON('web-browser'), gtk.ICON_SIZE_MENU))
1381 item.connect('activate', lambda w: util.open_website(self.active_channel.link))
1382 menu.append(item)
1384 if self.active_channel.channel_is_locked:
1385 item = gtk.ImageMenuItem(_('Allow deletion of all episodes'))
1386 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1387 item.connect('activate', self.on_channel_toggle_lock_activate)
1388 menu.append(self.set_finger_friendly(item))
1389 else:
1390 item = gtk.ImageMenuItem(_('Prohibit deletion of all episodes'))
1391 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1392 item.connect('activate', self.on_channel_toggle_lock_activate)
1393 menu.append(self.set_finger_friendly(item))
1396 menu.append( gtk.SeparatorMenuItem())
1398 item = gtk.ImageMenuItem(gtk.STOCK_EDIT)
1399 item.connect( 'activate', self.on_itemEditChannel_activate)
1400 menu.append( item)
1402 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1403 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1404 menu.append( item)
1406 menu.show_all()
1407 # Disable tooltips while we are showing the menu, so
1408 # the tooltip will not appear over the menu
1409 self.treeview_allow_tooltips(self.treeChannels, False)
1410 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1411 menu.popup( None, None, None, event.button, event.time)
1413 return True
1415 def on_itemClose_activate(self, widget):
1416 if self.tray_icon is not None:
1417 self.iconify_main_window()
1418 else:
1419 self.on_gPodder_delete_event(widget)
1421 def cover_file_removed(self, channel_url):
1423 The Cover Downloader calls this when a previously-
1424 available cover has been removed from the disk. We
1425 have to update our model to reflect this change.
1427 self.podcast_list_model.delete_cover_by_url(channel_url)
1429 def cover_download_finished(self, channel_url, pixbuf):
1431 The Cover Downloader calls this when it has finished
1432 downloading (or registering, if already downloaded)
1433 a new channel cover, which is ready for displaying.
1435 self.podcast_list_model.add_cover_by_url(channel_url, pixbuf)
1437 def save_episode_as_file(self, episode):
1438 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1439 if episode.was_downloaded(and_exists=True):
1440 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1441 copy_from = episode.local_filename(create=False)
1442 assert copy_from is not None
1443 copy_to = episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)
1444 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1445 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1447 def copy_episodes_bluetooth(self, episodes):
1448 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1450 def convert_and_send_thread(episode):
1451 for episode in episodes:
1452 filename = episode.local_filename(create=False)
1453 assert filename is not None
1454 destfile = os.path.join(tempfile.gettempdir(), \
1455 util.sanitize_filename(episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)))
1456 (base, ext) = os.path.splitext(filename)
1457 if not destfile.endswith(ext):
1458 destfile += ext
1460 try:
1461 shutil.copyfile(filename, destfile)
1462 util.bluetooth_send_file(destfile)
1463 except:
1464 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1465 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1467 util.delete_file(destfile)
1469 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1471 def get_device_name(self):
1472 if self.config.device_type == 'ipod':
1473 return _('iPod')
1474 elif self.config.device_type in ('filesystem', 'mtp'):
1475 return _('MP3 player')
1476 else:
1477 return '(unknown device)'
1479 def _treeview_button_released(self, treeview, event):
1480 xpos, ypos = TreeViewHelper.get_button_press_event(treeview)
1481 dy = int(abs(event.y-ypos))
1482 dx = int(event.x-xpos)
1484 selection = treeview.get_selection()
1485 path = treeview.get_path_at_pos(int(event.x), int(event.y))
1486 if path is None or dy > 30:
1487 return (False, dx, dy)
1489 path, column, x, y = path
1490 selection.select_path(path)
1491 treeview.set_cursor(path)
1492 treeview.grab_focus()
1494 return (True, dx, dy)
1496 def treeview_channels_handle_gestures(self, treeview, event):
1497 if self.currently_updating:
1498 return False
1500 selected, dx, dy = self._treeview_button_released(treeview, event)
1502 if selected:
1503 if self.config.maemo_enable_gestures:
1504 if dx > 70:
1505 self.on_itemUpdateChannel_activate()
1506 elif dx < -70:
1507 self.on_itemEditChannel_activate(treeview)
1509 return False
1511 def treeview_available_handle_gestures(self, treeview, event):
1512 selected, dx, dy = self._treeview_button_released(treeview, event)
1514 if selected:
1515 if self.config.maemo_enable_gestures:
1516 if dx > 70:
1517 self.on_playback_selected_episodes(None)
1518 return True
1519 elif dx < -70:
1520 self.on_shownotes_selected_episodes(None)
1521 return True
1523 # Pass the event to the context menu handler for treeAvailable
1524 self.treeview_available_show_context_menu(treeview, event)
1526 return True
1528 def treeview_available_show_context_menu(self, treeview, event):
1529 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1530 if not paths:
1531 if not hasattr(treeview, 'is_rubber_banding_active'):
1532 return True
1533 else:
1534 return not treeview.is_rubber_banding_active()
1536 if event.button == self.context_menu_mouse_button:
1537 episodes = self.get_selected_episodes()
1538 any_locked = any(e.is_locked for e in episodes)
1539 any_played = any(e.is_played for e in episodes)
1540 one_is_new = any(e.state == gpodder.STATE_NORMAL and not e.is_played for e in episodes)
1542 menu = gtk.Menu()
1544 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1546 if open_instead_of_play:
1547 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1548 else:
1549 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1551 item.set_sensitive(can_play)
1552 item.connect('activate', self.on_playback_selected_episodes)
1553 menu.append(self.set_finger_friendly(item))
1555 if not can_cancel:
1556 item = gtk.ImageMenuItem(_('Download'))
1557 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1558 item.set_sensitive(can_download)
1559 item.connect('activate', self.on_download_selected_episodes)
1560 menu.append(self.set_finger_friendly(item))
1561 else:
1562 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
1563 item.connect('activate', self.on_item_cancel_download_activate)
1564 menu.append(self.set_finger_friendly(item))
1566 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1567 item.set_sensitive(can_delete)
1568 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1569 menu.append(self.set_finger_friendly(item))
1571 if one_is_new:
1572 item = gtk.ImageMenuItem(_('Do not download'))
1573 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1574 item.connect('activate', lambda w: self.mark_selected_episodes_old())
1575 menu.append(self.set_finger_friendly(item))
1576 elif can_download:
1577 item = gtk.ImageMenuItem(_('Mark as new'))
1578 item.set_image(gtk.image_new_from_stock(gtk.STOCK_ABOUT, gtk.ICON_SIZE_MENU))
1579 item.connect('activate', lambda w: self.mark_selected_episodes_new())
1580 menu.append(self.set_finger_friendly(item))
1582 ICON = lambda x: x
1584 # Ok, this probably makes sense to only display for downloaded files
1585 if can_play and not can_download:
1586 menu.append( gtk.SeparatorMenuItem())
1587 item = gtk.ImageMenuItem(_('Save to disk'))
1588 item.set_image(gtk.image_new_from_stock(gtk.STOCK_SAVE_AS, gtk.ICON_SIZE_MENU))
1589 item.connect('activate', lambda w: [self.save_episode_as_file(e) for e in episodes])
1590 menu.append(self.set_finger_friendly(item))
1591 if self.bluetooth_available:
1592 item = gtk.ImageMenuItem(_('Send via bluetooth'))
1593 item.set_image(gtk.image_new_from_icon_name(ICON('bluetooth'), gtk.ICON_SIZE_MENU))
1594 item.connect('activate', lambda w: self.copy_episodes_bluetooth(episodes))
1595 menu.append(self.set_finger_friendly(item))
1596 if can_transfer:
1597 item = gtk.ImageMenuItem(_('Transfer to %s') % self.get_device_name())
1598 item.set_image(gtk.image_new_from_icon_name(ICON('multimedia-player'), gtk.ICON_SIZE_MENU))
1599 item.connect('activate', lambda w: self.on_sync_to_ipod_activate(w, episodes))
1600 menu.append(self.set_finger_friendly(item))
1602 if can_play:
1603 menu.append( gtk.SeparatorMenuItem())
1604 if any_played:
1605 item = gtk.ImageMenuItem(_('Mark as unplayed'))
1606 item.set_image( gtk.image_new_from_stock( gtk.STOCK_CANCEL, gtk.ICON_SIZE_MENU))
1607 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, False))
1608 menu.append(self.set_finger_friendly(item))
1609 else:
1610 item = gtk.ImageMenuItem(_('Mark as played'))
1611 item.set_image( gtk.image_new_from_stock( gtk.STOCK_APPLY, gtk.ICON_SIZE_MENU))
1612 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, True))
1613 menu.append(self.set_finger_friendly(item))
1615 if any_locked:
1616 item = gtk.ImageMenuItem(_('Allow deletion'))
1617 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1618 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, False))
1619 menu.append(self.set_finger_friendly(item))
1620 else:
1621 item = gtk.ImageMenuItem(_('Prohibit deletion'))
1622 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1623 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, True))
1624 menu.append(self.set_finger_friendly(item))
1626 menu.append(gtk.SeparatorMenuItem())
1627 # Single item, add episode information menu item
1628 item = gtk.ImageMenuItem(_('Episode details'))
1629 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1630 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
1631 menu.append(self.set_finger_friendly(item))
1633 # If we have it, also add episode website link
1634 if episodes[0].link and episodes[0].link != episodes[0].url:
1635 item = gtk.ImageMenuItem(_('Visit website'))
1636 item.set_image(gtk.image_new_from_icon_name(ICON('web-browser'), gtk.ICON_SIZE_MENU))
1637 item.connect('activate', lambda w: util.open_website(episodes[0].link))
1638 menu.append(self.set_finger_friendly(item))
1640 if gpodder.ui.maemo:
1641 # Because we open the popup on left-click for Maemo,
1642 # we also include a non-action to close the menu
1643 menu.append(gtk.SeparatorMenuItem())
1644 item = gtk.ImageMenuItem(_('Close this menu'))
1645 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1646 menu.append(self.set_finger_friendly(item))
1648 menu.show_all()
1649 # Disable tooltips while we are showing the menu, so
1650 # the tooltip will not appear over the menu
1651 self.treeview_allow_tooltips(self.treeAvailable, False)
1652 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
1653 menu.popup( None, None, None, event.button, event.time)
1655 return True
1657 def set_title(self, new_title):
1658 if not gpodder.ui.fremantle:
1659 self.default_title = new_title
1660 self.gPodder.set_title(new_title)
1662 def update_episode_list_icons(self, urls=None, selected=False, all=False):
1664 Updates the status icons in the episode list.
1666 If urls is given, it should be a list of URLs
1667 of episodes that should be updated.
1669 If urls is None, set ONE OF selected, all to
1670 True (the former updates just the selected
1671 episodes and the latter updates all episodes).
1673 if urls is not None:
1674 # We have a list of URLs to walk through
1675 self.episode_list_model.update_by_urls(urls, \
1676 self.episode_is_downloading, \
1677 self.config.episode_list_descriptions and \
1678 gpodder.ui.desktop)
1679 elif selected and not all:
1680 # We should update all selected episodes
1681 selection = self.treeAvailable.get_selection()
1682 model, paths = selection.get_selected_rows()
1683 for path in reversed(paths):
1684 iter = model.get_iter(path)
1685 self.episode_list_model.update_by_filter_iter(iter, \
1686 self.episode_is_downloading, \
1687 self.config.episode_list_descriptions and \
1688 gpodder.ui.desktop)
1689 elif all and not selected:
1690 # We update all (even the filter-hidden) episodes
1691 self.episode_list_model.update_all(\
1692 self.episode_is_downloading, \
1693 self.config.episode_list_descriptions and \
1694 gpodder.ui.desktop)
1695 else:
1696 # Wrong/invalid call - have to specify at least one parameter
1697 raise ValueError('Invalid call to update_episode_list_icons')
1699 def episode_list_status_changed(self, episodes):
1700 self.update_episode_list_icons(set(e.url for e in episodes))
1701 self.update_podcast_list_model(set(e.channel.url for e in episodes))
1702 self.db.commit()
1704 def clean_up_downloads(self, delete_partial=False):
1705 # Clean up temporary files left behind by old gPodder versions
1706 temporary_files = glob.glob('%s/*/.tmp-*' % self.config.download_dir)
1708 if delete_partial:
1709 temporary_files += glob.glob('%s/*/*.partial' % self.config.download_dir)
1711 for tempfile in temporary_files:
1712 util.delete_file(tempfile)
1714 # Clean up empty download folders and abandoned download folders
1715 download_dirs = glob.glob(os.path.join(self.config.download_dir, '*'))
1716 for ddir in download_dirs:
1717 if os.path.isdir(ddir) and False: # FIXME not db.channel_foldername_exists(os.path.basename(ddir)):
1718 globr = glob.glob(os.path.join(ddir, '*'))
1719 if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
1720 log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
1721 shutil.rmtree(ddir, ignore_errors=True)
1723 def streaming_possible(self):
1724 if gpodder.ui.desktop:
1725 # User has to have a media player set on the Desktop, or else we
1726 # would probably open the browser when giving a URL to xdg-open..
1727 return (self.config.player and self.config.player != 'default')
1728 elif gpodder.ui.maemo:
1729 # On Maemo, the default is to use the Nokia Media Player, which is
1730 # already able to deal with HTTP URLs the right way, so we
1731 # unconditionally enable streaming always on Maemo
1732 return True
1734 return False
1736 def playback_episodes_for_real(self, episodes):
1737 groups = collections.defaultdict(list)
1738 for episode in episodes:
1739 file_type = episode.file_type()
1740 if file_type == 'video' and self.config.videoplayer and \
1741 self.config.videoplayer != 'default':
1742 player = self.config.videoplayer
1743 if gpodder.ui.diablo:
1744 # Use the wrapper script if it's installed to crop 3GP YouTube
1745 # videos to fit the screen (looks much nicer than w/ black border)
1746 if player == 'mplayer' and util.find_command('gpodder-mplayer'):
1747 player = 'gpodder-mplayer'
1748 elif file_type == 'audio' and self.config.player and \
1749 self.config.player != 'default':
1750 player = self.config.player
1751 else:
1752 player = 'default'
1754 if file_type not in ('audio', 'video') or \
1755 (file_type == 'audio' and not self.config.audio_played_dbus) or \
1756 (file_type == 'video' and not self.config.video_played_dbus):
1757 # Mark episode as played in the database
1758 episode.mark(is_played=True)
1760 filename = episode.local_filename(create=False)
1761 if filename is None or not os.path.exists(filename):
1762 filename = episode.url
1763 groups[player].append(filename)
1765 # Open episodes with system default player
1766 if 'default' in groups:
1767 for filename in groups['default']:
1768 log('Opening with system default: %s', filename, sender=self)
1769 util.gui_open(filename)
1770 del groups['default']
1771 elif gpodder.ui.maemo:
1772 # When on Maemo and not opening with default, show a notification
1773 # (no startup notification for Panucci / MPlayer yet...)
1774 if len(episodes) == 1:
1775 text = _('Opening %s') % episodes[0].title
1776 else:
1777 count = len(episodes)
1778 text = N_('Opening %d episode', 'Opening %d episodes', count) % count
1780 banner = hildon.hildon_banner_show_animation(self.gPodder, '', text)
1782 def destroy_banner_later(banner):
1783 banner.destroy()
1784 return False
1785 gobject.timeout_add(5000, destroy_banner_later, banner)
1787 # For each type now, go and create play commands
1788 for group in groups:
1789 for command in util.format_desktop_command(group, groups[group]):
1790 log('Executing: %s', repr(command), sender=self)
1791 subprocess.Popen(command)
1793 def playback_episodes(self, episodes):
1794 episodes = [e for e in episodes if \
1795 e.was_downloaded(and_exists=True) or self.streaming_possible()]
1797 try:
1798 self.playback_episodes_for_real(episodes)
1799 except Exception, e:
1800 log('Error in playback!', sender=self, traceback=True)
1801 if gpodder.ui.desktop:
1802 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
1803 _('Error opening player'), widget=self.toolPreferences)
1804 else:
1805 self.show_message(_('Please check your media player settings in the preferences dialog.'))
1807 channel_urls = set()
1808 episode_urls = set()
1809 for episode in episodes:
1810 channel_urls.add(episode.channel.url)
1811 episode_urls.add(episode.url)
1812 self.update_episode_list_icons(episode_urls)
1813 self.update_podcast_list_model(channel_urls)
1815 def play_or_download(self):
1816 if not gpodder.ui.fremantle:
1817 if self.wNotebook.get_current_page() > 0:
1818 if gpodder.ui.desktop:
1819 self.toolCancel.set_sensitive(True)
1820 return
1822 if self.currently_updating:
1823 return (False, False, False, False, False, False)
1825 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
1826 ( is_played, is_locked ) = (False,)*2
1828 open_instead_of_play = False
1830 selection = self.treeAvailable.get_selection()
1831 if selection.count_selected_rows() > 0:
1832 (model, paths) = selection.get_selected_rows()
1834 for path in paths:
1835 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
1837 if episode.file_type() not in ('audio', 'video'):
1838 open_instead_of_play = True
1840 if episode.was_downloaded():
1841 can_play = episode.was_downloaded(and_exists=True)
1842 can_delete = True
1843 is_played = episode.is_played
1844 is_locked = episode.is_locked
1845 if not can_play:
1846 can_download = True
1847 else:
1848 if self.episode_is_downloading(episode):
1849 can_cancel = True
1850 else:
1851 can_download = True
1853 can_download = can_download and not can_cancel
1854 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
1855 can_transfer = can_play and self.config.device_type != 'none' and not can_cancel and not can_download and not open_instead_of_play
1857 if gpodder.ui.desktop:
1858 if open_instead_of_play:
1859 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
1860 else:
1861 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
1862 self.toolPlay.set_sensitive( can_play)
1863 self.toolDownload.set_sensitive( can_download)
1864 self.toolTransfer.set_sensitive( can_transfer)
1865 self.toolCancel.set_sensitive( can_cancel)
1867 if not gpodder.ui.fremantle:
1868 self.item_cancel_download.set_sensitive(can_cancel)
1869 self.itemDownloadSelected.set_sensitive(can_download)
1870 self.itemOpenSelected.set_sensitive(can_play)
1871 self.itemPlaySelected.set_sensitive(can_play)
1872 self.itemDeleteSelected.set_sensitive(can_play and not can_download)
1873 self.item_toggle_played.set_sensitive(can_play)
1874 self.item_toggle_lock.set_sensitive(can_play)
1875 self.itemOpenSelected.set_visible(open_instead_of_play)
1876 self.itemPlaySelected.set_visible(not open_instead_of_play)
1878 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
1880 def on_cbMaxDownloads_toggled(self, widget, *args):
1881 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
1883 def on_cbLimitDownloads_toggled(self, widget, *args):
1884 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
1886 def episode_new_status_changed(self, urls):
1887 self.update_podcast_list_model()
1888 self.update_episode_list_icons(urls)
1890 def update_podcast_list_model(self, urls=None, selected=False, select_url=None):
1891 """Update the podcast list treeview model
1893 If urls is given, it should list the URLs of each
1894 podcast that has to be updated in the list.
1896 If selected is True, only update the model contents
1897 for the currently-selected podcast - nothing more.
1899 The caller can optionally specify "select_url",
1900 which is the URL of the podcast that is to be
1901 selected in the list after the update is complete.
1902 This only works if the podcast list has to be
1903 reloaded; i.e. something has been added or removed
1904 since the last update of the podcast list).
1906 selection = self.treeChannels.get_selection()
1907 model, iter = selection.get_selected()
1909 if selected:
1910 # very cheap! only update selected channel
1911 if iter is not None:
1912 self.podcast_list_model.update_by_filter_iter(iter)
1913 elif not self.channel_list_changed:
1914 # we can keep the model, but have to update some
1915 if urls is None:
1916 # still cheaper than reloading the whole list
1917 self.podcast_list_model.update_all()
1918 else:
1919 # ok, we got a bunch of urls to update
1920 self.podcast_list_model.update_by_urls(urls)
1921 else:
1922 if model and iter and select_url is None:
1923 # Get the URL of the currently-selected podcast
1924 select_url = model.get_value(iter, PodcastListModel.C_URL)
1926 # Update the podcast list model with new channels
1927 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
1929 try:
1930 selected_iter = model.get_iter_first()
1931 # Find the previously-selected URL in the new
1932 # model if we have an URL (else select first)
1933 if select_url is not None:
1934 pos = model.get_iter_first()
1935 while pos is not None:
1936 url = model.get_value(pos, PodcastListModel.C_URL)
1937 if url == select_url:
1938 selected_iter = pos
1939 break
1940 pos = model.iter_next(pos)
1942 if not gpodder.ui.fremantle:
1943 if selected_iter is not None:
1944 selection.select_iter(selected_iter)
1945 self.on_treeChannels_cursor_changed(self.treeChannels)
1946 except:
1947 log('Cannot select podcast in list', traceback=True, sender=self)
1948 self.channel_list_changed = False
1950 def episode_is_downloading(self, episode):
1951 """Returns True if the given episode is being downloaded at the moment"""
1952 if episode is None:
1953 return False
1955 return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
1957 def update_episode_list_model(self):
1958 if self.channels and self.active_channel is not None:
1959 if gpodder.ui.diablo:
1960 banner = hildon.hildon_banner_show_animation(self.gPodder, None, _('Loading episodes'))
1961 else:
1962 banner = None
1964 if gpodder.ui.fremantle:
1965 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, True)
1967 self.currently_updating = True
1968 self.episode_list_model.clear()
1969 def do_update_episode_list_model():
1970 self.episode_list_model.add_from_channel(\
1971 self.active_channel, \
1972 self.episode_is_downloading, \
1973 self.config.episode_list_descriptions \
1974 and gpodder.ui.desktop)
1976 def on_episode_list_model_updated():
1977 if banner is not None:
1978 banner.destroy()
1979 if gpodder.ui.fremantle:
1980 hildon.hildon_gtk_window_set_progress_indicator(self.episodes_window.main_window, False)
1981 self.treeAvailable.columns_autosize()
1982 self.currently_updating = False
1983 self.play_or_download()
1984 util.idle_add(on_episode_list_model_updated)
1985 threading.Thread(target=do_update_episode_list_model).start()
1986 else:
1987 self.episode_list_model.clear()
1989 def offer_new_episodes(self, channels=None):
1990 new_episodes = self.get_new_episodes(channels)
1991 if new_episodes:
1992 self.new_episodes_show(new_episodes)
1993 return True
1994 return False
1996 def add_podcast_list(self, urls, auth_tokens=None):
1997 """Subscribe to a list of podcast given their URLs
1999 If auth_tokens is given, it should be a dictionary
2000 mapping URLs to (username, password) tuples."""
2002 if auth_tokens is None:
2003 auth_tokens = {}
2005 # Sort and split the URL list into five buckets
2006 queued, failed, existing, worked, authreq = [], [], [], [], []
2007 for input_url in urls:
2008 url = util.normalize_feed_url(input_url)
2009 if url is None:
2010 # Fail this one because the URL is not valid
2011 failed.append(input_url)
2012 elif self.podcast_list_model.get_filter_path_from_url(url) is not None:
2013 # A podcast already exists in the list for this URL
2014 existing.append(url)
2015 else:
2016 # This URL has survived the first round - queue for add
2017 queued.append(url)
2018 if url != input_url and input_url in auth_tokens:
2019 auth_tokens[url] = auth_tokens[input_url]
2021 error_messages = {}
2022 redirections = {}
2024 progress = ProgressIndicator(_('Adding podcasts'), \
2025 _('Please wait while episode information is downloaded.'), \
2026 parent=self.main_window)
2028 def on_after_update():
2029 progress.on_finished()
2030 # Report already-existing subscriptions to the user
2031 if existing:
2032 title = _('Existing subscriptions skipped')
2033 message = _('You are already subscribed to these podcasts:') \
2034 + '\n\n' + '\n'.join(saxutils.escape(url) for url in existing)
2035 self.show_message(message, title, widget=self.treeChannels)
2037 # Report subscriptions that require authentication
2038 if authreq:
2039 retry_podcasts = {}
2040 for url in authreq:
2041 title = _('Podcast requires authentication')
2042 message = _('Please login to %s:') % (saxutils.escape(url),)
2043 success, auth_tokens = self.show_login_dialog(title, message)
2044 if success:
2045 retry_podcasts[url] = auth_tokens
2046 else:
2047 # Stop asking the user for more login data
2048 retry_podcasts = {}
2049 for url in authreq:
2050 error_messages[url] = _('Authentication failed')
2051 failed.append(url)
2052 break
2054 # If we have authentication data to retry, do so here
2055 if retry_podcasts:
2056 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
2058 # Report website redirections
2059 for url in redirections:
2060 title = _('Website redirection detected')
2061 message = _('The URL %s redirects to %s.') \
2062 + '\n\n' + _('Do you want to visit the website now?')
2063 message = message % (url, redirections[url])
2064 if self.show_confirmation(message, title):
2065 util.open_website(url)
2066 else:
2067 break
2069 # Report failed subscriptions to the user
2070 if failed:
2071 title = _('Could not add some podcasts')
2072 message = _('Some podcasts could not be added to your list:') \
2073 + '\n\n' + '\n'.join(saxutils.escape('%s: %s' % (url, \
2074 error_messages.get(url, _('Unknown')))) for url in failed)
2075 self.show_message(message, title, important=True)
2077 # If at least one podcast has been added, save and update all
2078 if self.channel_list_changed:
2079 self.save_channels_opml()
2081 # If only one podcast was added, select it after the update
2082 if len(worked) == 1:
2083 url = worked[0]
2084 else:
2085 url = None
2087 # Update the list of subscribed podcasts
2088 self.update_feed_cache(force_update=False, select_url_afterwards=url)
2089 self.update_podcasts_tab()
2091 # Offer to download new episodes
2092 self.offer_new_episodes(channels=[c for c in self.channels if c.url in worked])
2094 def thread_proc():
2095 # After the initial sorting and splitting, try all queued podcasts
2096 length = len(queued)
2097 for index, url in enumerate(queued):
2098 progress.on_progress(float(index)/float(length))
2099 progress.on_message(url)
2100 log('QUEUE RUNNER: %s', url, sender=self)
2101 try:
2102 # The URL is valid and does not exist already - subscribe!
2103 channel = PodcastChannel.load(self.db, url=url, create=True, \
2104 authentication_tokens=auth_tokens.get(url, None), \
2105 max_episodes=self.config.max_episodes_per_feed, \
2106 download_dir=self.config.download_dir, \
2107 allow_empty_feeds=self.config.allow_empty_feeds)
2109 try:
2110 username, password = util.username_password_from_url(url)
2111 except ValueError, ve:
2112 username, password = (None, None)
2114 if username is not None and channel.username is None and \
2115 password is not None and channel.password is None:
2116 channel.username = username
2117 channel.password = password
2118 channel.save()
2120 self._update_cover(channel)
2121 except feedcore.AuthenticationRequired:
2122 if url in auth_tokens:
2123 # Fail for wrong authentication data
2124 error_messages[url] = _('Authentication failed')
2125 failed.append(url)
2126 else:
2127 # Queue for login dialog later
2128 authreq.append(url)
2129 continue
2130 except feedcore.WifiLogin, error:
2131 redirections[url] = error.data
2132 failed.append(url)
2133 error_messages[url] = _('Redirection detected')
2134 continue
2135 except Exception, e:
2136 log('Subscription error: %s', e, traceback=True, sender=self)
2137 error_messages[url] = str(e)
2138 failed.append(url)
2139 continue
2141 assert channel is not None
2142 worked.append(channel.url)
2143 self.channels.append(channel)
2144 self.channel_list_changed = True
2145 util.idle_add(on_after_update)
2146 threading.Thread(target=thread_proc).start()
2148 def save_channels_opml(self):
2149 exporter = opml.Exporter(gpodder.subscription_file)
2150 return exporter.write(self.channels)
2152 def update_feed_cache_finish_callback(self, updated_urls=None, select_url_afterwards=None):
2153 self.db.commit()
2154 self.updating_feed_cache = False
2156 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2157 self.channel_list_changed = True
2158 self.update_podcast_list_model(select_url=select_url_afterwards)
2160 # Only search for new episodes in podcasts that have been
2161 # updated, not in other podcasts (for single-feed updates)
2162 episodes = self.get_new_episodes([c for c in self.channels if c.url in updated_urls])
2164 if gpodder.ui.fremantle:
2165 self.button_subscribe.set_sensitive(True)
2166 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
2167 self.ICON_GENERAL_REFRESH, gtk.ICON_SIZE_BUTTON))
2168 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, False)
2169 self.update_podcasts_tab()
2170 if self.feed_cache_update_cancelled:
2171 return
2173 if episodes:
2174 if self.config.auto_download == 'always':
2175 count = len(episodes)
2176 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2177 self.show_message(title)
2178 self.download_episode_list(episodes)
2179 elif self.config.auto_download == 'queue':
2180 self.show_message(_('New episodes have been added to the download list.'))
2181 self.download_episode_list_paused(episodes)
2182 else:
2183 self.new_episodes_show(episodes)
2184 elif not self.config.auto_update_feeds:
2185 self.show_message(_('No new episodes. Please check for new episodes later.'))
2186 return
2188 if self.tray_icon:
2189 self.tray_icon.set_status()
2191 if self.feed_cache_update_cancelled:
2192 # The user decided to abort the feed update
2193 self.show_update_feeds_buttons()
2194 elif not episodes:
2195 # Nothing new here - but inform the user
2196 self.pbFeedUpdate.set_fraction(1.0)
2197 self.pbFeedUpdate.set_text(_('No new episodes'))
2198 self.feed_cache_update_cancelled = True
2199 self.btnCancelFeedUpdate.show()
2200 self.btnCancelFeedUpdate.set_sensitive(True)
2201 if gpodder.ui.maemo:
2202 # btnCancelFeedUpdate is a ToolButton on Maemo
2203 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_APPLY)
2204 else:
2205 # btnCancelFeedUpdate is a normal gtk.Button
2206 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
2207 else:
2208 count = len(episodes)
2209 # New episodes are available
2210 self.pbFeedUpdate.set_fraction(1.0)
2211 # Are we minimized and should we auto download?
2212 if (self.is_iconified() and (self.config.auto_download == 'minimized')) or (self.config.auto_download == 'always'):
2213 self.download_episode_list(episodes)
2214 title = N_('Downloading %d new episode.', 'Downloading %d new episodes.', count) % count
2215 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2216 self.show_update_feeds_buttons()
2217 else:
2218 self.show_update_feeds_buttons()
2219 # New episodes are available and we are not minimized
2220 if not self.config.do_not_show_new_episodes_dialog:
2221 self.new_episodes_show(episodes, notification=True)
2222 else:
2223 message = N_('%d new episode available', '%d new episodes available', count) % count
2224 self.pbFeedUpdate.set_text(message)
2226 def _update_cover(self, channel):
2227 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
2228 self.cover_downloader.request_cover(channel)
2230 def update_feed_cache_proc(self, channels, select_url_afterwards):
2231 total = len(channels)
2233 for updated, channel in enumerate(channels):
2234 if not self.feed_cache_update_cancelled:
2235 try:
2236 # Update if timeout is not reached or we update a single podcast or skipping is disabled
2237 if channel.query_automatic_update() or total == 1 or not self.config.feed_update_skipping:
2238 channel.update(max_episodes=self.config.max_episodes_per_feed)
2239 else:
2240 log('Skipping update of %s (see feed_update_skipping)', channel.title, sender=self)
2241 self._update_cover(channel)
2242 except Exception, e:
2243 self.notification(_('There has been an error updating %s: %s') % (saxutils.escape(channel.url), saxutils.escape(str(e))), _('Error while updating feed'), widget=self.treeChannels)
2244 log('Error: %s', str(e), sender=self, traceback=True)
2246 if self.feed_cache_update_cancelled:
2247 break
2249 if gpodder.ui.fremantle:
2250 util.idle_add(self.button_refresh.set_title, \
2251 _('%d/%d updated') % (updated, total))
2252 continue
2254 # By the time we get here the update may have already been cancelled
2255 if not self.feed_cache_update_cancelled:
2256 def update_progress():
2257 progression = _('Updated %s (%d/%d)') % (channel.title, updated, total)
2258 self.pbFeedUpdate.set_text(progression)
2259 if self.tray_icon:
2260 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression)
2261 self.pbFeedUpdate.set_fraction(float(updated)/float(total))
2262 util.idle_add(update_progress)
2264 updated_urls = [c.url for c in channels]
2265 util.idle_add(self.update_feed_cache_finish_callback, updated_urls, select_url_afterwards)
2267 def show_update_feeds_buttons(self):
2268 # Make sure that the buttons for updating feeds
2269 # appear - this should happen after a feed update
2270 if gpodder.ui.maemo:
2271 self.btnUpdateSelectedFeed.show()
2272 self.toolFeedUpdateProgress.hide()
2273 self.btnCancelFeedUpdate.hide()
2274 self.btnCancelFeedUpdate.set_is_important(False)
2275 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_CLOSE)
2276 self.toolbarSpacer.set_expand(True)
2277 self.toolbarSpacer.set_draw(False)
2278 else:
2279 self.hboxUpdateFeeds.hide()
2280 self.btnUpdateFeeds.show()
2281 self.itemUpdate.set_sensitive(True)
2282 self.itemUpdateChannel.set_sensitive(True)
2284 def on_btnCancelFeedUpdate_clicked(self, widget):
2285 if not self.feed_cache_update_cancelled:
2286 self.pbFeedUpdate.set_text(_('Cancelling...'))
2287 self.feed_cache_update_cancelled = True
2288 self.btnCancelFeedUpdate.set_sensitive(False)
2289 else:
2290 self.show_update_feeds_buttons()
2292 def update_feed_cache(self, channels=None, force_update=True, select_url_afterwards=None):
2293 if self.updating_feed_cache:
2294 if gpodder.ui.fremantle:
2295 self.feed_cache_update_cancelled = True
2296 return
2298 if not force_update:
2299 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
2300 self.channel_list_changed = True
2301 self.update_podcast_list_model(select_url=select_url_afterwards)
2302 return
2304 self.updating_feed_cache = True
2306 if channels is None:
2307 channels = self.channels
2309 if gpodder.ui.fremantle:
2310 hildon.hildon_gtk_window_set_progress_indicator(self.main_window, True)
2311 self.button_refresh.set_title(_('Updating...'))
2312 self.button_subscribe.set_sensitive(False)
2313 self.button_refresh.set_image(gtk.image_new_from_icon_name(\
2314 self.ICON_GENERAL_CLOSE, gtk.ICON_SIZE_BUTTON))
2315 self.feed_cache_update_cancelled = False
2316 else:
2317 self.itemUpdate.set_sensitive(False)
2318 self.itemUpdateChannel.set_sensitive(False)
2320 if self.tray_icon:
2321 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
2323 if len(channels) == 1:
2324 text = _('Updating "%s"...') % channels[0].title
2325 else:
2326 count = len(channels)
2327 text = N_('Updating %d feed...', 'Updating %d feeds...', count) % count
2328 self.pbFeedUpdate.set_text(text)
2329 self.pbFeedUpdate.set_fraction(0)
2331 self.feed_cache_update_cancelled = False
2332 self.btnCancelFeedUpdate.show()
2333 self.btnCancelFeedUpdate.set_sensitive(True)
2334 if gpodder.ui.maemo:
2335 self.toolbarSpacer.set_expand(False)
2336 self.toolbarSpacer.set_draw(True)
2337 self.btnUpdateSelectedFeed.hide()
2338 self.toolFeedUpdateProgress.show_all()
2339 else:
2340 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
2341 self.hboxUpdateFeeds.show_all()
2342 self.btnUpdateFeeds.hide()
2344 args = (channels, select_url_afterwards)
2345 threading.Thread(target=self.update_feed_cache_proc, args=args).start()
2347 def on_gPodder_delete_event(self, widget, *args):
2348 """Called when the GUI wants to close the window
2349 Displays a confirmation dialog (and closes/hides gPodder)
2352 downloading = self.download_status_model.are_downloads_in_progress()
2354 # Only iconify if we are using the window's "X" button,
2355 # but not when we are using "Quit" in the menu or toolbar
2356 if self.config.on_quit_systray and self.tray_icon and widget.get_name() not in ('toolQuit', 'itemQuit'):
2357 self.iconify_main_window()
2358 elif self.config.on_quit_ask or downloading:
2359 if gpodder.ui.fremantle:
2360 self.close_gpodder()
2361 elif gpodder.ui.diablo:
2362 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
2363 if result:
2364 self.close_gpodder()
2365 else:
2366 return True
2367 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2368 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2369 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
2371 title = _('Quit gPodder')
2372 if downloading:
2373 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
2374 else:
2375 message = _('Do you really want to quit gPodder now?')
2377 dialog.set_title(title)
2378 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
2379 if not downloading:
2380 cb_ask = gtk.CheckButton(_("Don't ask me again"))
2381 dialog.vbox.pack_start(cb_ask)
2382 cb_ask.show_all()
2384 quit_button.grab_focus()
2385 result = dialog.run()
2386 dialog.destroy()
2388 if result == gtk.RESPONSE_CLOSE:
2389 if not downloading and cb_ask.get_active() == True:
2390 self.config.on_quit_ask = False
2391 self.close_gpodder()
2392 else:
2393 self.close_gpodder()
2395 return True
2397 def close_gpodder(self):
2398 """ clean everything and exit properly
2400 if self.channels:
2401 if self.save_channels_opml():
2402 if self.config.my_gpodder_autoupload:
2403 log('Uploading to my.gpodder.org on close', sender=self)
2404 util.idle_add(self.on_upload_to_mygpo, None)
2405 else:
2406 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'), important=True)
2408 self.gPodder.hide()
2410 if self.tray_icon is not None:
2411 self.tray_icon.set_visible(False)
2413 # Notify all tasks to to carry out any clean-up actions
2414 self.download_status_model.tell_all_tasks_to_quit()
2416 while gtk.events_pending():
2417 gtk.main_iteration(False)
2419 self.db.close()
2421 self.quit()
2422 sys.exit(0)
2424 def get_old_episodes(self):
2425 episodes = []
2426 for channel in self.channels:
2427 for episode in channel.get_downloaded_episodes():
2428 if episode.age_in_days() > self.config.episode_old_age and \
2429 not episode.is_locked and episode.is_played:
2430 episodes.append(episode)
2431 return episodes
2433 def delete_episode_list(self, episodes, confirm=True):
2434 if not episodes:
2435 return False
2437 count = len(episodes)
2439 if count == 1:
2440 episode = episodes[0]
2441 if episode.is_locked:
2442 title = _('%s is locked') % saxutils.escape(episode.title)
2443 message = _('You cannot delete this locked episode. You must unlock it before you can delete it.')
2444 self.notification(message, title, widget=self.treeAvailable)
2445 return False
2447 title = _('Remove %s?') % saxutils.escape(episode.title)
2448 message = _("If you remove this episode, it will be deleted from your computer. If you want to listen to this episode again, you will have to re-download it.")
2449 else:
2450 title = N_('Remove %d episode?', 'Remove %d episodes?', count) % count
2451 message = _('If you remove these episodes, they will be deleted from your computer. If you want to listen to any of these episodes again, you will have to re-download the episodes in question.')
2453 locked_count = sum(int(e.is_locked) for e in episodes if e.is_locked is not None)
2455 if count == locked_count:
2456 title = _('Episodes are locked')
2457 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
2458 self.notification(message, title, widget=self.treeAvailable)
2459 return False
2460 elif locked_count > 0:
2461 title = _('Remove %d out of %d episodes?') % (count-locked_count, count)
2462 message = _('The selection contains locked episodes that will not be deleted. If you want to listen to the deleted episodes, you will have to re-download them.')
2464 if confirm and not self.show_confirmation(message, title):
2465 return False
2467 progress = ProgressIndicator(_('Removing episodes'), \
2468 _('Please wait while episodes are deleted'), \
2469 parent=self.main_window)
2471 def finish_deletion(episode_urls, channel_urls):
2472 progress.on_finished()
2474 # Episodes have been deleted - persist the database
2475 self.db.commit()
2477 self.update_episode_list_icons(episode_urls)
2478 self.update_podcast_list_model(channel_urls)
2479 self.play_or_download()
2481 def thread_proc():
2482 episode_urls = set()
2483 channel_urls = set()
2485 for idx, episode in enumerate(episodes):
2486 progress.on_progress(float(idx)/float(len(episodes)))
2487 if episode.is_locked:
2488 log('Not deleting episode (is locked): %s', episode.title)
2489 else:
2490 log('Deleting episode: %s', episode.title)
2491 progress.on_message(_('Deleting: %s') % episode.title)
2492 episode.delete_from_disk()
2493 episode_urls.add(episode.url)
2494 channel_urls.add(episode.channel.url)
2496 # Tell the shownotes window that we have removed the episode
2497 if self.episode_shownotes_window is not None and \
2498 self.episode_shownotes_window.episode is not None and \
2499 self.episode_shownotes_window.episode.url == episode.url:
2500 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
2502 util.idle_add(finish_deletion, episode_urls, channel_urls)
2504 threading.Thread(target=thread_proc).start()
2506 return True
2508 def on_itemRemoveOldEpisodes_activate( self, widget):
2509 if gpodder.ui.maemo:
2510 columns = (
2511 ('maemo_remove_markup', None, None, _('Episode')),
2513 else:
2514 columns = (
2515 ('title_markup', None, None, _('Episode')),
2516 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
2517 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
2518 ('played_prop', None, None, _('Status')),
2519 ('age_prop', None, None, _('Downloaded')),
2522 msg_older_than = N_('Select older than %d day', 'Select older than %d days', self.config.episode_old_age)
2523 selection_buttons = {
2524 _('Select played'): lambda episode: episode.is_played,
2525 msg_older_than % self.config.episode_old_age: lambda episode: episode.age_in_days() > self.config.episode_old_age,
2528 instructions = _('Select the episodes you want to delete:')
2530 episodes = []
2531 selected = []
2532 for channel in self.channels:
2533 for episode in channel.get_downloaded_episodes():
2534 # Disallow deletion of locked episodes that still exist
2535 if not episode.is_locked or not episode.file_exists():
2536 episodes.append(episode)
2537 # Automatically select played and file-less episodes
2538 selected.append(episode.is_played or \
2539 not episode.file_exists())
2541 gPodderEpisodeSelector(self.gPodder, title = _('Remove old episodes'), instructions = instructions, \
2542 episodes = episodes, selected = selected, columns = columns, \
2543 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
2544 selection_buttons = selection_buttons, _config=self.config)
2546 def on_selected_episodes_status_changed(self):
2547 self.update_episode_list_icons(selected=True)
2548 self.update_podcast_list_model(selected=True)
2549 self.db.commit()
2551 def mark_selected_episodes_new(self):
2552 for episode in self.get_selected_episodes():
2553 episode.mark_new()
2554 self.on_selected_episodes_status_changed()
2556 def mark_selected_episodes_old(self):
2557 for episode in self.get_selected_episodes():
2558 episode.mark_old()
2559 self.on_selected_episodes_status_changed()
2561 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
2562 for episode in self.get_selected_episodes():
2563 if toggle:
2564 episode.mark(is_played=not episode.is_played)
2565 else:
2566 episode.mark(is_played=new_value)
2567 self.on_selected_episodes_status_changed()
2569 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2570 for episode in self.get_selected_episodes():
2571 if toggle:
2572 episode.mark(is_locked=not episode.is_locked)
2573 else:
2574 episode.mark(is_locked=new_value)
2575 self.on_selected_episodes_status_changed()
2577 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2578 if self.active_channel is None:
2579 return
2581 self.active_channel.channel_is_locked = not self.active_channel.channel_is_locked
2582 self.active_channel.update_channel_lock()
2584 for episode in self.active_channel.get_all_episodes():
2585 episode.mark(is_locked=self.active_channel.channel_is_locked)
2587 self.update_podcast_list_model(selected=True)
2588 self.update_episode_list_icons(all=True)
2590 def on_itemUpdateChannel_activate(self, widget=None):
2591 if self.active_channel is None:
2592 title = _('No podcast selected')
2593 message = _('Please select a podcast in the podcasts list to update.')
2594 self.show_message( message, title, widget=self.treeChannels)
2595 return
2597 self.update_feed_cache(channels=[self.active_channel])
2599 def on_itemUpdate_activate(self, widget=None):
2600 if self.channels:
2601 self.update_feed_cache()
2602 else:
2603 gPodderWelcome(self.gPodder, center_on_widget=self.gPodder, show_example_podcasts_callback=self.on_itemImportChannels_activate, setup_my_gpodder_callback=self.on_download_from_mygpo)
2605 def download_episode_list_paused(self, episodes):
2606 self.download_episode_list(episodes, True)
2608 def download_episode_list(self, episodes, add_paused=False):
2609 for episode in episodes:
2610 log('Downloading episode: %s', episode.title, sender = self)
2611 if not episode.was_downloaded(and_exists=True):
2612 task_exists = False
2613 for task in self.download_tasks_seen:
2614 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
2615 self.download_queue_manager.add_task(task)
2616 self.enable_download_list_update()
2617 task_exists = True
2618 continue
2620 if task_exists:
2621 continue
2623 try:
2624 task = download.DownloadTask(episode, self.config)
2625 except Exception, e:
2626 self.show_message(_('Download error while downloading %s:\n\n%s') % (episode.title, str(e)), _('Download error'), important=True)
2627 log('Download error while downloading %s', episode.title, sender=self, traceback=True)
2628 continue
2630 if add_paused:
2631 task.status = task.PAUSED
2632 else:
2633 self.download_queue_manager.add_task(task)
2635 self.download_status_model.register_task(task)
2636 self.enable_download_list_update()
2638 def cancel_task_list(self, tasks):
2639 if not tasks:
2640 return
2642 for task in tasks:
2643 if task.status in (task.QUEUED, task.DOWNLOADING):
2644 task.status = task.CANCELLED
2645 elif task.status == task.PAUSED:
2646 task.status = task.CANCELLED
2647 # Call run, so the partial file gets deleted
2648 task.run()
2650 self.update_episode_list_icons([task.url for task in tasks])
2651 self.play_or_download()
2653 # Update the tab title and downloads list
2654 self.update_downloads_list()
2656 def new_episodes_show(self, episodes, notification=False):
2657 if gpodder.ui.maemo:
2658 columns = (
2659 ('maemo_markup', None, None, _('Episode')),
2661 show_notification = notification
2662 else:
2663 columns = (
2664 ('title_markup', None, None, _('Episode')),
2665 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
2666 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
2668 show_notification = False
2670 instructions = _('Select the episodes you want to download:')
2672 if self.new_episodes_window is not None:
2673 self.new_episodes_window.main_window.destroy()
2674 self.new_episodes_window = None
2676 def download_episodes_callback(episodes):
2677 self.new_episodes_window = None
2678 self.download_episode_list(episodes)
2680 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
2681 title=_('New episodes available'), \
2682 instructions=instructions, \
2683 episodes=episodes, \
2684 columns=columns, \
2685 selected_default=True, \
2686 stock_ok_button = 'gpodder-download', \
2687 callback=download_episodes_callback, \
2688 remove_callback=lambda e: e.mark_old(), \
2689 remove_action=_('Mark as old'), \
2690 remove_finished=self.episode_new_status_changed, \
2691 _config=self.config, \
2692 show_notification=show_notification)
2694 def on_itemDownloadAllNew_activate(self, widget, *args):
2695 if not self.offer_new_episodes():
2696 self.show_message(_('Please check for new episodes later.'), \
2697 _('No new episodes available'), widget=self.btnUpdateFeeds)
2699 def get_new_episodes(self, channels=None):
2700 if channels is None:
2701 channels = self.channels
2702 episodes = []
2703 for channel in channels:
2704 for episode in channel.get_new_episodes(downloading=self.episode_is_downloading):
2705 episodes.append(episode)
2707 return episodes
2709 def on_sync_to_ipod_activate(self, widget, episodes=None):
2710 self.sync_ui.on_synchronize_episodes(self.channels, episodes)
2711 # The sync process might have updated the status of episodes,
2712 # therefore persist the database here to avoid losing data
2713 self.db.commit()
2715 def on_cleanup_ipod_activate(self, widget, *args):
2716 self.sync_ui.on_cleanup_device()
2718 def on_manage_device_playlist(self, widget):
2719 self.sync_ui.on_manage_device_playlist()
2721 def show_hide_tray_icon(self):
2722 if self.config.display_tray_icon and have_trayicon and self.tray_icon is None:
2723 self.tray_icon = GPodderStatusIcon(self, gpodder.icon_file, self.config)
2724 elif not self.config.display_tray_icon and self.tray_icon is not None:
2725 self.tray_icon.set_visible(False)
2726 del self.tray_icon
2727 self.tray_icon = None
2729 if self.config.minimize_to_tray and self.tray_icon:
2730 self.tray_icon.set_visible(self.is_iconified())
2731 elif self.tray_icon:
2732 self.tray_icon.set_visible(True)
2734 def on_itemShowToolbar_activate(self, widget):
2735 self.config.show_toolbar = self.itemShowToolbar.get_active()
2737 def on_itemShowDescription_activate(self, widget):
2738 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
2740 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
2741 self.config.podcast_list_hide_boring = toggleaction.get_active()
2742 if self.config.podcast_list_hide_boring:
2743 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
2744 else:
2745 self.podcast_list_model.set_view_mode(-1)
2747 def on_item_view_podcasts_changed(self, radioaction, current):
2748 # Only on Fremantle
2749 if current == self.item_view_podcasts_all:
2750 self.podcast_list_model.set_view_mode(-1)
2751 elif current == self.item_view_podcasts_downloaded:
2752 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
2753 elif current == self.item_view_podcasts_unplayed:
2754 self.podcast_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
2756 self.config.podcast_list_view_mode = self.podcast_list_model.get_view_mode()
2758 def on_item_view_episodes_changed(self, radioaction, current):
2759 if current == self.item_view_episodes_all:
2760 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_ALL)
2761 elif current == self.item_view_episodes_undeleted:
2762 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNDELETED)
2763 elif current == self.item_view_episodes_downloaded:
2764 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
2765 elif current == self.item_view_episodes_unplayed:
2766 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
2768 self.config.episode_list_view_mode = self.episode_list_model.get_view_mode()
2770 if self.config.podcast_list_hide_boring and not gpodder.ui.fremantle:
2771 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
2773 def update_item_device( self):
2774 if not gpodder.ui.fremantle:
2775 if self.config.device_type != 'none':
2776 self.itemDevice.set_visible(True)
2777 self.itemDevice.label = self.get_device_name()
2778 else:
2779 self.itemDevice.set_visible(False)
2781 def properties_closed( self):
2782 self.show_hide_tray_icon()
2783 self.update_item_device()
2784 if gpodder.ui.maemo:
2785 selection = self.treeAvailable.get_selection()
2786 if self.config.maemo_enable_gestures or \
2787 self.config.enable_fingerscroll:
2788 selection.set_mode(gtk.SELECTION_SINGLE)
2789 else:
2790 selection.set_mode(gtk.SELECTION_MULTIPLE)
2792 def on_itemPreferences_activate(self, widget, *args):
2793 gPodderPreferences(self.gPodder, _config=self.config, \
2794 callback_finished=self.properties_closed, \
2795 user_apps_reader=self.user_apps_reader, \
2796 mygpo_login=lambda: self.require_my_gpodder_authentication(force_dialog=True))
2798 def on_itemDependencies_activate(self, widget):
2799 gPodderDependencyManager(self.gPodder)
2801 def require_my_gpodder_authentication(self, force_dialog=False):
2802 if force_dialog or (not self.config.my_gpodder_username or not self.config.my_gpodder_password):
2803 success, authentication = self.show_login_dialog(_('Login to my.gpodder.org'), _('Please enter your e-mail address and your password.'), username=self.config.my_gpodder_username, password=self.config.my_gpodder_password, username_prompt=_('E-Mail Address'), register_callback=lambda: util.open_website('http://my.gpodder.org/register'))
2804 if success:
2805 self.config.my_gpodder_username, self.config.my_gpodder_password = authentication
2806 return True
2807 else:
2808 return False
2810 return True
2812 def on_goto_mygpo(self, widget):
2813 client = my.MygPodderClient(self.config.my_gpodder_service, \
2814 self.config.my_gpodder_username, \
2815 self.config.my_gpodder_password)
2816 client.open_website()
2818 def on_download_from_mygpo(self, widget=None):
2819 if self.require_my_gpodder_authentication():
2820 client = my.MygPodderClient(self.config.my_gpodder_service, \
2821 self.config.my_gpodder_username, self.config.my_gpodder_password)
2822 opml_data = client.download_subscriptions()
2823 if len(opml_data) > 0:
2824 fp = open(gpodder.subscription_file, 'w')
2825 fp.write(opml_data)
2826 fp.close()
2827 (added, skipped) = (0, 0)
2828 i = opml.Importer(gpodder.subscription_file)
2830 existing = [c.url for c in self.channels]
2831 urls = [item['url'] for item in i.items if item['url'] not in existing]
2833 skipped = len(i.items) - len(urls)
2834 added = len(urls)
2836 self.add_podcast_list(urls)
2837 if added > 0:
2838 self.show_message(_('Added %d new subscriptions and skipped %d existing ones.') % (added, skipped), _('Result of subscription download'), widget=self.treeChannels)
2839 elif widget is not None:
2840 self.show_message(_('Your local subscription list is up to date.'), _('Result of subscription download'), widget=self.treeChannels)
2841 else:
2842 self.config.my_gpodder_password = ''
2843 self.on_download_from_mygpo(widget)
2844 else:
2845 self.show_message(_('Please set up your username and password first.'), _('Username and password needed'), important=True)
2847 def on_upload_to_mygpo(self, widget):
2848 if self.require_my_gpodder_authentication():
2849 client = my.MygPodderClient(self.config.my_gpodder_service, \
2850 self.config.my_gpodder_username, self.config.my_gpodder_password)
2851 self.save_channels_opml()
2852 success, messages = client.upload_subscriptions(gpodder.subscription_file)
2853 if widget is not None:
2854 if not success:
2855 self.show_message('\n'.join(messages), _('Results of upload'), important=True)
2856 self.config.my_gpodder_password = ''
2857 self.on_upload_to_mygpo(widget)
2858 else:
2859 self.show_message('\n'.join(messages), _('Results of upload'), widget=self.treeChannels)
2860 elif not success:
2861 log('Upload to my.gpodder.org failed, but widget is None!', sender=self)
2862 elif widget is not None:
2863 self.show_message(_('Please set up your username and password first.'), _('Username and password needed'), important=True)
2865 def on_itemAddChannel_activate(self, widget=None):
2866 gPodderAddPodcast(self.gPodder, \
2867 add_urls_callback=self.add_podcast_list)
2869 def on_itemEditChannel_activate(self, widget, *args):
2870 if self.active_channel is None:
2871 title = _('No podcast selected')
2872 message = _('Please select a podcast in the podcasts list to edit.')
2873 self.show_message( message, title, widget=self.treeChannels)
2874 return
2876 callback_closed = lambda: self.update_podcast_list_model(selected=True)
2877 gPodderChannel(self.main_window, \
2878 channel=self.active_channel, \
2879 callback_closed=callback_closed, \
2880 cover_downloader=self.cover_downloader)
2882 def on_itemMassUnsubscribe_activate(self, item=None):
2883 columns = (
2884 ('title', None, None, _('Podcast')),
2887 # We're abusing the Episode Selector for selecting Podcasts here,
2888 # but it works and looks good, so why not? -- thp
2889 gPodderEpisodeSelector(self.main_window, \
2890 title=_('Remove podcasts'), \
2891 instructions=_('Select the podcast you want to remove.'), \
2892 episodes=self.channels, \
2893 columns=columns, \
2894 size_attribute=None, \
2895 stock_ok_button=gtk.STOCK_DELETE, \
2896 callback=self.remove_podcast_list, \
2897 _config=self.config)
2899 def remove_podcast_list(self, channels, confirm=True):
2900 if not channels:
2901 log('No podcasts selected for deletion', sender=self)
2902 return
2904 if len(channels) == 1:
2905 title = _('Removing podcast')
2906 info = _('Please wait while the podcast is removed')
2907 message = _('Do you really want to remove this podcast and its episodes?')
2908 else:
2909 title = _('Removing podcasts')
2910 info = _('Please wait while the podcasts are removed')
2911 message = _('Do you really want to remove the selected podcasts and their episodes?')
2913 if confirm and not self.show_confirmation(message, title):
2914 return
2916 progress = ProgressIndicator(title, info, parent=self.main_window)
2918 def finish_deletion(select_url):
2919 # Re-load the channels and select the desired new channel
2920 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
2921 progress.on_finished()
2922 self.update_podcasts_tab()
2924 def thread_proc():
2925 select_url = None
2927 for idx, channel in enumerate(channels):
2928 # Update the UI for correct status messages
2929 progress.on_progress(float(idx)/float(len(channels)))
2930 progress.on_message(_('Removing %s') % channel.title)
2932 # Delete downloaded episodes
2933 channel.remove_downloaded()
2935 # cancel any active downloads from this channel
2936 for episode in channel.get_all_episodes():
2937 util.idle_add(self.download_status_model.cancel_by_url,
2938 episode.url)
2940 if len(channels) == 1:
2941 # get the URL of the podcast we want to select next
2942 position = self.channels.index(channel)
2943 if position == len(self.channels)-1:
2944 # this is the last podcast, so select the URL
2945 # of the item before this one (i.e. the "new last")
2946 select_url = self.channels[position-1].url
2947 else:
2948 # there is a podcast after the deleted one, so
2949 # we simply select the one that comes after it
2950 select_url = self.channels[position+1].url
2952 # Remove the channel and clean the database entries
2953 channel.delete(purge=True)
2954 self.channels.remove(channel)
2956 # Clean up downloads and download directories
2957 self.clean_up_downloads()
2959 self.channel_list_changed = True
2960 self.save_channels_opml()
2962 # The remaining stuff is to be done in the GTK main thread
2963 util.idle_add(finish_deletion, select_url)
2965 threading.Thread(target=thread_proc).start()
2967 def on_itemRemoveChannel_activate(self, widget, *args):
2968 if self.active_channel is None:
2969 title = _('No podcast selected')
2970 message = _('Please select a podcast in the podcasts list to remove.')
2971 self.show_message( message, title, widget=self.treeChannels)
2972 return
2974 self.remove_podcast_list([self.active_channel])
2976 def get_opml_filter(self):
2977 filter = gtk.FileFilter()
2978 filter.add_pattern('*.opml')
2979 filter.add_pattern('*.xml')
2980 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
2981 return filter
2983 def on_item_import_from_file_activate(self, widget, filename=None):
2984 if filename is None:
2985 if gpodder.ui.desktop or gpodder.ui.fremantle:
2986 # FIXME: Hildonization on Fremantle
2987 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
2988 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2989 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
2990 elif gpodder.ui.diablo:
2991 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
2992 dlg.set_filter(self.get_opml_filter())
2993 response = dlg.run()
2994 filename = None
2995 if response == gtk.RESPONSE_OK:
2996 filename = dlg.get_filename()
2997 dlg.destroy()
2999 if filename is not None:
3000 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3001 custom_title=_('Import podcasts from OPML file'), \
3002 add_urls_callback=self.add_podcast_list, \
3003 hide_url_entry=True)
3004 dir.download_opml_file(filename)
3006 def on_itemExportChannels_activate(self, widget, *args):
3007 if not self.channels:
3008 title = _('Nothing to export')
3009 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3010 self.show_message(message, title, widget=self.treeChannels)
3011 return
3013 if gpodder.ui.desktop or gpodder.ui.fremantle:
3014 # FIXME: Hildonization on Fremantle
3015 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3016 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3017 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3018 elif gpodder.ui.diablo:
3019 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
3020 dlg.set_filter(self.get_opml_filter())
3021 response = dlg.run()
3022 if response == gtk.RESPONSE_OK:
3023 filename = dlg.get_filename()
3024 dlg.destroy()
3025 exporter = opml.Exporter( filename)
3026 if exporter.write(self.channels):
3027 count = len(self.channels)
3028 title = N_('%d subscription exported', '%d subscriptions exported', count) % count
3029 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3030 else:
3031 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3032 else:
3033 dlg.destroy()
3035 def on_itemImportChannels_activate(self, widget, *args):
3036 if gpodder.ui.fremantle:
3037 gPodderPodcastDirectory.show_add_podcast_picker(self.main_window, \
3038 self.config.toplist_url, \
3039 self.config.opml_url, \
3040 self.add_podcast_list, \
3041 self.on_itemAddChannel_activate, \
3042 self.on_download_from_mygpo, \
3043 self.show_text_edit_dialog)
3044 else:
3045 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3046 add_urls_callback=self.add_podcast_list)
3047 util.idle_add(dir.download_opml_file, self.config.opml_url)
3049 def on_homepage_activate(self, widget, *args):
3050 util.open_website(gpodder.__url__)
3052 def on_wiki_activate(self, widget, *args):
3053 util.open_website('http://wiki.gpodder.org/')
3055 def on_bug_tracker_activate(self, widget, *args):
3056 if gpodder.ui.maemo:
3057 util.open_website('http://bugs.maemo.org/enter_bug.cgi?product=gPodder')
3058 else:
3059 util.open_website('http://bugs.gpodder.org/')
3061 def on_shop_activate(self, widget, *args):
3062 util.open_website('http://gpodder.org/shop')
3064 def on_wishlist_activate(self, widget, *args):
3065 util.open_website('http://amzn.com/w/2L04WZKX274VB')
3067 def on_item_support_activate(self, widget):
3068 util.open_website('http://gpodder.org/donate')
3070 def on_itemAbout_activate(self, widget, *args):
3071 dlg = gtk.AboutDialog()
3072 dlg.set_name('gPodder')
3073 dlg.set_version(gpodder.__version__)
3074 dlg.set_copyright(gpodder.__copyright__)
3075 dlg.set_comments(_('A podcast client with focus on usability'))
3076 if not gpodder.ui.fremantle:
3077 # Disable the URL label in Fremantle because of style issues
3078 dlg.set_website(gpodder.__url__)
3079 dlg.set_translator_credits( _('translator-credits'))
3080 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
3082 if gpodder.ui.desktop:
3083 # For the "GUI" version, we add some more
3084 # items to the about dialog (credits and logo)
3085 app_authors = [
3086 _('Maintainer:'),
3087 'Thomas Perl <thpinfo.com>',
3090 if os.path.exists(gpodder.credits_file):
3091 credits = open(gpodder.credits_file).read().strip().split('\n')
3092 app_authors += ['', _('Patches, bug reports and donations by:')]
3093 app_authors += credits
3095 dlg.set_authors(app_authors)
3096 try:
3097 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(gpodder.icon_file))
3098 except:
3099 dlg.set_logo_icon_name('gpodder')
3100 elif gpodder.ui.fremantle:
3101 for parent in dlg.vbox.get_children():
3102 for child in parent.get_children():
3103 if isinstance(child, gtk.Label):
3104 child.set_selectable(False)
3105 child.set_alignment(0.0, 0.5)
3107 dlg.run()
3109 def on_wNotebook_switch_page(self, widget, *args):
3110 page_num = args[1]
3111 if gpodder.ui.maemo:
3112 self.tool_downloads.set_active(page_num == 1)
3113 page = self.wNotebook.get_nth_page(page_num)
3114 tab_label = self.wNotebook.get_tab_label(page).get_text()
3115 if page_num == 0 and self.active_channel is not None:
3116 self.set_title(self.active_channel.title)
3117 else:
3118 self.set_title(tab_label)
3119 if page_num == 0:
3120 self.play_or_download()
3121 self.menuChannels.set_sensitive(True)
3122 self.menuSubscriptions.set_sensitive(True)
3123 # The message area in the downloads tab should be hidden
3124 # when the user switches away from the downloads tab
3125 if self.message_area is not None:
3126 self.message_area.hide()
3127 self.message_area = None
3128 else:
3129 self.menuChannels.set_sensitive(False)
3130 self.menuSubscriptions.set_sensitive(False)
3131 if gpodder.ui.desktop:
3132 self.toolDownload.set_sensitive(False)
3133 self.toolPlay.set_sensitive(False)
3134 self.toolTransfer.set_sensitive(False)
3135 self.toolCancel.set_sensitive(False)
3137 def on_treeChannels_row_activated(self, widget, path, *args):
3138 # double-click action of the podcast list or enter
3139 self.treeChannels.set_cursor(path)
3141 def on_treeChannels_cursor_changed(self, widget, *args):
3142 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3144 if model is not None and iter is not None:
3145 old_active_channel = self.active_channel
3146 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3148 if self.active_channel == old_active_channel:
3149 return
3151 if gpodder.ui.maemo:
3152 self.set_title(self.active_channel.title)
3153 self.itemEditChannel.set_visible(True)
3154 self.itemRemoveChannel.set_visible(True)
3155 else:
3156 self.active_channel = None
3157 self.itemEditChannel.set_visible(False)
3158 self.itemRemoveChannel.set_visible(False)
3160 self.update_episode_list_model()
3162 def on_btnEditChannel_clicked(self, widget, *args):
3163 self.on_itemEditChannel_activate( widget, args)
3165 def get_selected_episodes(self):
3166 """Get a list of selected episodes from treeAvailable"""
3167 selection = self.treeAvailable.get_selection()
3168 model, paths = selection.get_selected_rows()
3170 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3171 return episodes
3173 def on_transfer_selected_episodes(self, widget):
3174 self.on_sync_to_ipod_activate(widget, self.get_selected_episodes())
3176 def on_playback_selected_episodes(self, widget):
3177 self.playback_episodes(self.get_selected_episodes())
3179 def on_shownotes_selected_episodes(self, widget):
3180 episodes = self.get_selected_episodes()
3181 if episodes:
3182 episode = episodes.pop(0)
3183 self.show_episode_shownotes(episode)
3184 else:
3185 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3187 def on_download_selected_episodes(self, widget):
3188 episodes = self.get_selected_episodes()
3189 self.download_episode_list(episodes)
3190 self.update_episode_list_icons([episode.url for episode in episodes])
3191 self.play_or_download()
3193 def on_treeAvailable_row_activated(self, widget, path, view_column):
3194 """Double-click/enter action handler for treeAvailable"""
3195 # We should only have one one selected as it was double clicked!
3196 e = self.get_selected_episodes()[0]
3198 if (self.config.double_click_episode_action == 'download'):
3199 # If the episode has already been downloaded and exists then play it
3200 if e.was_downloaded(and_exists=True):
3201 self.playback_episodes(self.get_selected_episodes())
3202 # else download it if it is not already downloading
3203 elif not self.episode_is_downloading(e):
3204 self.download_episode_list([e])
3205 self.update_episode_list_icons([e.url])
3206 self.play_or_download()
3207 elif (self.config.double_click_episode_action == 'stream'):
3208 # If we happen to have downloaded this episode simple play it
3209 if e.was_downloaded(and_exists=True):
3210 self.playback_episodes(self.get_selected_episodes())
3211 # else if streaming is possible stream it
3212 elif self.streaming_possible():
3213 self.playback_episodes(self.get_selected_episodes())
3214 else:
3215 log('Unable to stream episode - default media player selected!', sender=self, traceback=True)
3216 self.show_message(_('Please check your media player settings in the preferences dialog.'), _('Unable to stream episode'), widget=self.toolPreferences)
3217 else:
3218 # default action is to display show notes
3219 self.on_shownotes_selected_episodes(widget)
3221 def show_episode_shownotes(self, episode):
3222 if self.episode_shownotes_window is None:
3223 log('First-time use of episode window --- creating', sender=self)
3224 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3225 _download_episode_list=self.download_episode_list, \
3226 _playback_episodes=self.playback_episodes, \
3227 _delete_episode_list=self.delete_episode_list, \
3228 _episode_list_status_changed=self.episode_list_status_changed, \
3229 _cancel_task_list=self.cancel_task_list, \
3230 _episode_is_downloading=self.episode_is_downloading, \
3231 _streaming_possible=self.streaming_possible())
3232 self.episode_shownotes_window.show(episode)
3233 if self.episode_is_downloading(episode):
3234 self.update_downloads_list()
3236 def restart_auto_update_timer(self):
3237 if self._auto_update_timer_source_id is not None:
3238 log('Removing existing auto update timer.', sender=self)
3239 gobject.source_remove(self._auto_update_timer_source_id)
3240 self._auto_update_timer_source_id = None
3242 if self.config.auto_update_feeds:
3243 interval = 60*1000*self.config.auto_update_frequency
3244 log('Setting up auto update timer with interval %d.', \
3245 self.config.auto_update_frequency, sender=self)
3246 self._auto_update_timer_source_id = gobject.timeout_add(\
3247 interval, self._on_auto_update_timer)
3249 def _on_auto_update_timer(self):
3250 log('Auto update timer fired.', sender=self)
3251 self.update_feed_cache(force_update=True)
3252 return True
3254 def on_treeDownloads_row_activated(self, widget, *args):
3255 # Use the standard way of working on the treeview
3256 selection = self.treeDownloads.get_selection()
3257 (model, paths) = selection.get_selected_rows()
3258 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3260 for tree_row_reference, task in selected_tasks:
3261 if task.status in (task.DOWNLOADING, task.QUEUED):
3262 task.status = task.PAUSED
3263 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3264 self.download_queue_manager.add_task(task)
3265 self.enable_download_list_update()
3266 elif task.status == task.DONE:
3267 model.remove(model.get_iter(tree_row_reference.get_path()))
3269 self.play_or_download()
3271 # Update the tab title and downloads list
3272 self.update_downloads_list()
3274 def on_item_cancel_download_activate(self, widget):
3275 if self.wNotebook.get_current_page() == 0:
3276 selection = self.treeAvailable.get_selection()
3277 (model, paths) = selection.get_selected_rows()
3278 urls = [model.get_value(model.get_iter(path), \
3279 self.episode_list_model.C_URL) for path in paths]
3280 selected_tasks = [task for task in self.download_tasks_seen \
3281 if task.url in urls]
3282 else:
3283 selection = self.treeDownloads.get_selection()
3284 (model, paths) = selection.get_selected_rows()
3285 selected_tasks = [model.get_value(model.get_iter(path), \
3286 self.download_status_model.C_TASK) for path in paths]
3287 self.cancel_task_list(selected_tasks)
3289 def on_btnCancelAll_clicked(self, widget, *args):
3290 self.cancel_task_list(self.download_tasks_seen)
3292 def on_btnDownloadedDelete_clicked(self, widget, *args):
3293 if self.wNotebook.get_current_page() == 1:
3294 # Downloads tab visibile - skip (for now)
3295 return
3297 episodes = self.get_selected_episodes()
3298 self.delete_episode_list(episodes)
3300 def on_key_press(self, widget, event):
3301 # Allow tab switching with Ctrl + PgUp/PgDown
3302 if event.state & gtk.gdk.CONTROL_MASK:
3303 if event.keyval == gtk.keysyms.Page_Up:
3304 self.wNotebook.prev_page()
3305 return True
3306 elif event.keyval == gtk.keysyms.Page_Down:
3307 self.wNotebook.next_page()
3308 return True
3310 # After this code we only handle Maemo hardware keys,
3311 # so if we are not a Maemo app, we don't do anything
3312 if not gpodder.ui.maemo:
3313 return False
3315 diff = 0
3316 if event.keyval == gtk.keysyms.F7: #plus
3317 diff = 1
3318 elif event.keyval == gtk.keysyms.F8: #minus
3319 diff = -1
3321 if diff != 0 and not self.currently_updating:
3322 selection = self.treeChannels.get_selection()
3323 (model, iter) = selection.get_selected()
3324 new_path = ((model.get_path(iter)[0]+diff)%len(model),)
3325 selection.select_path(new_path)
3326 self.treeChannels.set_cursor(new_path)
3327 return True
3329 return False
3331 def on_iconify(self):
3332 if self.tray_icon:
3333 self.gPodder.set_skip_taskbar_hint(True)
3334 if self.config.minimize_to_tray:
3335 self.tray_icon.set_visible(True)
3336 else:
3337 self.gPodder.set_skip_taskbar_hint(False)
3339 def on_uniconify(self):
3340 if self.tray_icon:
3341 self.gPodder.set_skip_taskbar_hint(False)
3342 if self.config.minimize_to_tray:
3343 self.tray_icon.set_visible(False)
3344 else:
3345 self.gPodder.set_skip_taskbar_hint(False)
3347 def uniconify_main_window(self):
3348 if self.is_iconified():
3349 self.gPodder.present()
3351 def iconify_main_window(self):
3352 if not self.is_iconified():
3353 self.gPodder.iconify()
3355 def update_podcasts_tab(self):
3356 if len(self.channels):
3357 if gpodder.ui.fremantle:
3358 self.button_refresh.set_title(_('Check for new episodes'))
3359 self.button_refresh.show()
3360 else:
3361 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
3362 else:
3363 if gpodder.ui.fremantle:
3364 self.button_refresh.hide()
3365 else:
3366 self.label2.set_text(_('Podcasts'))
3368 @dbus.service.method(gpodder.dbus_interface)
3369 def show_gui_window(self):
3370 self.gPodder.present()
3372 @dbus.service.method(gpodder.dbus_interface)
3373 def subscribe_to_url(self, url):
3374 gPodderAddPodcast(self.gPodder,
3375 add_urls_callback=self.add_podcast_list,
3376 preset_url=url)
3378 @dbus.service.method(gpodder.dbus_interface)
3379 def mark_episode_played(self, filename):
3380 if filename is None:
3381 return False
3383 for channel in self.channels:
3384 for episode in channel.get_all_episodes():
3385 fn = episode.local_filename(create=False, check_only=True)
3386 if fn == filename:
3387 episode.mark(is_played=True)
3388 self.db.commit()
3389 self.update_episode_list_icons([episode.url])
3390 self.update_podcast_list_model([episode.channel.url])
3391 return True
3393 return False
3396 def main(options=None):
3397 gobject.threads_init()
3398 gobject.set_application_name('gPodder')
3400 if gpodder.ui.maemo:
3401 # Try to enable the custom icon theme for gPodder on Maemo
3402 settings = gtk.settings_get_default()
3403 settings.set_string_property('gtk-icon-theme-name', \
3404 'gpodder', __file__)
3405 # Extend the search path for the optified icon theme (Maemo 5)
3406 icon_theme = gtk.icon_theme_get_default()
3407 icon_theme.prepend_search_path('/opt/gpodder-icon-theme/')
3409 gtk.window_set_default_icon_name('gpodder')
3410 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
3412 try:
3413 session_bus = dbus.SessionBus(mainloop=dbus.glib.DBusGMainLoop())
3414 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=session_bus)
3415 except dbus.exceptions.DBusException, dbe:
3416 log('Warning: Cannot get "on the bus".', traceback=True)
3417 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
3418 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
3419 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
3420 dlg.set_title('gPodder')
3421 dlg.run()
3422 dlg.destroy()
3423 sys.exit(0)
3425 util.make_directory(gpodder.home)
3426 gpodder.load_plugins()
3428 config = UIConfig(gpodder.config_file)
3430 if gpodder.ui.diablo:
3431 # Detect changing of SD cards between mmc1/mmc2 if a gpodder
3432 # folder exists there (allow moving "gpodder" between SD cards or USB)
3433 # Also allow moving "gpodder" to home folder (e.g. rootfs on SD)
3434 if not os.path.exists(config.download_dir):
3435 log('Downloads might have been moved. Trying to locate them...')
3436 for basedir in ['/media/mmc1', '/media/mmc2']+glob.glob('/media/usb/*')+['/home/user/MyDocs']:
3437 dir = os.path.join(basedir, 'gpodder')
3438 if os.path.exists(dir):
3439 log('Downloads found in: %s', dir)
3440 config.download_dir = dir
3441 break
3442 else:
3443 log('Downloads NOT FOUND in %s', dir)
3445 if config.enable_fingerscroll:
3446 BuilderWidget.use_fingerscroll = True
3447 elif gpodder.ui.fremantle:
3448 config.on_quit_ask = False
3450 gp = gPodder(bus_name, config)
3452 # Handle options
3453 if options.subscribe:
3454 util.idle_add(gp.subscribe_to_url, options.subscribe)
3456 gp.run()