Maemo downloads might be in MyDocs, but not $HOME
[gpodder.git] / src / gpodder / gui.py
blobea4b9f72649ab33e949c0192d37b108d074e07e5
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 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
75 from gpodder.model import PodcastChannel
76 from gpodder.dbsqlite import Database
78 from gpodder.gtkui.model import PodcastListModel
79 from gpodder.gtkui.model import EpisodeListModel
80 from gpodder.gtkui.config import UIConfig
81 from gpodder.gtkui.download import DownloadStatusModel
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.interface == gpodder.GUI:
93 from gpodder.gtkui.desktop.sync import gPodderSyncUI
95 from gpodder.gtkui.desktop.channel import gPodderChannel
96 from gpodder.gtkui.desktop.preferences import gPodderPreferences
97 from gpodder.gtkui.desktop.shownotes import gPodderShownotes
98 from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
99 from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
100 try:
101 from gpodder.gtkui.desktop.trayicon import GPodderStatusIcon
102 have_trayicon = True
103 except Exception, exc:
104 log('Warning: Could not import gpodder.trayicon.', traceback=True)
105 log('Warning: This probably means your PyGTK installation is too old!')
106 have_trayicon = False
107 from gpodder.gtkui.interface.dependencymanager import gPodderDependencyManager
108 else:
109 from gpodder.gtkui.maemo.channel import gPodderChannel
110 from gpodder.gtkui.maemo.preferences import gPodderPreferences
111 from gpodder.gtkui.maemo.shownotes import gPodderShownotes
112 from gpodder.gtkui.maemo.episodeselector import gPodderEpisodeSelector
113 from gpodder.gtkui.maemo.podcastdirectory import gPodderPodcastDirectory
114 have_trayicon = False
116 from gpodder.gtkui.interface.welcome import gPodderWelcome
118 if gpodder.interface == gpodder.MAEMO:
119 import hildon
121 class gPodder(BuilderWidget, dbus.service.Object):
122 finger_friendly_widgets = ['btnCleanUpDownloads']
123 TREEVIEW_WIDGETS = ('treeAvailable', 'treeChannels', 'treeDownloads')
125 def __init__(self, bus_name, config):
126 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
127 self.db = Database(gpodder.database_file)
128 self.config = config
129 BuilderWidget.__init__(self, None)
131 def new(self):
132 if gpodder.interface == gpodder.MAEMO:
133 self.app = hildon.Program()
134 self.app.add_window(self.main_window)
135 self.main_window.add_toolbar(self.toolbar)
136 menu = gtk.Menu()
137 for child in self.main_menu.get_children():
138 child.reparent(menu)
139 self.main_window.set_menu(self.set_finger_friendly(menu))
140 self.bluetooth_available = False
141 else:
142 if gpodder.win32:
143 # FIXME: Implement e-mail sending of list in win32
144 self.item_email_subscriptions.set_sensitive(False)
145 self.bluetooth_available = util.bluetooth_available()
146 self.toolbar.set_property('visible', self.config.show_toolbar)
148 self.config.connect_gtk_window(self.gPodder, 'main_window')
149 self.config.connect_gtk_paned('paned_position', self.channelPaned)
150 self.main_window.show()
152 self.gPodder.connect('key-press-event', self.on_key_press)
154 self.config.add_observer(self.on_config_changed)
156 self.tray_icon = None
157 self.episode_shownotes_window = None
159 if gpodder.interface == gpodder.GUI:
160 self.sync_ui = gPodderSyncUI(self.config, self.notification, \
161 self.main_window, self.show_confirmation, \
162 self.update_episode_list_icons, \
163 self.update_podcast_list_model, self.toolPreferences, \
164 gPodderEpisodeSelector)
165 else:
166 self.sync_ui = None
168 self.download_status_model = DownloadStatusModel()
169 self.download_queue_manager = download.DownloadQueueManager(self.config)
171 self.show_hide_tray_icon()
173 self.itemShowToolbar.set_active(self.config.show_toolbar)
174 self.itemShowDescription.set_active(self.config.episode_list_descriptions)
176 self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
177 self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
178 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
179 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
181 # Then the amount of maximum downloads changes, notify the queue manager
182 changed_cb = lambda spinbutton: self.download_queue_manager.spawn_and_retire_threads()
183 self.spinMaxDownloads.connect('value-changed', changed_cb)
185 self.default_title = None
186 if gpodder.__version__.rfind('git') != -1:
187 self.set_title('gPodder %s' % gpodder.__version__)
188 else:
189 title = self.gPodder.get_title()
190 if title is not None:
191 self.set_title(title)
192 else:
193 self.set_title(_('gPodder'))
195 self.cover_downloader = CoverDownloader()
197 # Generate list models for podcasts and their episodes
198 self.podcast_list_model = PodcastListModel(self.config.podcast_list_icon_size, self.cover_downloader)
200 self.cover_downloader.register('cover-available', self.cover_download_finished)
201 self.cover_downloader.register('cover-removed', self.cover_file_removed)
203 # Init the treeviews that we use
204 self.init_podcast_list_treeview()
205 self.init_episode_list_treeview()
206 self.init_download_list_treeview()
208 if self.config.podcast_list_hide_boring:
209 self.item_view_hide_boring_podcasts.set_active(True)
211 # on Maemo 5, we need to set hildon-ui-mode of TreeView widgets to 1
212 if gpodder.interface == gpodder.MAEMO:
213 HUIM = 'hildon-ui-mode'
214 if HUIM in [p.name for p in gobject.list_properties(gtk.TreeView)]:
215 for treeview_name in self.TREEVIEW_WIDGETS:
216 treeview = getattr(self, treeview_name)
217 treeview.set_property(HUIM, 1)
219 self.currently_updating = False
221 if gpodder.interface == gpodder.MAEMO:
222 self.context_menu_mouse_button = 1
223 else:
224 self.context_menu_mouse_button = 3
226 if self.config.start_iconified:
227 self.iconify_main_window()
229 self.download_tasks_seen = set()
230 self.download_list_update_enabled = False
231 self.last_download_count = 0
233 # Subscribed channels
234 self.active_channel = None
235 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
236 self.channel_list_changed = True
237 self.update_podcasts_tab()
239 # load list of user applications for audio playback
240 self.user_apps_reader = UserAppsReader(['audio', 'video'])
241 def read_apps():
242 time.sleep(3) # give other parts of gpodder a chance to start up
243 self.user_apps_reader.read()
244 util.idle_add(self.user_apps_reader.get_applications_as_model, 'audio', False)
245 util.idle_add(self.user_apps_reader.get_applications_as_model, 'video', False)
246 threading.Thread(target=read_apps).start()
248 # Set the "Device" menu item for the first time
249 self.update_item_device()
251 # Now, update the feed cache, when everything's in place
252 self.btnUpdateFeeds.show()
253 self.updating_feed_cache = False
254 self.feed_cache_update_cancelled = False
255 self.update_feed_cache(force_update=self.config.update_on_startup)
257 # Look for partial file downloads
258 partial_files = glob.glob(os.path.join(self.config.download_dir, '*', '*.partial'))
260 # Message area
261 self.message_area = None
263 resumable_episodes = []
264 if len(partial_files) > 0:
265 for f in partial_files:
266 correct_name = f[:-len('.partial')] # strip ".partial"
267 log('Searching episode for file: %s', correct_name, sender=self)
268 found_episode = False
269 for c in self.channels:
270 for e in c.get_all_episodes():
271 if e.local_filename(create=False, check_only=True) == correct_name:
272 log('Found episode: %s', e.title, sender=self)
273 resumable_episodes.append(e)
274 found_episode = True
275 if found_episode:
276 break
277 if found_episode:
278 break
279 if not found_episode:
280 log('Partial file without episode: %s', f, sender=self)
281 util.delete_file(f)
283 if len(resumable_episodes):
284 self.download_episode_list_paused(resumable_episodes)
285 self.message_area = SimpleMessageArea(_('There are unfinished downloads from your last session.\nPick the ones you want to continue downloading.'))
286 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
287 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
288 self.message_area.show_all()
289 self.wNotebook.set_current_page(1)
291 self.clean_up_downloads(delete_partial=False)
292 else:
293 self.clean_up_downloads(delete_partial=True)
295 # Start the auto-update procedure
296 self.auto_update_procedure(first_run=True)
298 # Delete old episodes if the user wishes to
299 if self.config.auto_remove_old_episodes:
300 old_episodes = self.get_old_episodes()
301 if len(old_episodes) > 0:
302 self.delete_episode_list(old_episodes, confirm=False)
303 self.update_podcast_list_model(set(e.channel.url for e in old_episodes))
305 # First-time users should be asked if they want to see the OPML
306 if not self.channels:
307 util.idle_add(self.on_itemUpdate_activate)
309 def on_treeview_podcasts_selection_changed(self, selection):
310 model, iter = selection.get_selected()
311 if iter is None:
312 self.active_channel = None
313 self.episode_list_model.clear()
315 def on_treeview_button_pressed(self, treeview, event):
316 TreeViewHelper.save_button_press_event(treeview, event)
318 if getattr(treeview, TreeViewHelper.ROLE) == \
319 TreeViewHelper.ROLE_PODCASTS:
320 return self.currently_updating
322 return event.button == self.context_menu_mouse_button and \
323 gpodder.interface != gpodder.MAEMO
325 def on_treeview_podcasts_button_released(self, treeview, event):
326 if gpodder.interface == gpodder.MAEMO:
327 return self.treeview_channels_handle_gestures(treeview, event)
329 return self.treeview_channels_show_context_menu(treeview, event)
331 def on_treeview_episodes_button_released(self, treeview, event):
332 if gpodder.interface == gpodder.MAEMO:
333 if self.config.enable_fingerscroll or self.config.maemo_enable_gestures:
334 return self.treeview_available_handle_gestures(treeview, event)
336 return self.treeview_available_show_context_menu(treeview, event)
338 def on_treeview_downloads_button_released(self, treeview, event):
339 return self.treeview_downloads_show_context_menu(treeview, event)
341 def init_podcast_list_treeview(self):
342 # Set up podcast channel tree view widget
343 self.treeChannels.set_search_equal_func(TreeViewHelper.make_search_equal_func(PodcastListModel))
345 iconcolumn = gtk.TreeViewColumn('')
346 iconcell = gtk.CellRendererPixbuf()
347 iconcolumn.pack_start(iconcell, False)
348 iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
349 self.treeChannels.append_column(iconcolumn)
351 namecolumn = gtk.TreeViewColumn('')
352 namecell = gtk.CellRendererText()
353 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
354 namecolumn.pack_start(namecell, True)
355 namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
357 iconcell = gtk.CellRendererPixbuf()
358 iconcell.set_property('xalign', 1.0)
359 namecolumn.pack_start(iconcell, False)
360 namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
361 namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
362 self.treeChannels.append_column(namecolumn)
364 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
366 # When no podcast is selected, clear the episode list model
367 selection = self.treeChannels.get_selection()
368 selection.connect('changed', self.on_treeview_podcasts_selection_changed)
370 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
372 def init_episode_list_treeview(self):
373 self.episode_list_model = EpisodeListModel()
375 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
376 self.item_view_episodes_undeleted.set_active(True)
377 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
378 self.item_view_episodes_downloaded.set_active(True)
379 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
380 self.item_view_episodes_unplayed.set_active(True)
381 else:
382 self.item_view_episodes_all.set_active(True)
384 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
386 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
388 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
390 iconcell = gtk.CellRendererPixbuf()
391 if gpodder.interface == gpodder.MAEMO:
392 iconcell.set_fixed_size(50, 50)
393 status_column_label = ''
394 else:
395 status_column_label = _('Status')
396 iconcolumn = gtk.TreeViewColumn(status_column_label, iconcell, pixbuf=EpisodeListModel.C_STATUS_ICON)
398 namecell = gtk.CellRendererText()
399 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
400 namecolumn = gtk.TreeViewColumn(_('Episode'), namecell, markup=EpisodeListModel.C_DESCRIPTION)
401 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
402 namecolumn.set_resizable(True)
403 namecolumn.set_expand(True)
405 sizecell = gtk.CellRendererText()
406 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
408 releasecell = gtk.CellRendererText()
409 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
411 for itemcolumn in (iconcolumn, namecolumn, sizecolumn, releasecolumn):
412 itemcolumn.set_reorderable(True)
413 self.treeAvailable.append_column(itemcolumn)
415 if gpodder.interface == gpodder.MAEMO:
416 sizecolumn.set_visible(False)
417 releasecolumn.set_visible(False)
419 self.treeAvailable.set_search_equal_func(TreeViewHelper.make_search_equal_func(EpisodeListModel))
421 selection = self.treeAvailable.get_selection()
422 if gpodder.interface == gpodder.MAEMO:
423 if self.config.maemo_enable_gestures or self.config.enable_fingerscroll:
424 selection.set_mode(gtk.SELECTION_SINGLE)
425 else:
426 selection.set_mode(gtk.SELECTION_MULTIPLE)
427 else:
428 selection.set_mode(gtk.SELECTION_MULTIPLE)
430 if gpodder.interface == gpodder.MAEMO:
431 # Set up the tap-and-hold context menu for podcasts
432 menu = gtk.Menu()
433 menu.append(self.itemUpdateChannel.create_menu_item())
434 menu.append(self.itemEditChannel.create_menu_item())
435 menu.append(gtk.SeparatorMenuItem())
436 menu.append(self.itemRemoveChannel.create_menu_item())
437 menu.append(gtk.SeparatorMenuItem())
438 item = gtk.ImageMenuItem(_('Close this menu'))
439 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, \
440 gtk.ICON_SIZE_MENU))
441 menu.append(item)
442 menu.show_all()
443 menu = self.set_finger_friendly(menu)
444 self.treeChannels.tap_and_hold_setup(menu)
447 def init_download_list_treeview(self):
448 # enable multiple selection support
449 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
450 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
452 # columns and renderers for "download progress" tab
453 # First column: [ICON] Episodename
454 column = gtk.TreeViewColumn(_('Episode'))
456 cell = gtk.CellRendererPixbuf()
457 if gpodder.interface == gpodder.MAEMO:
458 cell.set_fixed_size(50, 50)
459 cell.set_property('stock-size', gtk.ICON_SIZE_MENU)
460 column.pack_start(cell, expand=False)
461 column.add_attribute(cell, 'stock-id', \
462 DownloadStatusModel.C_ICON_NAME)
464 cell = gtk.CellRendererText()
465 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
466 column.pack_start(cell, expand=True)
467 column.add_attribute(cell, 'text', DownloadStatusModel.C_NAME)
469 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
470 column.set_resizable(True)
471 column.set_expand(True)
472 self.treeDownloads.append_column(column)
474 # Second column: Progress
475 column = gtk.TreeViewColumn(_('Progress'), gtk.CellRendererProgress(),
476 value=DownloadStatusModel.C_PROGRESS, \
477 text=DownloadStatusModel.C_PROGRESS_TEXT)
478 self.treeDownloads.append_column(column)
480 # Third column: Size
481 if gpodder.interface != gpodder.MAEMO:
482 column = gtk.TreeViewColumn(_('Size'), gtk.CellRendererText(),
483 text=DownloadStatusModel.C_SIZE_TEXT)
484 self.treeDownloads.append_column(column)
486 # Fourth column: Speed
487 column = gtk.TreeViewColumn(_('Speed'), gtk.CellRendererText(),
488 text=DownloadStatusModel.C_SPEED_TEXT)
489 self.treeDownloads.append_column(column)
491 # Fifth column: Status
492 column = gtk.TreeViewColumn(_('Status'), gtk.CellRendererText(),
493 text=DownloadStatusModel.C_STATUS_TEXT)
494 self.treeDownloads.append_column(column)
496 self.treeDownloads.set_model(self.download_status_model)
497 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
499 def on_treeview_expose_event(self, treeview, event):
500 if event.window == treeview.get_bin_window():
501 model = treeview.get_model()
502 if (model is not None and model.get_iter_first() is not None):
503 return False
505 role = getattr(treeview, TreeViewHelper.ROLE)
506 ctx = event.window.cairo_create()
507 png = treeview.get_pango_context()
508 ctx.rectangle(event.area.x, event.area.y,
509 event.area.width, event.area.height)
510 ctx.clip()
512 x, y, width, height, depth = event.window.get_geometry()
514 if role == TreeViewHelper.ROLE_EPISODES:
515 if self.currently_updating:
516 text = _('Loading episodes') + '...'
517 elif self.config.episode_list_view_mode != \
518 EpisodeListModel.VIEW_ALL:
519 text = _('Select "View" > "All episodes" to show episodes')
520 else:
521 text = _('No episodes available')
522 elif role == TreeViewHelper.ROLE_PODCASTS:
523 if self.config.episode_list_view_mode != \
524 EpisodeListModel.VIEW_ALL and \
525 self.config.podcast_list_hide_boring and \
526 len(self.channels) > 0:
527 text = _('No podcasts in this view')
528 else:
529 text = _('No subscriptions')
530 elif role == TreeViewHelper.ROLE_DOWNLOADS:
531 text = _('No downloads')
532 else:
533 raise Exception('on_treeview_expose_event: unknown role')
535 draw_text_box_centered(ctx, treeview, width, height, text)
537 return False
539 def enable_download_list_update(self):
540 if not self.download_list_update_enabled:
541 gobject.timeout_add(1500, self.update_downloads_list)
542 self.download_list_update_enabled = True
544 def on_btnCleanUpDownloads_clicked(self, button):
545 model = self.download_status_model
547 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
548 changed_episode_urls = []
549 for row_reference, task in all_tasks:
550 if task.status in (task.DONE, task.CANCELLED, task.FAILED):
551 model.remove(model.get_iter(row_reference.get_path()))
552 try:
553 # We don't "see" this task anymore - remove it;
554 # this is needed, so update_episode_list_icons()
555 # below gets the correct list of "seen" tasks
556 self.download_tasks_seen.remove(task)
557 except KeyError, key_error:
558 log('Cannot remove task from "seen" list: %s', task, sender=self)
559 changed_episode_urls.append(task.url)
560 # Tell the task that it has been removed (so it can clean up)
561 task.removed_from_list()
563 # Tell the podcasts tab to update icons for our removed podcasts
564 self.update_episode_list_icons(changed_episode_urls)
566 # Tell the shownotes window that we have removed the episode
567 if self.episode_shownotes_window is not None and \
568 self.episode_shownotes_window.episode is not None and \
569 self.episode_shownotes_window.episode.url in changed_episode_urls:
570 self.episode_shownotes_window._download_status_changed(None)
572 # Update the tab title and downloads list
573 self.update_downloads_list()
575 def on_tool_downloads_toggled(self, toolbutton):
576 if toolbutton.get_active():
577 self.wNotebook.set_current_page(1)
578 else:
579 self.wNotebook.set_current_page(0)
581 def update_downloads_list(self):
582 try:
583 model = self.download_status_model
585 downloading, failed, finished, queued, others = 0, 0, 0, 0, 0
586 total_speed, total_size, done_size = 0, 0, 0
588 # Keep a list of all download tasks that we've seen
589 download_tasks_seen = set()
591 # Remember the DownloadTask object for the episode that
592 # has been opened in the episode shownotes dialog (if any)
593 if self.episode_shownotes_window is not None:
594 shownotes_episode = self.episode_shownotes_window.episode
595 shownotes_task = None
596 else:
597 shownotes_episode = None
598 shownotes_task = None
600 # Do not go through the list of the model is not (yet) available
601 if model is None:
602 model = ()
604 for row in model:
605 self.download_status_model.request_update(row.iter)
607 task = row[self.download_status_model.C_TASK]
608 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
610 total_size += size
611 done_size += size*progress
613 if shownotes_episode is not None and \
614 shownotes_episode.url == task.episode.url:
615 shownotes_task = task
617 download_tasks_seen.add(task)
619 if status == download.DownloadTask.DOWNLOADING:
620 downloading += 1
621 total_speed += speed
622 elif status == download.DownloadTask.FAILED:
623 failed += 1
624 elif status == download.DownloadTask.DONE:
625 finished += 1
626 elif status == download.DownloadTask.QUEUED:
627 queued += 1
628 else:
629 others += 1
631 # Remember which tasks we have seen after this run
632 self.download_tasks_seen = download_tasks_seen
634 text = [_('Downloads')]
635 if downloading + failed + finished + queued > 0:
636 s = []
637 if downloading > 0:
638 s.append(_('%d active') % downloading)
639 if failed > 0:
640 s.append(_('%d failed') % failed)
641 if finished > 0:
642 s.append(_('%d done') % finished)
643 if queued > 0:
644 s.append(_('%d queued') % queued)
645 text.append(' (' + ', '.join(s)+')')
646 self.labelDownloads.set_text(''.join(text))
648 if gpodder.interface == gpodder.MAEMO:
649 sum = downloading + failed + finished + queued + others
650 if sum:
651 self.tool_downloads.set_label(_('Downloads (%d)') % sum)
652 else:
653 self.tool_downloads.set_label(_('Downloads'))
655 title = [self.default_title]
657 # We have to update all episodes/channels for which the status has
658 # changed. Accessing task.status_changed has the side effect of
659 # re-setting the changed flag, so we need to get the "changed" list
660 # of tuples first and split it into two lists afterwards
661 changed = [(task.url, task.podcast_url) for task in \
662 self.download_tasks_seen if task.status_changed]
663 episode_urls = [episode_url for episode_url, channel_url in changed]
664 channel_urls = [channel_url for episode_url, channel_url in changed]
666 count = downloading + queued
667 if count > 0:
668 if count == 1:
669 title.append( _('downloading one file'))
670 elif count > 1:
671 title.append( _('downloading %d files') % count)
673 if total_size > 0:
674 percentage = 100.0*done_size/total_size
675 else:
676 percentage = 0.0
677 total_speed = util.format_filesize(total_speed)
678 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
679 if self.tray_icon is not None:
680 # Update the tray icon status and progress bar
681 self.tray_icon.set_status(self.tray_icon.STATUS_DOWNLOAD_IN_PROGRESS, title[1])
682 self.tray_icon.draw_progress_bar(percentage/100.)
683 elif self.last_download_count > 0:
684 if self.tray_icon is not None:
685 # Update the tray icon status
686 self.tray_icon.set_status()
687 self.tray_icon.downloads_finished(self.download_tasks_seen)
688 if gpodder.interface == gpodder.MAEMO:
689 hildon.hildon_banner_show_information(self.gPodder, None, 'gPodder: %s' % _('All downloads finished'))
690 log('All downloads have finished.', sender=self)
691 if self.config.cmd_all_downloads_complete:
692 util.run_external_command(self.config.cmd_all_downloads_complete)
693 self.last_download_count = count
695 self.gPodder.set_title(' - '.join(title))
697 self.update_episode_list_icons(episode_urls)
698 if self.episode_shownotes_window is not None:
699 if (shownotes_task and shownotes_task.url in episode_urls) or \
700 shownotes_task != self.episode_shownotes_window.task:
701 self.episode_shownotes_window._download_status_changed(shownotes_task)
702 self.episode_shownotes_window._download_status_progress()
703 self.play_or_download()
704 if channel_urls:
705 self.update_podcast_list_model(channel_urls)
707 if not self.download_queue_manager.are_queued_or_active_tasks():
708 self.download_list_update_enabled = False
710 return self.download_list_update_enabled
711 except Exception, e:
712 log('Exception happened while updating download list.', sender=self, traceback=True)
713 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
714 # We return False here, so the update loop won't be called again,
715 # that's why we require the restart of gPodder in the message.
716 return False
718 def on_config_changed(self, name, old_value, new_value):
719 if name == 'show_toolbar' and gpodder.interface != gpodder.MAEMO:
720 self.toolbar.set_property('visible', new_value)
721 elif name == 'episode_list_descriptions':
722 self.update_episode_list_model()
724 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
725 # With get_bin_window, we get the window that contains the rows without
726 # the header. The Y coordinate of this window will be the height of the
727 # treeview header. This is the amount we have to subtract from the
728 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
729 (x_bin, y_bin) = treeview.get_bin_window().get_position()
730 y -= x_bin
731 y -= y_bin
732 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
734 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or (column is not None and column != treeview.get_columns()[0]):
735 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
736 return False
738 if path is not None:
739 model = treeview.get_model()
740 iter = model.get_iter(path)
741 role = getattr(treeview, TreeViewHelper.ROLE)
743 if role == TreeViewHelper.ROLE_EPISODES:
744 id = model.get_value(iter, EpisodeListModel.C_URL)
745 elif role == TreeViewHelper.ROLE_PODCASTS:
746 id = model.get_value(iter, PodcastListModel.C_URL)
748 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
749 if last_tooltip is not None and last_tooltip != id:
750 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
751 return False
752 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
754 if role == TreeViewHelper.ROLE_EPISODES:
755 description = model.get_value(iter, EpisodeListModel.C_DESCRIPTION_STRIPPED)
756 if len(description) > 400:
757 description = description[:398]+'[...]'
759 tooltip.set_text(description)
760 elif role == TreeViewHelper.ROLE_PODCASTS:
761 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
762 channel.request_save_dir_size()
763 diskspace_str = util.format_filesize(channel.save_dir_size, 0)
764 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
765 if error_str:
766 error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
767 error_str = '<span foreground="#ff0000">%s</span>' % error_str
768 table = gtk.Table(rows=3, columns=3)
769 table.set_row_spacings(5)
770 table.set_col_spacings(5)
771 table.set_border_width(5)
773 heading = gtk.Label()
774 heading.set_alignment(0, 1)
775 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
776 table.attach(heading, 0, 1, 0, 1)
777 size_info = gtk.Label()
778 size_info.set_alignment(1, 1)
779 size_info.set_justify(gtk.JUSTIFY_RIGHT)
780 size_info.set_markup('<b>%s</b>\n<small>%s</small>' % (diskspace_str, _('disk usage')))
781 table.attach(size_info, 2, 3, 0, 1)
783 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
785 if len(channel.description) < 500:
786 description = channel.description
787 else:
788 pos = channel.description.find('\n\n')
789 if pos == -1 or pos > 500:
790 description = channel.description[:498]+'[...]'
791 else:
792 description = channel.description[:pos]
794 description = gtk.Label(description)
795 if error_str:
796 description.set_markup(error_str)
797 description.set_alignment(0, 0)
798 description.set_line_wrap(True)
799 table.attach(description, 0, 3, 2, 3)
801 table.show_all()
802 tooltip.set_custom(table)
804 return True
806 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
807 return False
809 def treeview_allow_tooltips(self, treeview, allow):
810 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
812 def update_m3u_playlist_clicked(self, widget):
813 if self.active_channel is not None:
814 self.active_channel.update_m3u_playlist()
815 self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'), widget=self.treeChannels)
817 def treeview_handle_context_menu_click(self, treeview, event):
818 x, y = int(event.x), int(event.y)
819 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
821 selection = treeview.get_selection()
822 model, paths = selection.get_selected_rows()
824 if path is None or (path not in paths and \
825 event.button == self.context_menu_mouse_button):
826 # We have right-clicked, but not into the selection,
827 # assume we don't want to operate on the selection
828 paths = []
830 if path is not None and not paths and \
831 event.button == self.context_menu_mouse_button:
832 # No selection or clicked outside selection;
833 # select the single item where we clicked
834 treeview.grab_focus()
835 treeview.set_cursor(path, column, 0)
836 paths = [path]
838 if not paths:
839 # Unselect any remaining items (clicked elsewhere)
840 if hasattr(treeview, 'is_rubber_banding_active'):
841 if not treeview.is_rubber_banding_active():
842 selection.unselect_all()
843 else:
844 selection.unselect_all()
846 return model, paths
848 def treeview_downloads_show_context_menu(self, treeview, event):
849 model, paths = self.treeview_handle_context_menu_click(treeview, event)
850 if not paths:
851 if not hasattr(treeview, 'is_rubber_banding_active'):
852 return True
853 else:
854 return not treeview.is_rubber_banding_active()
856 if event.button == self.context_menu_mouse_button:
857 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
859 def make_menu_item(label, stock_id, tasks, status):
860 # This creates a menu item for selection-wide actions
861 def for_each_task_set_status(tasks, status):
862 changed_episode_urls = []
863 for row_reference, task in tasks:
864 if status is not None:
865 if status == download.DownloadTask.QUEUED:
866 # Only queue task when its paused/failed/cancelled
867 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED):
868 self.download_queue_manager.add_task(task)
869 self.enable_download_list_update()
870 elif status == download.DownloadTask.CANCELLED:
871 # Cancelling a download allowed when downloading/queued
872 if task.status in (task.QUEUED, task.DOWNLOADING):
873 task.status = status
874 # Cancelling paused downloads requires a call to .run()
875 elif task.status == task.PAUSED:
876 task.status = status
877 # Call run, so the partial file gets deleted
878 task.run()
879 elif status == download.DownloadTask.PAUSED:
880 # Pausing a download only when queued/downloading
881 if task.status in (task.DOWNLOADING, task.QUEUED):
882 task.status = status
883 else:
884 # We (hopefully) can simply set the task status here
885 task.status = status
886 else:
887 # Remove the selected task - cancel downloading/queued tasks
888 if task.status in (task.QUEUED, task.DOWNLOADING):
889 task.status = task.CANCELLED
890 model.remove(model.get_iter(row_reference.get_path()))
891 # Remember the URL, so we can tell the UI to update
892 try:
893 # We don't "see" this task anymore - remove it;
894 # this is needed, so update_episode_list_icons()
895 # below gets the correct list of "seen" tasks
896 self.download_tasks_seen.remove(task)
897 except KeyError, key_error:
898 log('Cannot remove task from "seen" list: %s', task, sender=self)
899 changed_episode_urls.append(task.url)
900 # Tell the task that it has been removed (so it can clean up)
901 task.removed_from_list()
902 # Tell the podcasts tab to update icons for our removed podcasts
903 self.update_episode_list_icons(changed_episode_urls)
904 # Update the tab title and downloads list
905 self.update_downloads_list()
906 return True
907 item = gtk.ImageMenuItem(label)
908 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
909 item.connect('activate', lambda item: for_each_task_set_status(tasks, status))
911 # Determine if we should disable this menu item
912 for row_reference, task in tasks:
913 if status == download.DownloadTask.QUEUED:
914 if task.status not in (download.DownloadTask.PAUSED, \
915 download.DownloadTask.FAILED, \
916 download.DownloadTask.CANCELLED):
917 item.set_sensitive(False)
918 break
919 elif status == download.DownloadTask.CANCELLED:
920 if task.status not in (download.DownloadTask.PAUSED, \
921 download.DownloadTask.QUEUED, \
922 download.DownloadTask.DOWNLOADING):
923 item.set_sensitive(False)
924 break
925 elif status == download.DownloadTask.PAUSED:
926 if task.status not in (download.DownloadTask.QUEUED, \
927 download.DownloadTask.DOWNLOADING):
928 item.set_sensitive(False)
929 break
930 elif status is None:
931 if task.status not in (download.DownloadTask.CANCELLED, \
932 download.DownloadTask.FAILED, \
933 download.DownloadTask.DONE):
934 item.set_sensitive(False)
935 break
937 return self.set_finger_friendly(item)
939 menu = gtk.Menu()
941 item = gtk.ImageMenuItem(_('Episode details'))
942 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
943 if len(selected_tasks) == 1:
944 row_reference, task = selected_tasks[0]
945 episode = task.episode
946 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
947 else:
948 item.set_sensitive(False)
949 menu.append(self.set_finger_friendly(item))
950 menu.append(gtk.SeparatorMenuItem())
951 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED))
952 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED))
953 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED))
954 menu.append(gtk.SeparatorMenuItem())
955 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None))
957 if gpodder.interface == gpodder.MAEMO:
958 # Because we open the popup on left-click for Maemo,
959 # we also include a non-action to close the menu
960 menu.append(gtk.SeparatorMenuItem())
961 item = gtk.ImageMenuItem(_('Close this menu'))
962 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
964 menu.append(self.set_finger_friendly(item))
966 menu.show_all()
967 menu.popup(None, None, None, event.button, event.time)
968 return True
970 def treeview_channels_show_context_menu(self, treeview, event):
971 model, paths = self.treeview_handle_context_menu_click(treeview, event)
972 if not paths:
973 return True
975 if event.button == 3:
976 menu = gtk.Menu()
978 ICON = lambda x: x
980 item = gtk.ImageMenuItem( _('Open download folder'))
981 item.set_image( gtk.image_new_from_icon_name(ICON('folder-open'), gtk.ICON_SIZE_MENU))
982 item.connect('activate', lambda x: util.gui_open(self.active_channel.save_dir))
983 menu.append( item)
985 item = gtk.ImageMenuItem( _('Update Feed'))
986 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
987 item.connect('activate', self.on_itemUpdateChannel_activate )
988 item.set_sensitive( not self.updating_feed_cache )
989 menu.append( item)
991 item = gtk.ImageMenuItem(_('Update M3U playlist'))
992 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
993 item.connect('activate', self.update_m3u_playlist_clicked)
994 menu.append(item)
996 if self.active_channel.link:
997 item = gtk.ImageMenuItem(_('Visit website'))
998 item.set_image(gtk.image_new_from_icon_name(ICON('web-browser'), gtk.ICON_SIZE_MENU))
999 item.connect('activate', lambda w: util.open_website(self.active_channel.link))
1000 menu.append(item)
1002 if self.active_channel.channel_is_locked:
1003 item = gtk.ImageMenuItem(_('Allow deletion of all episodes'))
1004 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1005 item.connect('activate', self.on_channel_toggle_lock_activate)
1006 menu.append(self.set_finger_friendly(item))
1007 else:
1008 item = gtk.ImageMenuItem(_('Prohibit deletion of all episodes'))
1009 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1010 item.connect('activate', self.on_channel_toggle_lock_activate)
1011 menu.append(self.set_finger_friendly(item))
1014 menu.append( gtk.SeparatorMenuItem())
1016 item = gtk.ImageMenuItem(gtk.STOCK_EDIT)
1017 item.connect( 'activate', self.on_itemEditChannel_activate)
1018 menu.append( item)
1020 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1021 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1022 menu.append( item)
1024 menu.show_all()
1025 # Disable tooltips while we are showing the menu, so
1026 # the tooltip will not appear over the menu
1027 self.treeview_allow_tooltips(self.treeChannels, False)
1028 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1029 menu.popup( None, None, None, event.button, event.time)
1031 return True
1033 def on_itemClose_activate(self, widget):
1034 if self.tray_icon is not None:
1035 self.iconify_main_window()
1036 else:
1037 self.on_gPodder_delete_event(widget)
1039 def cover_file_removed(self, channel_url):
1041 The Cover Downloader calls this when a previously-
1042 available cover has been removed from the disk. We
1043 have to update our model to reflect this change.
1045 self.podcast_list_model.delete_cover_by_url(channel_url)
1047 def cover_download_finished(self, channel_url, pixbuf):
1049 The Cover Downloader calls this when it has finished
1050 downloading (or registering, if already downloaded)
1051 a new channel cover, which is ready for displaying.
1053 self.podcast_list_model.add_cover_by_url(channel_url, pixbuf)
1055 def save_episode_as_file(self, episode):
1056 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1057 if episode.was_downloaded(and_exists=True):
1058 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1059 copy_from = episode.local_filename(create=False)
1060 assert copy_from is not None
1061 copy_to = episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)
1062 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1063 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1065 def copy_episodes_bluetooth(self, episodes):
1066 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1068 def convert_and_send_thread(episode):
1069 for episode in episodes:
1070 filename = episode.local_filename(create=False)
1071 assert filename is not None
1072 destfile = os.path.join(tempfile.gettempdir(), \
1073 util.sanitize_filename(episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)))
1074 (base, ext) = os.path.splitext(filename)
1075 if not destfile.endswith(ext):
1076 destfile += ext
1078 try:
1079 shutil.copyfile(filename, destfile)
1080 util.bluetooth_send_file(destfile)
1081 except:
1082 log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
1083 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1085 util.delete_file(destfile)
1087 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1089 def get_device_name(self):
1090 if self.config.device_type == 'ipod':
1091 return _('iPod')
1092 elif self.config.device_type in ('filesystem', 'mtp'):
1093 return _('MP3 player')
1094 else:
1095 return '(unknown device)'
1097 def _treeview_button_released(self, treeview, event):
1098 xpos, ypos = TreeViewHelper.get_button_press_event(treeview)
1099 dy = int(abs(event.y-ypos))
1100 dx = int(event.x-xpos)
1102 selection = treeview.get_selection()
1103 path = treeview.get_path_at_pos(int(event.x), int(event.y))
1104 if path is None or dy > 30:
1105 return (False, dx, dy)
1107 path, column, x, y = path
1108 selection.select_path(path)
1109 treeview.set_cursor(path)
1110 treeview.grab_focus()
1112 return (True, dx, dy)
1114 def treeview_channels_handle_gestures(self, treeview, event):
1115 if self.currently_updating:
1116 return False
1118 selected, dx, dy = self._treeview_button_released(treeview, event)
1120 if selected:
1121 if self.config.maemo_enable_gestures:
1122 if dx > 70:
1123 self.on_itemUpdateChannel_activate()
1124 elif dx < -70:
1125 self.on_itemEditChannel_activate(treeview)
1127 return False
1129 def treeview_available_handle_gestures(self, treeview, event):
1130 selected, dx, dy = self._treeview_button_released(treeview, event)
1132 if selected:
1133 if self.config.maemo_enable_gestures:
1134 if dx > 70:
1135 self.on_playback_selected_episodes(None)
1136 return True
1137 elif dx < -70:
1138 self.on_shownotes_selected_episodes(None)
1139 return True
1141 # Pass the event to the context menu handler for treeAvailable
1142 self.treeview_available_show_context_menu(treeview, event)
1144 return True
1146 def treeview_available_show_context_menu(self, treeview, event):
1147 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1148 if not paths:
1149 if not hasattr(treeview, 'is_rubber_banding_active'):
1150 return True
1151 else:
1152 return not treeview.is_rubber_banding_active()
1154 if event.button == self.context_menu_mouse_button:
1155 episodes = self.get_selected_episodes()
1156 any_locked = any(e.is_locked for e in episodes)
1157 any_played = any(e.is_played for e in episodes)
1158 one_is_new = any(e.state == gpodder.STATE_NORMAL and not e.is_played for e in episodes)
1160 menu = gtk.Menu()
1162 (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1164 if open_instead_of_play:
1165 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1166 else:
1167 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1169 item.set_sensitive(can_play)
1170 item.connect('activate', self.on_playback_selected_episodes)
1171 menu.append(self.set_finger_friendly(item))
1173 if not can_cancel:
1174 item = gtk.ImageMenuItem(_('Download'))
1175 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1176 item.set_sensitive(can_download)
1177 item.connect('activate', self.on_download_selected_episodes)
1178 menu.append(self.set_finger_friendly(item))
1179 else:
1180 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
1181 item.connect('activate', self.on_item_cancel_download_activate)
1182 menu.append(self.set_finger_friendly(item))
1184 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1185 item.set_sensitive(can_delete)
1186 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1187 menu.append(self.set_finger_friendly(item))
1189 if one_is_new:
1190 item = gtk.ImageMenuItem(_('Do not download'))
1191 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1192 item.connect('activate', lambda w: self.mark_selected_episodes_old())
1193 menu.append(self.set_finger_friendly(item))
1194 elif can_download:
1195 item = gtk.ImageMenuItem(_('Mark as new'))
1196 item.set_image(gtk.image_new_from_stock(gtk.STOCK_ABOUT, gtk.ICON_SIZE_MENU))
1197 item.connect('activate', lambda w: self.mark_selected_episodes_new())
1198 menu.append(self.set_finger_friendly(item))
1200 ICON = lambda x: x
1202 # Ok, this probably makes sense to only display for downloaded files
1203 if can_play and not can_download:
1204 menu.append( gtk.SeparatorMenuItem())
1205 item = gtk.ImageMenuItem(_('Save to disk'))
1206 item.set_image(gtk.image_new_from_stock(gtk.STOCK_SAVE_AS, gtk.ICON_SIZE_MENU))
1207 item.connect('activate', lambda w: [self.save_episode_as_file(e) for e in episodes])
1208 menu.append(self.set_finger_friendly(item))
1209 if self.bluetooth_available:
1210 item = gtk.ImageMenuItem(_('Send via bluetooth'))
1211 item.set_image(gtk.image_new_from_icon_name(ICON('bluetooth'), gtk.ICON_SIZE_MENU))
1212 item.connect('activate', lambda w: self.copy_episodes_bluetooth(episodes))
1213 menu.append(self.set_finger_friendly(item))
1214 if can_transfer:
1215 item = gtk.ImageMenuItem(_('Transfer to %s') % self.get_device_name())
1216 item.set_image(gtk.image_new_from_icon_name(ICON('multimedia-player'), gtk.ICON_SIZE_MENU))
1217 item.connect('activate', lambda w: self.on_sync_to_ipod_activate(w, episodes))
1218 menu.append(self.set_finger_friendly(item))
1220 if can_play:
1221 menu.append( gtk.SeparatorMenuItem())
1222 if any_played:
1223 item = gtk.ImageMenuItem(_('Mark as unplayed'))
1224 item.set_image( gtk.image_new_from_stock( gtk.STOCK_CANCEL, gtk.ICON_SIZE_MENU))
1225 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, False))
1226 menu.append(self.set_finger_friendly(item))
1227 else:
1228 item = gtk.ImageMenuItem(_('Mark as played'))
1229 item.set_image( gtk.image_new_from_stock( gtk.STOCK_APPLY, gtk.ICON_SIZE_MENU))
1230 item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, True))
1231 menu.append(self.set_finger_friendly(item))
1233 if any_locked:
1234 item = gtk.ImageMenuItem(_('Allow deletion'))
1235 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1236 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, False))
1237 menu.append(self.set_finger_friendly(item))
1238 else:
1239 item = gtk.ImageMenuItem(_('Prohibit deletion'))
1240 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
1241 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, True))
1242 menu.append(self.set_finger_friendly(item))
1244 menu.append(gtk.SeparatorMenuItem())
1245 # Single item, add episode information menu item
1246 item = gtk.ImageMenuItem(_('Episode details'))
1247 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1248 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
1249 menu.append(self.set_finger_friendly(item))
1251 # If we have it, also add episode website link
1252 if episodes[0].link and episodes[0].link != episodes[0].url:
1253 item = gtk.ImageMenuItem(_('Visit website'))
1254 item.set_image(gtk.image_new_from_icon_name(ICON('web-browser'), gtk.ICON_SIZE_MENU))
1255 item.connect('activate', lambda w: util.open_website(episodes[0].link))
1256 menu.append(self.set_finger_friendly(item))
1258 if gpodder.interface == gpodder.MAEMO:
1259 # Because we open the popup on left-click for Maemo,
1260 # we also include a non-action to close the menu
1261 menu.append(gtk.SeparatorMenuItem())
1262 item = gtk.ImageMenuItem(_('Close this menu'))
1263 item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
1264 menu.append(self.set_finger_friendly(item))
1266 menu.show_all()
1267 # Disable tooltips while we are showing the menu, so
1268 # the tooltip will not appear over the menu
1269 self.treeview_allow_tooltips(self.treeAvailable, False)
1270 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
1271 menu.popup( None, None, None, event.button, event.time)
1273 return True
1275 def set_title(self, new_title):
1276 self.default_title = new_title
1277 self.gPodder.set_title(new_title)
1279 def update_episode_list_icons(self, urls=None, selected=False, all=False):
1281 Updates the status icons in the episode list.
1283 If urls is given, it should be a list of URLs
1284 of episodes that should be updated.
1286 If urls is None, set ONE OF selected, all to
1287 True (the former updates just the selected
1288 episodes and the latter updates all episodes).
1290 if urls is not None:
1291 # We have a list of URLs to walk through
1292 self.episode_list_model.update_by_urls(urls, \
1293 self.episode_is_downloading, \
1294 self.config.episode_list_descriptions and \
1295 gpodder.interface != gpodder.MAEMO)
1296 elif selected and not all:
1297 # We should update all selected episodes
1298 selection = self.treeAvailable.get_selection()
1299 model, paths = selection.get_selected_rows()
1300 for path in reversed(paths):
1301 iter = model.get_iter(path)
1302 self.episode_list_model.update_by_filter_iter(iter, \
1303 self.episode_is_downloading, \
1304 self.config.episode_list_descriptions and \
1305 gpodder.interface != gpodder.MAEMO)
1306 elif all and not selected:
1307 # We update all (even the filter-hidden) episodes
1308 self.episode_list_model.update_all(\
1309 self.episode_is_downloading, \
1310 self.config.episode_list_descriptions and \
1311 gpodder.interface != gpodder.MAEMO)
1312 else:
1313 # Wrong/invalid call - have to specify at least one parameter
1314 raise ValueError('Invalid call to update_episode_list_icons')
1316 def episode_list_status_changed(self, episodes):
1317 self.update_episode_list_icons([episode.url for episode in episodes])
1319 def clean_up_downloads(self, delete_partial=False):
1320 # Clean up temporary files left behind by old gPodder versions
1321 temporary_files = glob.glob('%s/*/.tmp-*' % self.config.download_dir)
1323 if delete_partial:
1324 temporary_files += glob.glob('%s/*/*.partial' % self.config.download_dir)
1326 for tempfile in temporary_files:
1327 util.delete_file(tempfile)
1329 # Clean up empty download folders and abandoned download folders
1330 download_dirs = glob.glob(os.path.join(self.config.download_dir, '*'))
1331 for ddir in download_dirs:
1332 if os.path.isdir(ddir) and False: # FIXME not db.channel_foldername_exists(os.path.basename(ddir)):
1333 globr = glob.glob(os.path.join(ddir, '*'))
1334 if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
1335 log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
1336 shutil.rmtree(ddir, ignore_errors=True)
1338 def streaming_possible(self):
1339 return self.config.player and self.config.player != 'default' and \
1340 gpodder.interface != gpodder.MAEMO
1342 def playback_episodes_for_real(self, episodes):
1343 groups = collections.defaultdict(list)
1344 for episode in episodes:
1345 file_type = episode.file_type()
1346 if file_type == 'video' and self.config.videoplayer and \
1347 self.config.videoplayer != 'default':
1348 player = self.config.videoplayer
1349 if gpodder.interface == gpodder.MAEMO:
1350 # Use the wrapper script if it's installed to crop 3GP YouTube
1351 # videos to fit the screen (looks much nicer than w/ black border)
1352 if player == 'mplayer' and util.find_command('gpodder-mplayer'):
1353 player = 'gpodder-mplayer'
1354 elif file_type == 'audio' and self.config.player and \
1355 self.config.player != 'default':
1356 player = self.config.player
1357 else:
1358 player = 'default'
1360 if file_type not in ('audio', 'video') or \
1361 (file_type == 'audio' and not self.config.audio_played_dbus) or \
1362 (file_type == 'video' and not self.config.video_played_dbus):
1363 # Mark episode as played in the database
1364 episode.mark(is_played=True)
1366 filename = episode.local_filename(create=False)
1367 if filename is None or not os.path.exists(filename):
1368 filename = episode.url
1369 groups[player].append(filename)
1371 # Open episodes with system default player
1372 if 'default' in groups:
1373 for filename in groups['default']:
1374 log('Opening with system default: %s', filename, sender=self)
1375 util.gui_open(filename)
1376 del groups['default']
1378 # For each type now, go and create play commands
1379 for group in groups:
1380 for command in util.format_desktop_command(group, groups[group]):
1381 log('Executing: %s', repr(command), sender=self)
1382 subprocess.Popen(command)
1384 def playback_episodes(self, episodes):
1385 if gpodder.interface == gpodder.MAEMO:
1386 if len(episodes) == 1:
1387 text = _('Opening %s') % episodes[0].title
1388 else:
1389 text = _('Opening %d episodes') % len(episodes)
1390 banner = hildon.hildon_banner_show_animation(self.gPodder, None, text)
1391 def destroy_banner_later(banner):
1392 banner.destroy()
1393 return False
1394 gobject.timeout_add(5000, destroy_banner_later, banner)
1396 episodes = [e for e in episodes if \
1397 e.was_downloaded(and_exists=True) or self.streaming_possible()]
1399 try:
1400 self.playback_episodes_for_real(episodes)
1401 except Exception, e:
1402 log('Error in playback!', sender=self, traceback=True)
1403 self.show_message( _('Please check your media player settings in the preferences dialog.'), _('Error opening player'), widget=self.toolPreferences)
1405 channel_urls = set()
1406 episode_urls = set()
1407 for episode in episodes:
1408 channel_urls.add(episode.channel.url)
1409 episode_urls.add(episode.url)
1410 self.update_episode_list_icons(episode_urls)
1411 self.update_podcast_list_model(channel_urls)
1413 def play_or_download(self):
1414 if self.wNotebook.get_current_page() > 0:
1415 if gpodder.interface != gpodder.MAEMO:
1416 self.toolCancel.set_sensitive(True)
1417 return
1419 ( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
1420 ( is_played, is_locked ) = (False,)*2
1422 open_instead_of_play = False
1424 selection = self.treeAvailable.get_selection()
1425 if selection.count_selected_rows() > 0:
1426 (model, paths) = selection.get_selected_rows()
1428 for path in paths:
1429 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
1431 if episode.file_type() not in ('audio', 'video'):
1432 open_instead_of_play = True
1434 if episode.was_downloaded():
1435 can_play = episode.was_downloaded(and_exists=True)
1436 can_delete = True
1437 is_played = episode.is_played
1438 is_locked = episode.is_locked
1439 if not can_play:
1440 can_download = True
1441 else:
1442 if self.episode_is_downloading(episode):
1443 can_cancel = True
1444 else:
1445 can_download = True
1447 can_download = can_download and not can_cancel
1448 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
1449 can_transfer = can_play and self.config.device_type != 'none' and not can_cancel and not can_download and not open_instead_of_play
1451 if gpodder.interface != gpodder.MAEMO:
1452 if open_instead_of_play:
1453 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
1454 else:
1455 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
1457 if gpodder.interface != gpodder.MAEMO:
1458 self.toolPlay.set_sensitive( can_play)
1459 self.toolDownload.set_sensitive( can_download)
1460 self.toolTransfer.set_sensitive( can_transfer)
1461 self.toolCancel.set_sensitive( can_cancel)
1463 self.item_cancel_download.set_sensitive(can_cancel)
1464 self.itemDownloadSelected.set_sensitive(can_download)
1465 self.itemOpenSelected.set_sensitive(can_play)
1466 self.itemPlaySelected.set_sensitive(can_play)
1467 self.itemDeleteSelected.set_sensitive(can_play and not can_download)
1468 self.item_toggle_played.set_sensitive(can_play)
1469 self.item_toggle_lock.set_sensitive(can_play)
1471 self.itemOpenSelected.set_visible(open_instead_of_play)
1472 self.itemPlaySelected.set_visible(not open_instead_of_play)
1474 return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
1476 def on_cbMaxDownloads_toggled(self, widget, *args):
1477 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
1479 def on_cbLimitDownloads_toggled(self, widget, *args):
1480 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
1482 def episode_new_status_changed(self, urls):
1483 self.update_podcast_list_model()
1484 self.update_episode_list_icons(urls)
1486 def update_podcast_list_model(self, urls=None, selected=False, select_url=None):
1487 """Update the podcast list treeview model
1489 If urls is given, it should list the URLs of each
1490 podcast that has to be updated in the list.
1492 If selected is True, only update the model contents
1493 for the currently-selected podcast - nothing more.
1495 The caller can optionally specify "select_url",
1496 which is the URL of the podcast that is to be
1497 selected in the list after the update is complete.
1498 This only works if the podcast list has to be
1499 reloaded; i.e. something has been added or removed
1500 since the last update of the podcast list).
1502 selection = self.treeChannels.get_selection()
1503 model, iter = selection.get_selected()
1505 if selected:
1506 # very cheap! only update selected channel
1507 if iter is not None:
1508 self.podcast_list_model.update_by_filter_iter(iter)
1509 elif not self.channel_list_changed:
1510 # we can keep the model, but have to update some
1511 if urls is None:
1512 # still cheaper than reloading the whole list
1513 iter = model.get_iter_first()
1514 while iter is not None:
1515 self.podcast_list_model.update_by_filter_iter(iter)
1516 iter = model.iter_next(iter)
1517 else:
1518 # ok, we got a bunch of urls to update
1519 self.podcast_list_model.update_by_urls(urls)
1520 else:
1521 if model and iter and select_url is None:
1522 # Get the URL of the currently-selected podcast
1523 select_url = model.get_value(iter, PodcastListModel.C_URL)
1525 # Update the podcast list model with new channels
1526 self.podcast_list_model.set_channels(self.channels)
1528 try:
1529 selected_iter = model.get_iter_first()
1530 # Find the previously-selected URL in the new
1531 # model if we have an URL (else select first)
1532 if select_url is not None:
1533 pos = model.get_iter_first()
1534 while pos is not None:
1535 url = model.get_value(pos, PodcastListModel.C_URL)
1536 if url == select_url:
1537 selected_iter = pos
1538 break
1539 pos = model.iter_next(pos)
1541 selection.select_iter(selected_iter)
1542 self.on_treeChannels_cursor_changed(self.treeChannels)
1543 except:
1544 log('Cannot select podcast in list', traceback=True, sender=self)
1545 self.channel_list_changed = False
1547 def episode_is_downloading(self, episode):
1548 """Returns True if the given episode is being downloaded at the moment"""
1549 if episode is None:
1550 return False
1552 return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
1554 def update_episode_list_model(self):
1555 if self.channels and self.active_channel is not None:
1556 if gpodder.interface == gpodder.MAEMO:
1557 banner = hildon.hildon_banner_show_animation(self.gPodder, None, _('Loading episodes'))
1558 else:
1559 banner = None
1561 self.currently_updating = True
1562 def do_update_episode_list_model():
1563 self.episode_list_model.update_from_channel(\
1564 self.active_channel, \
1565 self.episode_is_downloading, \
1566 self.config.episode_list_descriptions \
1567 and gpodder.interface != gpodder.MAEMO)
1569 def on_episode_list_model_updated():
1570 if banner is not None:
1571 banner.destroy()
1572 self.treeAvailable.columns_autosize()
1573 self.play_or_download()
1574 self.currently_updating = False
1575 util.idle_add(on_episode_list_model_updated)
1576 threading.Thread(target=do_update_episode_list_model).start()
1577 else:
1578 self.episode_list_model.clear()
1580 def offer_new_episodes(self, channels=None):
1581 new_episodes = self.get_new_episodes(channels)
1582 if new_episodes:
1583 self.new_episodes_show(new_episodes)
1584 return True
1585 return False
1587 def add_podcast_list(self, urls, auth_tokens=None):
1588 """Subscribe to a list of podcast given their URLs
1590 If auth_tokens is given, it should be a dictionary
1591 mapping URLs to (username, password) tuples."""
1593 if auth_tokens is None:
1594 auth_tokens = {}
1596 # Sort and split the URL list into five buckets
1597 queued, failed, existing, worked, authreq = [], [], [], [], []
1598 for input_url in urls:
1599 url = util.normalize_feed_url(input_url)
1600 if url is None:
1601 # Fail this one because the URL is not valid
1602 failed.append(input_url)
1603 elif self.podcast_list_model.get_filter_path_from_url(url) is not None:
1604 # A podcast already exists in the list for this URL
1605 existing.append(url)
1606 else:
1607 # This URL has survived the first round - queue for add
1608 queued.append(url)
1609 if url != input_url and input_url in auth_tokens:
1610 auth_tokens[url] = auth_tokens[input_url]
1612 error_messages = {}
1613 redirections = {}
1615 # After the initial sorting and splitting, try all queued podcasts
1616 for url in queued:
1617 log('QUEUE RUNNER: %s', url, sender=self)
1618 try:
1619 # The URL is valid and does not exist already - subscribe!
1620 channel = PodcastChannel.load(self.db, url=url, create=True, \
1621 authentication_tokens=auth_tokens.get(url, None), \
1622 max_episodes=self.config.max_episodes_per_feed, \
1623 download_dir=self.config.download_dir)
1625 try:
1626 username, password = util.username_password_from_url(url)
1627 except ValueError, ve:
1628 username, password = (None, None)
1630 if username is not None and channel.username is None and \
1631 password is not None and channel.password is None:
1632 channel.username = username
1633 channel.password = password
1634 channel.save()
1636 self._update_cover(channel)
1637 except feedcore.AuthenticationRequired:
1638 if url in auth_tokens:
1639 # Fail for wrong authentication data
1640 error_messages[url] = _('Authentication failed')
1641 failed.append(url)
1642 else:
1643 # Queue for login dialog later
1644 authreq.append(url)
1645 continue
1646 except feedcore.WifiLogin, error:
1647 redirections[url] = error.data
1648 failed.append(url)
1649 error_messages[url] = _('Redirection detected')
1650 continue
1651 except Exception, e:
1652 log('Subscription error: %s', e, traceback=True, sender=self)
1653 error_messages[url] = str(e)
1654 failed.append(url)
1655 continue
1657 assert channel is not None
1658 worked.append(channel.url)
1659 self.channels.append(channel)
1660 self.channel_list_changed = True
1662 # Report already-existing subscriptions to the user
1663 if existing:
1664 title = _('Existing subscriptions skipped')
1665 message = _('You are already subscribed to these podcasts:') \
1666 + '\n\n' + '\n'.join(saxutils.escape(url) for url in existing)
1667 self.show_message(message, title, widget=self.treeChannels)
1669 # Report subscriptions that require authentication
1670 if authreq:
1671 retry_podcasts = {}
1672 for url in authreq:
1673 title = _('Podcast requires authentication')
1674 message = _('Please login to %s:') % (saxutils.escape(url),)
1675 success, auth_tokens = self.show_login_dialog(title, message)
1676 if success:
1677 retry_podcasts[url] = auth_tokens
1678 else:
1679 # Stop asking the user for more login data
1680 retry_podcasts = {}
1681 for url in authreq:
1682 error_messages[url] = _('Authentication failed')
1683 failed.append(url)
1684 break
1686 # If we have authentication data to retry, do so here
1687 if retry_podcasts:
1688 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
1690 # Report website redirections
1691 for url in redirections:
1692 title = _('Website redirection detected')
1693 message = _('The URL %s redirects to %s.') \
1694 + '\n\n' + _('Do you want to visit the website now?')
1695 message = message % (url, redirections[url])
1696 if self.show_confirmation(message, title):
1697 util.open_website(error.data)
1698 else:
1699 break
1701 # Report failed subscriptions to the user
1702 if failed:
1703 title = _('Could not add some podcasts')
1704 message = _('Some podcasts could not be added to your list:') \
1705 + '\n\n' + '\n'.join(saxutils.escape('%s: %s' % (url, \
1706 error_messages.get(url, _('Unknown')))) for url in failed)
1707 self.show_message(message, title, important=True)
1709 # If at least one podcast has been added, save and update all
1710 if self.channel_list_changed:
1711 self.save_channels_opml()
1713 # If only one podcast was added, select it after the update
1714 if len(worked) == 1:
1715 url = worked[0]
1716 else:
1717 url = None
1719 # Update the list of subscribed podcasts
1720 self.update_feed_cache(force_update=False, select_url_afterwards=url)
1721 self.update_podcasts_tab()
1723 # Offer to download new episodes
1724 self.offer_new_episodes(channels=[c for c in self.channels if c.url in worked])
1726 def save_channels_opml(self):
1727 exporter = opml.Exporter(gpodder.subscription_file)
1728 return exporter.write(self.channels)
1730 def update_feed_cache_finish_callback(self, updated_urls=None, select_url_afterwards=None):
1731 self.db.commit()
1732 self.updating_feed_cache = False
1734 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
1735 self.channel_list_changed = True
1736 self.update_podcast_list_model(select_url=select_url_afterwards)
1738 # Only search for new episodes in podcasts that have been
1739 # updated, not in other podcasts (for single-feed updates)
1740 episodes = self.get_new_episodes([c for c in self.channels if c.url in updated_urls])
1742 if self.tray_icon:
1743 self.tray_icon.set_status()
1745 if self.feed_cache_update_cancelled:
1746 # The user decided to abort the feed update
1747 self.show_update_feeds_buttons()
1748 elif not episodes:
1749 # Nothing new here - but inform the user
1750 self.pbFeedUpdate.set_fraction(1.0)
1751 self.pbFeedUpdate.set_text(_('No new episodes'))
1752 self.feed_cache_update_cancelled = True
1753 self.btnCancelFeedUpdate.show()
1754 self.btnCancelFeedUpdate.set_sensitive(True)
1755 if gpodder.interface == gpodder.MAEMO:
1756 # btnCancelFeedUpdate is a ToolButton on Maemo
1757 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_APPLY)
1758 else:
1759 # btnCancelFeedUpdate is a normal gtk.Button
1760 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
1761 else:
1762 # New episodes are available
1763 self.pbFeedUpdate.set_fraction(1.0)
1764 # Are we minimized and should we auto download?
1765 if (self.is_iconified() and (self.config.auto_download == 'minimized')) or (self.config.auto_download == 'always'):
1766 self.download_episode_list(episodes)
1767 if len(episodes) == 1:
1768 title = _('Downloading one new episode.')
1769 else:
1770 title = _('Downloading %d new episodes.') % len(episodes)
1772 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
1773 self.show_update_feeds_buttons()
1774 else:
1775 self.show_update_feeds_buttons()
1776 # New episodes are available and we are not minimized
1777 if not self.config.do_not_show_new_episodes_dialog:
1778 self.new_episodes_show(episodes, notification=True)
1779 else:
1780 if len(episodes) == 1:
1781 message = _('One new episode is available for download')
1782 else:
1783 message = _('%i new episodes are available for download' % len(episodes))
1785 self.pbFeedUpdate.set_text(message)
1787 def _update_cover(self, channel):
1788 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
1789 self.cover_downloader.request_cover(channel)
1791 def update_feed_cache_proc(self, channels, select_url_afterwards):
1792 total = len(channels)
1794 for updated, channel in enumerate(channels):
1795 if not self.feed_cache_update_cancelled:
1796 try:
1797 # Update if timeout is not reached or we update a single podcast or skipping is disabled
1798 if channel.query_automatic_update() or total == 1 or not self.config.feed_update_skipping:
1799 channel.update(max_episodes=self.config.max_episodes_per_feed)
1800 else:
1801 log('Skipping update of %s (see feed_update_skipping)', channel.title, sender=self)
1802 self._update_cover(channel)
1803 except Exception, e:
1804 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)
1805 log('Error: %s', str(e), sender=self, traceback=True)
1807 # By the time we get here the update may have already been cancelled
1808 if not self.feed_cache_update_cancelled:
1809 def update_progress():
1810 progression = _('Updated %s (%d/%d)') % (channel.title, updated, total)
1811 self.pbFeedUpdate.set_text(progression)
1812 if self.tray_icon:
1813 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE, progression)
1814 self.pbFeedUpdate.set_fraction(float(updated)/float(total))
1815 util.idle_add(update_progress)
1817 if self.feed_cache_update_cancelled:
1818 break
1820 updated_urls = [c.url for c in channels]
1821 util.idle_add(self.update_feed_cache_finish_callback, updated_urls, select_url_afterwards)
1823 def show_update_feeds_buttons(self):
1824 # Make sure that the buttons for updating feeds
1825 # appear - this should happen after a feed update
1826 if gpodder.interface == gpodder.MAEMO:
1827 self.btnUpdateSelectedFeed.show()
1828 self.toolFeedUpdateProgress.hide()
1829 self.btnCancelFeedUpdate.hide()
1830 self.btnCancelFeedUpdate.set_is_important(False)
1831 self.btnCancelFeedUpdate.set_stock_id(gtk.STOCK_CLOSE)
1832 self.toolbarSpacer.set_expand(True)
1833 self.toolbarSpacer.set_draw(False)
1834 else:
1835 self.hboxUpdateFeeds.hide()
1836 self.btnUpdateFeeds.show()
1837 self.itemUpdate.set_sensitive(True)
1838 self.itemUpdateChannel.set_sensitive(True)
1840 def on_btnCancelFeedUpdate_clicked(self, widget):
1841 if not self.feed_cache_update_cancelled:
1842 self.pbFeedUpdate.set_text(_('Cancelling...'))
1843 self.feed_cache_update_cancelled = True
1844 self.btnCancelFeedUpdate.set_sensitive(False)
1845 else:
1846 self.show_update_feeds_buttons()
1848 def update_feed_cache(self, channels=None, force_update=True, select_url_afterwards=None):
1849 if self.updating_feed_cache:
1850 return
1852 if not force_update:
1853 self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
1854 self.channel_list_changed = True
1855 self.update_podcast_list_model(select_url=select_url_afterwards)
1856 return
1858 self.updating_feed_cache = True
1859 self.itemUpdate.set_sensitive(False)
1860 self.itemUpdateChannel.set_sensitive(False)
1862 if self.tray_icon:
1863 self.tray_icon.set_status(self.tray_icon.STATUS_UPDATING_FEED_CACHE)
1865 if channels is None:
1866 channels = self.channels
1868 if len(channels) == 1:
1869 text = _('Updating "%s"...') % channels[0].title
1870 else:
1871 text = _('Updating %d feeds...') % len(channels)
1872 self.pbFeedUpdate.set_text(text)
1873 self.pbFeedUpdate.set_fraction(0)
1875 self.feed_cache_update_cancelled = False
1876 self.btnCancelFeedUpdate.show()
1877 self.btnCancelFeedUpdate.set_sensitive(True)
1878 if gpodder.interface == gpodder.MAEMO:
1879 self.toolbarSpacer.set_expand(False)
1880 self.toolbarSpacer.set_draw(True)
1881 self.btnUpdateSelectedFeed.hide()
1882 self.toolFeedUpdateProgress.show_all()
1883 else:
1884 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
1885 self.hboxUpdateFeeds.show_all()
1886 self.btnUpdateFeeds.hide()
1888 args = (channels, select_url_afterwards)
1889 threading.Thread(target=self.update_feed_cache_proc, args=args).start()
1891 def on_gPodder_delete_event(self, widget, *args):
1892 """Called when the GUI wants to close the window
1893 Displays a confirmation dialog (and closes/hides gPodder)
1896 downloading = self.download_status_model.are_downloads_in_progress()
1898 # Only iconify if we are using the window's "X" button,
1899 # but not when we are using "Quit" in the menu or toolbar
1900 if not self.config.on_quit_ask and self.config.on_quit_systray and self.tray_icon and widget.get_name() not in ('toolQuit', 'itemQuit'):
1901 self.iconify_main_window()
1902 elif self.config.on_quit_ask or downloading:
1903 if gpodder.interface == gpodder.MAEMO:
1904 result = self.show_confirmation(_('Do you really want to quit gPodder now?'))
1905 if result:
1906 self.close_gpodder()
1907 else:
1908 return True
1909 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
1910 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
1911 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
1913 title = _('Quit gPodder')
1914 if downloading:
1915 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
1916 else:
1917 message = _('Do you really want to quit gPodder now?')
1919 dialog.set_title(title)
1920 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
1921 if not downloading:
1922 cb_ask = gtk.CheckButton(_("Don't ask me again"))
1923 dialog.vbox.pack_start(cb_ask)
1924 cb_ask.show_all()
1926 quit_button.grab_focus()
1927 result = dialog.run()
1928 dialog.destroy()
1930 if result == gtk.RESPONSE_CLOSE:
1931 if not downloading and cb_ask.get_active() == True:
1932 self.config.on_quit_ask = False
1933 self.close_gpodder()
1934 else:
1935 self.close_gpodder()
1937 return True
1939 def close_gpodder(self):
1940 """ clean everything and exit properly
1942 if self.channels:
1943 if self.save_channels_opml():
1944 if self.config.my_gpodder_autoupload:
1945 log('Uploading to my.gpodder.org on close', sender=self)
1946 util.idle_add(self.on_upload_to_mygpo, None)
1947 else:
1948 self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'), important=True)
1950 self.gPodder.hide()
1952 if self.tray_icon is not None:
1953 self.tray_icon.set_visible(False)
1955 # Notify all tasks to to carry out any clean-up actions
1956 self.download_status_model.tell_all_tasks_to_quit()
1958 while gtk.events_pending():
1959 gtk.main_iteration(False)
1961 self.db.close()
1963 self.quit()
1964 sys.exit(0)
1966 def get_old_episodes(self):
1967 episodes = []
1968 for channel in self.channels:
1969 for episode in channel.get_downloaded_episodes():
1970 if episode.age_in_days() > self.config.episode_old_age and \
1971 not episode.is_locked and episode.is_played:
1972 episodes.append(episode)
1973 return episodes
1975 def delete_episode_list(self, episodes, confirm=True):
1976 if not episodes:
1977 return
1979 count = len(episodes)
1981 if count == 1:
1982 episode = episodes[0]
1983 if episode.is_locked:
1984 title = _('%s is locked') % saxutils.escape(episode.title)
1985 message = _('You cannot delete this locked episode. You must unlock it before you can delete it.')
1986 self.notification(message, title, widget=self.treeAvailable)
1987 return
1989 title = _('Remove %s?') % saxutils.escape(episode.title)
1990 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.")
1991 else:
1992 title = _('Remove %d episodes?') % count
1993 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.')
1995 locked_count = sum(int(e.is_locked) for e in episodes if e.is_locked is not None)
1997 if count == locked_count:
1998 title = _('Episodes are locked')
1999 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
2000 self.notification(message, title, widget=self.treeAvailable)
2001 return
2002 elif locked_count > 0:
2003 title = _('Remove %d out of %d episodes?') % (count-locked_count, count)
2004 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.')
2006 if confirm and not self.show_confirmation(message, title):
2007 return
2009 episode_urls = set()
2010 channel_urls = set()
2011 for episode in episodes:
2012 if episode.is_locked:
2013 log('Not deleting episode (is locked): %s', episode.title)
2014 else:
2015 log('Deleting episode: %s', episode.title)
2016 episode.delete_from_disk()
2017 episode_urls.add(episode.url)
2018 channel_urls.add(episode.channel.url)
2020 # Tell the shownotes window that we have removed the episode
2021 if self.episode_shownotes_window is not None and \
2022 self.episode_shownotes_window.episode is not None and \
2023 self.episode_shownotes_window.episode.url == episode.url:
2024 self.episode_shownotes_window._download_status_changed(None)
2026 # Episodes have been deleted - persist the database
2027 self.db.commit()
2029 self.update_episode_list_icons(episode_urls)
2030 self.update_podcast_list_model(channel_urls)
2031 self.play_or_download()
2033 def on_itemRemoveOldEpisodes_activate( self, widget):
2034 if gpodder.interface == gpodder.MAEMO:
2035 columns = (
2036 ('maemo_remove_markup', None, None, _('Episode')),
2038 else:
2039 columns = (
2040 ('title_markup', None, None, _('Episode')),
2041 ('channel_prop', None, None, _('Podcast')),
2042 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
2043 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
2044 ('played_prop', None, None, _('Status')),
2045 ('age_prop', None, None, _('Downloaded')),
2048 selection_buttons = {
2049 _('Select played'): lambda episode: episode.is_played,
2050 _('Select older than %d days') % self.config.episode_old_age: lambda episode: episode.age_in_days() > self.config.episode_old_age,
2053 instructions = _('Select the episodes you want to delete:')
2055 episodes = []
2056 selected = []
2057 for channel in self.channels:
2058 for episode in channel.get_downloaded_episodes():
2059 if not episode.is_locked:
2060 episodes.append(episode)
2061 selected.append(episode.is_played)
2063 gPodderEpisodeSelector(self.gPodder, title = _('Remove old episodes'), instructions = instructions, \
2064 episodes = episodes, selected = selected, columns = columns, \
2065 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
2066 selection_buttons = selection_buttons, _config=self.config)
2068 def on_selected_episodes_status_changed(self):
2069 self.update_episode_list_icons(selected=True)
2070 self.update_podcast_list_model(selected=True)
2071 self.db.commit()
2073 def mark_selected_episodes_new(self):
2074 for episode in self.get_selected_episodes():
2075 episode.mark_new()
2076 self.on_selected_episodes_status_changed()
2078 def mark_selected_episodes_old(self):
2079 for episode in self.get_selected_episodes():
2080 episode.mark_old()
2081 self.on_selected_episodes_status_changed()
2083 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
2084 for episode in self.get_selected_episodes():
2085 if toggle:
2086 episode.mark(is_played=not episode.is_played)
2087 else:
2088 episode.mark(is_played=new_value)
2089 self.on_selected_episodes_status_changed()
2091 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2092 for episode in self.get_selected_episodes():
2093 if toggle:
2094 episode.mark(is_locked=not episode.is_locked)
2095 else:
2096 episode.mark(is_locked=new_value)
2097 self.on_selected_episodes_status_changed()
2099 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2100 if self.active_channel is None:
2101 return
2103 self.active_channel.channel_is_locked = not self.active_channel.channel_is_locked
2104 self.active_channel.update_channel_lock()
2106 for episode in self.active_channel.get_all_episodes():
2107 episode.mark(is_locked=self.active_channel.channel_is_locked)
2109 self.update_podcast_list_model(selected=True)
2110 self.update_episode_list_icons(all=True)
2112 def send_subscriptions(self):
2113 try:
2114 subprocess.Popen(['xdg-email', '--subject', _('My podcast subscriptions'),
2115 '--attach', gpodder.subscription_file])
2116 except:
2117 return False
2119 return True
2121 def on_item_email_subscriptions_activate(self, widget):
2122 if not self.channels:
2123 self.show_message(_('Your subscription list is empty. Add some podcasts first.'), _('Could not send list'), widget=self.treeChannels)
2124 elif not self.send_subscriptions():
2125 self.show_message(_('There was an error sending your subscription list via e-mail.'), _('Could not send list'), important=True)
2127 def on_itemUpdateChannel_activate(self, widget=None):
2128 if self.active_channel is None:
2129 title = _('No podcast selected')
2130 message = _('Please select a podcast in the podcasts list to update.')
2131 self.show_message( message, title, widget=self.treeChannels)
2132 return
2134 self.update_feed_cache(channels=[self.active_channel])
2136 def on_itemUpdate_activate(self, widget=None):
2137 if self.channels:
2138 self.update_feed_cache()
2139 else:
2140 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)
2142 def download_episode_list_paused(self, episodes):
2143 self.download_episode_list(episodes, True)
2145 def download_episode_list(self, episodes, add_paused=False):
2146 for episode in episodes:
2147 log('Downloading episode: %s', episode.title, sender = self)
2148 if not episode.was_downloaded(and_exists=True):
2149 task_exists = False
2150 for task in self.download_tasks_seen:
2151 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
2152 self.download_queue_manager.add_task(task)
2153 self.enable_download_list_update()
2154 task_exists = True
2155 continue
2157 if task_exists:
2158 continue
2160 try:
2161 task = download.DownloadTask(episode, self.config)
2162 except Exception, e:
2163 self.show_message(_('Download error while downloading %s:\n\n%s') % (episode.title, str(e)), _('Download error'), important=True)
2164 log('Download error while downloading %s', episode.title, sender=self, traceback=True)
2165 continue
2167 if add_paused:
2168 task.status = task.PAUSED
2169 else:
2170 self.download_queue_manager.add_task(task)
2172 self.download_status_model.register_task(task)
2173 self.enable_download_list_update()
2175 def cancel_task_list(self, tasks):
2176 if not tasks:
2177 return
2179 for task in tasks:
2180 if task.status in (task.QUEUED, task.DOWNLOADING):
2181 task.status = task.CANCELLED
2182 elif task.status == task.PAUSED:
2183 task.status = task.CANCELLED
2184 # Call run, so the partial file gets deleted
2185 task.run()
2187 self.update_episode_list_icons([task.url for task in tasks])
2188 self.play_or_download()
2190 # Update the tab title and downloads list
2191 self.update_downloads_list()
2193 def new_episodes_show(self, episodes, notification=False):
2194 if gpodder.interface == gpodder.MAEMO:
2195 columns = (
2196 ('maemo_markup', None, None, _('Episode')),
2198 show_notification = notification
2199 else:
2200 columns = (
2201 ('title_markup', None, None, _('Episode')),
2202 ('channel_prop', None, None, _('Podcast')),
2203 ('filesize_prop', 'length', gobject.TYPE_INT, _('Size')),
2204 ('pubdate_prop', 'pubDate', gobject.TYPE_INT, _('Released')),
2206 show_notification = False
2208 instructions = _('Select the episodes you want to download:')
2210 gPodderEpisodeSelector(self.gPodder, title=_('New episodes available'), instructions=instructions, \
2211 episodes=episodes, columns=columns, selected_default=True, \
2212 stock_ok_button = 'gpodder-download', \
2213 callback=self.download_episode_list, \
2214 remove_callback=lambda e: e.mark_old(), \
2215 remove_action=_('Never download'), \
2216 remove_finished=self.episode_new_status_changed, \
2217 _config=self.config, \
2218 show_notification=show_notification)
2220 def on_itemDownloadAllNew_activate(self, widget, *args):
2221 if not self.offer_new_episodes():
2222 self.show_message(_('Please check for new episodes later.'), \
2223 _('No new episodes available'), widget=self.btnUpdateFeeds)
2225 def get_new_episodes(self, channels=None):
2226 if channels is None:
2227 channels = self.channels
2228 episodes = []
2229 for channel in channels:
2230 for episode in channel.get_new_episodes(downloading=self.episode_is_downloading):
2231 episodes.append(episode)
2233 return episodes
2235 def on_sync_to_ipod_activate(self, widget, episodes=None):
2236 self.sync_ui.on_synchronize_episodes(self.channels, episodes)
2237 # The sync process might have updated the status of episodes,
2238 # therefore persist the database here to avoid losing data
2239 self.db.commit()
2241 def on_cleanup_ipod_activate(self, widget, *args):
2242 self.sync_ui.on_cleanup_device()
2244 def on_manage_device_playlist(self, widget):
2245 self.sync_ui.on_manage_device_playlist()
2247 def show_hide_tray_icon(self):
2248 if self.config.display_tray_icon and have_trayicon and self.tray_icon is None:
2249 self.tray_icon = GPodderStatusIcon(self, gpodder.icon_file, self.config)
2250 elif not self.config.display_tray_icon and self.tray_icon is not None:
2251 self.tray_icon.set_visible(False)
2252 del self.tray_icon
2253 self.tray_icon = None
2255 if self.config.minimize_to_tray and self.tray_icon:
2256 self.tray_icon.set_visible(self.is_iconified())
2257 elif self.tray_icon:
2258 self.tray_icon.set_visible(True)
2260 def on_itemShowToolbar_activate(self, widget):
2261 self.config.show_toolbar = self.itemShowToolbar.get_active()
2263 def on_itemShowDescription_activate(self, widget):
2264 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
2266 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
2267 self.config.podcast_list_hide_boring = toggleaction.get_active()
2268 if self.config.podcast_list_hide_boring:
2269 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
2270 else:
2271 self.podcast_list_model.set_view_mode(-1)
2273 def on_item_view_episodes_changed(self, radioaction, current):
2274 if current == self.item_view_episodes_all:
2275 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_ALL)
2276 elif current == self.item_view_episodes_undeleted:
2277 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNDELETED)
2278 elif current == self.item_view_episodes_downloaded:
2279 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_DOWNLOADED)
2280 elif current == self.item_view_episodes_unplayed:
2281 self.episode_list_model.set_view_mode(EpisodeListModel.VIEW_UNPLAYED)
2283 self.config.episode_list_view_mode = self.episode_list_model.get_view_mode()
2285 if self.config.podcast_list_hide_boring:
2286 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
2288 def update_item_device( self):
2289 if self.config.device_type != 'none':
2290 self.itemDevice.set_visible(True)
2291 self.itemDevice.label = self.get_device_name()
2292 else:
2293 self.itemDevice.set_visible(False)
2295 def properties_closed( self):
2296 self.show_hide_tray_icon()
2297 self.update_item_device()
2298 if gpodder.interface == gpodder.MAEMO:
2299 selection = self.treeAvailable.get_selection()
2300 if self.config.maemo_enable_gestures or \
2301 self.config.enable_fingerscroll:
2302 selection.set_mode(gtk.SELECTION_SINGLE)
2303 else:
2304 selection.set_mode(gtk.SELECTION_MULTIPLE)
2306 def on_itemPreferences_activate(self, widget, *args):
2307 gPodderPreferences(self.gPodder, _config=self.config, \
2308 callback_finished=self.properties_closed, \
2309 user_apps_reader=self.user_apps_reader)
2311 def on_itemDependencies_activate(self, widget):
2312 gPodderDependencyManager(self.gPodder)
2314 def require_my_gpodder_authentication(self):
2315 if not self.config.my_gpodder_username or not self.config.my_gpodder_password:
2316 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'))
2317 if success and authentication[0] and authentication[1]:
2318 self.config.my_gpodder_username, self.config.my_gpodder_password = authentication
2319 return True
2320 else:
2321 return False
2323 return True
2325 def my_gpodder_offer_autoupload(self):
2326 if not self.config.my_gpodder_autoupload:
2327 if self.show_confirmation(_('gPodder can automatically upload your subscription list to my.gpodder.org when you close it. Do you want to enable this feature?'), _('Upload subscriptions on quit')):
2328 self.config.my_gpodder_autoupload = True
2330 def on_download_from_mygpo(self, widget):
2331 if self.require_my_gpodder_authentication():
2332 client = my.MygPodderClient(self.config.my_gpodder_username, self.config.my_gpodder_password)
2333 opml_data = client.download_subscriptions()
2334 if len(opml_data) > 0:
2335 fp = open(gpodder.subscription_file, 'w')
2336 fp.write(opml_data)
2337 fp.close()
2338 (added, skipped) = (0, 0)
2339 i = opml.Importer(gpodder.subscription_file)
2341 existing = [c.url for c in self.channels]
2342 urls = [item['url'] for item in i.items if item['url'] not in existing]
2344 skipped = len(i.items) - len(urls)
2345 added = len(urls)
2347 self.add_podcast_list(urls)
2349 self.my_gpodder_offer_autoupload()
2350 if added > 0:
2351 self.show_message(_('Added %d new subscriptions and skipped %d existing ones.') % (added, skipped), _('Result of subscription download'), widget=self.treeChannels)
2352 elif widget is not None:
2353 self.show_message(_('Your local subscription list is up to date.'), _('Result of subscription download'), widget=self.treeChannels)
2354 else:
2355 self.config.my_gpodder_password = ''
2356 self.on_download_from_mygpo(widget)
2357 else:
2358 self.show_message(_('Please set up your username and password first.'), _('Username and password needed'), important=True)
2360 def on_upload_to_mygpo(self, widget):
2361 if self.require_my_gpodder_authentication():
2362 client = my.MygPodderClient(self.config.my_gpodder_username, self.config.my_gpodder_password)
2363 self.save_channels_opml()
2364 success, messages = client.upload_subscriptions(gpodder.subscription_file)
2365 if widget is not None:
2366 if not success:
2367 self.show_message('\n'.join(messages), _('Results of upload'), important=True)
2368 self.config.my_gpodder_password = ''
2369 self.on_upload_to_mygpo(widget)
2370 else:
2371 self.my_gpodder_offer_autoupload()
2372 self.show_message('\n'.join(messages), _('Results of upload'), widget=self.treeChannels)
2373 elif not success:
2374 log('Upload to my.gpodder.org failed, but widget is None!', sender=self)
2375 elif widget is not None:
2376 self.show_message(_('Please set up your username and password first.'), _('Username and password needed'), important=True)
2378 def on_itemAddChannel_activate(self, widget, *args):
2379 gPodderAddPodcast(self.gPodder, \
2380 add_urls_callback=self.add_podcast_list)
2382 def on_itemEditChannel_activate(self, widget, *args):
2383 if self.active_channel is None:
2384 title = _('No podcast selected')
2385 message = _('Please select a podcast in the podcasts list to edit.')
2386 self.show_message( message, title, widget=self.treeChannels)
2387 return
2389 callback_closed = lambda: self.update_podcast_list_model(selected=True)
2390 gPodderChannel(self.main_window, \
2391 channel=self.active_channel, \
2392 callback_closed=callback_closed, \
2393 cover_downloader=self.cover_downloader)
2395 def on_itemRemoveChannel_activate(self, widget, *args):
2396 if self.active_channel is None:
2397 title = _('No podcast selected')
2398 message = _('Please select a podcast in the podcasts list to remove.')
2399 self.show_message( message, title, widget=self.treeChannels)
2400 return
2402 try:
2403 if gpodder.interface == gpodder.GUI:
2404 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2405 dialog.add_button(gtk.STOCK_NO, gtk.RESPONSE_NO)
2406 dialog.add_button(gtk.STOCK_YES, gtk.RESPONSE_YES)
2408 title = _('Remove podcast and episodes?')
2409 message = _('Do you really want to remove <b>%s</b> and all downloaded episodes?') % saxutils.escape(self.active_channel.title)
2411 dialog.set_title(title)
2412 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
2414 cb_ask = gtk.CheckButton(_('Do not delete my downloaded episodes'))
2415 dialog.vbox.pack_start(cb_ask)
2416 cb_ask.show_all()
2417 affirmative = gtk.RESPONSE_YES
2418 elif gpodder.interface == gpodder.MAEMO:
2419 cb_ask = gtk.CheckButton('') # dummy check button
2420 dialog = hildon.Note('confirmation', (self.gPodder, _('Do you really want to remove this podcast and all downloaded episodes?')))
2421 affirmative = gtk.RESPONSE_OK
2423 result = dialog.run()
2424 dialog.destroy()
2426 if result == affirmative:
2427 keep_episodes = cb_ask.get_active()
2428 # delete downloaded episodes only if checkbox is unchecked
2429 if keep_episodes:
2430 log('Not removing downloaded episodes', sender=self)
2431 else:
2432 self.active_channel.remove_downloaded()
2434 # Clean up downloads and download directories
2435 self.clean_up_downloads()
2437 # cancel any active downloads from this channel
2438 for episode in self.active_channel.get_all_episodes():
2439 self.download_status_model.cancel_by_url(episode.url)
2441 # get the URL of the podcast we want to select next
2442 position = self.channels.index(self.active_channel)
2443 if position == len(self.channels)-1:
2444 # this is the last podcast, so select the URL
2445 # of the item before this one (i.e. the "new last")
2446 select_url = self.channels[position-1].url
2447 else:
2448 # there is a podcast after the deleted one, so
2449 # we simply select the one that comes after it
2450 select_url = self.channels[position+1].url
2452 # Remove the channel
2453 self.active_channel.delete(purge=not keep_episodes)
2454 self.channels.remove(self.active_channel)
2455 self.channel_list_changed = True
2456 self.save_channels_opml()
2458 # Re-load the channels and select the desired new channel
2459 self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
2460 except:
2461 log('There has been an error removing the channel.', traceback=True, sender=self)
2462 self.update_podcasts_tab()
2464 def get_opml_filter(self):
2465 filter = gtk.FileFilter()
2466 filter.add_pattern('*.opml')
2467 filter.add_pattern('*.xml')
2468 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
2469 return filter
2471 def on_item_import_from_file_activate(self, widget, filename=None):
2472 if filename is None:
2473 if gpodder.interface == gpodder.GUI:
2474 dlg = gtk.FileChooserDialog(title=_('Import from OPML'), parent=None, action=gtk.FILE_CHOOSER_ACTION_OPEN)
2475 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2476 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
2477 elif gpodder.interface == gpodder.MAEMO:
2478 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_OPEN)
2479 dlg.set_filter(self.get_opml_filter())
2480 response = dlg.run()
2481 filename = None
2482 if response == gtk.RESPONSE_OK:
2483 filename = dlg.get_filename()
2484 dlg.destroy()
2486 if filename is not None:
2487 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
2488 custom_title=_('Import podcasts from OPML file'), \
2489 add_urls_callback=self.add_podcast_list, \
2490 hide_url_entry=True)
2491 dir.download_opml_file(filename)
2493 def on_itemExportChannels_activate(self, widget, *args):
2494 if not self.channels:
2495 title = _('Nothing to export')
2496 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
2497 self.show_message(message, title, widget=self.treeChannels)
2498 return
2500 if gpodder.interface == gpodder.GUI:
2501 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
2502 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2503 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
2504 elif gpodder.interface == gpodder.MAEMO:
2505 dlg = hildon.FileChooserDialog(self.gPodder, gtk.FILE_CHOOSER_ACTION_SAVE)
2506 dlg.set_filter(self.get_opml_filter())
2507 response = dlg.run()
2508 if response == gtk.RESPONSE_OK:
2509 filename = dlg.get_filename()
2510 dlg.destroy()
2511 exporter = opml.Exporter( filename)
2512 if exporter.write(self.channels):
2513 if len(self.channels) == 1:
2514 title = _('One subscription exported')
2515 else:
2516 title = _('%d subscriptions exported') % len(self.channels)
2517 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
2518 else:
2519 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
2520 else:
2521 dlg.destroy()
2523 def on_itemImportChannels_activate(self, widget, *args):
2524 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
2525 add_urls_callback=self.add_podcast_list)
2526 dir.download_opml_file(self.config.opml_url)
2528 def on_homepage_activate(self, widget, *args):
2529 util.open_website(gpodder.__url__)
2531 def on_wiki_activate(self, widget, *args):
2532 util.open_website('http://wiki.gpodder.org/')
2534 def on_bug_tracker_activate(self, widget, *args):
2535 if gpodder.interface == gpodder.MAEMO:
2536 util.open_website('http://bugs.maemo.org/enter_bug.cgi?product=gPodder')
2537 else:
2538 util.open_website('http://bugs.gpodder.org/')
2540 def on_shop_activate(self, widget, *args):
2541 util.open_website('http://gpodder.org/shop')
2543 def on_wishlist_activate(self, widget, *args):
2544 util.open_website('http://www.amazon.de/gp/registry/2PD2MYGHE6857')
2546 def on_itemAbout_activate(self, widget, *args):
2547 dlg = gtk.AboutDialog()
2548 dlg.set_name('gPodder')
2549 dlg.set_version(gpodder.__version__)
2550 dlg.set_copyright(gpodder.__copyright__)
2551 dlg.set_website(gpodder.__url__)
2552 dlg.set_translator_credits( _('translator-credits'))
2553 dlg.connect( 'response', lambda dlg, response: dlg.destroy())
2555 if gpodder.interface == gpodder.GUI:
2556 # For the "GUI" version, we add some more
2557 # items to the about dialog (credits and logo)
2558 app_authors = [
2559 _('Maintainer:'),
2560 'Thomas Perl <thpinfo.com>',
2563 if os.path.exists(gpodder.credits_file):
2564 credits = open(gpodder.credits_file).read().strip().split('\n')
2565 app_authors += ['', _('Patches, bug reports and donations by:')]
2566 app_authors += credits
2568 dlg.set_authors(app_authors)
2569 try:
2570 dlg.set_logo(gtk.gdk.pixbuf_new_from_file(gpodder.icon_file))
2571 except:
2572 dlg.set_logo_icon_name('gpodder')
2574 dlg.run()
2576 def on_wNotebook_switch_page(self, widget, *args):
2577 page_num = args[1]
2578 if gpodder.interface == gpodder.MAEMO:
2579 self.tool_downloads.set_active(page_num == 1)
2580 page = self.wNotebook.get_nth_page(page_num)
2581 tab_label = self.wNotebook.get_tab_label(page).get_text()
2582 if page_num == 0 and self.active_channel is not None:
2583 self.set_title(self.active_channel.title)
2584 else:
2585 self.set_title(tab_label)
2586 if page_num == 0:
2587 self.play_or_download()
2588 self.menuChannels.set_sensitive(True)
2589 self.menuSubscriptions.set_sensitive(True)
2590 # The message area in the downloads tab should be hidden
2591 # when the user switches away from the downloads tab
2592 if self.message_area is not None:
2593 self.message_area.hide()
2594 self.message_area = None
2595 else:
2596 self.menuChannels.set_sensitive(False)
2597 self.menuSubscriptions.set_sensitive(False)
2598 if gpodder.interface != gpodder.MAEMO:
2599 self.toolDownload.set_sensitive(False)
2600 self.toolPlay.set_sensitive(False)
2601 self.toolTransfer.set_sensitive(False)
2602 self.toolCancel.set_sensitive(False)
2604 def on_treeChannels_row_activated(self, widget, path, *args):
2605 # double-click action of the podcast list or enter
2606 self.treeChannels.set_cursor(path)
2608 def on_treeChannels_cursor_changed(self, widget, *args):
2609 ( model, iter ) = self.treeChannels.get_selection().get_selected()
2611 if model is not None and iter is not None:
2612 old_active_channel = self.active_channel
2613 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
2615 if self.active_channel == old_active_channel:
2616 return
2618 if gpodder.interface == gpodder.MAEMO:
2619 self.set_title(self.active_channel.title)
2620 self.itemEditChannel.set_visible(True)
2621 self.itemRemoveChannel.set_visible(True)
2622 else:
2623 self.active_channel = None
2624 self.itemEditChannel.set_visible(False)
2625 self.itemRemoveChannel.set_visible(False)
2627 self.update_episode_list_model()
2629 def on_btnEditChannel_clicked(self, widget, *args):
2630 self.on_itemEditChannel_activate( widget, args)
2632 def get_selected_episodes(self):
2633 """Get a list of selected episodes from treeAvailable"""
2634 selection = self.treeAvailable.get_selection()
2635 model, paths = selection.get_selected_rows()
2637 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
2638 return episodes
2640 def on_transfer_selected_episodes(self, widget):
2641 self.on_sync_to_ipod_activate(widget, self.get_selected_episodes())
2643 def on_playback_selected_episodes(self, widget):
2644 self.playback_episodes(self.get_selected_episodes())
2646 def on_shownotes_selected_episodes(self, widget):
2647 episodes = self.get_selected_episodes()
2648 if episodes:
2649 episode = episodes.pop(0)
2650 self.show_episode_shownotes(episode)
2651 else:
2652 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
2654 def on_download_selected_episodes(self, widget):
2655 episodes = self.get_selected_episodes()
2656 self.download_episode_list(episodes)
2657 self.update_episode_list_icons([episode.url for episode in episodes])
2658 self.play_or_download()
2660 def on_treeAvailable_row_activated(self, widget, path, view_column):
2661 """Double-click/enter action handler for treeAvailable"""
2662 # We should only have one one selected as it was double clicked!
2663 e = self.get_selected_episodes()[0]
2665 if (self.config.double_click_episode_action == 'download'):
2666 # If the episode has already been downloaded and exists then play it
2667 if e.was_downloaded(and_exists=True):
2668 self.playback_episodes(self.get_selected_episodes())
2669 # else download it if it is not already downloading
2670 elif not self.episode_is_downloading(e):
2671 self.download_episode_list([e])
2672 self.update_episode_list_icons([e.url])
2673 self.play_or_download()
2674 elif (self.config.double_click_episode_action == 'stream'):
2675 # If we happen to have downloaded this episode simple play it
2676 if e.was_downloaded(and_exists=True):
2677 self.playback_episodes(self.get_selected_episodes())
2678 # else if streaming is possible stream it
2679 elif self.streaming_possible():
2680 self.playback_episodes(self.get_selected_episodes())
2681 else:
2682 log('Unable to stream episode - default media player selected!', sender=self, traceback=True)
2683 self.show_message(_('Please check your media player settings in the preferences dialog.'), _('Unable to stream episode'), widget=self.toolPreferences)
2684 else:
2685 # default action is to display show notes
2686 self.on_shownotes_selected_episodes(widget)
2688 def show_episode_shownotes(self, episode):
2689 if self.episode_shownotes_window is None:
2690 log('First-time use of episode window --- creating', sender=self)
2691 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
2692 _download_episode_list=self.download_episode_list, \
2693 _playback_episodes=self.playback_episodes, \
2694 _delete_episode_list=self.delete_episode_list, \
2695 _episode_list_status_changed=self.episode_list_status_changed, \
2696 _cancel_task_list=self.cancel_task_list)
2697 self.episode_shownotes_window.show(episode)
2698 if self.episode_is_downloading(episode):
2699 self.update_downloads_list()
2701 def auto_update_procedure(self, first_run=False):
2702 log('auto_update_procedure() got called', sender=self)
2703 if not first_run and self.config.auto_update_feeds and self.is_iconified():
2704 self.update_feed_cache(force_update=True)
2706 next_update = 60*1000*self.config.auto_update_frequency
2707 gobject.timeout_add(next_update, self.auto_update_procedure)
2708 return False
2710 def on_treeDownloads_row_activated(self, widget, *args):
2711 # Use the standard way of working on the treeview
2712 selection = self.treeDownloads.get_selection()
2713 (model, paths) = selection.get_selected_rows()
2714 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
2716 for tree_row_reference, task in selected_tasks:
2717 if task.status in (task.DOWNLOADING, task.QUEUED):
2718 task.status = task.PAUSED
2719 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
2720 self.download_queue_manager.add_task(task)
2721 self.enable_download_list_update()
2722 elif task.status == task.DONE:
2723 model.remove(model.get_iter(tree_row_reference.get_path()))
2725 self.play_or_download()
2727 # Update the tab title and downloads list
2728 self.update_downloads_list()
2730 def on_item_cancel_download_activate(self, widget):
2731 if self.wNotebook.get_current_page() == 0:
2732 selection = self.treeAvailable.get_selection()
2733 (model, paths) = selection.get_selected_rows()
2734 urls = [model.get_value(model.get_iter(path), \
2735 self.episode_list_model.C_URL) for path in paths]
2736 selected_tasks = [task for task in self.download_tasks_seen \
2737 if task.url in urls]
2738 else:
2739 selection = self.treeDownloads.get_selection()
2740 (model, paths) = selection.get_selected_rows()
2741 selected_tasks = [model.get_value(model.get_iter(path), \
2742 self.download_status_model.C_TASK) for path in paths]
2743 self.cancel_task_list(selected_tasks)
2745 def on_btnCancelAll_clicked(self, widget, *args):
2746 self.cancel_task_list(self.download_tasks_seen)
2748 def on_btnDownloadedDelete_clicked(self, widget, *args):
2749 if self.wNotebook.get_current_page() == 1:
2750 # Downloads tab visibile - skip (for now)
2751 return
2753 episodes = self.get_selected_episodes()
2754 self.delete_episode_list(episodes)
2756 def on_key_press(self, widget, event):
2757 # Allow tab switching with Ctrl + PgUp/PgDown
2758 if event.state & gtk.gdk.CONTROL_MASK:
2759 if event.keyval == gtk.keysyms.Page_Up:
2760 self.wNotebook.prev_page()
2761 return True
2762 elif event.keyval == gtk.keysyms.Page_Down:
2763 self.wNotebook.next_page()
2764 return True
2766 # After this code we only handle Maemo hardware keys,
2767 # so if we are not a Maemo app, we don't do anything
2768 if gpodder.interface != gpodder.MAEMO:
2769 return False
2771 diff = 0
2772 if event.keyval == gtk.keysyms.F7: #plus
2773 diff = 1
2774 elif event.keyval == gtk.keysyms.F8: #minus
2775 diff = -1
2777 if diff != 0 and not self.currently_updating:
2778 selection = self.treeChannels.get_selection()
2779 (model, iter) = selection.get_selected()
2780 new_path = ((model.get_path(iter)[0]+diff)%len(model),)
2781 selection.select_path(new_path)
2782 self.treeChannels.set_cursor(new_path)
2783 return True
2785 return False
2787 def on_iconify(self):
2788 if self.tray_icon:
2789 self.gPodder.set_skip_taskbar_hint(True)
2790 if self.config.minimize_to_tray:
2791 self.tray_icon.set_visible(True)
2792 else:
2793 self.gPodder.set_skip_taskbar_hint(False)
2795 def on_uniconify(self):
2796 if self.tray_icon:
2797 self.gPodder.set_skip_taskbar_hint(False)
2798 if self.config.minimize_to_tray:
2799 self.tray_icon.set_visible(False)
2800 else:
2801 self.gPodder.set_skip_taskbar_hint(False)
2803 def uniconify_main_window(self):
2804 if self.is_iconified():
2805 self.gPodder.present()
2807 def iconify_main_window(self):
2808 if not self.is_iconified():
2809 self.gPodder.iconify()
2811 def update_podcasts_tab(self):
2812 if len(self.channels):
2813 self.label2.set_text(_('Podcasts (%d)') % len(self.channels))
2814 else:
2815 self.label2.set_text(_('Podcasts'))
2817 @dbus.service.method(gpodder.dbus_interface)
2818 def show_gui_window(self):
2819 self.gPodder.present()
2821 @dbus.service.method(gpodder.dbus_interface)
2822 def subscribe_to_url(self, url):
2823 gPodderAddPodcast(self.gPodder,
2824 add_urls_callback=self.add_podcast_list,
2825 preset_url=url)
2827 @dbus.service.method(gpodder.dbus_interface)
2828 def mark_episode_played(self, filename):
2829 if filename is None:
2830 return False
2832 for channel in self.channels:
2833 for episode in channel.get_all_episodes():
2834 fn = episode.local_filename(create=False, check_only=True)
2835 if fn == filename:
2836 episode.mark(is_played=True)
2837 self.db.commit()
2838 self.update_episode_list_icons([episode.url])
2839 self.update_podcast_list_model([episode.channel.url])
2840 return True
2842 return False
2845 def main(options=None):
2846 gobject.threads_init()
2847 gobject.set_application_name('gPodder')
2849 if gpodder.interface == gpodder.MAEMO:
2850 # Try to enable the custom icon theme for gPodder on Maemo
2851 settings = gtk.settings_get_default()
2852 settings.set_string_property('gtk-icon-theme-name', \
2853 'gpodder', __file__)
2855 gtk.window_set_default_icon_name('gpodder')
2856 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
2858 try:
2859 session_bus = dbus.SessionBus(mainloop=dbus.glib.DBusGMainLoop())
2860 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=session_bus)
2861 except dbus.exceptions.DBusException, dbe:
2862 log('Warning: Cannot get "on the bus".', traceback=True)
2863 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
2864 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
2865 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
2866 dlg.set_title('gPodder')
2867 dlg.run()
2868 dlg.destroy()
2869 sys.exit(0)
2871 util.make_directory(gpodder.home)
2872 config = UIConfig(gpodder.config_file)
2874 if gpodder.interface == gpodder.MAEMO:
2875 # Detect changing of SD cards between mmc1/mmc2 if a gpodder
2876 # folder exists there (allow moving "gpodder" between SD cards or USB)
2877 # Also allow moving "gpodder" to home folder (e.g. rootfs on SD)
2878 if not os.path.exists(config.download_dir):
2879 log('Downloads might have been moved. Trying to locate them...')
2880 for basedir in ['/media/mmc1', '/media/mmc2']+glob.glob('/media/usb/*')+['/home/user/MyDocs']:
2881 dir = os.path.join(basedir, 'gpodder')
2882 if os.path.exists(dir):
2883 log('Downloads found in: %s', dir)
2884 config.download_dir = dir
2885 break
2886 else:
2887 log('Downloads NOT FOUND in %s', dir)
2889 if config.enable_fingerscroll:
2890 BuilderWidget.use_fingerscroll = True
2892 gp = gPodder(bus_name, config)
2894 # Handle options
2895 if options.subscribe:
2896 util.idle_add(gp.subscribe_to_url, options.subscribe)
2898 gp.run()