Ubuntu Unity Launcher Integration
[gpodder.git] / src / gpodder / gtkui / main.py
blob197286a808ff121f2396b3f007ab984cee129abd
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2012 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 import os
21 import platform
22 import gtk
23 import gtk.gdk
24 import gobject
25 import pango
26 import random
27 import sys
28 import shutil
29 import subprocess
30 import glob
31 import time
32 import tempfile
33 import collections
34 import threading
35 import urllib
36 import cgi
39 import gpodder
41 import dbus
42 import dbus.service
43 import dbus.mainloop
44 import dbus.glib
46 from gpodder import core
47 from gpodder import feedcore
48 from gpodder import util
49 from gpodder import opml
50 from gpodder import download
51 from gpodder import my
52 from gpodder import youtube
53 from gpodder import player
55 import logging
56 logger = logging.getLogger(__name__)
58 _ = gpodder.gettext
59 N_ = gpodder.ngettext
61 from gpodder.gtkui.model import Model
62 from gpodder.gtkui.model import PodcastListModel
63 from gpodder.gtkui.model import EpisodeListModel
64 from gpodder.gtkui.config import UIConfig
65 from gpodder.gtkui.services import CoverDownloader
66 from gpodder.gtkui.widgets import SimpleMessageArea
67 from gpodder.gtkui.desktopfile import UserAppsReader
69 from gpodder.gtkui.draw import draw_text_box_centered, draw_cake_pixbuf
71 from gpodder.gtkui.interface.common import BuilderWidget
72 from gpodder.gtkui.interface.common import TreeViewHelper
73 from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
75 from gpodder.gtkui.download import DownloadStatusModel
77 from gpodder.gtkui.desktop.welcome import gPodderWelcome
78 from gpodder.gtkui.desktop.channel import gPodderChannel
79 from gpodder.gtkui.desktop.preferences import gPodderPreferences
80 from gpodder.gtkui.desktop.shownotes import gPodderShownotes
81 from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
82 from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
83 from gpodder.gtkui.interface.progress import ProgressIndicator
85 from gpodder.dbusproxy import DBusPodcastsProxy
86 from gpodder import extensions
88 class gPodder(BuilderWidget, dbus.service.Object):
89 # Delay until live search is started after typing stop
90 LIVE_SEARCH_DELAY = 500
92 # Width (in pixels) of episode list icon
93 EPISODE_LIST_ICON_WIDTH = 40
95 def __init__(self, bus_name, gpodder_core):
96 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
97 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels,
98 self.on_itemUpdate_activate,
99 self.playback_episodes,
100 self.download_episode_list,
101 self.episode_object_by_uri,
102 bus_name)
103 self.core = gpodder_core
104 self.config = self.core.config
105 self.db = self.core.db
106 self.model = self.core.model
107 BuilderWidget.__init__(self, None)
109 def new(self):
110 self.toolbar.set_property('visible', self.config.show_toolbar)
112 self.bluetooth_available = util.bluetooth_available()
114 self.config.connect_gtk_window(self.main_window, 'main_window')
116 self.config.connect_gtk_paned('paned_position', self.channelPaned)
118 self.main_window.show()
120 self.player_receiver = player.MediaPlayerDBusReceiver(self.on_played)
122 self.gPodder.connect('key-press-event', self.on_key_press)
124 self.episode_columns_menu = None
125 self.config.add_observer(self.on_config_changed)
127 self.episode_shownotes_window = None
128 self.new_episodes_window = None
130 try:
131 from gpodder.gtkui import ubuntu
132 self.ubuntu = ubuntu.LauncherEntry()
133 except Exception, e:
134 self.ubuntu = None
136 # Mac OS X-specific UI tweaks: Native main menu integration
137 # http://sourceforge.net/apps/trac/gtk-osx/wiki/Integrate
138 if getattr(gtk.gdk, 'WINDOWING', 'x11') == 'quartz':
139 try:
140 import igemacintegration as igemi
142 # Move the menu bar from the window to the Mac menu bar
143 self.mainMenu.hide()
144 igemi.ige_mac_menu_set_menu_bar(self.mainMenu)
146 # Reparent some items to the "Application" menu
147 for widget in ('/mainMenu/menuHelp/itemAbout',
148 '/mainMenu/menuPodcasts/itemPreferences'):
149 item = self.uimanager1.get_widget(widget)
150 group = igemi.ige_mac_menu_add_app_menu_group()
151 igemi.ige_mac_menu_add_app_menu_item(group, item, None)
153 quit_widget = '/mainMenu/menuPodcasts/itemQuit'
154 quit_item = self.uimanager1.get_widget(quit_widget)
155 igemi.ige_mac_menu_set_quit_menu_item(quit_item)
156 except ImportError:
157 print >>sys.stderr, """
158 Warning: ige-mac-integration not found - no native menus.
161 self.download_status_model = DownloadStatusModel()
162 self.download_queue_manager = download.DownloadQueueManager(self.config)
164 self.itemShowAllEpisodes.set_active(self.config.podcast_list_view_all)
165 self.itemShowToolbar.set_active(self.config.show_toolbar)
166 self.itemShowDescription.set_active(self.config.episode_list_descriptions)
168 self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
169 self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
170 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
171 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
173 self.config.connect_gtk_togglebutton('podcast_list_sections', self.item_podcast_sections)
175 # When the amount of maximum downloads changes, notify the queue manager
176 changed_cb = lambda spinbutton: self.download_queue_manager.spawn_threads()
177 self.spinMaxDownloads.connect('value-changed', changed_cb)
179 self.default_title = None
180 self.set_title(_('gPodder'))
182 self.cover_downloader = CoverDownloader()
184 # Generate list models for podcasts and their episodes
185 self.podcast_list_model = PodcastListModel(self.cover_downloader)
187 self.cover_downloader.register('cover-available', self.cover_download_finished)
188 self.cover_downloader.register('cover-removed', self.cover_file_removed)
190 # Source IDs for timeouts for search-as-you-type
191 self._podcast_list_search_timeout = None
192 self._episode_list_search_timeout = None
194 # Init the treeviews that we use
195 self.init_podcast_list_treeview()
196 self.init_episode_list_treeview()
197 self.init_download_list_treeview()
199 if self.config.podcast_list_hide_boring:
200 self.item_view_hide_boring_podcasts.set_active(True)
202 self.currently_updating = False
204 self.download_tasks_seen = set()
205 self.download_list_update_enabled = False
206 self.download_task_monitors = set()
208 # Subscribed channels
209 self.active_channel = None
210 self.channels = self.model.get_podcasts()
212 gpodder.user_extensions.on_ui_initialized(self.model,
213 self.extensions_podcast_update_cb,
214 self.extensions_episode_download_cb)
216 # load list of user applications for audio playback
217 self.user_apps_reader = UserAppsReader(['audio', 'video'])
218 threading.Thread(target=self.user_apps_reader.read).start()
220 # Set up the first instance of MygPoClient
221 self.mygpo_client = my.MygPoClient(self.config)
223 # Now, update the feed cache, when everything's in place
224 self.btnUpdateFeeds.show()
225 self.feed_cache_update_cancelled = False
226 self.update_podcast_list_model()
228 self.message_area = None
230 def find_partial_downloads():
231 # Look for partial file downloads
232 partial_files = glob.glob(os.path.join(gpodder.downloads, '*', '*.partial'))
233 count = len(partial_files)
234 resumable_episodes = []
235 if count:
236 util.idle_add(self.wNotebook.set_current_page, 1)
237 indicator = ProgressIndicator(_('Loading incomplete downloads'),
238 _('Some episodes have not finished downloading in a previous session.'),
239 False, self.get_dialog_parent())
240 indicator.on_message(N_('%(count)d partial file', '%(count)d partial files', count) % {'count':count})
242 candidates = [f[:-len('.partial')] for f in partial_files]
243 found = 0
245 for c in self.channels:
246 for e in c.get_all_episodes():
247 filename = e.local_filename(create=False, check_only=True)
248 if filename in candidates:
249 found += 1
250 indicator.on_message(e.title)
251 indicator.on_progress(float(found)/count)
252 candidates.remove(filename)
253 partial_files.remove(filename+'.partial')
255 if os.path.exists(filename):
256 # The file has already been downloaded;
257 # remove the leftover partial file
258 util.delete_file(filename+'.partial')
259 else:
260 resumable_episodes.append(e)
262 if not candidates:
263 break
265 if not candidates:
266 break
268 for f in partial_files:
269 logger.warn('Partial file without episode: %s', f)
270 util.delete_file(f)
272 util.idle_add(indicator.on_finished)
274 if len(resumable_episodes):
275 def offer_resuming():
276 self.download_episode_list_paused(resumable_episodes)
277 resume_all = gtk.Button(_('Resume all'))
278 def on_resume_all(button):
279 selection = self.treeDownloads.get_selection()
280 selection.select_all()
281 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = self.downloads_list_get_selection()
282 selection.unselect_all()
283 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
284 self.message_area.hide()
285 resume_all.connect('clicked', on_resume_all)
287 self.message_area = SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all,))
288 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
289 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
290 self.message_area.show_all()
291 self.clean_up_downloads(delete_partial=False)
292 util.idle_add(offer_resuming)
293 else:
294 util.idle_add(self.wNotebook.set_current_page, 0)
295 else:
296 util.idle_add(self.clean_up_downloads, True)
297 threading.Thread(target=find_partial_downloads).start()
299 # Start the auto-update procedure
300 self._auto_update_timer_source_id = None
301 if self.config.auto_update_feeds:
302 self.restart_auto_update_timer()
304 # Find expired (old) episodes and delete them
305 old_episodes = list(self.get_expired_episodes())
306 if len(old_episodes) > 0:
307 self.delete_episode_list(old_episodes, confirm=False)
308 updated_urls = set(e.channel.url for e in old_episodes)
309 self.update_podcast_list_model(updated_urls)
311 # Do the initial sync with the web service
312 util.idle_add(self.mygpo_client.flush, True)
314 # First-time users should be asked if they want to see the OPML
315 if not self.channels:
316 self.on_itemUpdate_activate()
318 def episode_object_by_uri(self, uri):
319 """Get an episode object given a local or remote URI
321 This can be used to quickly access an episode object
322 when all we have is its download filename or episode
323 URL (e.g. from external D-Bus calls / signals, etc..)
325 if uri.startswith('/'):
326 uri = 'file://' + urllib.quote(uri)
328 prefix = 'file://' + urllib.quote(gpodder.downloads)
330 # By default, assume we can't pre-select any channel
331 # but can match episodes simply via the download URL
332 is_channel = lambda c: True
333 is_episode = lambda e: e.url == uri
335 if uri.startswith(prefix):
336 # File is on the local filesystem in the download folder
337 # Try to reduce search space by pre-selecting the channel
338 # based on the folder name of the local file
340 filename = urllib.unquote(uri[len(prefix):])
341 file_parts = filter(None, filename.split(os.sep))
343 if len(file_parts) != 2:
344 return None
346 foldername, filename = file_parts
348 is_channel = lambda c: c.download_folder == foldername
349 is_episode = lambda e: e.download_filename == filename
351 # Deep search through channels and episodes for a match
352 for channel in filter(is_channel, self.channels):
353 for episode in filter(is_episode, channel.get_all_episodes()):
354 return episode
356 return None
358 def on_played(self, start, end, total, file_uri):
359 """Handle the "played" signal from a media player"""
360 if start == 0 and end == 0 and total == 0:
361 # Ignore bogus play event
362 return
363 elif end < start + 5:
364 # Ignore "less than five seconds" segments,
365 # as they can happen with seeking, etc...
366 return
368 logger.debug('Received play action: %s (%d, %d, %d)', file_uri, start, end, total)
369 episode = self.episode_object_by_uri(file_uri)
371 if episode is not None:
372 file_type = episode.file_type()
374 now = time.time()
375 if total > 0:
376 episode.total_time = total
377 elif total == 0:
378 # Assume the episode's total time for the action
379 total = episode.total_time
381 assert (episode.current_position_updated is None or
382 now >= episode.current_position_updated)
384 episode.current_position = end
385 episode.current_position_updated = now
386 episode.mark(is_played=True)
387 episode.save()
388 self.db.commit()
389 self.update_episode_list_icons([episode.url])
390 self.update_podcast_list_model([episode.channel.url])
392 # Submit this action to the webservice
393 self.mygpo_client.on_playback_full(episode, start, end, total)
395 def on_add_remove_podcasts_mygpo(self):
396 actions = self.mygpo_client.get_received_actions()
397 if not actions:
398 return False
400 existing_urls = [c.url for c in self.channels]
402 # Columns for the episode selector window - just one...
403 columns = (
404 ('description', None, None, _('Action')),
407 # A list of actions that have to be chosen from
408 changes = []
410 # Actions that are ignored (already carried out)
411 ignored = []
413 for action in actions:
414 if action.is_add and action.url not in existing_urls:
415 changes.append(my.Change(action))
416 elif action.is_remove and action.url in existing_urls:
417 podcast_object = None
418 for podcast in self.channels:
419 if podcast.url == action.url:
420 podcast_object = podcast
421 break
422 changes.append(my.Change(action, podcast_object))
423 else:
424 ignored.append(action)
426 # Confirm all ignored changes
427 self.mygpo_client.confirm_received_actions(ignored)
429 def execute_podcast_actions(selected):
430 add_list = [c.action.url for c in selected if c.action.is_add]
431 remove_list = [c.podcast for c in selected if c.action.is_remove]
433 # Apply the accepted changes locally
434 self.add_podcast_list(add_list)
435 self.remove_podcast_list(remove_list, confirm=False)
437 # All selected items are now confirmed
438 self.mygpo_client.confirm_received_actions(c.action for c in selected)
440 # Revert the changes on the server
441 rejected = [c.action for c in changes if c not in selected]
442 self.mygpo_client.reject_received_actions(rejected)
444 def ask():
445 # We're abusing the Episode Selector again ;) -- thp
446 gPodderEpisodeSelector(self.main_window, \
447 title=_('Confirm changes from gpodder.net'), \
448 instructions=_('Select the actions you want to carry out.'), \
449 episodes=changes, \
450 columns=columns, \
451 size_attribute=None, \
452 stock_ok_button=gtk.STOCK_APPLY, \
453 callback=execute_podcast_actions, \
454 _config=self.config)
456 # There are some actions that need the user's attention
457 if changes:
458 util.idle_add(ask)
459 return True
461 # We have no remaining actions - no selection happens
462 return False
464 def rewrite_urls_mygpo(self):
465 # Check if we have to rewrite URLs since the last add
466 rewritten_urls = self.mygpo_client.get_rewritten_urls()
467 changed = False
469 for rewritten_url in rewritten_urls:
470 if not rewritten_url.new_url:
471 continue
473 for channel in self.channels:
474 if channel.url == rewritten_url.old_url:
475 logger.info('Updating URL of %s to %s', channel,
476 rewritten_url.new_url)
477 channel.url = rewritten_url.new_url
478 channel.save()
479 changed = True
480 break
482 if changed:
483 util.idle_add(self.update_episode_list_model)
485 def on_send_full_subscriptions(self):
486 # Send the full subscription list to the gpodder.net client
487 # (this will overwrite the subscription list on the server)
488 indicator = ProgressIndicator(_('Uploading subscriptions'), \
489 _('Your subscriptions are being uploaded to the server.'), \
490 False, self.get_dialog_parent())
492 try:
493 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
494 util.idle_add(self.show_message, _('List uploaded successfully.'))
495 except Exception, e:
496 def show_error(e):
497 message = str(e)
498 if not message:
499 message = e.__class__.__name__
500 self.show_message(message, \
501 _('Error while uploading'), \
502 important=True)
503 util.idle_add(show_error, e)
505 util.idle_add(indicator.on_finished)
507 def on_podcast_selected(self, treeview, path, column):
508 # for Maemo 5's UI
509 model = treeview.get_model()
510 channel = model.get_value(model.get_iter(path), \
511 PodcastListModel.C_CHANNEL)
512 self.active_channel = channel
513 self.update_episode_list_model()
514 self.episodes_window.channel = self.active_channel
515 self.episodes_window.show()
517 def on_button_subscribe_clicked(self, button):
518 self.on_itemImportChannels_activate(button)
520 def on_button_downloads_clicked(self, widget):
521 self.downloads_window.show()
523 def for_each_episode_set_task_status(self, episodes, status):
524 episode_urls = set(episode.url for episode in episodes)
525 model = self.treeDownloads.get_model()
526 selected_tasks = [(gtk.TreeRowReference(model, row.path), \
527 model.get_value(row.iter, \
528 DownloadStatusModel.C_TASK)) for row in model \
529 if model.get_value(row.iter, DownloadStatusModel.C_TASK).url \
530 in episode_urls]
531 self._for_each_task_set_status(selected_tasks, status)
533 def on_treeview_button_pressed(self, treeview, event):
534 if event.window != treeview.get_bin_window():
535 return False
537 role = getattr(treeview, TreeViewHelper.ROLE)
538 if role == TreeViewHelper.ROLE_PODCASTS:
539 return self.currently_updating
540 elif (role == TreeViewHelper.ROLE_EPISODES and event.button == 1):
541 # Toggle episode "new" status by clicking the icon (bug 1432)
542 result = treeview.get_path_at_pos(int(event.x), int(event.y))
543 if result is not None:
544 path, column, x, y = result
545 # The user clicked the icon if she clicked in the first column
546 # and the x position is in the area where the icon resides
547 if (x < self.EPISODE_LIST_ICON_WIDTH and
548 column == treeview.get_columns()[0]):
549 model = treeview.get_model()
550 cursor_episode = model.get_value(model.get_iter(path),
551 EpisodeListModel.C_EPISODE)
553 new_value = cursor_episode.is_new
554 selected_episodes = self.get_selected_episodes()
556 # Avoid changing anything if the clicked episode is not
557 # selected already - otherwise update all selected
558 if cursor_episode in selected_episodes:
559 for episode in selected_episodes:
560 episode.mark(is_played=new_value)
562 self.update_episode_list_icons(selected=True)
563 self.update_podcast_list_model(selected=True)
564 return True
566 return event.button == 3
568 def on_treeview_podcasts_button_released(self, treeview, event):
569 if event.window != treeview.get_bin_window():
570 return False
572 return self.treeview_channels_show_context_menu(treeview, event)
574 def on_treeview_episodes_button_released(self, treeview, event):
575 if event.window != treeview.get_bin_window():
576 return False
578 return self.treeview_available_show_context_menu(treeview, event)
580 def on_treeview_downloads_button_released(self, treeview, event):
581 if event.window != treeview.get_bin_window():
582 return False
584 return self.treeview_downloads_show_context_menu(treeview, event)
586 def on_entry_search_podcasts_changed(self, editable):
587 if self.hbox_search_podcasts.get_property('visible'):
588 def set_search_term(self, text):
589 self.podcast_list_model.set_search_term(text)
590 self._podcast_list_search_timeout = None
591 return False
593 if self._podcast_list_search_timeout is not None:
594 gobject.source_remove(self._podcast_list_search_timeout)
595 self._podcast_list_search_timeout = gobject.timeout_add(\
596 self.LIVE_SEARCH_DELAY, \
597 set_search_term, self, editable.get_chars(0, -1))
599 def on_entry_search_podcasts_key_press(self, editable, event):
600 if event.keyval == gtk.keysyms.Escape:
601 self.hide_podcast_search()
602 return True
604 def hide_podcast_search(self, *args):
605 if self._podcast_list_search_timeout is not None:
606 gobject.source_remove(self._podcast_list_search_timeout)
607 self._podcast_list_search_timeout = None
608 self.hbox_search_podcasts.hide()
609 self.entry_search_podcasts.set_text('')
610 self.podcast_list_model.set_search_term(None)
611 self.treeChannels.grab_focus()
613 def show_podcast_search(self, input_char):
614 self.hbox_search_podcasts.show()
615 self.entry_search_podcasts.insert_text(input_char, -1)
616 self.entry_search_podcasts.grab_focus()
617 self.entry_search_podcasts.set_position(-1)
619 def init_podcast_list_treeview(self):
620 # Set up podcast channel tree view widget
621 column = gtk.TreeViewColumn('')
622 iconcell = gtk.CellRendererPixbuf()
623 iconcell.set_property('width', 45)
624 column.pack_start(iconcell, False)
625 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
626 column.add_attribute(iconcell, 'visible', PodcastListModel.C_COVER_VISIBLE)
628 namecell = gtk.CellRendererText()
629 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
630 column.pack_start(namecell, True)
631 column.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
633 iconcell = gtk.CellRendererPixbuf()
634 iconcell.set_property('xalign', 1.0)
635 column.pack_start(iconcell, False)
636 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
637 column.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
639 self.treeChannels.append_column(column)
641 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
643 # When no podcast is selected, clear the episode list model
644 selection = self.treeChannels.get_selection()
645 def select_function(selection, model, path, path_currently_selected):
646 url = model.get_value(model.get_iter(path), PodcastListModel.C_URL)
647 return (url != '-')
648 selection.set_select_function(select_function, full=True)
650 # Set up type-ahead find for the podcast list
651 def on_key_press(treeview, event):
652 if event.keyval == gtk.keysyms.Right:
653 self.treeAvailable.grab_focus()
654 elif event.keyval in (gtk.keysyms.Up, gtk.keysyms.Down):
655 # If section markers exist in the treeview, we want to
656 # "jump over" them when moving the cursor up and down
657 selection = self.treeChannels.get_selection()
658 model, it = selection.get_selected()
660 if event.keyval == gtk.keysyms.Up:
661 step = -1
662 else:
663 step = 1
665 path = model.get_path(it)
666 while True:
667 path = (path[0]+step,)
669 if path[0] < 0:
670 # Valid paths must have a value >= 0
671 return True
673 try:
674 it = model.get_iter(path)
675 except ValueError:
676 # Already at the end of the list
677 return True
679 if model.get_value(it, PodcastListModel.C_URL) != '-':
680 break
682 self.treeChannels.set_cursor(path)
683 elif event.keyval == gtk.keysyms.Escape:
684 self.hide_podcast_search()
685 elif event.state & gtk.gdk.CONTROL_MASK:
686 # Don't handle type-ahead when control is pressed (so shortcuts
687 # with the Ctrl key still work, e.g. Ctrl+A, ...)
688 return True
689 else:
690 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
691 if unicode_char_id == 0:
692 return False
693 input_char = unichr(unicode_char_id)
694 self.show_podcast_search(input_char)
695 return True
696 self.treeChannels.connect('key-press-event', on_key_press)
698 self.treeChannels.connect('popup-menu', self.treeview_channels_show_context_menu)
700 # Enable separators to the podcast list to separate special podcasts
701 # from others (this is used for the "all episodes" view)
702 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
704 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
706 def on_entry_search_episodes_changed(self, editable):
707 if self.hbox_search_episodes.get_property('visible'):
708 def set_search_term(self, text):
709 self.episode_list_model.set_search_term(text)
710 self._episode_list_search_timeout = None
711 return False
713 if self._episode_list_search_timeout is not None:
714 gobject.source_remove(self._episode_list_search_timeout)
715 self._episode_list_search_timeout = gobject.timeout_add(\
716 self.LIVE_SEARCH_DELAY, \
717 set_search_term, self, editable.get_chars(0, -1))
719 def on_entry_search_episodes_key_press(self, editable, event):
720 if event.keyval == gtk.keysyms.Escape:
721 self.hide_episode_search()
722 return True
724 def hide_episode_search(self, *args):
725 if self._episode_list_search_timeout is not None:
726 gobject.source_remove(self._episode_list_search_timeout)
727 self._episode_list_search_timeout = None
728 self.hbox_search_episodes.hide()
729 self.entry_search_episodes.set_text('')
730 self.episode_list_model.set_search_term(None)
731 self.treeAvailable.grab_focus()
733 def show_episode_search(self, input_char):
734 self.hbox_search_episodes.show()
735 self.entry_search_episodes.insert_text(input_char, -1)
736 self.entry_search_episodes.grab_focus()
737 self.entry_search_episodes.set_position(-1)
739 def set_episode_list_column(self, index, new_value):
740 mask = (1 << index)
741 if new_value:
742 self.config.episode_list_columns |= mask
743 else:
744 self.config.episode_list_columns &= ~mask
746 def update_episode_list_columns_visibility(self):
747 columns = TreeViewHelper.get_columns(self.treeAvailable)
748 for index, column in enumerate(columns):
749 visible = bool(self.config.episode_list_columns & (1 << index))
750 column.set_visible(visible)
751 self.treeAvailable.columns_autosize()
753 if self.episode_columns_menu is not None:
754 children = self.episode_columns_menu.get_children()
755 for index, child in enumerate(children):
756 active = bool(self.config.episode_list_columns & (1 << index))
757 child.set_active(active)
759 def on_episode_list_header_clicked(self, button, event):
760 if event.button != 3:
761 return False
763 if self.episode_columns_menu is not None:
764 self.episode_columns_menu.popup(None, None, None, event.button, \
765 event.time, None)
767 return False
769 def init_episode_list_treeview(self):
770 # For loading the list model
771 self.episode_list_model = EpisodeListModel(self.on_episode_list_filter_changed)
773 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
774 self.item_view_episodes_undeleted.set_active(True)
775 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
776 self.item_view_episodes_downloaded.set_active(True)
777 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
778 self.item_view_episodes_unplayed.set_active(True)
779 else:
780 self.item_view_episodes_all.set_active(True)
782 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
784 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
786 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
788 iconcell = gtk.CellRendererPixbuf()
789 iconcell.set_property('stock-size', gtk.ICON_SIZE_BUTTON)
790 iconcell.set_fixed_size(self.EPISODE_LIST_ICON_WIDTH, -1)
792 namecell = gtk.CellRendererText()
793 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
794 namecolumn = gtk.TreeViewColumn(_('Episode'))
795 namecolumn.pack_start(iconcell, False)
796 namecolumn.add_attribute(iconcell, 'icon-name', EpisodeListModel.C_STATUS_ICON)
797 namecolumn.pack_start(namecell, True)
798 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
799 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
800 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
801 namecolumn.set_resizable(True)
802 namecolumn.set_expand(True)
804 lockcell = gtk.CellRendererPixbuf()
805 lockcell.set_fixed_size(40, -1)
806 lockcell.set_property('stock-size', gtk.ICON_SIZE_MENU)
807 lockcell.set_property('icon-name', 'emblem-readonly')
808 namecolumn.pack_start(lockcell, False)
809 namecolumn.add_attribute(lockcell, 'visible', EpisodeListModel.C_LOCKED)
811 sizecell = gtk.CellRendererText()
812 sizecell.set_property('xalign', 1)
813 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
814 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
816 timecell = gtk.CellRendererText()
817 timecell.set_property('xalign', 1)
818 timecolumn = gtk.TreeViewColumn(_('Duration'), timecell, text=EpisodeListModel.C_TIME)
819 timecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME)
821 releasecell = gtk.CellRendererText()
822 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
823 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
825 namecolumn.set_reorderable(True)
826 self.treeAvailable.append_column(namecolumn)
828 for itemcolumn in (sizecolumn, timecolumn, releasecolumn):
829 itemcolumn.set_reorderable(True)
830 self.treeAvailable.append_column(itemcolumn)
831 TreeViewHelper.register_column(self.treeAvailable, itemcolumn)
833 # Add context menu to all tree view column headers
834 for column in self.treeAvailable.get_columns():
835 label = gtk.Label(column.get_title())
836 label.show_all()
837 column.set_widget(label)
839 w = column.get_widget()
840 while w is not None and not isinstance(w, gtk.Button):
841 w = w.get_parent()
843 w.connect('button-release-event', self.on_episode_list_header_clicked)
845 # Create a new menu for the visible episode list columns
846 for child in self.mainMenu.get_children():
847 if child.get_name() == 'menuView':
848 submenu = child.get_submenu()
849 item = gtk.MenuItem(_('Visible columns'))
850 submenu.append(gtk.SeparatorMenuItem())
851 submenu.append(item)
852 submenu.show_all()
854 self.episode_columns_menu = gtk.Menu()
855 item.set_submenu(self.episode_columns_menu)
856 break
858 # For each column that can be shown/hidden, add a menu item
859 columns = TreeViewHelper.get_columns(self.treeAvailable)
860 for index, column in enumerate(columns):
861 item = gtk.CheckMenuItem(column.get_title())
862 self.episode_columns_menu.append(item)
863 def on_item_toggled(item, index):
864 self.set_episode_list_column(index, item.get_active())
865 item.connect('toggled', on_item_toggled, index)
866 self.episode_columns_menu.show_all()
868 # Update the visibility of the columns and the check menu items
869 self.update_episode_list_columns_visibility()
871 # Set up type-ahead find for the episode list
872 def on_key_press(treeview, event):
873 if event.keyval == gtk.keysyms.Left:
874 self.treeChannels.grab_focus()
875 elif event.keyval == gtk.keysyms.Escape:
876 self.hide_episode_search()
877 elif event.state & gtk.gdk.CONTROL_MASK:
878 # Don't handle type-ahead when control is pressed (so shortcuts
879 # with the Ctrl key still work, e.g. Ctrl+A, ...)
880 return False
881 else:
882 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
883 if unicode_char_id == 0:
884 return False
885 input_char = unichr(unicode_char_id)
886 self.show_episode_search(input_char)
887 return True
888 self.treeAvailable.connect('key-press-event', on_key_press)
890 self.treeAvailable.connect('popup-menu', self.treeview_available_show_context_menu)
892 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
893 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
894 def drag_data_get(tree, context, selection_data, info, timestamp):
895 uris = ['file://'+e.local_filename(create=False) \
896 for e in self.get_selected_episodes() \
897 if e.was_downloaded(and_exists=True)]
898 uris.append('') # for the trailing '\r\n'
899 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
900 self.treeAvailable.connect('drag-data-get', drag_data_get)
902 selection = self.treeAvailable.get_selection()
903 selection.set_mode(gtk.SELECTION_MULTIPLE)
904 # Update the sensitivity of the toolbar buttons on the Desktop
905 selection.connect('changed', lambda s: self.play_or_download())
907 def init_download_list_treeview(self):
908 # enable multiple selection support
909 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
910 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
912 # columns and renderers for "download progress" tab
913 # First column: [ICON] Episodename
914 column = gtk.TreeViewColumn(_('Episode'))
916 cell = gtk.CellRendererPixbuf()
917 cell.set_property('stock-size', gtk.ICON_SIZE_BUTTON)
918 column.pack_start(cell, expand=False)
919 column.add_attribute(cell, 'icon-name', \
920 DownloadStatusModel.C_ICON_NAME)
922 cell = gtk.CellRendererText()
923 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
924 column.pack_start(cell, expand=True)
925 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
926 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
927 column.set_expand(True)
928 self.treeDownloads.append_column(column)
930 # Second column: Progress
931 cell = gtk.CellRendererProgress()
932 cell.set_property('yalign', .5)
933 cell.set_property('ypad', 6)
934 column = gtk.TreeViewColumn(_('Progress'), cell,
935 value=DownloadStatusModel.C_PROGRESS, \
936 text=DownloadStatusModel.C_PROGRESS_TEXT)
937 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
938 column.set_expand(False)
939 self.treeDownloads.append_column(column)
940 column.set_property('min-width', 150)
941 column.set_property('max-width', 150)
943 self.treeDownloads.set_model(self.download_status_model)
944 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
946 self.treeDownloads.connect('popup-menu', self.treeview_downloads_show_context_menu)
948 def on_treeview_expose_event(self, treeview, event):
949 if event.window == treeview.get_bin_window():
950 model = treeview.get_model()
951 if (model is not None and model.get_iter_first() is not None):
952 return False
954 role = getattr(treeview, TreeViewHelper.ROLE, None)
955 if role is None:
956 return False
958 ctx = event.window.cairo_create()
959 ctx.rectangle(event.area.x, event.area.y,
960 event.area.width, event.area.height)
961 ctx.clip()
963 x, y, width, height, depth = event.window.get_geometry()
964 progress = None
966 if role == TreeViewHelper.ROLE_EPISODES:
967 if self.currently_updating:
968 text = _('Loading episodes')
969 elif self.config.episode_list_view_mode != \
970 EpisodeListModel.VIEW_ALL:
971 text = _('No episodes in current view')
972 else:
973 text = _('No episodes available')
974 elif role == TreeViewHelper.ROLE_PODCASTS:
975 if self.config.episode_list_view_mode != \
976 EpisodeListModel.VIEW_ALL and \
977 self.config.podcast_list_hide_boring and \
978 len(self.channels) > 0:
979 text = _('No podcasts in this view')
980 else:
981 text = _('No subscriptions')
982 elif role == TreeViewHelper.ROLE_DOWNLOADS:
983 text = _('No active downloads')
984 else:
985 raise Exception('on_treeview_expose_event: unknown role')
987 font_desc = None
988 draw_text_box_centered(ctx, treeview, width, height, text, font_desc, progress)
990 return False
992 def enable_download_list_update(self):
993 if not self.download_list_update_enabled:
994 self.update_downloads_list()
995 gobject.timeout_add(1500, self.update_downloads_list)
996 self.download_list_update_enabled = True
998 def cleanup_downloads(self):
999 model = self.download_status_model
1001 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
1002 changed_episode_urls = set()
1003 for row_reference, task in all_tasks:
1004 if task.status in (task.DONE, task.CANCELLED):
1005 model.remove(model.get_iter(row_reference.get_path()))
1006 try:
1007 # We don't "see" this task anymore - remove it;
1008 # this is needed, so update_episode_list_icons()
1009 # below gets the correct list of "seen" tasks
1010 self.download_tasks_seen.remove(task)
1011 except KeyError, key_error:
1012 pass
1013 changed_episode_urls.add(task.url)
1014 # Tell the task that it has been removed (so it can clean up)
1015 task.removed_from_list()
1017 # Tell the podcasts tab to update icons for our removed podcasts
1018 self.update_episode_list_icons(changed_episode_urls)
1020 # Tell the shownotes window that we have removed the episode
1021 if self.episode_shownotes_window is not None and \
1022 self.episode_shownotes_window.episode is not None and \
1023 self.episode_shownotes_window.episode.url in changed_episode_urls:
1024 self.episode_shownotes_window._download_status_changed(None)
1026 # Update the downloads list one more time
1027 self.update_downloads_list(can_call_cleanup=False)
1029 def on_tool_downloads_toggled(self, toolbutton):
1030 if toolbutton.get_active():
1031 self.wNotebook.set_current_page(1)
1032 else:
1033 self.wNotebook.set_current_page(0)
1035 def add_download_task_monitor(self, monitor):
1036 self.download_task_monitors.add(monitor)
1037 model = self.download_status_model
1038 if model is None:
1039 model = ()
1040 for row in model:
1041 task = row[self.download_status_model.C_TASK]
1042 monitor.task_updated(task)
1044 def remove_download_task_monitor(self, monitor):
1045 self.download_task_monitors.remove(monitor)
1047 def set_download_progress(self, progress):
1048 if self.ubuntu is not None:
1049 self.ubuntu.set_progress(progress)
1051 def set_new_episodes_count(self, count):
1052 if self.ubuntu is not None:
1053 self.ubuntu.set_count(count)
1055 def update_downloads_list(self, can_call_cleanup=True):
1056 try:
1057 model = self.download_status_model
1059 downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
1060 total_speed, total_size, done_size = 0, 0, 0
1062 # Keep a list of all download tasks that we've seen
1063 download_tasks_seen = set()
1065 # Remember the DownloadTask object for the episode that
1066 # has been opened in the episode shownotes dialog (if any)
1067 if self.episode_shownotes_window is not None:
1068 shownotes_episode = self.episode_shownotes_window.episode
1069 shownotes_task = None
1070 else:
1071 shownotes_episode = None
1072 shownotes_task = None
1074 # Do not go through the list of the model is not (yet) available
1075 if model is None:
1076 model = ()
1078 for row in model:
1079 self.download_status_model.request_update(row.iter)
1081 task = row[self.download_status_model.C_TASK]
1082 speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
1084 # Let the download task monitors know of changes
1085 for monitor in self.download_task_monitors:
1086 monitor.task_updated(task)
1088 total_size += size
1089 done_size += size*progress
1091 if shownotes_episode is not None and \
1092 shownotes_episode.url == task.episode.url:
1093 shownotes_task = task
1095 download_tasks_seen.add(task)
1097 if status == download.DownloadTask.DOWNLOADING:
1098 downloading += 1
1099 total_speed += speed
1100 elif status == download.DownloadTask.FAILED:
1101 failed += 1
1102 elif status == download.DownloadTask.DONE:
1103 finished += 1
1104 elif status == download.DownloadTask.QUEUED:
1105 queued += 1
1106 elif status == download.DownloadTask.PAUSED:
1107 paused += 1
1108 else:
1109 others += 1
1111 # Remember which tasks we have seen after this run
1112 self.download_tasks_seen = download_tasks_seen
1114 text = [_('Downloads')]
1115 if downloading + failed + queued > 0:
1116 s = []
1117 if downloading > 0:
1118 s.append(N_('%(count)d active', '%(count)d active', downloading) % {'count':downloading})
1119 if failed > 0:
1120 s.append(N_('%(count)d failed', '%(count)d failed', failed) % {'count':failed})
1121 if queued > 0:
1122 s.append(N_('%(count)d queued', '%(count)d queued', queued) % {'count':queued})
1123 text.append(' (' + ', '.join(s)+')')
1124 self.labelDownloads.set_text(''.join(text))
1126 title = [self.default_title]
1128 # Accessing task.status_changed has the side effect of re-setting
1129 # the changed flag, but we only do it once here so that's okay
1130 channel_urls = [task.podcast_url for task in
1131 self.download_tasks_seen if task.status_changed]
1132 episode_urls = [task.url for task in self.download_tasks_seen]
1134 count = downloading + queued
1135 if count > 0:
1136 title.append(N_('downloading %(count)d file', 'downloading %(count)d files', count) % {'count':count})
1138 if total_size > 0:
1139 percentage = 100.0*done_size/total_size
1140 else:
1141 percentage = 0.0
1142 self.set_download_progress(percentage/100.)
1143 total_speed = util.format_filesize(total_speed)
1144 title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
1145 else:
1146 self.set_download_progress(1.)
1147 self.downloads_finished(self.download_tasks_seen)
1148 gpodder.user_extensions.on_all_episodes_downloaded()
1149 logger.info('All downloads have finished.')
1151 # Remove finished episodes
1152 if self.config.auto_cleanup_downloads and can_call_cleanup:
1153 self.cleanup_downloads()
1155 # Stop updating the download list here
1156 self.download_list_update_enabled = False
1158 self.gPodder.set_title(' - '.join(title))
1160 self.update_episode_list_icons(episode_urls)
1161 if self.episode_shownotes_window is not None:
1162 if (shownotes_task and shownotes_task.url in episode_urls) or \
1163 shownotes_task != self.episode_shownotes_window.task:
1164 self.episode_shownotes_window._download_status_changed(shownotes_task)
1165 self.episode_shownotes_window._download_status_progress()
1166 self.play_or_download()
1167 if channel_urls:
1168 self.update_podcast_list_model(channel_urls)
1170 return self.download_list_update_enabled
1171 except Exception, e:
1172 logger.error('Exception happened while updating download list.', exc_info=True)
1173 self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'), important=True)
1174 # We return False here, so the update loop won't be called again,
1175 # that's why we require the restart of gPodder in the message.
1176 return False
1178 def on_config_changed(self, *args):
1179 util.idle_add(self._on_config_changed, *args)
1181 def _on_config_changed(self, name, old_value, new_value):
1182 if name == 'ui.gtk.toolbar':
1183 self.toolbar.set_property('visible', new_value)
1184 elif name == 'ui.gtk.episode_list.descriptions':
1185 self.update_episode_list_model()
1186 elif name in ('auto.update.enabled', 'auto.update.frequency'):
1187 self.restart_auto_update_timer()
1188 elif name in ('ui.gtk.podcast_list.all_episodes',
1189 'ui.gtk.podcast_list.sections'):
1190 # Force a update of the podcast list model
1191 self.update_podcast_list_model()
1192 elif name == 'ui.gtk.episode_list.columns':
1193 self.update_episode_list_columns_visibility()
1195 def on_treeview_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
1196 # With get_bin_window, we get the window that contains the rows without
1197 # the header. The Y coordinate of this window will be the height of the
1198 # treeview header. This is the amount we have to subtract from the
1199 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1200 (x_bin, y_bin) = treeview.get_bin_window().get_position()
1201 y -= x_bin
1202 y -= y_bin
1203 (path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
1205 if not getattr(treeview, TreeViewHelper.CAN_TOOLTIP) or x > 50 or (column is not None and column != treeview.get_columns()[0]):
1206 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1207 return False
1209 if path is not None:
1210 model = treeview.get_model()
1211 iter = model.get_iter(path)
1212 role = getattr(treeview, TreeViewHelper.ROLE)
1214 if role == TreeViewHelper.ROLE_EPISODES:
1215 id = model.get_value(iter, EpisodeListModel.C_URL)
1216 elif role == TreeViewHelper.ROLE_PODCASTS:
1217 id = model.get_value(iter, PodcastListModel.C_URL)
1218 if id == '-':
1219 # Section header - no tooltip here (for now at least)
1220 return False
1222 last_tooltip = getattr(treeview, TreeViewHelper.LAST_TOOLTIP)
1223 if last_tooltip is not None and last_tooltip != id:
1224 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1225 return False
1226 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, id)
1228 if role == TreeViewHelper.ROLE_EPISODES:
1229 description = model.get_value(iter, EpisodeListModel.C_TOOLTIP)
1230 if description:
1231 tooltip.set_text(description)
1232 else:
1233 return False
1234 elif role == TreeViewHelper.ROLE_PODCASTS:
1235 channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
1236 if channel is None or not hasattr(channel, 'title'):
1237 return False
1238 error_str = model.get_value(iter, PodcastListModel.C_ERROR)
1239 if error_str:
1240 error_str = _('Feedparser error: %s') % cgi.escape(error_str.strip())
1241 error_str = '<span foreground="#ff0000">%s</span>' % error_str
1242 table = gtk.Table(rows=3, columns=3)
1243 table.set_row_spacings(5)
1244 table.set_col_spacings(5)
1245 table.set_border_width(5)
1247 heading = gtk.Label()
1248 heading.set_alignment(0, 1)
1249 heading.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (cgi.escape(channel.title), cgi.escape(channel.url)))
1250 table.attach(heading, 0, 1, 0, 1)
1252 table.attach(gtk.HSeparator(), 0, 3, 1, 2)
1254 if len(channel.description) < 500:
1255 description = channel.description
1256 else:
1257 pos = channel.description.find('\n\n')
1258 if pos == -1 or pos > 500:
1259 description = channel.description[:498]+'[...]'
1260 else:
1261 description = channel.description[:pos]
1263 description = gtk.Label(description)
1264 if error_str:
1265 description.set_markup(error_str)
1266 description.set_alignment(0, 0)
1267 description.set_line_wrap(True)
1268 table.attach(description, 0, 3, 2, 3)
1270 table.show_all()
1271 tooltip.set_custom(table)
1273 return True
1275 setattr(treeview, TreeViewHelper.LAST_TOOLTIP, None)
1276 return False
1278 def treeview_allow_tooltips(self, treeview, allow):
1279 setattr(treeview, TreeViewHelper.CAN_TOOLTIP, allow)
1281 def treeview_handle_context_menu_click(self, treeview, event):
1282 if event is None:
1283 selection = treeview.get_selection()
1284 return selection.get_selected_rows()
1286 x, y = int(event.x), int(event.y)
1287 path, column, rx, ry = treeview.get_path_at_pos(x, y) or (None,)*4
1289 selection = treeview.get_selection()
1290 model, paths = selection.get_selected_rows()
1292 if path is None or (path not in paths and \
1293 event.button == 3):
1294 # We have right-clicked, but not into the selection,
1295 # assume we don't want to operate on the selection
1296 paths = []
1298 if path is not None and not paths and \
1299 event.button == 3:
1300 # No selection or clicked outside selection;
1301 # select the single item where we clicked
1302 treeview.grab_focus()
1303 treeview.set_cursor(path, column, 0)
1304 paths = [path]
1306 if not paths:
1307 # Unselect any remaining items (clicked elsewhere)
1308 if hasattr(treeview, 'is_rubber_banding_active'):
1309 if not treeview.is_rubber_banding_active():
1310 selection.unselect_all()
1311 else:
1312 selection.unselect_all()
1314 return model, paths
1316 def downloads_list_get_selection(self, model=None, paths=None):
1317 if model is None and paths is None:
1318 selection = self.treeDownloads.get_selection()
1319 model, paths = selection.get_selected_rows()
1321 can_queue, can_cancel, can_pause, can_remove, can_force = (True,)*5
1322 selected_tasks = [(gtk.TreeRowReference(model, path), \
1323 model.get_value(model.get_iter(path), \
1324 DownloadStatusModel.C_TASK)) for path in paths]
1326 for row_reference, task in selected_tasks:
1327 if task.status != download.DownloadTask.QUEUED:
1328 can_force = False
1329 if task.status not in (download.DownloadTask.PAUSED, \
1330 download.DownloadTask.FAILED, \
1331 download.DownloadTask.CANCELLED):
1332 can_queue = False
1333 if task.status not in (download.DownloadTask.PAUSED, \
1334 download.DownloadTask.QUEUED, \
1335 download.DownloadTask.DOWNLOADING, \
1336 download.DownloadTask.FAILED):
1337 can_cancel = False
1338 if task.status not in (download.DownloadTask.QUEUED, \
1339 download.DownloadTask.DOWNLOADING):
1340 can_pause = False
1341 if task.status not in (download.DownloadTask.CANCELLED, \
1342 download.DownloadTask.FAILED, \
1343 download.DownloadTask.DONE):
1344 can_remove = False
1346 return selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force
1348 def downloads_finished(self, download_tasks_seen):
1349 finished_downloads = [str(task) for task in download_tasks_seen if task.notify_as_finished()]
1350 failed_downloads = [str(task)+' ('+task.error_message+')' for task in download_tasks_seen if task.notify_as_failed()]
1352 if finished_downloads and failed_downloads:
1353 message = self.format_episode_list(finished_downloads, 5)
1354 message += '\n\n<i>%s</i>\n' % _('These downloads failed:')
1355 message += self.format_episode_list(failed_downloads, 5)
1356 self.show_message(message, _('Downloads finished'), True, widget=self.labelDownloads)
1357 elif finished_downloads:
1358 message = self.format_episode_list(finished_downloads)
1359 self.show_message(message, _('Downloads finished'), widget=self.labelDownloads)
1360 elif failed_downloads:
1361 message = self.format_episode_list(failed_downloads)
1362 self.show_message(message, _('Downloads failed'), True, widget=self.labelDownloads)
1365 def format_episode_list(self, episode_list, max_episodes=10):
1367 Format a list of episode names for notifications
1369 Will truncate long episode names and limit the amount of
1370 episodes displayed (max_episodes=10).
1372 The episode_list parameter should be a list of strings.
1374 MAX_TITLE_LENGTH = 100
1376 result = []
1377 for title in episode_list[:min(len(episode_list), max_episodes)]:
1378 if len(title) > MAX_TITLE_LENGTH:
1379 middle = (MAX_TITLE_LENGTH/2)-2
1380 title = '%s...%s' % (title[0:middle], title[-middle:])
1381 result.append(cgi.escape(title))
1382 result.append('\n')
1384 more_episodes = len(episode_list) - max_episodes
1385 if more_episodes > 0:
1386 result.append('(...')
1387 result.append(N_('%(count)d more episode', '%(count)d more episodes', more_episodes) % {'count':more_episodes})
1388 result.append('...)')
1390 return (''.join(result)).strip()
1392 def _for_each_task_set_status(self, tasks, status, force_start=False):
1393 episode_urls = set()
1394 model = self.treeDownloads.get_model()
1395 for row_reference, task in tasks:
1396 if status == download.DownloadTask.QUEUED:
1397 # Only queue task when its paused/failed/cancelled (or forced)
1398 if task.status in (task.PAUSED, task.FAILED, task.CANCELLED) or force_start:
1399 self.download_queue_manager.add_task(task, force_start)
1400 self.enable_download_list_update()
1401 elif status == download.DownloadTask.CANCELLED:
1402 # Cancelling a download allowed when downloading/queued
1403 if task.status in (task.QUEUED, task.DOWNLOADING):
1404 task.status = status
1405 # Cancelling paused/failed downloads requires a call to .run()
1406 elif task.status in (task.PAUSED, task.FAILED):
1407 task.status = status
1408 # Call run, so the partial file gets deleted
1409 task.run()
1410 elif status == download.DownloadTask.PAUSED:
1411 # Pausing a download only when queued/downloading
1412 if task.status in (task.DOWNLOADING, task.QUEUED):
1413 task.status = status
1414 elif status is None:
1415 # Remove the selected task - cancel downloading/queued tasks
1416 if task.status in (task.QUEUED, task.DOWNLOADING):
1417 task.status = task.CANCELLED
1418 model.remove(model.get_iter(row_reference.get_path()))
1419 # Remember the URL, so we can tell the UI to update
1420 try:
1421 # We don't "see" this task anymore - remove it;
1422 # this is needed, so update_episode_list_icons()
1423 # below gets the correct list of "seen" tasks
1424 self.download_tasks_seen.remove(task)
1425 except KeyError, key_error:
1426 pass
1427 episode_urls.add(task.url)
1428 # Tell the task that it has been removed (so it can clean up)
1429 task.removed_from_list()
1430 else:
1431 # We can (hopefully) simply set the task status here
1432 task.status = status
1433 # Tell the podcasts tab to update icons for our removed podcasts
1434 self.update_episode_list_icons(episode_urls)
1435 # Update the tab title and downloads list
1436 self.update_downloads_list()
1438 def treeview_downloads_show_context_menu(self, treeview, event=None):
1439 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1440 if not paths:
1441 if not hasattr(treeview, 'is_rubber_banding_active'):
1442 return True
1443 else:
1444 return not treeview.is_rubber_banding_active()
1446 if event is None or event.button == 3:
1447 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = \
1448 self.downloads_list_get_selection(model, paths)
1450 def make_menu_item(label, stock_id, tasks, status, sensitive, force_start=False):
1451 # This creates a menu item for selection-wide actions
1452 item = gtk.ImageMenuItem(label)
1453 item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
1454 item.connect('activate', lambda item: self._for_each_task_set_status(tasks, status, force_start))
1455 item.set_sensitive(sensitive)
1456 return item
1458 menu = gtk.Menu()
1460 item = gtk.ImageMenuItem(_('Episode details'))
1461 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1462 if len(selected_tasks) == 1:
1463 row_reference, task = selected_tasks[0]
1464 episode = task.episode
1465 item.connect('activate', lambda item: self.show_episode_shownotes(episode))
1466 else:
1467 item.set_sensitive(False)
1468 menu.append(item)
1469 menu.append(gtk.SeparatorMenuItem())
1470 if can_force:
1471 menu.append(make_menu_item(_('Start download now'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, True, True))
1472 else:
1473 menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED, can_queue, False))
1474 menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED, can_cancel))
1475 menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED, can_pause))
1476 menu.append(gtk.SeparatorMenuItem())
1477 menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None, can_remove))
1479 menu.show_all()
1481 if event is None:
1482 func = TreeViewHelper.make_popup_position_func(treeview)
1483 menu.popup(None, None, func, 3, 0)
1484 else:
1485 menu.popup(None, None, None, event.button, event.time)
1486 return True
1488 def on_mark_episodes_as_old(self, item):
1489 assert self.active_channel is not None
1491 for episode in self.active_channel.get_all_episodes():
1492 if not episode.was_downloaded(and_exists=True):
1493 episode.mark(is_played=True)
1495 self.update_podcast_list_model(selected=True)
1496 self.update_episode_list_icons(all=True)
1498 def treeview_channels_show_context_menu(self, treeview, event=None):
1499 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1500 if not paths:
1501 return True
1503 # Check for valid channel id, if there's no id then
1504 # assume that it is a proxy channel or equivalent
1505 # and cannot be operated with right click
1506 if self.active_channel.id is None:
1507 return True
1509 if event is None or event.button == 3:
1510 menu = gtk.Menu()
1512 item = gtk.ImageMenuItem( _('Update podcast'))
1513 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1514 item.connect('activate', self.on_itemUpdateChannel_activate)
1515 menu.append(item)
1517 menu.append(gtk.SeparatorMenuItem())
1519 item = gtk.MenuItem(_('Mark episodes as old'))
1520 item.connect('activate', self.on_mark_episodes_as_old)
1521 menu.append(item)
1523 item = gtk.CheckMenuItem(_('Archive'))
1524 item.set_active(self.active_channel.auto_archive_episodes)
1525 item.connect('activate', self.on_channel_toggle_lock_activate)
1526 menu.append(item)
1528 item = gtk.ImageMenuItem(_('Remove podcast'))
1529 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1530 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1531 menu.append( item)
1533 menu.append( gtk.SeparatorMenuItem())
1535 item = gtk.ImageMenuItem(_('Podcast details'))
1536 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1537 item.connect('activate', self.on_itemEditChannel_activate)
1538 menu.append(item)
1540 menu.show_all()
1541 # Disable tooltips while we are showing the menu, so
1542 # the tooltip will not appear over the menu
1543 self.treeview_allow_tooltips(self.treeChannels, False)
1544 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1546 if event is None:
1547 func = TreeViewHelper.make_popup_position_func(treeview)
1548 menu.popup(None, None, func, 3, 0)
1549 else:
1550 menu.popup(None, None, None, event.button, event.time)
1552 return True
1554 def cover_file_removed(self, channel_url):
1556 The Cover Downloader calls this when a previously-
1557 available cover has been removed from the disk. We
1558 have to update our model to reflect this change.
1560 self.podcast_list_model.delete_cover_by_url(channel_url)
1562 def cover_download_finished(self, channel, pixbuf):
1564 The Cover Downloader calls this when it has finished
1565 downloading (or registering, if already downloaded)
1566 a new channel cover, which is ready for displaying.
1568 self.podcast_list_model.add_cover_by_channel(channel, pixbuf)
1570 def save_episodes_as_file(self, episodes):
1571 for episode in episodes:
1572 self.save_episode_as_file(episode)
1574 def save_episode_as_file(self, episode):
1575 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1576 if episode.was_downloaded(and_exists=True):
1577 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1578 copy_from = episode.local_filename(create=False)
1579 assert copy_from is not None
1580 copy_to = util.sanitize_filename(episode.sync_filename())
1581 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1582 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1584 def copy_episodes_bluetooth(self, episodes):
1585 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1587 def convert_and_send_thread(episode):
1588 for episode in episodes:
1589 filename = episode.local_filename(create=False)
1590 assert filename is not None
1591 destfile = os.path.join(tempfile.gettempdir(), \
1592 util.sanitize_filename(episode.sync_filename()))
1593 (base, ext) = os.path.splitext(filename)
1594 if not destfile.endswith(ext):
1595 destfile += ext
1597 try:
1598 shutil.copyfile(filename, destfile)
1599 util.bluetooth_send_file(destfile)
1600 except:
1601 logger.error('Cannot copy "%s" to "%s".', filename, destfile)
1602 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1604 util.delete_file(destfile)
1606 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1608 def treeview_available_show_context_menu(self, treeview, event=None):
1609 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1610 if not paths:
1611 if not hasattr(treeview, 'is_rubber_banding_active'):
1612 return True
1613 else:
1614 return not treeview.is_rubber_banding_active()
1616 if event is None or event.button == 3:
1617 episodes = self.get_selected_episodes()
1618 any_locked = any(e.archive for e in episodes)
1619 any_new = any(e.is_new for e in episodes)
1620 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1621 downloading = any(e.downloading for e in episodes)
1623 menu = gtk.Menu()
1625 (can_play, can_download, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1627 if open_instead_of_play:
1628 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1629 elif downloaded:
1630 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1631 else:
1632 if downloading:
1633 item = gtk.ImageMenuItem(_('Preview'))
1634 else:
1635 item = gtk.ImageMenuItem(_('Stream'))
1636 item.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PLAY, gtk.ICON_SIZE_MENU))
1638 item.set_sensitive(can_play)
1639 item.connect('activate', self.on_playback_selected_episodes)
1640 menu.append(item)
1642 if not can_cancel:
1643 item = gtk.ImageMenuItem(_('Download'))
1644 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1645 item.set_sensitive(can_download)
1646 item.connect('activate', self.on_download_selected_episodes)
1647 menu.append(item)
1648 else:
1649 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
1650 item.connect('activate', self.on_item_cancel_download_activate)
1651 menu.append(item)
1653 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1654 item.set_sensitive(can_delete)
1655 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1656 menu.append(item)
1658 result = gpodder.user_extensions.on_episodes_context_menu(episodes)
1659 if result:
1660 menu.append(gtk.SeparatorMenuItem())
1661 for label, callback in result:
1662 item = gtk.MenuItem(label)
1663 item.connect('activate', lambda item, callback:
1664 callback(episodes), callback)
1665 menu.append(item)
1667 # Ok, this probably makes sense to only display for downloaded files
1668 if downloaded:
1669 menu.append(gtk.SeparatorMenuItem())
1670 share_item = gtk.MenuItem(_('Send to'))
1671 menu.append(share_item)
1672 share_menu = gtk.Menu()
1674 item = gtk.ImageMenuItem(_('Local folder'))
1675 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU))
1676 item.connect('button-press-event', lambda w, ee: self.save_episodes_as_file(episodes))
1677 share_menu.append(item)
1678 if self.bluetooth_available:
1679 item = gtk.ImageMenuItem(_('Bluetooth device'))
1680 item.set_image(gtk.image_new_from_icon_name('bluetooth', gtk.ICON_SIZE_MENU))
1681 item.connect('button-press-event', lambda w, ee: self.copy_episodes_bluetooth(episodes))
1682 share_menu.append(item)
1684 share_item.set_submenu(share_menu)
1686 menu.append(gtk.SeparatorMenuItem())
1688 item = gtk.CheckMenuItem(_('New'))
1689 item.set_active(any_new)
1690 if any_new:
1691 item.connect('activate', lambda w: self.mark_selected_episodes_old())
1692 else:
1693 item.connect('activate', lambda w: self.mark_selected_episodes_new())
1694 menu.append(item)
1696 if downloaded:
1697 item = gtk.CheckMenuItem(_('Archive'))
1698 item.set_active(any_locked)
1699 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, not any_locked))
1700 menu.append(item)
1702 menu.append(gtk.SeparatorMenuItem())
1703 # Single item, add episode information menu item
1704 item = gtk.ImageMenuItem(_('Episode details'))
1705 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1706 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
1707 menu.append(item)
1709 menu.show_all()
1710 # Disable tooltips while we are showing the menu, so
1711 # the tooltip will not appear over the menu
1712 self.treeview_allow_tooltips(self.treeAvailable, False)
1713 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
1714 if event is None:
1715 func = TreeViewHelper.make_popup_position_func(treeview)
1716 menu.popup(None, None, func, 3, 0)
1717 else:
1718 menu.popup(None, None, None, event.button, event.time)
1720 return True
1722 def set_title(self, new_title):
1723 self.default_title = new_title
1724 self.gPodder.set_title(new_title)
1726 def update_episode_list_icons(self, urls=None, selected=False, all=False):
1728 Updates the status icons in the episode list.
1730 If urls is given, it should be a list of URLs
1731 of episodes that should be updated.
1733 If urls is None, set ONE OF selected, all to
1734 True (the former updates just the selected
1735 episodes and the latter updates all episodes).
1737 descriptions = self.config.episode_list_descriptions
1739 if urls is not None:
1740 # We have a list of URLs to walk through
1741 self.episode_list_model.update_by_urls(urls, descriptions)
1742 elif selected and not all:
1743 # We should update all selected episodes
1744 selection = self.treeAvailable.get_selection()
1745 model, paths = selection.get_selected_rows()
1746 for path in reversed(paths):
1747 iter = model.get_iter(path)
1748 self.episode_list_model.update_by_filter_iter(iter, descriptions)
1749 elif all and not selected:
1750 # We update all (even the filter-hidden) episodes
1751 self.episode_list_model.update_all(descriptions)
1752 else:
1753 # Wrong/invalid call - have to specify at least one parameter
1754 raise ValueError('Invalid call to update_episode_list_icons')
1756 def episode_list_status_changed(self, episodes):
1757 self.update_episode_list_icons(set(e.url for e in episodes))
1758 self.update_podcast_list_model(set(e.channel.url for e in episodes))
1759 self.db.commit()
1761 def clean_up_downloads(self, delete_partial=False):
1762 # Clean up temporary files left behind by old gPodder versions
1763 temporary_files = glob.glob('%s/*/.tmp-*' % gpodder.downloads)
1765 if delete_partial:
1766 temporary_files += glob.glob('%s/*/*.partial' % gpodder.downloads)
1768 for tempfile in temporary_files:
1769 util.delete_file(tempfile)
1772 def streaming_possible(self):
1773 # User has to have a media player set on the Desktop, or else we
1774 # would probably open the browser when giving a URL to xdg-open..
1775 return (self.config.player and self.config.player != 'default')
1777 def playback_episodes_for_real(self, episodes):
1778 groups = collections.defaultdict(list)
1779 for episode in episodes:
1780 file_type = episode.file_type()
1781 if file_type == 'video' and self.config.videoplayer and \
1782 self.config.videoplayer != 'default':
1783 player = self.config.videoplayer
1784 elif file_type == 'audio' and self.config.player and \
1785 self.config.player != 'default':
1786 player = self.config.player
1787 else:
1788 player = 'default'
1790 # Mark episode as played in the database
1791 episode.playback_mark()
1792 self.mygpo_client.on_playback([episode])
1794 fmt_id = self.config.youtube_preferred_fmt_id
1795 allow_partial = (player != 'default')
1796 filename = episode.get_playback_url(fmt_id, allow_partial)
1798 # Determine the playback resume position - if the file
1799 # was played 100%, we simply start from the beginning
1800 resume_position = episode.current_position
1801 if resume_position == episode.total_time:
1802 resume_position = 0
1804 # If Panucci is configured, use D-Bus on Maemo to call it
1805 if player == 'panucci':
1806 try:
1807 PANUCCI_NAME = 'org.panucci.panucciInterface'
1808 PANUCCI_PATH = '/panucciInterface'
1809 PANUCCI_INTF = 'org.panucci.panucciInterface'
1810 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
1811 i = dbus.Interface(o, PANUCCI_INTF)
1813 def on_reply(*args):
1814 pass
1816 def error_handler(filename, err):
1817 logger.error('Exception in D-Bus call: %s', str(err))
1819 # Fallback: use the command line client
1820 for command in util.format_desktop_command('panucci', \
1821 [filename]):
1822 logger.info('Executing: %s', repr(command))
1823 subprocess.Popen(command)
1825 on_error = lambda err: error_handler(filename, err)
1827 # This method only exists in Panucci > 0.9 ('new Panucci')
1828 i.playback_from(filename, resume_position, \
1829 reply_handler=on_reply, error_handler=on_error)
1831 continue # This file was handled by the D-Bus call
1832 except Exception, e:
1833 logger.error('Calling Panucci using D-Bus', exc_info=True)
1835 groups[player].append(filename)
1837 # Open episodes with system default player
1838 if 'default' in groups:
1839 # Special-casing for a single episode when the object is a PDF
1840 # file - this is needed on Maemo 5, so we only use gui_open()
1841 # for single PDF files, but still use the built-in media player
1842 # with an M3U file for single audio/video files. (The Maemo 5
1843 # media player behaves differently when opening a single-file
1844 # M3U playlist compared to opening the single file directly.)
1845 if len(groups['default']) == 1:
1846 fn = groups['default'][0]
1847 # The list of extensions is taken from gui_open in util.py
1848 # where all special-cases of Maemo apps are listed
1849 for extension in ('.pdf', '.jpg', '.jpeg', '.png'):
1850 if fn.lower().endswith(extension):
1851 util.gui_open(fn)
1852 groups['default'] = []
1853 break
1855 for filename in groups['default']:
1856 logger.debug('Opening with system default: %s', filename)
1857 util.gui_open(filename)
1858 del groups['default']
1860 # For each type now, go and create play commands
1861 for group in groups:
1862 for command in util.format_desktop_command(group, groups[group], resume_position):
1863 logger.debug('Executing: %s', repr(command))
1864 subprocess.Popen(command)
1866 # Persist episode status changes to the database
1867 self.db.commit()
1869 # Flush updated episode status
1870 self.mygpo_client.flush()
1872 def playback_episodes(self, episodes):
1873 # We need to create a list, because we run through it more than once
1874 episodes = list(Model.sort_episodes_by_pubdate(e for e in episodes if \
1875 e.was_downloaded(and_exists=True) or self.streaming_possible()))
1877 try:
1878 self.playback_episodes_for_real(episodes)
1879 except Exception, e:
1880 logger.error('Error in playback!', exc_info=True)
1881 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
1882 _('Error opening player'), widget=self.toolPreferences)
1884 channel_urls = set()
1885 episode_urls = set()
1886 for episode in episodes:
1887 channel_urls.add(episode.channel.url)
1888 episode_urls.add(episode.url)
1889 self.update_episode_list_icons(episode_urls)
1890 self.update_podcast_list_model(channel_urls)
1892 def play_or_download(self):
1893 if self.wNotebook.get_current_page() > 0:
1894 self.toolCancel.set_sensitive(True)
1895 return
1897 if self.currently_updating:
1898 return (False, False, False, False, False, False)
1900 ( can_play, can_download, can_cancel, can_delete ) = (False,)*4
1901 ( is_played, is_locked ) = (False,)*2
1903 open_instead_of_play = False
1905 selection = self.treeAvailable.get_selection()
1906 if selection.count_selected_rows() > 0:
1907 (model, paths) = selection.get_selected_rows()
1909 for path in paths:
1910 try:
1911 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
1912 except TypeError, te:
1913 logger.error('Invalid episode at path %s', str(path))
1914 continue
1916 if episode.file_type() not in ('audio', 'video'):
1917 open_instead_of_play = True
1919 if episode.was_downloaded():
1920 can_play = episode.was_downloaded(and_exists=True)
1921 is_played = not episode.is_new
1922 is_locked = episode.archive
1923 if not can_play:
1924 can_download = True
1925 else:
1926 if episode.downloading:
1927 can_cancel = True
1928 else:
1929 can_download = True
1931 can_download = can_download and not can_cancel
1932 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
1933 can_delete = not can_cancel
1935 if open_instead_of_play:
1936 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
1937 else:
1938 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
1939 self.toolPlay.set_sensitive( can_play)
1940 self.toolDownload.set_sensitive( can_download)
1941 self.toolCancel.set_sensitive( can_cancel)
1943 self.item_cancel_download.set_sensitive(can_cancel)
1944 self.itemDownloadSelected.set_sensitive(can_download)
1945 self.itemOpenSelected.set_sensitive(can_play)
1946 self.itemPlaySelected.set_sensitive(can_play)
1947 self.itemDeleteSelected.set_sensitive(can_delete)
1948 self.item_toggle_played.set_sensitive(can_play)
1949 self.item_toggle_lock.set_sensitive(can_play)
1950 self.itemOpenSelected.set_visible(open_instead_of_play)
1951 self.itemPlaySelected.set_visible(not open_instead_of_play)
1953 return (can_play, can_download, can_cancel, can_delete, open_instead_of_play)
1955 def on_cbMaxDownloads_toggled(self, widget, *args):
1956 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
1958 def on_cbLimitDownloads_toggled(self, widget, *args):
1959 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
1961 def episode_new_status_changed(self, urls):
1962 self.update_podcast_list_model()
1963 self.update_episode_list_icons(urls)
1965 def update_podcast_list_model(self, urls=None, selected=False, select_url=None,
1966 sections_changed=False):
1967 """Update the podcast list treeview model
1969 If urls is given, it should list the URLs of each
1970 podcast that has to be updated in the list.
1972 If selected is True, only update the model contents
1973 for the currently-selected podcast - nothing more.
1975 The caller can optionally specify "select_url",
1976 which is the URL of the podcast that is to be
1977 selected in the list after the update is complete.
1978 This only works if the podcast list has to be
1979 reloaded; i.e. something has been added or removed
1980 since the last update of the podcast list).
1982 _, _, new, _, _ = self.db.get_podcast_statistics()
1983 self.set_new_episodes_count(new)
1985 selection = self.treeChannels.get_selection()
1986 model, iter = selection.get_selected()
1988 is_section = lambda r: r[PodcastListModel.C_URL] == '-'
1989 is_separator = lambda r: r[PodcastListModel.C_SEPARATOR]
1990 sections_active = any(is_section(x) for x in self.podcast_list_model)
1992 if self.config.podcast_list_view_all:
1993 # Update "all episodes" view in any case (if enabled)
1994 self.podcast_list_model.update_first_row()
1995 # List model length minus 1, because of "All"
1996 list_model_length = len(self.podcast_list_model) - 1
1997 else:
1998 list_model_length = len(self.podcast_list_model)
2000 force_update = (sections_active != self.config.podcast_list_sections or
2001 sections_changed)
2003 # Filter items in the list model that are not podcasts, so we get the
2004 # correct podcast list count (ignore section headers and separators)
2005 is_not_podcast = lambda r: is_section(r) or is_separator(r)
2006 list_model_length -= len(filter(is_not_podcast, self.podcast_list_model))
2008 if selected and not force_update:
2009 # very cheap! only update selected channel
2010 if iter is not None:
2011 # If we have selected the "all episodes" view, we have
2012 # to update all channels for selected episodes:
2013 if self.config.podcast_list_view_all and \
2014 self.podcast_list_model.iter_is_first_row(iter):
2015 urls = self.get_podcast_urls_from_selected_episodes()
2016 self.podcast_list_model.update_by_urls(urls)
2017 else:
2018 # Otherwise just update the selected row (a podcast)
2019 self.podcast_list_model.update_by_filter_iter(iter)
2021 if self.config.podcast_list_sections:
2022 self.podcast_list_model.update_sections()
2023 elif list_model_length == len(self.channels) and not force_update:
2024 # we can keep the model, but have to update some
2025 if urls is None:
2026 # still cheaper than reloading the whole list
2027 self.podcast_list_model.update_all()
2028 else:
2029 # ok, we got a bunch of urls to update
2030 self.podcast_list_model.update_by_urls(urls)
2031 if self.config.podcast_list_sections:
2032 self.podcast_list_model.update_sections()
2033 else:
2034 if model and iter and select_url is None:
2035 # Get the URL of the currently-selected podcast
2036 select_url = model.get_value(iter, PodcastListModel.C_URL)
2038 # Update the podcast list model with new channels
2039 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2041 try:
2042 selected_iter = model.get_iter_first()
2043 # Find the previously-selected URL in the new
2044 # model if we have an URL (else select first)
2045 if select_url is not None:
2046 pos = model.get_iter_first()
2047 while pos is not None:
2048 url = model.get_value(pos, PodcastListModel.C_URL)
2049 if url == select_url:
2050 selected_iter = pos
2051 break
2052 pos = model.iter_next(pos)
2054 if selected_iter is not None:
2055 selection.select_iter(selected_iter)
2056 self.on_treeChannels_cursor_changed(self.treeChannels)
2057 except:
2058 logger.error('Cannot select podcast in list', exc_info=True)
2060 def on_episode_list_filter_changed(self, has_episodes):
2061 pass # XXX: Remove?
2063 def update_episode_list_model(self):
2064 if self.channels and self.active_channel is not None:
2065 self.currently_updating = True
2066 self.episode_list_model.clear()
2068 def update():
2069 descriptions = self.config.episode_list_descriptions
2070 self.episode_list_model.replace_from_channel(self.active_channel, descriptions)
2072 self.treeAvailable.get_selection().unselect_all()
2073 self.treeAvailable.scroll_to_point(0, 0)
2075 self.currently_updating = False
2076 self.play_or_download()
2078 util.idle_add(update)
2079 else:
2080 self.episode_list_model.clear()
2082 @dbus.service.method(gpodder.dbus_interface)
2083 def offer_new_episodes(self, channels=None):
2084 new_episodes = self.get_new_episodes(channels)
2085 if new_episodes:
2086 self.new_episodes_show(new_episodes)
2087 return True
2088 return False
2090 def add_podcast_list(self, urls, auth_tokens=None):
2091 """Subscribe to a list of podcast given their URLs
2093 If auth_tokens is given, it should be a dictionary
2094 mapping URLs to (username, password) tuples."""
2096 if auth_tokens is None:
2097 auth_tokens = {}
2099 existing_urls = set(podcast.url for podcast in self.channels)
2101 # Sort and split the URL list into five buckets
2102 queued, failed, existing, worked, authreq = [], [], [], [], []
2103 for input_url in urls:
2104 url = util.normalize_feed_url(input_url)
2105 if url is None:
2106 # Fail this one because the URL is not valid
2107 failed.append(input_url)
2108 elif url in existing_urls:
2109 # A podcast already exists in the list for this URL
2110 existing.append(url)
2111 else:
2112 # This URL has survived the first round - queue for add
2113 queued.append(url)
2114 if url != input_url and input_url in auth_tokens:
2115 auth_tokens[url] = auth_tokens[input_url]
2117 error_messages = {}
2118 redirections = {}
2120 progress = ProgressIndicator(_('Adding podcasts'), \
2121 _('Please wait while episode information is downloaded.'), \
2122 parent=self.get_dialog_parent())
2124 def on_after_update():
2125 progress.on_finished()
2126 # Report already-existing subscriptions to the user
2127 if existing:
2128 title = _('Existing subscriptions skipped')
2129 message = _('You are already subscribed to these podcasts:') \
2130 + '\n\n' + '\n'.join(cgi.escape(url) for url in existing)
2131 self.show_message(message, title, widget=self.treeChannels)
2133 # Report subscriptions that require authentication
2134 retry_podcasts = {}
2135 if authreq:
2136 for url in authreq:
2137 title = _('Podcast requires authentication')
2138 message = _('Please login to %s:') % (cgi.escape(url),)
2139 success, auth_tokens = self.show_login_dialog(title, message)
2140 if success:
2141 retry_podcasts[url] = auth_tokens
2142 else:
2143 # Stop asking the user for more login data
2144 retry_podcasts = {}
2145 for url in authreq:
2146 error_messages[url] = _('Authentication failed')
2147 failed.append(url)
2148 break
2150 # Report website redirections
2151 for url in redirections:
2152 title = _('Website redirection detected')
2153 message = _('The URL %(url)s redirects to %(target)s.') \
2154 + '\n\n' + _('Do you want to visit the website now?')
2155 message = message % {'url': url, 'target': redirections[url]}
2156 if self.show_confirmation(message, title):
2157 util.open_website(url)
2158 else:
2159 break
2161 # Report failed subscriptions to the user
2162 if failed:
2163 title = _('Could not add some podcasts')
2164 message = _('Some podcasts could not be added to your list:') \
2165 + '\n\n' + '\n'.join(cgi.escape('%s: %s' % (url, \
2166 error_messages.get(url, _('Unknown')))) for url in failed)
2167 self.show_message(message, title, important=True)
2169 # Upload subscription changes to gpodder.net
2170 self.mygpo_client.on_subscribe(worked)
2172 # Fix URLs if mygpo has rewritten them
2173 self.rewrite_urls_mygpo()
2175 # If only one podcast was added, select it after the update
2176 if len(worked) == 1:
2177 url = worked[0]
2178 else:
2179 url = None
2181 # Update the list of subscribed podcasts
2182 self.update_podcast_list_model(select_url=url)
2184 # If we have authentication data to retry, do so here
2185 if retry_podcasts:
2186 self.add_podcast_list(retry_podcasts.keys(), retry_podcasts)
2187 # This will NOT show new episodes for podcasts that have
2188 # been added ("worked"), but it will prevent problems with
2189 # multiple dialogs being open at the same time ;)
2190 return
2192 # Offer to download new episodes
2193 episodes = []
2194 for podcast in self.channels:
2195 if podcast.url in worked:
2196 episodes.extend(podcast.get_all_episodes())
2198 if episodes:
2199 episodes = list(Model.sort_episodes_by_pubdate(episodes, \
2200 reverse=True))
2201 self.new_episodes_show(episodes, \
2202 selected=[e.check_is_new() for e in episodes])
2205 def thread_proc():
2206 # After the initial sorting and splitting, try all queued podcasts
2207 length = len(queued)
2208 for index, url in enumerate(queued):
2209 progress.on_progress(float(index)/float(length))
2210 progress.on_message(url)
2211 try:
2212 # The URL is valid and does not exist already - subscribe!
2213 channel = self.model.load_podcast(url=url, create=True, \
2214 authentication_tokens=auth_tokens.get(url, None), \
2215 max_episodes=self.config.max_episodes_per_feed)
2217 try:
2218 username, password = util.username_password_from_url(url)
2219 except ValueError, ve:
2220 username, password = (None, None)
2222 if username is not None and channel.auth_username is None and \
2223 password is not None and channel.auth_password is None:
2224 channel.auth_username = username
2225 channel.auth_password = password
2226 channel.save()
2228 self._update_cover(channel)
2229 except feedcore.AuthenticationRequired:
2230 if url in auth_tokens:
2231 # Fail for wrong authentication data
2232 error_messages[url] = _('Authentication failed')
2233 failed.append(url)
2234 else:
2235 # Queue for login dialog later
2236 authreq.append(url)
2237 continue
2238 except feedcore.WifiLogin, error:
2239 redirections[url] = error.data
2240 failed.append(url)
2241 error_messages[url] = _('Redirection detected')
2242 continue
2243 except Exception, e:
2244 logger.error('Subscription error: %s', e, exc_info=True)
2245 error_messages[url] = str(e)
2246 failed.append(url)
2247 continue
2249 assert channel is not None
2250 worked.append(channel.url)
2252 util.idle_add(on_after_update)
2253 threading.Thread(target=thread_proc).start()
2255 def find_episode(self, podcast_url, episode_url):
2256 """Find an episode given its podcast and episode URL
2258 The function will return a PodcastEpisode object if
2259 the episode is found, or None if it's not found.
2261 for podcast in self.channels:
2262 if podcast_url == podcast.url:
2263 for episode in podcast.get_all_episodes():
2264 if episode_url == episode.url:
2265 return episode
2267 return None
2269 def process_received_episode_actions(self):
2270 """Process/merge episode actions from gpodder.net
2272 This function will merge all changes received from
2273 the server to the local database and update the
2274 status of the affected episodes as necessary.
2276 indicator = ProgressIndicator(_('Merging episode actions'), \
2277 _('Episode actions from gpodder.net are merged.'), \
2278 False, self.get_dialog_parent())
2280 while gtk.events_pending():
2281 gtk.main_iteration(False)
2283 self.mygpo_client.process_episode_actions(self.find_episode)
2285 indicator.on_finished()
2286 self.db.commit()
2288 def _update_cover(self, channel):
2289 if channel is not None and not os.path.exists(channel.cover_file) and channel.image:
2290 self.cover_downloader.request_cover(channel)
2292 def show_update_feeds_buttons(self):
2293 # Make sure that the buttons for updating feeds
2294 # appear - this should happen after a feed update
2295 self.hboxUpdateFeeds.hide()
2296 self.btnUpdateFeeds.show()
2297 self.itemUpdate.set_sensitive(True)
2298 self.itemUpdateChannel.set_sensitive(True)
2300 def on_btnCancelFeedUpdate_clicked(self, widget):
2301 if not self.feed_cache_update_cancelled:
2302 self.pbFeedUpdate.set_text(_('Cancelling...'))
2303 self.feed_cache_update_cancelled = True
2304 self.btnCancelFeedUpdate.set_sensitive(False)
2305 else:
2306 self.show_update_feeds_buttons()
2308 def update_feed_cache(self, channels=None,
2309 show_new_episodes_dialog=True):
2310 # Fix URLs if mygpo has rewritten them
2311 self.rewrite_urls_mygpo()
2313 if channels is None:
2314 # Only update podcasts for which updates are enabled
2315 channels = [c for c in self.channels if not c.pause_subscription]
2317 self.itemUpdate.set_sensitive(False)
2318 self.itemUpdateChannel.set_sensitive(False)
2320 self.feed_cache_update_cancelled = False
2321 self.btnCancelFeedUpdate.show()
2322 self.btnCancelFeedUpdate.set_sensitive(True)
2323 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_BUTTON))
2324 self.hboxUpdateFeeds.show_all()
2325 self.btnUpdateFeeds.hide()
2327 count = len(channels)
2328 text = N_('Updating %(count)d feed...', 'Updating %(count)d feeds...', count) % {'count':count}
2330 self.pbFeedUpdate.set_text(text)
2331 self.pbFeedUpdate.set_fraction(0)
2333 def update_feed_cache_proc():
2334 updated_channels = []
2335 for updated, channel in enumerate(channels):
2336 if self.feed_cache_update_cancelled:
2337 break
2339 try:
2340 channel.update(max_episodes=self.config.max_episodes_per_feed)
2341 self._update_cover(channel)
2342 except Exception, e:
2343 d = {'url': cgi.escape(channel.url), 'message': cgi.escape(str(e))}
2344 if d['message']:
2345 message = _('Error while updating %(url)s: %(message)s')
2346 else:
2347 message = _('The feed at %(url)s could not be updated.')
2348 self.notification(message % d, _('Error while updating feed'), widget=self.treeChannels)
2349 logger.error('Error: %s', str(e), exc_info=True)
2351 updated_channels.append(channel)
2353 def update_progress(channel):
2354 self.update_podcast_list_model([channel.url])
2356 # If the currently-viewed podcast is updated, reload episodes
2357 if self.active_channel is not None and \
2358 self.active_channel == channel:
2359 logger.debug('Updated channel is active, updating UI')
2360 self.update_episode_list_model()
2362 d = {'podcast': channel.title, 'position': updated+1, 'total': count}
2363 progression = _('Updated %(podcast)s (%(position)d/%(total)d)') % d
2365 self.pbFeedUpdate.set_text(progression)
2366 self.pbFeedUpdate.set_fraction(float(updated+1)/float(count))
2368 util.idle_add(update_progress, channel)
2370 def update_feed_cache_finish_callback():
2371 # Process received episode actions for all updated URLs
2372 self.process_received_episode_actions()
2374 # If we are currently viewing "All episodes", update its episode list now
2375 if self.active_channel is not None and \
2376 getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
2377 self.update_episode_list_model()
2379 if self.feed_cache_update_cancelled:
2380 # The user decided to abort the feed update
2381 self.show_update_feeds_buttons()
2383 # Only search for new episodes in podcasts that have been
2384 # updated, not in other podcasts (for single-feed updates)
2385 episodes = self.get_new_episodes([c for c in updated_channels])
2387 if not episodes:
2388 # Nothing new here - but inform the user
2389 self.pbFeedUpdate.set_fraction(1.0)
2390 self.pbFeedUpdate.set_text(_('No new episodes'))
2391 self.feed_cache_update_cancelled = True
2392 self.btnCancelFeedUpdate.show()
2393 self.btnCancelFeedUpdate.set_sensitive(True)
2394 self.itemUpdate.set_sensitive(True)
2395 self.btnCancelFeedUpdate.set_image(gtk.image_new_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON))
2396 else:
2397 count = len(episodes)
2398 # New episodes are available
2399 self.pbFeedUpdate.set_fraction(1.0)
2401 if self.config.auto_download == 'download':
2402 self.download_episode_list(episodes)
2403 title = N_('Downloading %(count)d new episode.', 'Downloading %(count)d new episodes.', count) % {'count':count}
2404 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2405 elif self.config.auto_download == 'queue':
2406 self.download_episode_list_paused(episodes)
2407 title = N_('%(count)d new episode added to download list.', '%(count)d new episodes added to download list.', count) % {'count':count}
2408 self.show_message(title, _('New episodes available'), widget=self.labelDownloads)
2409 else:
2410 if (show_new_episodes_dialog and
2411 self.config.auto_download == 'show'):
2412 self.new_episodes_show(episodes, notification=True)
2413 else: # !show_new_episodes_dialog or auto_download == 'ignore'
2414 message = N_('%(count)d new episode available', '%(count)d new episodes available', count) % {'count':count}
2415 self.pbFeedUpdate.set_text(message)
2417 self.show_update_feeds_buttons()
2419 util.idle_add(update_feed_cache_finish_callback)
2421 threading.Thread(target=update_feed_cache_proc).start()
2423 def on_gPodder_delete_event(self, widget, *args):
2424 """Called when the GUI wants to close the window
2425 Displays a confirmation dialog (and closes/hides gPodder)
2428 downloading = self.download_status_model.are_downloads_in_progress()
2430 if downloading:
2431 dialog = gtk.MessageDialog(self.gPodder, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_NONE)
2432 dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2433 quit_button = dialog.add_button(gtk.STOCK_QUIT, gtk.RESPONSE_CLOSE)
2435 title = _('Quit gPodder')
2436 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
2438 dialog.set_title(title)
2439 dialog.set_markup('<span weight="bold" size="larger">%s</span>\n\n%s'%(title, message))
2441 quit_button.grab_focus()
2442 result = dialog.run()
2443 dialog.destroy()
2445 if result == gtk.RESPONSE_CLOSE:
2446 self.close_gpodder()
2447 else:
2448 self.close_gpodder()
2450 return True
2452 def close_gpodder(self):
2453 """ clean everything and exit properly
2455 self.gPodder.hide()
2457 # Notify all tasks to to carry out any clean-up actions
2458 self.download_status_model.tell_all_tasks_to_quit()
2460 while gtk.events_pending():
2461 gtk.main_iteration(False)
2463 self.core.shutdown()
2465 self.quit()
2466 sys.exit(0)
2468 def get_expired_episodes(self):
2469 # XXX: Move out of gtkui and into a generic module (gpodder.model)?
2471 # Only expire episodes if the age in days is positive
2472 if self.config.episode_old_age < 1:
2473 return
2475 for channel in self.channels:
2476 for episode in channel.get_downloaded_episodes():
2477 # Never consider archived episodes as old
2478 if episode.archive:
2479 continue
2481 # Never consider fresh episodes as old
2482 if episode.age_in_days() < self.config.episode_old_age:
2483 continue
2485 # Do not delete played episodes (except if configured)
2486 if not episode.is_new:
2487 if not self.config.auto_remove_played_episodes:
2488 continue
2490 # Do not delete unfinished episodes (except if configured)
2491 if not episode.is_finished():
2492 if not self.config.auto_remove_unfinished_episodes:
2493 continue
2495 # Do not delete unplayed episodes (except if configured)
2496 if episode.is_new:
2497 if not self.config.auto_remove_unplayed_episodes:
2498 continue
2500 yield episode
2502 def delete_episode_list(self, episodes, confirm=True, skip_locked=True):
2503 if not episodes:
2504 return False
2506 if skip_locked:
2507 episodes = [e for e in episodes if not e.archive]
2509 if not episodes:
2510 title = _('Episodes are locked')
2511 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
2512 self.notification(message, title, widget=self.treeAvailable)
2513 return False
2515 count = len(episodes)
2516 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?', count) % {'count':count}
2517 message = _('Deleting episodes removes downloaded files.')
2519 if confirm and not self.show_confirmation(message, title):
2520 return False
2522 progress = ProgressIndicator(_('Deleting episodes'), \
2523 _('Please wait while episodes are deleted'), \
2524 parent=self.get_dialog_parent())
2526 def finish_deletion(episode_urls, channel_urls):
2527 progress.on_finished()
2529 # Episodes have been deleted - persist the database
2530 self.db.commit()
2532 self.update_episode_list_icons(episode_urls)
2533 self.update_podcast_list_model(channel_urls)
2534 self.play_or_download()
2536 def thread_proc():
2537 episode_urls = set()
2538 channel_urls = set()
2540 episodes_status_update = []
2541 for idx, episode in enumerate(episodes):
2542 progress.on_progress(float(idx)/float(len(episodes)))
2543 if not episode.archive or not skip_locked:
2544 progress.on_message(episode.title)
2545 episode.delete_from_disk()
2546 episode_urls.add(episode.url)
2547 channel_urls.add(episode.channel.url)
2548 episodes_status_update.append(episode)
2550 # Tell the shownotes window that we have removed the episode
2551 if self.episode_shownotes_window is not None and \
2552 self.episode_shownotes_window.episode is not None and \
2553 self.episode_shownotes_window.episode.url == episode.url:
2554 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
2556 # Notify the web service about the status update + upload
2557 self.mygpo_client.on_delete(episodes_status_update)
2558 self.mygpo_client.flush()
2560 util.idle_add(finish_deletion, episode_urls, channel_urls)
2562 threading.Thread(target=thread_proc).start()
2564 return True
2566 def on_itemRemoveOldEpisodes_activate(self, widget):
2567 self.show_delete_episodes_window()
2569 def show_delete_episodes_window(self, channel=None):
2570 """Offer deletion of episodes
2572 If channel is None, offer deletion of all episodes.
2573 Otherwise only offer deletion of episodes in the channel.
2575 columns = (
2576 ('markup_delete_episodes', None, None, _('Episode')),
2579 msg_older_than = N_('Select older than %(count)d day', 'Select older than %(count)d days', self.config.episode_old_age)
2580 selection_buttons = {
2581 _('Select played'): lambda episode: not episode.is_new,
2582 _('Select finished'): lambda episode: episode.is_finished(),
2583 msg_older_than % {'count':self.config.episode_old_age}: lambda episode: episode.age_in_days() > self.config.episode_old_age,
2586 instructions = _('Select the episodes you want to delete:')
2588 if channel is None:
2589 channels = self.channels
2590 else:
2591 channels = [channel]
2593 episodes = []
2594 for channel in channels:
2595 for episode in channel.get_downloaded_episodes():
2596 # Disallow deletion of locked episodes that still exist
2597 if not episode.archive or not episode.file_exists():
2598 episodes.append(episode)
2600 selected = [not e.is_new or not e.file_exists() for e in episodes]
2602 gPodderEpisodeSelector(self.gPodder, title = _('Delete episodes'), instructions = instructions, \
2603 episodes = episodes, selected = selected, columns = columns, \
2604 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
2605 selection_buttons = selection_buttons, _config=self.config, \
2606 show_episode_shownotes=self.show_episode_shownotes)
2608 def on_selected_episodes_status_changed(self):
2609 # The order of the updates here is important! When "All episodes" is
2610 # selected, the update of the podcast list model depends on the episode
2611 # list selection to determine which podcasts are affected. Updating
2612 # the episode list could remove the selection if a filter is active.
2613 self.update_podcast_list_model(selected=True)
2614 self.update_episode_list_icons(selected=True)
2615 self.db.commit()
2617 def mark_selected_episodes_new(self):
2618 for episode in self.get_selected_episodes():
2619 episode.mark_new()
2620 self.on_selected_episodes_status_changed()
2622 def mark_selected_episodes_old(self):
2623 for episode in self.get_selected_episodes():
2624 episode.mark_old()
2625 self.on_selected_episodes_status_changed()
2627 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
2628 for episode in self.get_selected_episodes():
2629 if toggle:
2630 episode.mark(is_played=episode.is_new)
2631 else:
2632 episode.mark(is_played=new_value)
2633 self.on_selected_episodes_status_changed()
2635 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2636 for episode in self.get_selected_episodes():
2637 if toggle:
2638 episode.mark(is_locked=not episode.archive)
2639 else:
2640 episode.mark(is_locked=new_value)
2641 self.on_selected_episodes_status_changed()
2643 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2644 if self.active_channel is None:
2645 return
2647 self.active_channel.auto_archive_episodes = not self.active_channel.auto_archive_episodes
2648 self.active_channel.save()
2650 for episode in self.active_channel.get_all_episodes():
2651 episode.mark(is_locked=self.active_channel.auto_archive_episodes)
2653 self.update_podcast_list_model(selected=True)
2654 self.update_episode_list_icons(all=True)
2656 def on_itemUpdateChannel_activate(self, widget=None):
2657 if self.active_channel is None:
2658 title = _('No podcast selected')
2659 message = _('Please select a podcast in the podcasts list to update.')
2660 self.show_message( message, title, widget=self.treeChannels)
2661 return
2663 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
2664 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
2665 self.update_feed_cache()
2666 else:
2667 self.update_feed_cache(channels=[self.active_channel])
2669 def on_itemUpdate_activate(self, widget=None):
2670 # Check if we have outstanding subscribe/unsubscribe actions
2671 self.on_add_remove_podcasts_mygpo()
2673 if self.channels:
2674 self.update_feed_cache()
2675 else:
2676 welcome_window = gPodderWelcome(self.main_window,
2677 center_on_widget=self.main_window)
2679 result = welcome_window.main_window.run()
2681 welcome_window.main_window.destroy()
2682 if result == gPodderWelcome.RESPONSE_OPML:
2683 self.on_itemImportChannels_activate(None)
2684 elif result == gPodderWelcome.RESPONSE_MYGPO:
2685 self.on_download_subscriptions_from_mygpo(None)
2687 def download_episode_list_paused(self, episodes):
2688 self.download_episode_list(episodes, True)
2690 def download_episode_list(self, episodes, add_paused=False, force_start=False):
2691 enable_update = False
2693 for episode in episodes:
2694 logger.debug('Downloading episode: %s', episode.title)
2695 if not episode.was_downloaded(and_exists=True):
2696 task_exists = False
2697 for task in self.download_tasks_seen:
2698 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
2699 self.download_queue_manager.add_task(task, force_start)
2700 enable_update = True
2701 task_exists = True
2702 continue
2704 if task_exists:
2705 continue
2707 try:
2708 task = download.DownloadTask(episode, self.config)
2709 except Exception, e:
2710 d = {'episode': episode.title, 'message': str(e)}
2711 message = _('Download error while downloading %(episode)s: %(message)s')
2712 self.show_message(message % d, _('Download error'), important=True)
2713 logger.error('While downloading %s', episode.title, exc_info=True)
2714 continue
2716 if add_paused:
2717 task.status = task.PAUSED
2718 else:
2719 self.mygpo_client.on_download([task.episode])
2720 self.download_queue_manager.add_task(task, force_start)
2722 self.download_status_model.register_task(task)
2723 enable_update = True
2725 if enable_update:
2726 self.enable_download_list_update()
2728 # Flush updated episode status
2729 self.mygpo_client.flush()
2731 def cancel_task_list(self, tasks):
2732 if not tasks:
2733 return
2735 for task in tasks:
2736 if task.status in (task.QUEUED, task.DOWNLOADING):
2737 task.status = task.CANCELLED
2738 elif task.status == task.PAUSED:
2739 task.status = task.CANCELLED
2740 # Call run, so the partial file gets deleted
2741 task.run()
2743 self.update_episode_list_icons([task.url for task in tasks])
2744 self.play_or_download()
2746 # Update the tab title and downloads list
2747 self.update_downloads_list()
2749 def new_episodes_show(self, episodes, notification=False, selected=None):
2750 columns = (
2751 ('markup_new_episodes', None, None, _('Episode')),
2754 instructions = _('Select the episodes you want to download:')
2756 if self.new_episodes_window is not None:
2757 self.new_episodes_window.main_window.destroy()
2758 self.new_episodes_window = None
2760 def download_episodes_callback(episodes):
2761 self.new_episodes_window = None
2762 self.download_episode_list(episodes)
2764 if selected is None:
2765 # Select all by default
2766 selected = [True]*len(episodes)
2768 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
2769 title=_('New episodes available'), \
2770 instructions=instructions, \
2771 episodes=episodes, \
2772 columns=columns, \
2773 selected=selected, \
2774 stock_ok_button = 'gpodder-download', \
2775 callback=download_episodes_callback, \
2776 remove_callback=lambda e: e.mark_old(), \
2777 remove_action=_('Mark as old'), \
2778 remove_finished=self.episode_new_status_changed, \
2779 _config=self.config, \
2780 show_notification=False, \
2781 show_episode_shownotes=self.show_episode_shownotes)
2783 def on_itemDownloadAllNew_activate(self, widget, *args):
2784 if not self.offer_new_episodes():
2785 self.show_message(_('Please check for new episodes later.'), \
2786 _('No new episodes available'), widget=self.btnUpdateFeeds)
2788 def get_new_episodes(self, channels=None):
2789 return [e for c in channels or self.channels for e in
2790 filter(lambda e: e.check_is_new(), c.get_all_episodes())]
2792 def commit_changes_to_database(self):
2793 """This will be called after the sync process is finished"""
2794 self.db.commit()
2796 def on_itemShowAllEpisodes_activate(self, widget):
2797 self.config.podcast_list_view_all = widget.get_active()
2799 def on_itemShowToolbar_activate(self, widget):
2800 self.config.show_toolbar = self.itemShowToolbar.get_active()
2802 def on_itemShowDescription_activate(self, widget):
2803 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
2805 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
2806 self.config.podcast_list_hide_boring = toggleaction.get_active()
2807 if self.config.podcast_list_hide_boring:
2808 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
2809 else:
2810 self.podcast_list_model.set_view_mode(-1)
2812 def on_item_view_episodes_changed(self, radioaction, current):
2813 if current == self.item_view_episodes_all:
2814 self.config.episode_list_view_mode = EpisodeListModel.VIEW_ALL
2815 elif current == self.item_view_episodes_undeleted:
2816 self.config.episode_list_view_mode = EpisodeListModel.VIEW_UNDELETED
2817 elif current == self.item_view_episodes_downloaded:
2818 self.config.episode_list_view_mode = EpisodeListModel.VIEW_DOWNLOADED
2819 elif current == self.item_view_episodes_unplayed:
2820 self.config.episode_list_view_mode = EpisodeListModel.VIEW_UNPLAYED
2822 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
2824 if self.config.podcast_list_hide_boring:
2825 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
2827 def on_itemPreferences_activate(self, widget, *args):
2828 gPodderPreferences(self.main_window, \
2829 _config=self.config, \
2830 user_apps_reader=self.user_apps_reader, \
2831 parent_window=self.main_window, \
2832 mygpo_client=self.mygpo_client, \
2833 on_send_full_subscriptions=self.on_send_full_subscriptions, \
2834 on_itemExportChannels_activate=self.on_itemExportChannels_activate)
2836 def on_goto_mygpo(self, widget):
2837 self.mygpo_client.open_website()
2839 def on_download_subscriptions_from_mygpo(self, action=None):
2840 title = _('Login to gpodder.net')
2841 message = _('Please login to download your subscriptions.')
2842 success, (username, password) = self.show_login_dialog(title, message, \
2843 self.config.mygpo.username, self.config.mygpo.password)
2844 if not success:
2845 return
2847 self.config.mygpo.username = username
2848 self.config.mygpo.password = password
2850 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
2851 custom_title=_('Subscriptions on gpodder.net'), \
2852 add_urls_callback=self.add_podcast_list, \
2853 hide_url_entry=True)
2855 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
2856 # we do not have to hardcode the URL here
2857 OPML_URL = 'http://gpodder.net/subscriptions/%s.opml' % self.config.mygpo.username
2858 url = util.url_add_authentication(OPML_URL, \
2859 self.config.mygpo.username, \
2860 self.config.mygpo.password)
2861 dir.download_opml_file(url)
2863 def on_itemAddChannel_activate(self, widget=None):
2864 gPodderAddPodcast(self.gPodder, \
2865 add_urls_callback=self.add_podcast_list)
2867 def on_itemEditChannel_activate(self, widget, *args):
2868 if self.active_channel is None:
2869 title = _('No podcast selected')
2870 message = _('Please select a podcast in the podcasts list to edit.')
2871 self.show_message( message, title, widget=self.treeChannels)
2872 return
2874 gPodderChannel(self.main_window, \
2875 channel=self.active_channel, \
2876 update_podcast_list_model=self.update_podcast_list_model, \
2877 cover_downloader=self.cover_downloader, \
2878 sections=set(c.section for c in self.channels))
2880 def on_itemMassUnsubscribe_activate(self, item=None):
2881 columns = (
2882 ('title', None, None, _('Podcast')),
2885 # We're abusing the Episode Selector for selecting Podcasts here,
2886 # but it works and looks good, so why not? -- thp
2887 gPodderEpisodeSelector(self.main_window, \
2888 title=_('Remove podcasts'), \
2889 instructions=_('Select the podcast you want to remove.'), \
2890 episodes=self.channels, \
2891 columns=columns, \
2892 size_attribute=None, \
2893 stock_ok_button=_('Remove'), \
2894 callback=self.remove_podcast_list, \
2895 _config=self.config)
2897 def remove_podcast_list(self, channels, confirm=True):
2898 if not channels:
2899 return
2901 if len(channels) == 1:
2902 title = _('Removing podcast')
2903 info = _('Please wait while the podcast is removed')
2904 message = _('Do you really want to remove this podcast and its episodes?')
2905 else:
2906 title = _('Removing podcasts')
2907 info = _('Please wait while the podcasts are removed')
2908 message = _('Do you really want to remove the selected podcasts and their episodes?')
2910 if confirm and not self.show_confirmation(message, title):
2911 return
2913 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
2915 def finish_deletion(select_url):
2916 # Upload subscription list changes to the web service
2917 self.mygpo_client.on_unsubscribe([c.url for c in channels])
2919 # Re-load the channels and select the desired new channel
2920 self.update_podcast_list_model(select_url=select_url)
2921 progress.on_finished()
2923 def thread_proc():
2924 select_url = None
2926 for idx, channel in enumerate(channels):
2927 # Update the UI for correct status messages
2928 progress.on_progress(float(idx)/float(len(channels)))
2929 progress.on_message(channel.title)
2931 # Delete downloaded episodes
2932 channel.remove_downloaded()
2934 # cancel any active downloads from this channel
2935 for episode in channel.get_all_episodes():
2936 if episode.downloading:
2937 episode.download_task.cancel()
2939 if len(channels) == 1:
2940 # get the URL of the podcast we want to select next
2941 if channel in self.channels:
2942 position = self.channels.index(channel)
2943 else:
2944 position = -1
2946 if position == len(self.channels)-1:
2947 # this is the last podcast, so select the URL
2948 # of the item before this one (i.e. the "new last")
2949 select_url = self.channels[position-1].url
2950 else:
2951 # there is a podcast after the deleted one, so
2952 # we simply select the one that comes after it
2953 select_url = self.channels[position+1].url
2955 # Remove the channel and clean the database entries
2956 channel.delete()
2958 # Clean up downloads and download directories
2959 self.clean_up_downloads()
2961 # The remaining stuff is to be done in the GTK main thread
2962 util.idle_add(finish_deletion, select_url)
2964 threading.Thread(target=thread_proc).start()
2966 def on_itemRemoveChannel_activate(self, widget, *args):
2967 if self.active_channel is None:
2968 title = _('No podcast selected')
2969 message = _('Please select a podcast in the podcasts list to remove.')
2970 self.show_message( message, title, widget=self.treeChannels)
2971 return
2973 self.remove_podcast_list([self.active_channel])
2975 def get_opml_filter(self):
2976 filter = gtk.FileFilter()
2977 filter.add_pattern('*.opml')
2978 filter.add_pattern('*.xml')
2979 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
2980 return filter
2982 def on_item_import_from_file_activate(self, widget, filename=None):
2983 if filename is None:
2984 dlg = gtk.FileChooserDialog(title=_('Import from OPML'),
2985 parent=self.main_window,
2986 action=gtk.FILE_CHOOSER_ACTION_OPEN)
2987 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
2988 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
2989 dlg.set_filter(self.get_opml_filter())
2990 response = dlg.run()
2991 filename = None
2992 if response == gtk.RESPONSE_OK:
2993 filename = dlg.get_filename()
2994 dlg.destroy()
2996 if filename is not None:
2997 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
2998 custom_title=_('Import podcasts from OPML file'), \
2999 add_urls_callback=self.add_podcast_list, \
3000 hide_url_entry=True)
3001 dir.download_opml_file(filename)
3003 def on_itemExportChannels_activate(self, widget, *args):
3004 if not self.channels:
3005 title = _('Nothing to export')
3006 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3007 self.show_message(message, title, widget=self.treeChannels)
3008 return
3010 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3011 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3012 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3013 dlg.set_filter(self.get_opml_filter())
3014 response = dlg.run()
3015 if response == gtk.RESPONSE_OK:
3016 filename = dlg.get_filename()
3017 dlg.destroy()
3018 exporter = opml.Exporter( filename)
3019 if filename is not None and exporter.write(self.channels):
3020 count = len(self.channels)
3021 title = N_('%(count)d subscription exported', '%(count)d subscriptions exported', count) % {'count':count}
3022 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3023 else:
3024 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3025 else:
3026 dlg.destroy()
3028 def on_itemImportChannels_activate(self, widget, *args):
3029 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3030 add_urls_callback=self.add_podcast_list)
3031 util.idle_add(dir.download_opml_file, my.EXAMPLES_OPML)
3033 def on_homepage_activate(self, widget, *args):
3034 util.open_website(gpodder.__url__)
3036 def on_wiki_activate(self, widget, *args):
3037 util.open_website('http://gpodder.org/wiki/User_Manual')
3039 def on_bug_tracker_activate(self, widget, *args):
3040 util.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder&component=Application&version=%s' % gpodder.__version__)
3042 def on_item_support_activate(self, widget):
3043 util.open_website('http://gpodder.org/donate')
3045 def on_itemAbout_activate(self, widget, *args):
3046 dlg = gtk.Dialog(_('About gPodder'), self.main_window, \
3047 gtk.DIALOG_MODAL)
3048 dlg.add_button(gtk.STOCK_CLOSE, gtk.RESPONSE_OK).show()
3049 dlg.set_resizable(False)
3051 bg = gtk.HBox(spacing=10)
3052 bg.pack_start(gtk.image_new_from_file(gpodder.icon_file), expand=False)
3053 vb = gtk.VBox()
3054 vb.set_spacing(6)
3055 label = gtk.Label()
3056 label.set_alignment(0, 1)
3057 label.set_markup('<b><big>gPodder</big> %s</b>' % gpodder.__version__)
3058 vb.pack_start(label)
3059 label = gtk.Label()
3060 label.set_alignment(0, 0)
3061 label.set_markup('<small><a href="%s">%s</a></small>' % \
3062 ((cgi.escape(gpodder.__url__),)*2))
3063 vb.pack_start(label)
3064 bg.pack_start(vb)
3066 out = gtk.VBox(spacing=10)
3067 out.set_border_width(12)
3068 out.pack_start(bg, expand=False)
3069 out.pack_start(gtk.HSeparator())
3070 out.pack_start(gtk.Label(gpodder.__copyright__))
3072 button_box = gtk.HButtonBox()
3073 button = gtk.Button(_('Donate / Wishlist'))
3074 button.connect('clicked', self.on_item_support_activate)
3075 button_box.pack_start(button)
3076 button = gtk.Button(_('Report a problem'))
3077 button.connect('clicked', self.on_bug_tracker_activate)
3078 button_box.pack_start(button)
3079 out.pack_start(button_box, expand=False)
3081 credits = gtk.TextView()
3082 credits.set_left_margin(5)
3083 credits.set_right_margin(5)
3084 credits.set_pixels_above_lines(5)
3085 credits.set_pixels_below_lines(5)
3086 credits.set_editable(False)
3087 credits.set_cursor_visible(False)
3088 sw = gtk.ScrolledWindow()
3089 sw.set_shadow_type(gtk.SHADOW_IN)
3090 sw.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
3091 sw.add(credits)
3092 credits.set_size_request(-1, 160)
3093 out.pack_start(sw, expand=True, fill=True)
3095 dlg.vbox.pack_start(out, expand=False)
3096 dlg.connect('response', lambda dlg, response: dlg.destroy())
3098 dlg.vbox.show_all()
3100 if os.path.exists(gpodder.credits_file):
3101 credits_txt = open(gpodder.credits_file).read().strip().split('\n')
3102 translator_credits = _('translator-credits')
3103 if translator_credits != 'translator-credits':
3104 app_authors = [_('Translation by:'), translator_credits, '']
3105 else:
3106 app_authors = []
3108 app_authors += [_('Thanks to:')]
3109 app_authors += credits_txt
3111 buffer = gtk.TextBuffer()
3112 buffer.set_text('\n'.join(app_authors))
3113 credits.set_buffer(buffer)
3114 else:
3115 sw.hide()
3117 credits.grab_focus()
3118 dlg.run()
3120 def on_wNotebook_switch_page(self, notebook, page, page_num):
3121 if page_num == 0:
3122 self.play_or_download()
3123 # The message area in the downloads tab should be hidden
3124 # when the user switches away from the downloads tab
3125 if self.message_area is not None:
3126 self.message_area.hide()
3127 self.message_area = None
3128 else:
3129 self.toolDownload.set_sensitive(False)
3130 self.toolPlay.set_sensitive(False)
3131 self.toolCancel.set_sensitive(False)
3133 def on_treeChannels_row_activated(self, widget, path, *args):
3134 # double-click action of the podcast list or enter
3135 self.treeChannels.set_cursor(path)
3137 def on_treeChannels_cursor_changed(self, widget, *args):
3138 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3140 if model is not None and iter is not None:
3141 old_active_channel = self.active_channel
3142 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3144 if self.active_channel == old_active_channel:
3145 return
3147 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3148 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3149 self.itemEditChannel.set_visible(False)
3150 self.itemRemoveChannel.set_visible(False)
3151 else:
3152 self.itemEditChannel.set_visible(True)
3153 self.itemRemoveChannel.set_visible(True)
3154 else:
3155 self.active_channel = None
3156 self.itemEditChannel.set_visible(False)
3157 self.itemRemoveChannel.set_visible(False)
3159 self.update_episode_list_model()
3161 def on_btnEditChannel_clicked(self, widget, *args):
3162 self.on_itemEditChannel_activate( widget, args)
3164 def get_podcast_urls_from_selected_episodes(self):
3165 """Get a set of podcast URLs based on the selected episodes"""
3166 return set(episode.channel.url for episode in \
3167 self.get_selected_episodes())
3169 def get_selected_episodes(self):
3170 """Get a list of selected episodes from treeAvailable"""
3171 selection = self.treeAvailable.get_selection()
3172 model, paths = selection.get_selected_rows()
3174 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3175 return episodes
3177 def on_playback_selected_episodes(self, widget):
3178 self.playback_episodes(self.get_selected_episodes())
3180 def on_shownotes_selected_episodes(self, widget):
3181 episodes = self.get_selected_episodes()
3182 if episodes:
3183 episode = episodes.pop(0)
3184 self.show_episode_shownotes(episode)
3185 else:
3186 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3188 def on_download_selected_episodes(self, widget):
3189 episodes = self.get_selected_episodes()
3190 self.download_episode_list(episodes)
3191 self.update_episode_list_icons([episode.url for episode in episodes])
3192 self.play_or_download()
3194 def on_treeAvailable_row_activated(self, widget, path, view_column):
3195 """Double-click/enter action handler for treeAvailable"""
3196 self.on_shownotes_selected_episodes(widget)
3198 def show_episode_shownotes(self, episode):
3199 if self.episode_shownotes_window is None:
3200 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3201 _download_episode_list=self.download_episode_list, \
3202 _playback_episodes=self.playback_episodes, \
3203 _delete_episode_list=self.delete_episode_list, \
3204 _episode_list_status_changed=self.episode_list_status_changed, \
3205 _cancel_task_list=self.cancel_task_list, \
3206 _streaming_possible=self.streaming_possible())
3207 self.episode_shownotes_window.show(episode)
3208 if episode.downloading:
3209 self.update_downloads_list()
3211 def restart_auto_update_timer(self):
3212 if self._auto_update_timer_source_id is not None:
3213 logger.debug('Removing existing auto update timer.')
3214 gobject.source_remove(self._auto_update_timer_source_id)
3215 self._auto_update_timer_source_id = None
3217 if self.config.auto_update_feeds and \
3218 self.config.auto_update_frequency:
3219 interval = 60*1000*self.config.auto_update_frequency
3220 logger.debug('Setting up auto update timer with interval %d.',
3221 self.config.auto_update_frequency)
3222 self._auto_update_timer_source_id = gobject.timeout_add(\
3223 interval, self._on_auto_update_timer)
3225 def _on_auto_update_timer(self):
3226 logger.debug('Auto update timer fired.')
3227 self.update_feed_cache()
3229 # Ask web service for sub changes (if enabled)
3230 self.mygpo_client.flush()
3232 return True
3234 def on_treeDownloads_row_activated(self, widget, *args):
3235 # Use the standard way of working on the treeview
3236 selection = self.treeDownloads.get_selection()
3237 (model, paths) = selection.get_selected_rows()
3238 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3240 for tree_row_reference, task in selected_tasks:
3241 if task.status in (task.DOWNLOADING, task.QUEUED):
3242 task.status = task.PAUSED
3243 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3244 self.download_queue_manager.add_task(task)
3245 self.enable_download_list_update()
3246 elif task.status == task.DONE:
3247 model.remove(model.get_iter(tree_row_reference.get_path()))
3249 self.play_or_download()
3251 # Update the tab title and downloads list
3252 self.update_downloads_list()
3254 def on_item_cancel_download_activate(self, widget):
3255 if self.wNotebook.get_current_page() == 0:
3256 selection = self.treeAvailable.get_selection()
3257 (model, paths) = selection.get_selected_rows()
3258 urls = [model.get_value(model.get_iter(path), \
3259 self.episode_list_model.C_URL) for path in paths]
3260 selected_tasks = [task for task in self.download_tasks_seen \
3261 if task.url in urls]
3262 else:
3263 selection = self.treeDownloads.get_selection()
3264 (model, paths) = selection.get_selected_rows()
3265 selected_tasks = [model.get_value(model.get_iter(path), \
3266 self.download_status_model.C_TASK) for path in paths]
3267 self.cancel_task_list(selected_tasks)
3269 def on_btnCancelAll_clicked(self, widget, *args):
3270 self.cancel_task_list(self.download_tasks_seen)
3272 def on_btnDownloadedDelete_clicked(self, widget, *args):
3273 episodes = self.get_selected_episodes()
3274 if len(episodes) == 1:
3275 self.delete_episode_list(episodes, skip_locked=False)
3276 else:
3277 self.delete_episode_list(episodes)
3279 def on_key_press(self, widget, event):
3280 # Allow tab switching with Ctrl + PgUp/PgDown
3281 if event.state & gtk.gdk.CONTROL_MASK:
3282 if event.keyval == gtk.keysyms.Page_Up:
3283 self.wNotebook.prev_page()
3284 return True
3285 elif event.keyval == gtk.keysyms.Page_Down:
3286 self.wNotebook.next_page()
3287 return True
3289 return False
3291 def uniconify_main_window(self):
3292 if self.is_iconified():
3293 # We need to hide and then show the window in WMs like Metacity
3294 # or KWin4 to move the window to the active workspace
3295 # (see http://gpodder.org/bug/1125)
3296 self.gPodder.hide()
3297 self.gPodder.show()
3298 self.gPodder.present()
3300 def iconify_main_window(self):
3301 if not self.is_iconified():
3302 self.gPodder.iconify()
3304 @dbus.service.method(gpodder.dbus_interface)
3305 def show_gui_window(self):
3306 parent = self.get_dialog_parent()
3307 parent.present()
3309 @dbus.service.method(gpodder.dbus_interface)
3310 def subscribe_to_url(self, url):
3311 gPodderAddPodcast(self.gPodder,
3312 add_urls_callback=self.add_podcast_list,
3313 preset_url=url)
3315 @dbus.service.method(gpodder.dbus_interface)
3316 def mark_episode_played(self, filename):
3317 if filename is None:
3318 return False
3320 for channel in self.channels:
3321 for episode in channel.get_all_episodes():
3322 fn = episode.local_filename(create=False, check_only=True)
3323 if fn == filename:
3324 episode.mark(is_played=True)
3325 self.db.commit()
3326 self.update_episode_list_icons([episode.url])
3327 self.update_podcast_list_model([episode.channel.url])
3328 return True
3330 return False
3332 def extensions_podcast_update_cb(self, podcast):
3333 logger.debug('extensions_podcast_update_cb(%s)', podcast)
3334 self.update_feed_cache(channels=[podcast],
3335 show_new_episodes_dialog=False)
3337 def extensions_episode_download_cb(self, episode):
3338 logger.debug('extension_episode_download_cb(%s)', episode)
3339 self.download_episode_list(episodes=[episode])
3341 def main(options=None):
3342 gobject.threads_init()
3343 gobject.set_application_name('gPodder')
3345 for i in range(EpisodeListModel.PROGRESS_STEPS + 1):
3346 pixbuf = draw_cake_pixbuf(float(i) /
3347 float(EpisodeListModel.PROGRESS_STEPS))
3348 icon_name = 'gpodder-progress-%d' % i
3349 gtk.icon_theme_add_builtin_icon(icon_name, pixbuf.get_width(), pixbuf)
3351 gtk.window_set_default_icon_name('gpodder')
3352 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
3354 try:
3355 dbus_main_loop = dbus.glib.DBusGMainLoop(set_as_default=True)
3356 gpodder.dbus_session_bus = dbus.SessionBus(dbus_main_loop)
3358 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=gpodder.dbus_session_bus)
3359 except dbus.exceptions.DBusException, dbe:
3360 logger.warn('Cannot get "on the bus".', exc_info=True)
3361 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
3362 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
3363 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
3364 dlg.set_title('gPodder')
3365 dlg.run()
3366 dlg.destroy()
3367 sys.exit(0)
3369 gp = gPodder(bus_name, core.Core(UIConfig, model_class=Model))
3371 # Handle options
3372 if options.subscribe:
3373 util.idle_add(gp.subscribe_to_url, options.subscribe)
3375 # mac OS X stuff :
3376 # handle "subscribe to podcast" events from firefox
3377 if platform.system() == 'Darwin':
3378 from gpodder.gtkui import macosx
3379 macosx.register_handlers(gp)
3380 # end mac OS X stuff
3382 gp.run()