Gtk UI: Reintroduce "Open Download Folder" menu item
[gpodder.git] / src / gpodder / gtkui / main.py
blob65e00e356c80f6b61e0a851b6911ea1fb60dd574
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
70 from gpodder.gtkui.draw import EPISODE_LIST_ICON_SIZE
72 from gpodder.gtkui.interface.common import BuilderWidget
73 from gpodder.gtkui.interface.common import TreeViewHelper
74 from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
76 from gpodder.gtkui.download import DownloadStatusModel
78 from gpodder.gtkui.desktop.welcome import gPodderWelcome
79 from gpodder.gtkui.desktop.channel import gPodderChannel
80 from gpodder.gtkui.desktop.preferences import gPodderPreferences
81 from gpodder.gtkui.desktop.shownotes import gPodderShownotes
82 from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
83 from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
84 from gpodder.gtkui.interface.progress import ProgressIndicator
86 from gpodder.dbusproxy import DBusPodcastsProxy
87 from gpodder import extensions
90 macapp = None
91 if gpodder.osx and getattr(gtk.gdk, 'WINDOWING', 'x11') == 'quartz':
92 try:
93 from gtk_osxapplication import *
94 macapp = OSXApplication()
95 except ImportError:
96 print >> sys.stderr, """
97 Warning: gtk-mac-integration not found, disabling native menus
98 """
101 class gPodder(BuilderWidget, dbus.service.Object):
102 # Width (in pixels) of episode list icon
103 EPISODE_LIST_ICON_WIDTH = 40
105 def __init__(self, bus_name, gpodder_core):
106 dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
107 self.podcasts_proxy = DBusPodcastsProxy(lambda: self.channels,
108 self.on_itemUpdate_activate,
109 self.playback_episodes,
110 self.download_episode_list,
111 self.episode_object_by_uri,
112 bus_name)
113 self.core = gpodder_core
114 self.config = self.core.config
115 self.db = self.core.db
116 self.model = self.core.model
117 BuilderWidget.__init__(self, None)
119 def new(self):
120 gpodder.user_extensions.on_ui_object_available('gpodder-gtk', self)
121 self.toolbar.set_property('visible', self.config.show_toolbar)
123 self.bluetooth_available = util.bluetooth_available()
125 self.config.connect_gtk_window(self.main_window, 'main_window')
127 self.config.connect_gtk_paned('paned_position', self.channelPaned)
129 self.main_window.show()
131 self.player_receiver = player.MediaPlayerDBusReceiver(self.on_played)
133 self.gPodder.connect('key-press-event', self.on_key_press)
135 self.episode_columns_menu = None
136 self.config.add_observer(self.on_config_changed)
138 self.episode_shownotes_window = None
139 self.new_episodes_window = None
141 # Mac OS X-specific UI tweaks: Native main menu integration
142 # http://sourceforge.net/apps/trac/gtk-osx/wiki/Integrate
143 if macapp is not None:
144 # Move the menu bar from the window to the Mac menu bar
145 self.mainMenu.hide()
146 macapp.set_menu_bar(self.mainMenu)
148 # Reparent some items to the "Application" menu
149 item = self.uimanager1.get_widget('/mainMenu/menuHelp/itemAbout')
150 macapp.insert_app_menu_item(item, 0)
151 macapp.insert_app_menu_item(gtk.SeparatorMenuItem(), 1)
152 item = self.uimanager1.get_widget('/mainMenu/menuPodcasts/itemPreferences')
153 macapp.insert_app_menu_item(item, 2)
155 quit_item = self.uimanager1.get_widget('/mainMenu/menuPodcasts/itemQuit')
156 quit_item.hide()
157 # end Mac OS X specific UI tweaks
159 self.download_status_model = DownloadStatusModel()
160 self.download_queue_manager = download.DownloadQueueManager(self.config)
162 self.itemShowAllEpisodes.set_active(self.config.podcast_list_view_all)
163 self.itemShowToolbar.set_active(self.config.show_toolbar)
164 self.itemShowDescription.set_active(self.config.episode_list_descriptions)
166 self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
167 self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
168 self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
169 self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
171 self.config.connect_gtk_togglebutton('podcast_list_sections', self.item_podcast_sections)
173 # When the amount of maximum downloads changes, notify the queue manager
174 changed_cb = lambda spinbutton: self.download_queue_manager.spawn_threads()
175 self.spinMaxDownloads.connect('value-changed', changed_cb)
177 self.default_title = None
178 self.set_title(_('gPodder'))
180 self.cover_downloader = CoverDownloader()
182 # Generate list models for podcasts and their episodes
183 self.podcast_list_model = PodcastListModel(self.cover_downloader)
185 self.cover_downloader.register('cover-available', self.cover_download_finished)
187 # Source IDs for timeouts for search-as-you-type
188 self._podcast_list_search_timeout = None
189 self._episode_list_search_timeout = None
191 # Init the treeviews that we use
192 self.init_podcast_list_treeview()
193 self.init_episode_list_treeview()
194 self.init_download_list_treeview()
196 if self.config.podcast_list_hide_boring:
197 self.item_view_hide_boring_podcasts.set_active(True)
199 self.currently_updating = False
201 self.download_tasks_seen = set()
202 self.download_list_update_enabled = False
203 self.download_task_monitors = set()
205 # Subscribed channels
206 self.active_channel = None
207 self.channels = self.model.get_podcasts()
209 gpodder.user_extensions.on_ui_initialized(self.model,
210 self.extensions_podcast_update_cb,
211 self.extensions_episode_download_cb)
213 # load list of user applications for audio playback
214 self.user_apps_reader = UserAppsReader(['audio', 'video'])
215 threading.Thread(target=self.user_apps_reader.read).start()
217 # Set up the first instance of MygPoClient
218 self.mygpo_client = my.MygPoClient(self.config)
220 # Now, update the feed cache, when everything's in place
221 self.btnUpdateFeeds.show()
222 self.feed_cache_update_cancelled = False
223 self.update_podcast_list_model()
225 self.message_area = None
227 def find_partial_downloads():
228 # Look for partial file downloads
229 partial_files = glob.glob(os.path.join(gpodder.downloads, '*', '*.partial'))
230 count = len(partial_files)
231 resumable_episodes = []
232 if count:
233 util.idle_add(self.wNotebook.set_current_page, 1)
234 indicator = ProgressIndicator(_('Loading incomplete downloads'),
235 _('Some episodes have not finished downloading in a previous session.'),
236 False, self.get_dialog_parent())
237 indicator.on_message(N_('%(count)d partial file', '%(count)d partial files', count) % {'count':count})
239 candidates = [f[:-len('.partial')] for f in partial_files]
240 found = 0
242 for c in self.channels:
243 for e in c.get_all_episodes():
244 filename = e.local_filename(create=False, check_only=True)
245 if filename in candidates:
246 found += 1
247 indicator.on_message(e.title)
248 indicator.on_progress(float(found)/count)
249 candidates.remove(filename)
250 partial_files.remove(filename+'.partial')
252 if os.path.exists(filename):
253 # The file has already been downloaded;
254 # remove the leftover partial file
255 util.delete_file(filename+'.partial')
256 else:
257 resumable_episodes.append(e)
259 if not candidates:
260 break
262 if not candidates:
263 break
265 for f in partial_files:
266 logger.warn('Partial file without episode: %s', f)
267 util.delete_file(f)
269 util.idle_add(indicator.on_finished)
271 if len(resumable_episodes):
272 def offer_resuming():
273 self.download_episode_list_paused(resumable_episodes)
274 resume_all = gtk.Button(_('Resume all'))
275 def on_resume_all(button):
276 selection = self.treeDownloads.get_selection()
277 selection.select_all()
278 selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = self.downloads_list_get_selection()
279 selection.unselect_all()
280 self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
281 self.message_area.hide()
282 resume_all.connect('clicked', on_resume_all)
284 self.message_area = SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all,))
285 self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
286 self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
287 self.message_area.show_all()
288 self.clean_up_downloads(delete_partial=False)
289 util.idle_add(offer_resuming)
290 else:
291 util.idle_add(self.wNotebook.set_current_page, 0)
292 else:
293 util.idle_add(self.clean_up_downloads, True)
294 threading.Thread(target=find_partial_downloads).start()
296 # Start the auto-update procedure
297 self._auto_update_timer_source_id = None
298 if self.config.auto_update_feeds:
299 self.restart_auto_update_timer()
301 # Find expired (old) episodes and delete them
302 old_episodes = list(self.get_expired_episodes())
303 if len(old_episodes) > 0:
304 self.delete_episode_list(old_episodes, confirm=False)
305 updated_urls = set(e.channel.url for e in old_episodes)
306 self.update_podcast_list_model(updated_urls)
308 # Do the initial sync with the web service
309 util.idle_add(self.mygpo_client.flush, True)
311 # First-time users should be asked if they want to see the OPML
312 if not self.channels:
313 self.on_itemUpdate_activate()
314 elif self.config.software_update.check_on_startup:
315 # Check for software updates from gpodder.org
316 diff = time.time() - self.config.software_update.last_check
317 if diff > (60*60*24)*self.config.software_update.interval:
318 self.config.software_update.last_check = int(time.time())
319 self.check_for_updates(silent=True)
321 def episode_object_by_uri(self, uri):
322 """Get an episode object given a local or remote URI
324 This can be used to quickly access an episode object
325 when all we have is its download filename or episode
326 URL (e.g. from external D-Bus calls / signals, etc..)
328 if uri.startswith('/'):
329 uri = 'file://' + urllib.quote(uri)
331 prefix = 'file://' + urllib.quote(gpodder.downloads)
333 # By default, assume we can't pre-select any channel
334 # but can match episodes simply via the download URL
335 is_channel = lambda c: True
336 is_episode = lambda e: e.url == uri
338 if uri.startswith(prefix):
339 # File is on the local filesystem in the download folder
340 # Try to reduce search space by pre-selecting the channel
341 # based on the folder name of the local file
343 filename = urllib.unquote(uri[len(prefix):])
344 file_parts = filter(None, filename.split(os.sep))
346 if len(file_parts) != 2:
347 return None
349 foldername, filename = file_parts
351 is_channel = lambda c: c.download_folder == foldername
352 is_episode = lambda e: e.download_filename == filename
354 # Deep search through channels and episodes for a match
355 for channel in filter(is_channel, self.channels):
356 for episode in filter(is_episode, channel.get_all_episodes()):
357 return episode
359 return None
361 def on_played(self, start, end, total, file_uri):
362 """Handle the "played" signal from a media player"""
363 if start == 0 and end == 0 and total == 0:
364 # Ignore bogus play event
365 return
366 elif end < start + 5:
367 # Ignore "less than five seconds" segments,
368 # as they can happen with seeking, etc...
369 return
371 logger.debug('Received play action: %s (%d, %d, %d)', file_uri, start, end, total)
372 episode = self.episode_object_by_uri(file_uri)
374 if episode is not None:
375 file_type = episode.file_type()
377 now = time.time()
378 if total > 0:
379 episode.total_time = total
380 elif total == 0:
381 # Assume the episode's total time for the action
382 total = episode.total_time
384 assert (episode.current_position_updated is None or
385 now >= episode.current_position_updated)
387 episode.current_position = end
388 episode.current_position_updated = now
389 episode.mark(is_played=True)
390 episode.save()
391 self.db.commit()
392 self.update_episode_list_icons([episode.url])
393 self.update_podcast_list_model([episode.channel.url])
395 # Submit this action to the webservice
396 self.mygpo_client.on_playback_full(episode, start, end, total)
398 def on_add_remove_podcasts_mygpo(self):
399 actions = self.mygpo_client.get_received_actions()
400 if not actions:
401 return False
403 existing_urls = [c.url for c in self.channels]
405 # Columns for the episode selector window - just one...
406 columns = (
407 ('description', None, None, _('Action')),
410 # A list of actions that have to be chosen from
411 changes = []
413 # Actions that are ignored (already carried out)
414 ignored = []
416 for action in actions:
417 if action.is_add and action.url not in existing_urls:
418 changes.append(my.Change(action))
419 elif action.is_remove and action.url in existing_urls:
420 podcast_object = None
421 for podcast in self.channels:
422 if podcast.url == action.url:
423 podcast_object = podcast
424 break
425 changes.append(my.Change(action, podcast_object))
426 else:
427 ignored.append(action)
429 # Confirm all ignored changes
430 self.mygpo_client.confirm_received_actions(ignored)
432 def execute_podcast_actions(selected):
433 add_list = [c.action.url for c in selected if c.action.is_add]
434 remove_list = [c.podcast for c in selected if c.action.is_remove]
436 # Apply the accepted changes locally
437 self.add_podcast_list(add_list)
438 self.remove_podcast_list(remove_list, confirm=False)
440 # All selected items are now confirmed
441 self.mygpo_client.confirm_received_actions(c.action for c in selected)
443 # Revert the changes on the server
444 rejected = [c.action for c in changes if c not in selected]
445 self.mygpo_client.reject_received_actions(rejected)
447 def ask():
448 # We're abusing the Episode Selector again ;) -- thp
449 gPodderEpisodeSelector(self.main_window, \
450 title=_('Confirm changes from gpodder.net'), \
451 instructions=_('Select the actions you want to carry out.'), \
452 episodes=changes, \
453 columns=columns, \
454 size_attribute=None, \
455 stock_ok_button=gtk.STOCK_APPLY, \
456 callback=execute_podcast_actions, \
457 _config=self.config)
459 # There are some actions that need the user's attention
460 if changes:
461 util.idle_add(ask)
462 return True
464 # We have no remaining actions - no selection happens
465 return False
467 def rewrite_urls_mygpo(self):
468 # Check if we have to rewrite URLs since the last add
469 rewritten_urls = self.mygpo_client.get_rewritten_urls()
470 changed = False
472 for rewritten_url in rewritten_urls:
473 if not rewritten_url.new_url:
474 continue
476 for channel in self.channels:
477 if channel.url == rewritten_url.old_url:
478 logger.info('Updating URL of %s to %s', channel,
479 rewritten_url.new_url)
480 channel.url = rewritten_url.new_url
481 channel.save()
482 changed = True
483 break
485 if changed:
486 util.idle_add(self.update_episode_list_model)
488 def on_send_full_subscriptions(self):
489 # Send the full subscription list to the gpodder.net client
490 # (this will overwrite the subscription list on the server)
491 indicator = ProgressIndicator(_('Uploading subscriptions'), \
492 _('Your subscriptions are being uploaded to the server.'), \
493 False, self.get_dialog_parent())
495 try:
496 self.mygpo_client.set_subscriptions([c.url for c in self.channels])
497 util.idle_add(self.show_message, _('List uploaded successfully.'))
498 except Exception, e:
499 def show_error(e):
500 message = str(e)
501 if not message:
502 message = e.__class__.__name__
503 self.show_message(message, \
504 _('Error while uploading'), \
505 important=True)
506 util.idle_add(show_error, e)
508 util.idle_add(indicator.on_finished)
510 def on_podcast_selected(self, treeview, path, column):
511 # for Maemo 5's UI
512 model = treeview.get_model()
513 channel = model.get_value(model.get_iter(path), \
514 PodcastListModel.C_CHANNEL)
515 self.active_channel = channel
516 self.update_episode_list_model()
517 self.episodes_window.channel = self.active_channel
518 self.episodes_window.show()
520 def on_button_subscribe_clicked(self, button):
521 self.on_itemImportChannels_activate(button)
523 def on_button_downloads_clicked(self, widget):
524 self.downloads_window.show()
526 def for_each_episode_set_task_status(self, episodes, status):
527 episode_urls = set(episode.url for episode in episodes)
528 model = self.treeDownloads.get_model()
529 selected_tasks = [(gtk.TreeRowReference(model, row.path), \
530 model.get_value(row.iter, \
531 DownloadStatusModel.C_TASK)) for row in model \
532 if model.get_value(row.iter, DownloadStatusModel.C_TASK).url \
533 in episode_urls]
534 self._for_each_task_set_status(selected_tasks, status)
536 def on_treeview_button_pressed(self, treeview, event):
537 if event.window != treeview.get_bin_window():
538 return False
540 role = getattr(treeview, TreeViewHelper.ROLE)
541 if role == TreeViewHelper.ROLE_PODCASTS:
542 return self.currently_updating
543 elif (role == TreeViewHelper.ROLE_EPISODES and event.button == 1):
544 # Toggle episode "new" status by clicking the icon (bug 1432)
545 result = treeview.get_path_at_pos(int(event.x), int(event.y))
546 if result is not None:
547 path, column, x, y = result
548 # The user clicked the icon if she clicked in the first column
549 # and the x position is in the area where the icon resides
550 if (x < self.EPISODE_LIST_ICON_WIDTH and
551 column == treeview.get_columns()[0]):
552 model = treeview.get_model()
553 cursor_episode = model.get_value(model.get_iter(path),
554 EpisodeListModel.C_EPISODE)
556 new_value = cursor_episode.is_new
557 selected_episodes = self.get_selected_episodes()
559 # Avoid changing anything if the clicked episode is not
560 # selected already - otherwise update all selected
561 if cursor_episode in selected_episodes:
562 for episode in selected_episodes:
563 episode.mark(is_played=new_value)
565 self.update_episode_list_icons(selected=True)
566 self.update_podcast_list_model(selected=True)
567 return True
569 return event.button == 3
571 def on_treeview_podcasts_button_released(self, treeview, event):
572 if event.window != treeview.get_bin_window():
573 return False
575 return self.treeview_channels_show_context_menu(treeview, event)
577 def on_treeview_episodes_button_released(self, treeview, event):
578 if event.window != treeview.get_bin_window():
579 return False
581 return self.treeview_available_show_context_menu(treeview, event)
583 def on_treeview_downloads_button_released(self, treeview, event):
584 if event.window != treeview.get_bin_window():
585 return False
587 return self.treeview_downloads_show_context_menu(treeview, event)
589 def on_entry_search_podcasts_changed(self, editable):
590 if self.hbox_search_podcasts.get_property('visible'):
591 def set_search_term(self, text):
592 self.podcast_list_model.set_search_term(text)
593 self._podcast_list_search_timeout = None
594 return False
596 if self._podcast_list_search_timeout is not None:
597 gobject.source_remove(self._podcast_list_search_timeout)
598 self._podcast_list_search_timeout = gobject.timeout_add(
599 self.config.ui.gtk.live_search_delay,
600 set_search_term, self, editable.get_chars(0, -1))
602 def on_entry_search_podcasts_key_press(self, editable, event):
603 if event.keyval == gtk.keysyms.Escape:
604 self.hide_podcast_search()
605 return True
607 def hide_podcast_search(self, *args):
608 if self._podcast_list_search_timeout is not None:
609 gobject.source_remove(self._podcast_list_search_timeout)
610 self._podcast_list_search_timeout = None
611 self.hbox_search_podcasts.hide()
612 self.entry_search_podcasts.set_text('')
613 self.podcast_list_model.set_search_term(None)
614 self.treeChannels.grab_focus()
616 def show_podcast_search(self, input_char):
617 self.hbox_search_podcasts.show()
618 self.entry_search_podcasts.insert_text(input_char, -1)
619 self.entry_search_podcasts.grab_focus()
620 self.entry_search_podcasts.set_position(-1)
622 def init_podcast_list_treeview(self):
623 # Set up podcast channel tree view widget
624 column = gtk.TreeViewColumn('')
625 iconcell = gtk.CellRendererPixbuf()
626 iconcell.set_property('width', 45)
627 column.pack_start(iconcell, False)
628 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
629 column.add_attribute(iconcell, 'visible', PodcastListModel.C_COVER_VISIBLE)
631 namecell = gtk.CellRendererText()
632 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
633 column.pack_start(namecell, True)
634 column.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
636 iconcell = gtk.CellRendererPixbuf()
637 iconcell.set_property('xalign', 1.0)
638 column.pack_start(iconcell, False)
639 column.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
640 column.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
642 self.treeChannels.append_column(column)
644 self.treeChannels.set_model(self.podcast_list_model.get_filtered_model())
646 # When no podcast is selected, clear the episode list model
647 selection = self.treeChannels.get_selection()
648 def select_function(selection, model, path, path_currently_selected):
649 url = model.get_value(model.get_iter(path), PodcastListModel.C_URL)
650 return (url != '-')
651 selection.set_select_function(select_function, full=True)
653 # Set up type-ahead find for the podcast list
654 def on_key_press(treeview, event):
655 if event.keyval == gtk.keysyms.Right:
656 self.treeAvailable.grab_focus()
657 elif event.keyval in (gtk.keysyms.Up, gtk.keysyms.Down):
658 # If section markers exist in the treeview, we want to
659 # "jump over" them when moving the cursor up and down
660 selection = self.treeChannels.get_selection()
661 model, it = selection.get_selected()
663 if event.keyval == gtk.keysyms.Up:
664 step = -1
665 else:
666 step = 1
668 path = model.get_path(it)
669 while True:
670 path = (path[0]+step,)
672 if path[0] < 0:
673 # Valid paths must have a value >= 0
674 return True
676 try:
677 it = model.get_iter(path)
678 except ValueError:
679 # Already at the end of the list
680 return True
682 if model.get_value(it, PodcastListModel.C_URL) != '-':
683 break
685 self.treeChannels.set_cursor(path)
686 elif event.keyval == gtk.keysyms.Escape:
687 self.hide_podcast_search()
688 elif event.state & gtk.gdk.CONTROL_MASK:
689 # Don't handle type-ahead when control is pressed (so shortcuts
690 # with the Ctrl key still work, e.g. Ctrl+A, ...)
691 return True
692 else:
693 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
694 if unicode_char_id == 0:
695 return False
696 input_char = unichr(unicode_char_id)
697 self.show_podcast_search(input_char)
698 return True
699 self.treeChannels.connect('key-press-event', on_key_press)
701 self.treeChannels.connect('popup-menu', self.treeview_channels_show_context_menu)
703 # Enable separators to the podcast list to separate special podcasts
704 # from others (this is used for the "all episodes" view)
705 self.treeChannels.set_row_separator_func(PodcastListModel.row_separator_func)
707 TreeViewHelper.set(self.treeChannels, TreeViewHelper.ROLE_PODCASTS)
709 def on_entry_search_episodes_changed(self, editable):
710 if self.hbox_search_episodes.get_property('visible'):
711 def set_search_term(self, text):
712 self.episode_list_model.set_search_term(text)
713 self._episode_list_search_timeout = None
714 return False
716 if self._episode_list_search_timeout is not None:
717 gobject.source_remove(self._episode_list_search_timeout)
718 self._episode_list_search_timeout = gobject.timeout_add(
719 self.config.ui.gtk.live_search_delay,
720 set_search_term, self, editable.get_chars(0, -1))
722 def on_entry_search_episodes_key_press(self, editable, event):
723 if event.keyval == gtk.keysyms.Escape:
724 self.hide_episode_search()
725 return True
727 def hide_episode_search(self, *args):
728 if self._episode_list_search_timeout is not None:
729 gobject.source_remove(self._episode_list_search_timeout)
730 self._episode_list_search_timeout = None
731 self.hbox_search_episodes.hide()
732 self.entry_search_episodes.set_text('')
733 self.episode_list_model.set_search_term(None)
734 self.treeAvailable.grab_focus()
736 def show_episode_search(self, input_char):
737 self.hbox_search_episodes.show()
738 self.entry_search_episodes.insert_text(input_char, -1)
739 self.entry_search_episodes.grab_focus()
740 self.entry_search_episodes.set_position(-1)
742 def set_episode_list_column(self, index, new_value):
743 mask = (1 << index)
744 if new_value:
745 self.config.episode_list_columns |= mask
746 else:
747 self.config.episode_list_columns &= ~mask
749 def update_episode_list_columns_visibility(self):
750 columns = TreeViewHelper.get_columns(self.treeAvailable)
751 for index, column in enumerate(columns):
752 visible = bool(self.config.episode_list_columns & (1 << index))
753 column.set_visible(visible)
754 self.treeAvailable.columns_autosize()
756 if self.episode_columns_menu is not None:
757 children = self.episode_columns_menu.get_children()
758 for index, child in enumerate(children):
759 active = bool(self.config.episode_list_columns & (1 << index))
760 child.set_active(active)
762 def on_episode_list_header_clicked(self, button, event):
763 if event.button != 3:
764 return False
766 if self.episode_columns_menu is not None:
767 self.episode_columns_menu.popup(None, None, None, event.button, \
768 event.time, None)
770 return False
772 def init_episode_list_treeview(self):
773 # For loading the list model
774 self.episode_list_model = EpisodeListModel(self.on_episode_list_filter_changed)
776 if self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNDELETED:
777 self.item_view_episodes_undeleted.set_active(True)
778 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_DOWNLOADED:
779 self.item_view_episodes_downloaded.set_active(True)
780 elif self.config.episode_list_view_mode == EpisodeListModel.VIEW_UNPLAYED:
781 self.item_view_episodes_unplayed.set_active(True)
782 else:
783 self.item_view_episodes_all.set_active(True)
785 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
787 self.treeAvailable.set_model(self.episode_list_model.get_filtered_model())
789 TreeViewHelper.set(self.treeAvailable, TreeViewHelper.ROLE_EPISODES)
791 iconcell = gtk.CellRendererPixbuf()
792 episode_list_icon_size = gtk.icon_size_register('episode-list',
793 EPISODE_LIST_ICON_SIZE, EPISODE_LIST_ICON_SIZE)
794 iconcell.set_property('stock-size', episode_list_icon_size)
795 iconcell.set_fixed_size(self.EPISODE_LIST_ICON_WIDTH, -1)
797 namecell = gtk.CellRendererText()
798 namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
799 namecolumn = gtk.TreeViewColumn(_('Episode'))
800 namecolumn.pack_start(iconcell, False)
801 namecolumn.add_attribute(iconcell, 'icon-name', EpisodeListModel.C_STATUS_ICON)
802 namecolumn.pack_start(namecell, True)
803 namecolumn.add_attribute(namecell, 'markup', EpisodeListModel.C_DESCRIPTION)
804 namecolumn.set_sort_column_id(EpisodeListModel.C_DESCRIPTION)
805 namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
806 namecolumn.set_resizable(True)
807 namecolumn.set_expand(True)
809 lockcell = gtk.CellRendererPixbuf()
810 lockcell.set_fixed_size(40, -1)
811 lockcell.set_property('stock-size', gtk.ICON_SIZE_MENU)
812 lockcell.set_property('icon-name', 'emblem-readonly')
813 namecolumn.pack_start(lockcell, False)
814 namecolumn.add_attribute(lockcell, 'visible', EpisodeListModel.C_LOCKED)
816 sizecell = gtk.CellRendererText()
817 sizecell.set_property('xalign', 1)
818 sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
819 sizecolumn.set_sort_column_id(EpisodeListModel.C_FILESIZE)
821 timecell = gtk.CellRendererText()
822 timecell.set_property('xalign', 1)
823 timecolumn = gtk.TreeViewColumn(_('Duration'), timecell, text=EpisodeListModel.C_TIME)
824 timecolumn.set_sort_column_id(EpisodeListModel.C_TOTAL_TIME)
826 releasecell = gtk.CellRendererText()
827 releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
828 releasecolumn.set_sort_column_id(EpisodeListModel.C_PUBLISHED)
830 namecolumn.set_reorderable(True)
831 self.treeAvailable.append_column(namecolumn)
833 for itemcolumn in (sizecolumn, timecolumn, releasecolumn):
834 itemcolumn.set_reorderable(True)
835 self.treeAvailable.append_column(itemcolumn)
836 TreeViewHelper.register_column(self.treeAvailable, itemcolumn)
838 # Add context menu to all tree view column headers
839 for column in self.treeAvailable.get_columns():
840 label = gtk.Label(column.get_title())
841 label.show_all()
842 column.set_widget(label)
844 w = column.get_widget()
845 while w is not None and not isinstance(w, gtk.Button):
846 w = w.get_parent()
848 w.connect('button-release-event', self.on_episode_list_header_clicked)
850 # Create a new menu for the visible episode list columns
851 for child in self.mainMenu.get_children():
852 if child.get_name() == 'menuView':
853 submenu = child.get_submenu()
854 item = gtk.MenuItem(_('Visible columns'))
855 submenu.append(gtk.SeparatorMenuItem())
856 submenu.append(item)
857 submenu.show_all()
859 self.episode_columns_menu = gtk.Menu()
860 item.set_submenu(self.episode_columns_menu)
861 break
863 # For each column that can be shown/hidden, add a menu item
864 columns = TreeViewHelper.get_columns(self.treeAvailable)
865 for index, column in enumerate(columns):
866 item = gtk.CheckMenuItem(column.get_title())
867 self.episode_columns_menu.append(item)
868 def on_item_toggled(item, index):
869 self.set_episode_list_column(index, item.get_active())
870 item.connect('toggled', on_item_toggled, index)
871 self.episode_columns_menu.show_all()
873 # Update the visibility of the columns and the check menu items
874 self.update_episode_list_columns_visibility()
876 # Set up type-ahead find for the episode list
877 def on_key_press(treeview, event):
878 if event.keyval == gtk.keysyms.Left:
879 self.treeChannels.grab_focus()
880 elif event.keyval == gtk.keysyms.Escape:
881 self.hide_episode_search()
882 elif event.state & gtk.gdk.CONTROL_MASK:
883 # Don't handle type-ahead when control is pressed (so shortcuts
884 # with the Ctrl key still work, e.g. Ctrl+A, ...)
885 return False
886 else:
887 unicode_char_id = gtk.gdk.keyval_to_unicode(event.keyval)
888 if unicode_char_id == 0:
889 return False
890 input_char = unichr(unicode_char_id)
891 self.show_episode_search(input_char)
892 return True
893 self.treeAvailable.connect('key-press-event', on_key_press)
895 self.treeAvailable.connect('popup-menu', self.treeview_available_show_context_menu)
897 self.treeAvailable.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, \
898 (('text/uri-list', 0, 0),), gtk.gdk.ACTION_COPY)
899 def drag_data_get(tree, context, selection_data, info, timestamp):
900 uris = ['file://'+e.local_filename(create=False) \
901 for e in self.get_selected_episodes() \
902 if e.was_downloaded(and_exists=True)]
903 uris.append('') # for the trailing '\r\n'
904 selection_data.set(selection_data.target, 8, '\r\n'.join(uris))
905 self.treeAvailable.connect('drag-data-get', drag_data_get)
907 selection = self.treeAvailable.get_selection()
908 selection.set_mode(gtk.SELECTION_MULTIPLE)
909 # Update the sensitivity of the toolbar buttons on the Desktop
910 selection.connect('changed', lambda s: self.play_or_download())
912 def init_download_list_treeview(self):
913 # enable multiple selection support
914 self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
915 self.treeDownloads.set_search_equal_func(TreeViewHelper.make_search_equal_func(DownloadStatusModel))
917 # columns and renderers for "download progress" tab
918 # First column: [ICON] Episodename
919 column = gtk.TreeViewColumn(_('Episode'))
921 cell = gtk.CellRendererPixbuf()
922 cell.set_property('stock-size', gtk.ICON_SIZE_BUTTON)
923 column.pack_start(cell, expand=False)
924 column.add_attribute(cell, 'icon-name', \
925 DownloadStatusModel.C_ICON_NAME)
927 cell = gtk.CellRendererText()
928 cell.set_property('ellipsize', pango.ELLIPSIZE_END)
929 column.pack_start(cell, expand=True)
930 column.add_attribute(cell, 'markup', DownloadStatusModel.C_NAME)
931 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
932 column.set_expand(True)
933 self.treeDownloads.append_column(column)
935 # Second column: Progress
936 cell = gtk.CellRendererProgress()
937 cell.set_property('yalign', .5)
938 cell.set_property('ypad', 6)
939 column = gtk.TreeViewColumn(_('Progress'), cell,
940 value=DownloadStatusModel.C_PROGRESS, \
941 text=DownloadStatusModel.C_PROGRESS_TEXT)
942 column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
943 column.set_expand(False)
944 self.treeDownloads.append_column(column)
945 column.set_property('min-width', 150)
946 column.set_property('max-width', 150)
948 self.treeDownloads.set_model(self.download_status_model)
949 TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
951 self.treeDownloads.connect('popup-menu', self.treeview_downloads_show_context_menu)
953 def on_treeview_expose_event(self, treeview, event):
954 if event.window == treeview.get_bin_window():
955 model = treeview.get_model()
956 if (model is not None and model.get_iter_first() is not None):
957 return False
959 role = getattr(treeview, TreeViewHelper.ROLE, None)
960 if role is None:
961 return False
963 ctx = event.window.cairo_create()
964 ctx.rectangle(event.area.x, event.area.y,
965 event.area.width, event.area.height)
966 ctx.clip()
968 x, y, width, height, depth = event.window.get_geometry()
969 progress = None
971 if role == TreeViewHelper.ROLE_EPISODES:
972 if self.currently_updating:
973 text = _('Loading episodes')
974 elif self.config.episode_list_view_mode != \
975 EpisodeListModel.VIEW_ALL:
976 text = _('No episodes in current view')
977 else:
978 text = _('No episodes available')
979 elif role == TreeViewHelper.ROLE_PODCASTS:
980 if self.config.episode_list_view_mode != \
981 EpisodeListModel.VIEW_ALL and \
982 self.config.podcast_list_hide_boring and \
983 len(self.channels) > 0:
984 text = _('No podcasts in this view')
985 else:
986 text = _('No subscriptions')
987 elif role == TreeViewHelper.ROLE_DOWNLOADS:
988 text = _('No active downloads')
989 else:
990 raise Exception('on_treeview_expose_event: unknown role')
992 font_desc = None
993 draw_text_box_centered(ctx, treeview, width, height, text, font_desc, progress)
995 return False
997 def enable_download_list_update(self):
998 if not self.download_list_update_enabled:
999 self.update_downloads_list()
1000 gobject.timeout_add(1500, self.update_downloads_list)
1001 self.download_list_update_enabled = True
1003 def cleanup_downloads(self):
1004 model = self.download_status_model
1006 all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
1007 changed_episode_urls = set()
1008 for row_reference, task in all_tasks:
1009 if task.status in (task.DONE, task.CANCELLED):
1010 model.remove(model.get_iter(row_reference.get_path()))
1011 try:
1012 # We don't "see" this task anymore - remove it;
1013 # this is needed, so update_episode_list_icons()
1014 # below gets the correct list of "seen" tasks
1015 self.download_tasks_seen.remove(task)
1016 except KeyError, key_error:
1017 pass
1018 changed_episode_urls.add(task.url)
1019 # Tell the task that it has been removed (so it can clean up)
1020 task.removed_from_list()
1022 # Tell the podcasts tab to update icons for our removed podcasts
1023 self.update_episode_list_icons(changed_episode_urls)
1025 # Tell the shownotes window that we have removed the episode
1026 if self.episode_shownotes_window is not None and \
1027 self.episode_shownotes_window.episode is not None and \
1028 self.episode_shownotes_window.episode.url in changed_episode_urls:
1029 self.episode_shownotes_window._download_status_changed(None)
1031 # Update the downloads list one more time
1032 self.update_downloads_list(can_call_cleanup=False)
1034 def on_tool_downloads_toggled(self, toolbutton):
1035 if toolbutton.get_active():
1036 self.wNotebook.set_current_page(1)
1037 else:
1038 self.wNotebook.set_current_page(0)
1040 def add_download_task_monitor(self, monitor):
1041 self.download_task_monitors.add(monitor)
1042 model = self.download_status_model
1043 if model is None:
1044 model = ()
1045 for row in model:
1046 task = row[self.download_status_model.C_TASK]
1047 monitor.task_updated(task)
1049 def remove_download_task_monitor(self, monitor):
1050 self.download_task_monitors.remove(monitor)
1052 def set_download_progress(self, progress):
1053 gpodder.user_extensions.on_download_progress(progress)
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 on_open_download_folder(self, item):
1499 assert self.active_channel is not None
1500 util.gui_open(self.active_channel.save_dir)
1502 def treeview_channels_show_context_menu(self, treeview, event=None):
1503 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1504 if not paths:
1505 return True
1507 # Check for valid channel id, if there's no id then
1508 # assume that it is a proxy channel or equivalent
1509 # and cannot be operated with right click
1510 if self.active_channel.id is None:
1511 return True
1513 if event is None or event.button == 3:
1514 menu = gtk.Menu()
1516 item = gtk.ImageMenuItem( _('Update podcast'))
1517 item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
1518 item.connect('activate', self.on_itemUpdateChannel_activate)
1519 menu.append(item)
1521 menu.append(gtk.SeparatorMenuItem())
1523 item = gtk.MenuItem(_('Open download folder'))
1524 item.connect('activate', self.on_open_download_folder)
1525 menu.append(item)
1527 menu.append(gtk.SeparatorMenuItem())
1529 item = gtk.MenuItem(_('Mark episodes as old'))
1530 item.connect('activate', self.on_mark_episodes_as_old)
1531 menu.append(item)
1533 item = gtk.CheckMenuItem(_('Archive'))
1534 item.set_active(self.active_channel.auto_archive_episodes)
1535 item.connect('activate', self.on_channel_toggle_lock_activate)
1536 menu.append(item)
1538 item = gtk.ImageMenuItem(_('Remove podcast'))
1539 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
1540 item.connect( 'activate', self.on_itemRemoveChannel_activate)
1541 menu.append( item)
1543 menu.append( gtk.SeparatorMenuItem())
1545 item = gtk.ImageMenuItem(_('Podcast settings'))
1546 item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1547 item.connect('activate', self.on_itemEditChannel_activate)
1548 menu.append(item)
1550 menu.show_all()
1551 # Disable tooltips while we are showing the menu, so
1552 # the tooltip will not appear over the menu
1553 self.treeview_allow_tooltips(self.treeChannels, False)
1554 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeChannels, True))
1556 if event is None:
1557 func = TreeViewHelper.make_popup_position_func(treeview)
1558 menu.popup(None, None, func, 3, 0)
1559 else:
1560 menu.popup(None, None, None, event.button, event.time)
1562 return True
1564 def cover_download_finished(self, channel, pixbuf):
1566 The Cover Downloader calls this when it has finished
1567 downloading (or registering, if already downloaded)
1568 a new channel cover, which is ready for displaying.
1570 util.idle_add(self.podcast_list_model.add_cover_by_channel,
1571 channel, pixbuf)
1573 def save_episodes_as_file(self, episodes):
1574 for episode in episodes:
1575 self.save_episode_as_file(episode)
1577 def save_episode_as_file(self, episode):
1578 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1579 if episode.was_downloaded(and_exists=True):
1580 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1581 copy_from = episode.local_filename(create=False)
1582 assert copy_from is not None
1583 copy_to = util.sanitize_filename(episode.sync_filename())
1584 (result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
1585 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1587 def copy_episodes_bluetooth(self, episodes):
1588 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1590 def convert_and_send_thread(episode):
1591 for episode in episodes:
1592 filename = episode.local_filename(create=False)
1593 assert filename is not None
1594 destfile = os.path.join(tempfile.gettempdir(), \
1595 util.sanitize_filename(episode.sync_filename()))
1596 (base, ext) = os.path.splitext(filename)
1597 if not destfile.endswith(ext):
1598 destfile += ext
1600 try:
1601 shutil.copyfile(filename, destfile)
1602 util.bluetooth_send_file(destfile)
1603 except:
1604 logger.error('Cannot copy "%s" to "%s".', filename, destfile)
1605 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1607 util.delete_file(destfile)
1609 threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy]).start()
1611 def treeview_available_show_context_menu(self, treeview, event=None):
1612 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1613 if not paths:
1614 if not hasattr(treeview, 'is_rubber_banding_active'):
1615 return True
1616 else:
1617 return not treeview.is_rubber_banding_active()
1619 if event is None or event.button == 3:
1620 episodes = self.get_selected_episodes()
1621 any_locked = any(e.archive for e in episodes)
1622 any_new = any(e.is_new for e in episodes)
1623 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1624 downloading = any(e.downloading for e in episodes)
1626 menu = gtk.Menu()
1628 (can_play, can_download, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1630 if open_instead_of_play:
1631 item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
1632 elif downloaded:
1633 item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
1634 else:
1635 if downloading:
1636 item = gtk.ImageMenuItem(_('Preview'))
1637 else:
1638 item = gtk.ImageMenuItem(_('Stream'))
1639 item.set_image(gtk.image_new_from_stock(gtk.STOCK_MEDIA_PLAY, gtk.ICON_SIZE_MENU))
1641 item.set_sensitive(can_play)
1642 item.connect('activate', self.on_playback_selected_episodes)
1643 menu.append(item)
1645 if not can_cancel:
1646 item = gtk.ImageMenuItem(_('Download'))
1647 item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
1648 item.set_sensitive(can_download)
1649 item.connect('activate', self.on_download_selected_episodes)
1650 menu.append(item)
1651 else:
1652 item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
1653 item.connect('activate', self.on_item_cancel_download_activate)
1654 menu.append(item)
1656 item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
1657 item.set_sensitive(can_delete)
1658 item.connect('activate', self.on_btnDownloadedDelete_clicked)
1659 menu.append(item)
1661 result = gpodder.user_extensions.on_episodes_context_menu(episodes)
1662 if result:
1663 menu.append(gtk.SeparatorMenuItem())
1664 for label, callback in result:
1665 item = gtk.MenuItem(label)
1666 item.connect('activate', lambda item, callback:
1667 callback(episodes), callback)
1668 menu.append(item)
1670 # Ok, this probably makes sense to only display for downloaded files
1671 if downloaded:
1672 menu.append(gtk.SeparatorMenuItem())
1673 share_item = gtk.MenuItem(_('Send to'))
1674 menu.append(share_item)
1675 share_menu = gtk.Menu()
1677 item = gtk.ImageMenuItem(_('Local folder'))
1678 item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU))
1679 item.connect('button-press-event', lambda w, ee: self.save_episodes_as_file(episodes))
1680 share_menu.append(item)
1681 if self.bluetooth_available:
1682 item = gtk.ImageMenuItem(_('Bluetooth device'))
1683 item.set_image(gtk.image_new_from_icon_name('bluetooth', gtk.ICON_SIZE_MENU))
1684 item.connect('button-press-event', lambda w, ee: self.copy_episodes_bluetooth(episodes))
1685 share_menu.append(item)
1687 share_item.set_submenu(share_menu)
1689 menu.append(gtk.SeparatorMenuItem())
1691 item = gtk.CheckMenuItem(_('New'))
1692 item.set_active(any_new)
1693 if any_new:
1694 item.connect('activate', lambda w: self.mark_selected_episodes_old())
1695 else:
1696 item.connect('activate', lambda w: self.mark_selected_episodes_new())
1697 menu.append(item)
1699 if downloaded:
1700 item = gtk.CheckMenuItem(_('Archive'))
1701 item.set_active(any_locked)
1702 item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, not any_locked))
1703 menu.append(item)
1705 menu.append(gtk.SeparatorMenuItem())
1706 # Single item, add episode information menu item
1707 item = gtk.ImageMenuItem(_('Episode details'))
1708 item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
1709 item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
1710 menu.append(item)
1712 menu.show_all()
1713 # Disable tooltips while we are showing the menu, so
1714 # the tooltip will not appear over the menu
1715 self.treeview_allow_tooltips(self.treeAvailable, False)
1716 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
1717 if event is None:
1718 func = TreeViewHelper.make_popup_position_func(treeview)
1719 menu.popup(None, None, func, 3, 0)
1720 else:
1721 menu.popup(None, None, None, event.button, event.time)
1723 return True
1725 def set_title(self, new_title):
1726 self.default_title = new_title
1727 self.gPodder.set_title(new_title)
1729 def update_episode_list_icons(self, urls=None, selected=False, all=False):
1731 Updates the status icons in the episode list.
1733 If urls is given, it should be a list of URLs
1734 of episodes that should be updated.
1736 If urls is None, set ONE OF selected, all to
1737 True (the former updates just the selected
1738 episodes and the latter updates all episodes).
1740 descriptions = self.config.episode_list_descriptions
1742 if urls is not None:
1743 # We have a list of URLs to walk through
1744 self.episode_list_model.update_by_urls(urls, descriptions)
1745 elif selected and not all:
1746 # We should update all selected episodes
1747 selection = self.treeAvailable.get_selection()
1748 model, paths = selection.get_selected_rows()
1749 for path in reversed(paths):
1750 iter = model.get_iter(path)
1751 self.episode_list_model.update_by_filter_iter(iter, descriptions)
1752 elif all and not selected:
1753 # We update all (even the filter-hidden) episodes
1754 self.episode_list_model.update_all(descriptions)
1755 else:
1756 # Wrong/invalid call - have to specify at least one parameter
1757 raise ValueError('Invalid call to update_episode_list_icons')
1759 def episode_list_status_changed(self, episodes):
1760 self.update_episode_list_icons(set(e.url for e in episodes))
1761 self.update_podcast_list_model(set(e.channel.url for e in episodes))
1762 self.db.commit()
1764 def clean_up_downloads(self, delete_partial=False):
1765 # Clean up temporary files left behind by old gPodder versions
1766 temporary_files = glob.glob('%s/*/.tmp-*' % gpodder.downloads)
1768 if delete_partial:
1769 temporary_files += glob.glob('%s/*/*.partial' % gpodder.downloads)
1771 for tempfile in temporary_files:
1772 util.delete_file(tempfile)
1775 def streaming_possible(self):
1776 # User has to have a media player set on the Desktop, or else we
1777 # would probably open the browser when giving a URL to xdg-open..
1778 return (self.config.player and self.config.player != 'default')
1780 def playback_episodes_for_real(self, episodes):
1781 groups = collections.defaultdict(list)
1782 for episode in episodes:
1783 file_type = episode.file_type()
1784 if file_type == 'video' and self.config.videoplayer and \
1785 self.config.videoplayer != 'default':
1786 player = self.config.videoplayer
1787 elif file_type == 'audio' and self.config.player and \
1788 self.config.player != 'default':
1789 player = self.config.player
1790 else:
1791 player = 'default'
1793 # Mark episode as played in the database
1794 episode.playback_mark()
1795 self.mygpo_client.on_playback([episode])
1797 fmt_id = self.config.youtube_preferred_fmt_id
1798 allow_partial = (player != 'default')
1799 filename = episode.get_playback_url(fmt_id, allow_partial)
1801 # Determine the playback resume position - if the file
1802 # was played 100%, we simply start from the beginning
1803 resume_position = episode.current_position
1804 if resume_position == episode.total_time:
1805 resume_position = 0
1807 # If Panucci is configured, use D-Bus on Maemo to call it
1808 if player == 'panucci':
1809 try:
1810 PANUCCI_NAME = 'org.panucci.panucciInterface'
1811 PANUCCI_PATH = '/panucciInterface'
1812 PANUCCI_INTF = 'org.panucci.panucciInterface'
1813 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
1814 i = dbus.Interface(o, PANUCCI_INTF)
1816 def on_reply(*args):
1817 pass
1819 def error_handler(filename, err):
1820 logger.error('Exception in D-Bus call: %s', str(err))
1822 # Fallback: use the command line client
1823 for command in util.format_desktop_command('panucci', \
1824 [filename]):
1825 logger.info('Executing: %s', repr(command))
1826 subprocess.Popen(command)
1828 on_error = lambda err: error_handler(filename, err)
1830 # This method only exists in Panucci > 0.9 ('new Panucci')
1831 i.playback_from(filename, resume_position, \
1832 reply_handler=on_reply, error_handler=on_error)
1834 continue # This file was handled by the D-Bus call
1835 except Exception, e:
1836 logger.error('Calling Panucci using D-Bus', exc_info=True)
1838 groups[player].append(filename)
1840 # Open episodes with system default player
1841 if 'default' in groups:
1842 # Special-casing for a single episode when the object is a PDF
1843 # file - this is needed on Maemo 5, so we only use gui_open()
1844 # for single PDF files, but still use the built-in media player
1845 # with an M3U file for single audio/video files. (The Maemo 5
1846 # media player behaves differently when opening a single-file
1847 # M3U playlist compared to opening the single file directly.)
1848 if len(groups['default']) == 1:
1849 fn = groups['default'][0]
1850 # The list of extensions is taken from gui_open in util.py
1851 # where all special-cases of Maemo apps are listed
1852 for extension in ('.pdf', '.jpg', '.jpeg', '.png'):
1853 if fn.lower().endswith(extension):
1854 util.gui_open(fn)
1855 groups['default'] = []
1856 break
1858 for filename in groups['default']:
1859 logger.debug('Opening with system default: %s', filename)
1860 util.gui_open(filename)
1861 del groups['default']
1863 # For each type now, go and create play commands
1864 for group in groups:
1865 for command in util.format_desktop_command(group, groups[group], resume_position):
1866 logger.debug('Executing: %s', repr(command))
1867 subprocess.Popen(command)
1869 # Persist episode status changes to the database
1870 self.db.commit()
1872 # Flush updated episode status
1873 self.mygpo_client.flush()
1875 def playback_episodes(self, episodes):
1876 # We need to create a list, because we run through it more than once
1877 episodes = list(Model.sort_episodes_by_pubdate(e for e in episodes if \
1878 e.was_downloaded(and_exists=True) or self.streaming_possible()))
1880 try:
1881 self.playback_episodes_for_real(episodes)
1882 except Exception, e:
1883 logger.error('Error in playback!', exc_info=True)
1884 self.show_message(_('Please check your media player settings in the preferences dialog.'), \
1885 _('Error opening player'), widget=self.toolPreferences)
1887 channel_urls = set()
1888 episode_urls = set()
1889 for episode in episodes:
1890 channel_urls.add(episode.channel.url)
1891 episode_urls.add(episode.url)
1892 self.update_episode_list_icons(episode_urls)
1893 self.update_podcast_list_model(channel_urls)
1895 def play_or_download(self):
1896 if self.wNotebook.get_current_page() > 0:
1897 self.toolCancel.set_sensitive(True)
1898 return
1900 if self.currently_updating:
1901 return (False, False, False, False, False, False)
1903 ( can_play, can_download, can_cancel, can_delete ) = (False,)*4
1904 ( is_played, is_locked ) = (False,)*2
1906 open_instead_of_play = False
1908 selection = self.treeAvailable.get_selection()
1909 if selection.count_selected_rows() > 0:
1910 (model, paths) = selection.get_selected_rows()
1912 for path in paths:
1913 try:
1914 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
1915 except TypeError, te:
1916 logger.error('Invalid episode at path %s', str(path))
1917 continue
1919 if episode.file_type() not in ('audio', 'video'):
1920 open_instead_of_play = True
1922 if episode.was_downloaded():
1923 can_play = episode.was_downloaded(and_exists=True)
1924 is_played = not episode.is_new
1925 is_locked = episode.archive
1926 if not can_play:
1927 can_download = True
1928 else:
1929 if episode.downloading:
1930 can_cancel = True
1931 else:
1932 can_download = True
1934 can_download = can_download and not can_cancel
1935 can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
1936 can_delete = not can_cancel
1938 if open_instead_of_play:
1939 self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
1940 else:
1941 self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
1942 self.toolPlay.set_sensitive( can_play)
1943 self.toolDownload.set_sensitive( can_download)
1944 self.toolCancel.set_sensitive( can_cancel)
1946 self.item_cancel_download.set_sensitive(can_cancel)
1947 self.itemDownloadSelected.set_sensitive(can_download)
1948 self.itemOpenSelected.set_sensitive(can_play)
1949 self.itemPlaySelected.set_sensitive(can_play)
1950 self.itemDeleteSelected.set_sensitive(can_delete)
1951 self.item_toggle_played.set_sensitive(can_play)
1952 self.item_toggle_lock.set_sensitive(can_play)
1953 self.itemOpenSelected.set_visible(open_instead_of_play)
1954 self.itemPlaySelected.set_visible(not open_instead_of_play)
1956 return (can_play, can_download, can_cancel, can_delete, open_instead_of_play)
1958 def on_cbMaxDownloads_toggled(self, widget, *args):
1959 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
1961 def on_cbLimitDownloads_toggled(self, widget, *args):
1962 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
1964 def episode_new_status_changed(self, urls):
1965 self.update_podcast_list_model()
1966 self.update_episode_list_icons(urls)
1968 def update_podcast_list_model(self, urls=None, selected=False, select_url=None,
1969 sections_changed=False):
1970 """Update the podcast list treeview model
1972 If urls is given, it should list the URLs of each
1973 podcast that has to be updated in the list.
1975 If selected is True, only update the model contents
1976 for the currently-selected podcast - nothing more.
1978 The caller can optionally specify "select_url",
1979 which is the URL of the podcast that is to be
1980 selected in the list after the update is complete.
1981 This only works if the podcast list has to be
1982 reloaded; i.e. something has been added or removed
1983 since the last update of the podcast list).
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:
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 quit_cb(self, macapp):
2453 """Called when OSX wants to quit the app (Cmd-Q or gPodder > Quit)
2455 # Event can't really be cancelled - don't even try
2456 self.close_gpodder()
2457 return False
2459 def close_gpodder(self):
2460 """ clean everything and exit properly
2462 self.gPodder.hide()
2464 # Notify all tasks to to carry out any clean-up actions
2465 self.download_status_model.tell_all_tasks_to_quit()
2467 while gtk.events_pending():
2468 gtk.main_iteration(False)
2470 self.core.shutdown()
2472 self.quit()
2473 if macapp is None:
2474 sys.exit(0)
2476 def get_expired_episodes(self):
2477 # XXX: Move out of gtkui and into a generic module (gpodder.model)?
2479 # Only expire episodes if the age in days is positive
2480 if self.config.episode_old_age < 1:
2481 return
2483 for channel in self.channels:
2484 for episode in channel.get_downloaded_episodes():
2485 # Never consider archived episodes as old
2486 if episode.archive:
2487 continue
2489 # Never consider fresh episodes as old
2490 if episode.age_in_days() < self.config.episode_old_age:
2491 continue
2493 # Do not delete played episodes (except if configured)
2494 if not episode.is_new:
2495 if not self.config.auto_remove_played_episodes:
2496 continue
2498 # Do not delete unfinished episodes (except if configured)
2499 if not episode.is_finished():
2500 if not self.config.auto_remove_unfinished_episodes:
2501 continue
2503 # Do not delete unplayed episodes (except if configured)
2504 if episode.is_new:
2505 if not self.config.auto_remove_unplayed_episodes:
2506 continue
2508 yield episode
2510 def delete_episode_list(self, episodes, confirm=True, skip_locked=True):
2511 if not episodes:
2512 return False
2514 if skip_locked:
2515 episodes = [e for e in episodes if not e.archive]
2517 if not episodes:
2518 title = _('Episodes are locked')
2519 message = _('The selected episodes are locked. Please unlock the episodes that you want to delete before trying to delete them.')
2520 self.notification(message, title, widget=self.treeAvailable)
2521 return False
2523 count = len(episodes)
2524 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?', count) % {'count':count}
2525 message = _('Deleting episodes removes downloaded files.')
2527 if confirm and not self.show_confirmation(message, title):
2528 return False
2530 progress = ProgressIndicator(_('Deleting episodes'), \
2531 _('Please wait while episodes are deleted'), \
2532 parent=self.get_dialog_parent())
2534 def finish_deletion(episode_urls, channel_urls):
2535 progress.on_finished()
2537 # Episodes have been deleted - persist the database
2538 self.db.commit()
2540 self.update_episode_list_icons(episode_urls)
2541 self.update_podcast_list_model(channel_urls)
2542 self.play_or_download()
2544 def thread_proc():
2545 episode_urls = set()
2546 channel_urls = set()
2548 episodes_status_update = []
2549 for idx, episode in enumerate(episodes):
2550 progress.on_progress(float(idx)/float(len(episodes)))
2551 if not episode.archive or not skip_locked:
2552 progress.on_message(episode.title)
2553 episode.delete_from_disk()
2554 episode_urls.add(episode.url)
2555 channel_urls.add(episode.channel.url)
2556 episodes_status_update.append(episode)
2558 # Tell the shownotes window that we have removed the episode
2559 if self.episode_shownotes_window is not None and \
2560 self.episode_shownotes_window.episode is not None and \
2561 self.episode_shownotes_window.episode.url == episode.url:
2562 util.idle_add(self.episode_shownotes_window._download_status_changed, None)
2564 # Notify the web service about the status update + upload
2565 self.mygpo_client.on_delete(episodes_status_update)
2566 self.mygpo_client.flush()
2568 util.idle_add(finish_deletion, episode_urls, channel_urls)
2570 threading.Thread(target=thread_proc).start()
2572 return True
2574 def on_itemRemoveOldEpisodes_activate(self, widget):
2575 self.show_delete_episodes_window()
2577 def show_delete_episodes_window(self, channel=None):
2578 """Offer deletion of episodes
2580 If channel is None, offer deletion of all episodes.
2581 Otherwise only offer deletion of episodes in the channel.
2583 columns = (
2584 ('markup_delete_episodes', None, None, _('Episode')),
2587 msg_older_than = N_('Select older than %(count)d day', 'Select older than %(count)d days', self.config.episode_old_age)
2588 selection_buttons = {
2589 _('Select played'): lambda episode: not episode.is_new,
2590 _('Select finished'): lambda episode: episode.is_finished(),
2591 msg_older_than % {'count':self.config.episode_old_age}: lambda episode: episode.age_in_days() > self.config.episode_old_age,
2594 instructions = _('Select the episodes you want to delete:')
2596 if channel is None:
2597 channels = self.channels
2598 else:
2599 channels = [channel]
2601 episodes = []
2602 for channel in channels:
2603 for episode in channel.get_downloaded_episodes():
2604 # Disallow deletion of locked episodes that still exist
2605 if not episode.archive or not episode.file_exists():
2606 episodes.append(episode)
2608 selected = [not e.is_new or not e.file_exists() for e in episodes]
2610 gPodderEpisodeSelector(self.gPodder, title = _('Delete episodes'), instructions = instructions, \
2611 episodes = episodes, selected = selected, columns = columns, \
2612 stock_ok_button = gtk.STOCK_DELETE, callback = self.delete_episode_list, \
2613 selection_buttons = selection_buttons, _config=self.config, \
2614 show_episode_shownotes=self.show_episode_shownotes)
2616 def on_selected_episodes_status_changed(self):
2617 # The order of the updates here is important! When "All episodes" is
2618 # selected, the update of the podcast list model depends on the episode
2619 # list selection to determine which podcasts are affected. Updating
2620 # the episode list could remove the selection if a filter is active.
2621 self.update_podcast_list_model(selected=True)
2622 self.update_episode_list_icons(selected=True)
2623 self.db.commit()
2625 def mark_selected_episodes_new(self):
2626 for episode in self.get_selected_episodes():
2627 episode.mark_new()
2628 self.on_selected_episodes_status_changed()
2630 def mark_selected_episodes_old(self):
2631 for episode in self.get_selected_episodes():
2632 episode.mark_old()
2633 self.on_selected_episodes_status_changed()
2635 def on_item_toggle_played_activate( self, widget, toggle = True, new_value = False):
2636 for episode in self.get_selected_episodes():
2637 if toggle:
2638 episode.mark(is_played=episode.is_new)
2639 else:
2640 episode.mark(is_played=new_value)
2641 self.on_selected_episodes_status_changed()
2643 def on_item_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2644 for episode in self.get_selected_episodes():
2645 if toggle:
2646 episode.mark(is_locked=not episode.archive)
2647 else:
2648 episode.mark(is_locked=new_value)
2649 self.on_selected_episodes_status_changed()
2651 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
2652 if self.active_channel is None:
2653 return
2655 self.active_channel.auto_archive_episodes = not self.active_channel.auto_archive_episodes
2656 self.active_channel.save()
2658 for episode in self.active_channel.get_all_episodes():
2659 episode.mark(is_locked=self.active_channel.auto_archive_episodes)
2661 self.update_podcast_list_model(selected=True)
2662 self.update_episode_list_icons(all=True)
2664 def on_itemUpdateChannel_activate(self, widget=None):
2665 if self.active_channel is None:
2666 title = _('No podcast selected')
2667 message = _('Please select a podcast in the podcasts list to update.')
2668 self.show_message( message, title, widget=self.treeChannels)
2669 return
2671 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
2672 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
2673 self.update_feed_cache()
2674 else:
2675 self.update_feed_cache(channels=[self.active_channel])
2677 def on_itemUpdate_activate(self, widget=None):
2678 # Check if we have outstanding subscribe/unsubscribe actions
2679 self.on_add_remove_podcasts_mygpo()
2681 if self.channels:
2682 self.update_feed_cache()
2683 else:
2684 def show_welcome_window():
2685 def on_show_example_podcasts(widget):
2686 welcome_window.main_window.response(gtk.RESPONSE_CANCEL)
2687 self.on_itemImportChannels_activate(None)
2689 def on_add_podcast_via_url(widget):
2690 welcome_window.main_window.response(gtk.RESPONSE_CANCEL)
2691 self.on_itemAddChannel_activate(None)
2693 def on_setup_my_gpodder(widget):
2694 welcome_window.main_window.response(gtk.RESPONSE_CANCEL)
2695 self.on_download_subscriptions_from_mygpo(None)
2697 welcome_window = gPodderWelcome(self.main_window,
2698 center_on_widget=self.main_window,
2699 on_show_example_podcasts=on_show_example_podcasts,
2700 on_add_podcast_via_url=on_add_podcast_via_url,
2701 on_setup_my_gpodder=on_setup_my_gpodder)
2703 welcome_window.main_window.run()
2704 welcome_window.main_window.destroy()
2706 util.idle_add(show_welcome_window)
2708 def download_episode_list_paused(self, episodes):
2709 self.download_episode_list(episodes, True)
2711 def download_episode_list(self, episodes, add_paused=False, force_start=False):
2712 enable_update = False
2714 for episode in episodes:
2715 logger.debug('Downloading episode: %s', episode.title)
2716 if not episode.was_downloaded(and_exists=True):
2717 task_exists = False
2718 for task in self.download_tasks_seen:
2719 if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
2720 self.download_queue_manager.add_task(task, force_start)
2721 enable_update = True
2722 task_exists = True
2723 continue
2725 if task_exists:
2726 continue
2728 try:
2729 task = download.DownloadTask(episode, self.config)
2730 except Exception, e:
2731 d = {'episode': episode.title, 'message': str(e)}
2732 message = _('Download error while downloading %(episode)s: %(message)s')
2733 self.show_message(message % d, _('Download error'), important=True)
2734 logger.error('While downloading %s', episode.title, exc_info=True)
2735 continue
2737 if add_paused:
2738 task.status = task.PAUSED
2739 else:
2740 self.mygpo_client.on_download([task.episode])
2741 self.download_queue_manager.add_task(task, force_start)
2743 self.download_status_model.register_task(task)
2744 enable_update = True
2746 if enable_update:
2747 self.enable_download_list_update()
2749 # Flush updated episode status
2750 self.mygpo_client.flush()
2752 def cancel_task_list(self, tasks):
2753 if not tasks:
2754 return
2756 for task in tasks:
2757 if task.status in (task.QUEUED, task.DOWNLOADING):
2758 task.status = task.CANCELLED
2759 elif task.status == task.PAUSED:
2760 task.status = task.CANCELLED
2761 # Call run, so the partial file gets deleted
2762 task.run()
2764 self.update_episode_list_icons([task.url for task in tasks])
2765 self.play_or_download()
2767 # Update the tab title and downloads list
2768 self.update_downloads_list()
2770 def new_episodes_show(self, episodes, notification=False, selected=None):
2771 columns = (
2772 ('markup_new_episodes', None, None, _('Episode')),
2775 instructions = _('Select the episodes you want to download:')
2777 if self.new_episodes_window is not None:
2778 self.new_episodes_window.main_window.destroy()
2779 self.new_episodes_window = None
2781 def download_episodes_callback(episodes):
2782 self.new_episodes_window = None
2783 self.download_episode_list(episodes)
2785 if selected is None:
2786 # Select all by default
2787 selected = [True]*len(episodes)
2789 self.new_episodes_window = gPodderEpisodeSelector(self.gPodder, \
2790 title=_('New episodes available'), \
2791 instructions=instructions, \
2792 episodes=episodes, \
2793 columns=columns, \
2794 selected=selected, \
2795 stock_ok_button = 'gpodder-download', \
2796 callback=download_episodes_callback, \
2797 remove_callback=lambda e: e.mark_old(), \
2798 remove_action=_('Mark as old'), \
2799 remove_finished=self.episode_new_status_changed, \
2800 _config=self.config, \
2801 show_notification=False, \
2802 show_episode_shownotes=self.show_episode_shownotes)
2804 def on_itemDownloadAllNew_activate(self, widget, *args):
2805 if not self.offer_new_episodes():
2806 self.show_message(_('Please check for new episodes later.'), \
2807 _('No new episodes available'), widget=self.btnUpdateFeeds)
2809 def get_new_episodes(self, channels=None):
2810 return [e for c in channels or self.channels for e in
2811 filter(lambda e: e.check_is_new(), c.get_all_episodes())]
2813 def commit_changes_to_database(self):
2814 """This will be called after the sync process is finished"""
2815 self.db.commit()
2817 def on_itemShowAllEpisodes_activate(self, widget):
2818 self.config.podcast_list_view_all = widget.get_active()
2820 def on_itemShowToolbar_activate(self, widget):
2821 self.config.show_toolbar = self.itemShowToolbar.get_active()
2823 def on_itemShowDescription_activate(self, widget):
2824 self.config.episode_list_descriptions = self.itemShowDescription.get_active()
2826 def on_item_view_hide_boring_podcasts_toggled(self, toggleaction):
2827 self.config.podcast_list_hide_boring = toggleaction.get_active()
2828 if self.config.podcast_list_hide_boring:
2829 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
2830 else:
2831 self.podcast_list_model.set_view_mode(-1)
2833 def on_item_view_episodes_changed(self, radioaction, current):
2834 if current == self.item_view_episodes_all:
2835 self.config.episode_list_view_mode = EpisodeListModel.VIEW_ALL
2836 elif current == self.item_view_episodes_undeleted:
2837 self.config.episode_list_view_mode = EpisodeListModel.VIEW_UNDELETED
2838 elif current == self.item_view_episodes_downloaded:
2839 self.config.episode_list_view_mode = EpisodeListModel.VIEW_DOWNLOADED
2840 elif current == self.item_view_episodes_unplayed:
2841 self.config.episode_list_view_mode = EpisodeListModel.VIEW_UNPLAYED
2843 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
2845 if self.config.podcast_list_hide_boring:
2846 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
2848 def on_itemPreferences_activate(self, widget, *args):
2849 gPodderPreferences(self.main_window, \
2850 _config=self.config, \
2851 user_apps_reader=self.user_apps_reader, \
2852 parent_window=self.main_window, \
2853 mygpo_client=self.mygpo_client, \
2854 on_send_full_subscriptions=self.on_send_full_subscriptions, \
2855 on_itemExportChannels_activate=self.on_itemExportChannels_activate)
2857 def on_goto_mygpo(self, widget):
2858 self.mygpo_client.open_website()
2860 def on_download_subscriptions_from_mygpo(self, action=None):
2861 title = _('Login to gpodder.net')
2862 message = _('Please login to download your subscriptions.')
2864 def on_register_button_clicked():
2865 util.open_website('http://gpodder.net/register/')
2867 success, (username, password) = self.show_login_dialog(title, message,
2868 self.config.mygpo.username, self.config.mygpo.password,
2869 register_callback=on_register_button_clicked)
2870 if not success:
2871 return
2873 self.config.mygpo.username = username
2874 self.config.mygpo.password = password
2876 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
2877 custom_title=_('Subscriptions on gpodder.net'), \
2878 add_urls_callback=self.add_podcast_list, \
2879 hide_url_entry=True)
2881 # TODO: Refactor this into "gpodder.my" or mygpoclient, so that
2882 # we do not have to hardcode the URL here
2883 OPML_URL = 'http://gpodder.net/subscriptions/%s.opml' % self.config.mygpo.username
2884 url = util.url_add_authentication(OPML_URL, \
2885 self.config.mygpo.username, \
2886 self.config.mygpo.password)
2887 dir.download_opml_file(url)
2889 def on_itemAddChannel_activate(self, widget=None):
2890 gPodderAddPodcast(self.gPodder, \
2891 add_urls_callback=self.add_podcast_list)
2893 def on_itemEditChannel_activate(self, widget, *args):
2894 if self.active_channel is None:
2895 title = _('No podcast selected')
2896 message = _('Please select a podcast in the podcasts list to edit.')
2897 self.show_message( message, title, widget=self.treeChannels)
2898 return
2900 gPodderChannel(self.main_window,
2901 channel=self.active_channel,
2902 update_podcast_list_model=self.update_podcast_list_model,
2903 cover_downloader=self.cover_downloader,
2904 sections=set(c.section for c in self.channels),
2905 clear_cover_cache=self.podcast_list_model.clear_cover_cache)
2907 def on_itemMassUnsubscribe_activate(self, item=None):
2908 columns = (
2909 ('title', None, None, _('Podcast')),
2912 # We're abusing the Episode Selector for selecting Podcasts here,
2913 # but it works and looks good, so why not? -- thp
2914 gPodderEpisodeSelector(self.main_window, \
2915 title=_('Remove podcasts'), \
2916 instructions=_('Select the podcast you want to remove.'), \
2917 episodes=self.channels, \
2918 columns=columns, \
2919 size_attribute=None, \
2920 stock_ok_button=_('Remove'), \
2921 callback=self.remove_podcast_list, \
2922 _config=self.config)
2924 def remove_podcast_list(self, channels, confirm=True):
2925 if not channels:
2926 return
2928 if len(channels) == 1:
2929 title = _('Removing podcast')
2930 info = _('Please wait while the podcast is removed')
2931 message = _('Do you really want to remove this podcast and its episodes?')
2932 else:
2933 title = _('Removing podcasts')
2934 info = _('Please wait while the podcasts are removed')
2935 message = _('Do you really want to remove the selected podcasts and their episodes?')
2937 if confirm and not self.show_confirmation(message, title):
2938 return
2940 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
2942 def finish_deletion(select_url):
2943 # Upload subscription list changes to the web service
2944 self.mygpo_client.on_unsubscribe([c.url for c in channels])
2946 # Re-load the channels and select the desired new channel
2947 self.update_podcast_list_model(select_url=select_url)
2948 progress.on_finished()
2950 def thread_proc():
2951 select_url = None
2953 for idx, channel in enumerate(channels):
2954 # Update the UI for correct status messages
2955 progress.on_progress(float(idx)/float(len(channels)))
2956 progress.on_message(channel.title)
2958 # Delete downloaded episodes
2959 channel.remove_downloaded()
2961 # cancel any active downloads from this channel
2962 for episode in channel.get_all_episodes():
2963 if episode.downloading:
2964 episode.download_task.cancel()
2966 if len(channels) == 1:
2967 # get the URL of the podcast we want to select next
2968 if channel in self.channels:
2969 position = self.channels.index(channel)
2970 else:
2971 position = -1
2973 if position == len(self.channels)-1:
2974 # this is the last podcast, so select the URL
2975 # of the item before this one (i.e. the "new last")
2976 select_url = self.channels[position-1].url
2977 else:
2978 # there is a podcast after the deleted one, so
2979 # we simply select the one that comes after it
2980 select_url = self.channels[position+1].url
2982 # Remove the channel and clean the database entries
2983 channel.delete()
2985 # Clean up downloads and download directories
2986 self.clean_up_downloads()
2988 # The remaining stuff is to be done in the GTK main thread
2989 util.idle_add(finish_deletion, select_url)
2991 threading.Thread(target=thread_proc).start()
2993 def on_itemRemoveChannel_activate(self, widget, *args):
2994 if self.active_channel is None:
2995 title = _('No podcast selected')
2996 message = _('Please select a podcast in the podcasts list to remove.')
2997 self.show_message( message, title, widget=self.treeChannels)
2998 return
3000 self.remove_podcast_list([self.active_channel])
3002 def get_opml_filter(self):
3003 filter = gtk.FileFilter()
3004 filter.add_pattern('*.opml')
3005 filter.add_pattern('*.xml')
3006 filter.set_name(_('OPML files')+' (*.opml, *.xml)')
3007 return filter
3009 def on_item_import_from_file_activate(self, widget, filename=None):
3010 if filename is None:
3011 dlg = gtk.FileChooserDialog(title=_('Import from OPML'),
3012 parent=self.main_window,
3013 action=gtk.FILE_CHOOSER_ACTION_OPEN)
3014 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3015 dlg.add_button(gtk.STOCK_OPEN, gtk.RESPONSE_OK)
3016 dlg.set_filter(self.get_opml_filter())
3017 response = dlg.run()
3018 filename = None
3019 if response == gtk.RESPONSE_OK:
3020 filename = dlg.get_filename()
3021 dlg.destroy()
3023 if filename is not None:
3024 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config, \
3025 custom_title=_('Import podcasts from OPML file'), \
3026 add_urls_callback=self.add_podcast_list, \
3027 hide_url_entry=True)
3028 dir.download_opml_file(filename)
3030 def on_itemExportChannels_activate(self, widget, *args):
3031 if not self.channels:
3032 title = _('Nothing to export')
3033 message = _('Your list of podcast subscriptions is empty. Please subscribe to some podcasts first before trying to export your subscription list.')
3034 self.show_message(message, title, widget=self.treeChannels)
3035 return
3037 dlg = gtk.FileChooserDialog(title=_('Export to OPML'), parent=self.gPodder, action=gtk.FILE_CHOOSER_ACTION_SAVE)
3038 dlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
3039 dlg.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_OK)
3040 dlg.set_filter(self.get_opml_filter())
3041 response = dlg.run()
3042 if response == gtk.RESPONSE_OK:
3043 filename = dlg.get_filename()
3044 dlg.destroy()
3045 exporter = opml.Exporter( filename)
3046 if filename is not None and exporter.write(self.channels):
3047 count = len(self.channels)
3048 title = N_('%(count)d subscription exported', '%(count)d subscriptions exported', count) % {'count':count}
3049 self.show_message(_('Your podcast list has been successfully exported.'), title, widget=self.treeChannels)
3050 else:
3051 self.show_message( _('Could not export OPML to file. Please check your permissions.'), _('OPML export failed'), important=True)
3052 else:
3053 dlg.destroy()
3055 def on_itemImportChannels_activate(self, widget, *args):
3056 dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \
3057 add_urls_callback=self.add_podcast_list)
3058 util.idle_add(dir.download_opml_file, my.EXAMPLES_OPML)
3060 def on_homepage_activate(self, widget, *args):
3061 util.open_website(gpodder.__url__)
3063 def on_wiki_activate(self, widget, *args):
3064 util.open_website('http://gpodder.org/wiki/User_Manual')
3066 def on_check_for_updates_activate(self, widget):
3067 self.check_for_updates(silent=False)
3069 def check_for_updates(self, silent):
3070 """Check for updates and (optionally) show a message
3072 If silent=False, a message will be shown even if no updates are
3073 available (set silent=False when the check is manually triggered).
3075 up_to_date, version, released, days = util.get_update_info()
3077 if up_to_date and not silent:
3078 title = _('No updates available')
3079 message = _('You have the latest version of gPodder.')
3080 self.show_message(message, title, important=True)
3082 if not up_to_date:
3083 title = _('New version available')
3084 message = '\n'.join([
3085 _('Installed version: %s') % gpodder.__version__,
3086 _('Newest version: %s') % version,
3087 _('Release date: %s') % released,
3089 _('Download the latest version from gpodder.org?'),
3092 if self.show_confirmation(message, title):
3093 util.open_website('http://gpodder.org/downloads')
3095 def on_bug_tracker_activate(self, widget, *args):
3096 util.open_website('https://bugs.gpodder.org/enter_bug.cgi?product=gPodder&component=Application&version=%s' % gpodder.__version__)
3098 def on_item_support_activate(self, widget):
3099 util.open_website('http://gpodder.org/donate')
3101 def on_itemAbout_activate(self, widget, *args):
3102 dlg = gtk.Dialog(_('About gPodder'), self.main_window, \
3103 gtk.DIALOG_MODAL)
3104 dlg.add_button(gtk.STOCK_CLOSE, gtk.RESPONSE_OK).show()
3105 dlg.set_resizable(False)
3107 bg = gtk.HBox(spacing=10)
3108 bg.pack_start(gtk.image_new_from_file(gpodder.icon_file), expand=False)
3109 vb = gtk.VBox()
3110 vb.set_spacing(6)
3111 label = gtk.Label()
3112 label.set_alignment(0, 1)
3113 label.set_markup('<b><big>gPodder</big> %s</b>' % gpodder.__version__)
3114 vb.pack_start(label)
3115 label = gtk.Label()
3116 label.set_alignment(0, 0)
3117 label.set_markup('<small><a href="%s">%s</a></small>' % \
3118 ((cgi.escape(gpodder.__url__),)*2))
3119 vb.pack_start(label)
3120 bg.pack_start(vb)
3122 out = gtk.VBox(spacing=10)
3123 out.set_border_width(12)
3124 out.pack_start(bg, expand=False)
3125 out.pack_start(gtk.HSeparator())
3126 out.pack_start(gtk.Label(gpodder.__copyright__))
3128 button_box = gtk.HButtonBox()
3129 button = gtk.Button(_('Donate / Wishlist'))
3130 button.connect('clicked', self.on_item_support_activate)
3131 button_box.pack_start(button)
3132 button = gtk.Button(_('Report a problem'))
3133 button.connect('clicked', self.on_bug_tracker_activate)
3134 button_box.pack_start(button)
3135 out.pack_start(button_box, expand=False)
3137 credits = gtk.TextView()
3138 credits.set_left_margin(5)
3139 credits.set_right_margin(5)
3140 credits.set_pixels_above_lines(5)
3141 credits.set_pixels_below_lines(5)
3142 credits.set_editable(False)
3143 credits.set_cursor_visible(False)
3144 sw = gtk.ScrolledWindow()
3145 sw.set_shadow_type(gtk.SHADOW_IN)
3146 sw.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
3147 sw.add(credits)
3148 credits.set_size_request(-1, 160)
3149 out.pack_start(sw, expand=True, fill=True)
3151 dlg.vbox.pack_start(out, expand=False)
3152 dlg.connect('response', lambda dlg, response: dlg.destroy())
3154 dlg.vbox.show_all()
3156 if os.path.exists(gpodder.credits_file):
3157 credits_txt = open(gpodder.credits_file).read().strip().split('\n')
3158 translator_credits = _('translator-credits')
3159 if translator_credits != 'translator-credits':
3160 app_authors = [_('Translation by:'), translator_credits, '']
3161 else:
3162 app_authors = []
3164 app_authors += [_('Thanks to:')]
3165 app_authors += credits_txt
3167 buffer = gtk.TextBuffer()
3168 buffer.set_text('\n'.join(app_authors))
3169 credits.set_buffer(buffer)
3170 else:
3171 sw.hide()
3173 credits.grab_focus()
3174 dlg.run()
3176 def on_wNotebook_switch_page(self, notebook, page, page_num):
3177 if page_num == 0:
3178 self.play_or_download()
3179 # The message area in the downloads tab should be hidden
3180 # when the user switches away from the downloads tab
3181 if self.message_area is not None:
3182 self.message_area.hide()
3183 self.message_area = None
3184 else:
3185 self.toolDownload.set_sensitive(False)
3186 self.toolPlay.set_sensitive(False)
3187 self.toolCancel.set_sensitive(False)
3189 def on_treeChannels_row_activated(self, widget, path, *args):
3190 # double-click action of the podcast list or enter
3191 self.treeChannels.set_cursor(path)
3193 def on_treeChannels_cursor_changed(self, widget, *args):
3194 ( model, iter ) = self.treeChannels.get_selection().get_selected()
3196 if model is not None and iter is not None:
3197 old_active_channel = self.active_channel
3198 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3200 if self.active_channel == old_active_channel:
3201 return
3203 # Dirty hack to check for "All episodes" (see gpodder.gtkui.model)
3204 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3205 self.itemEditChannel.set_visible(False)
3206 self.itemRemoveChannel.set_visible(False)
3207 else:
3208 self.itemEditChannel.set_visible(True)
3209 self.itemRemoveChannel.set_visible(True)
3210 else:
3211 self.active_channel = None
3212 self.itemEditChannel.set_visible(False)
3213 self.itemRemoveChannel.set_visible(False)
3215 self.update_episode_list_model()
3217 def on_btnEditChannel_clicked(self, widget, *args):
3218 self.on_itemEditChannel_activate( widget, args)
3220 def get_podcast_urls_from_selected_episodes(self):
3221 """Get a set of podcast URLs based on the selected episodes"""
3222 return set(episode.channel.url for episode in \
3223 self.get_selected_episodes())
3225 def get_selected_episodes(self):
3226 """Get a list of selected episodes from treeAvailable"""
3227 selection = self.treeAvailable.get_selection()
3228 model, paths = selection.get_selected_rows()
3230 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3231 return episodes
3233 def on_playback_selected_episodes(self, widget):
3234 self.playback_episodes(self.get_selected_episodes())
3236 def on_shownotes_selected_episodes(self, widget):
3237 episodes = self.get_selected_episodes()
3238 if episodes:
3239 episode = episodes.pop(0)
3240 self.show_episode_shownotes(episode)
3241 else:
3242 self.show_message(_('Please select an episode from the episode list to display shownotes.'), _('No episode selected'), widget=self.treeAvailable)
3244 def on_download_selected_episodes(self, widget):
3245 episodes = self.get_selected_episodes()
3246 self.download_episode_list(episodes)
3247 self.update_episode_list_icons([episode.url for episode in episodes])
3248 self.play_or_download()
3250 def on_treeAvailable_row_activated(self, widget, path, view_column):
3251 """Double-click/enter action handler for treeAvailable"""
3252 self.on_shownotes_selected_episodes(widget)
3254 def show_episode_shownotes(self, episode):
3255 if self.episode_shownotes_window is None:
3256 self.episode_shownotes_window = gPodderShownotes(self.gPodder, _config=self.config, \
3257 _download_episode_list=self.download_episode_list, \
3258 _playback_episodes=self.playback_episodes, \
3259 _delete_episode_list=self.delete_episode_list, \
3260 _episode_list_status_changed=self.episode_list_status_changed, \
3261 _cancel_task_list=self.cancel_task_list, \
3262 _streaming_possible=self.streaming_possible())
3263 self.episode_shownotes_window.show(episode)
3264 if episode.downloading:
3265 self.update_downloads_list()
3267 def restart_auto_update_timer(self):
3268 if self._auto_update_timer_source_id is not None:
3269 logger.debug('Removing existing auto update timer.')
3270 gobject.source_remove(self._auto_update_timer_source_id)
3271 self._auto_update_timer_source_id = None
3273 if self.config.auto_update_feeds and \
3274 self.config.auto_update_frequency:
3275 interval = 60*1000*self.config.auto_update_frequency
3276 logger.debug('Setting up auto update timer with interval %d.',
3277 self.config.auto_update_frequency)
3278 self._auto_update_timer_source_id = gobject.timeout_add(\
3279 interval, self._on_auto_update_timer)
3281 def _on_auto_update_timer(self):
3282 logger.debug('Auto update timer fired.')
3283 self.update_feed_cache()
3285 # Ask web service for sub changes (if enabled)
3286 self.mygpo_client.flush()
3288 return True
3290 def on_treeDownloads_row_activated(self, widget, *args):
3291 # Use the standard way of working on the treeview
3292 selection = self.treeDownloads.get_selection()
3293 (model, paths) = selection.get_selected_rows()
3294 selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3296 for tree_row_reference, task in selected_tasks:
3297 if task.status in (task.DOWNLOADING, task.QUEUED):
3298 task.status = task.PAUSED
3299 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3300 self.download_queue_manager.add_task(task)
3301 self.enable_download_list_update()
3302 elif task.status == task.DONE:
3303 model.remove(model.get_iter(tree_row_reference.get_path()))
3305 self.play_or_download()
3307 # Update the tab title and downloads list
3308 self.update_downloads_list()
3310 def on_item_cancel_download_activate(self, widget):
3311 if self.wNotebook.get_current_page() == 0:
3312 selection = self.treeAvailable.get_selection()
3313 (model, paths) = selection.get_selected_rows()
3314 urls = [model.get_value(model.get_iter(path), \
3315 self.episode_list_model.C_URL) for path in paths]
3316 selected_tasks = [task for task in self.download_tasks_seen \
3317 if task.url in urls]
3318 else:
3319 selection = self.treeDownloads.get_selection()
3320 (model, paths) = selection.get_selected_rows()
3321 selected_tasks = [model.get_value(model.get_iter(path), \
3322 self.download_status_model.C_TASK) for path in paths]
3323 self.cancel_task_list(selected_tasks)
3325 def on_btnCancelAll_clicked(self, widget, *args):
3326 self.cancel_task_list(self.download_tasks_seen)
3328 def on_btnDownloadedDelete_clicked(self, widget, *args):
3329 episodes = self.get_selected_episodes()
3330 if len(episodes) == 1:
3331 self.delete_episode_list(episodes, skip_locked=False)
3332 else:
3333 self.delete_episode_list(episodes)
3335 def on_key_press(self, widget, event):
3336 # Allow tab switching with Ctrl + PgUp/PgDown/Tab
3337 if event.state & gtk.gdk.CONTROL_MASK:
3338 if event.keyval == gtk.keysyms.Page_Up:
3339 self.wNotebook.prev_page()
3340 return True
3341 elif event.keyval == gtk.keysyms.Page_Down:
3342 self.wNotebook.next_page()
3343 return True
3344 elif event.keyval == gtk.keysyms.Tab:
3345 current_page = self.wNotebook.get_current_page()
3347 if current_page == self.wNotebook.get_n_pages()-1:
3348 self.wNotebook.set_current_page(0)
3349 else:
3350 self.wNotebook.next_page()
3351 return True
3353 return False
3355 def uniconify_main_window(self):
3356 if self.is_iconified():
3357 # We need to hide and then show the window in WMs like Metacity
3358 # or KWin4 to move the window to the active workspace
3359 # (see http://gpodder.org/bug/1125)
3360 self.gPodder.hide()
3361 self.gPodder.show()
3362 self.gPodder.present()
3364 def iconify_main_window(self):
3365 if not self.is_iconified():
3366 self.gPodder.iconify()
3368 @dbus.service.method(gpodder.dbus_interface)
3369 def show_gui_window(self):
3370 parent = self.get_dialog_parent()
3371 parent.present()
3373 @dbus.service.method(gpodder.dbus_interface)
3374 def subscribe_to_url(self, url):
3375 gPodderAddPodcast(self.gPodder,
3376 add_urls_callback=self.add_podcast_list,
3377 preset_url=url)
3379 @dbus.service.method(gpodder.dbus_interface)
3380 def mark_episode_played(self, filename):
3381 if filename is None:
3382 return False
3384 for channel in self.channels:
3385 for episode in channel.get_all_episodes():
3386 fn = episode.local_filename(create=False, check_only=True)
3387 if fn == filename:
3388 episode.mark(is_played=True)
3389 self.db.commit()
3390 self.update_episode_list_icons([episode.url])
3391 self.update_podcast_list_model([episode.channel.url])
3392 return True
3394 return False
3396 def extensions_podcast_update_cb(self, podcast):
3397 logger.debug('extensions_podcast_update_cb(%s)', podcast)
3398 self.update_feed_cache(channels=[podcast],
3399 show_new_episodes_dialog=False)
3401 def extensions_episode_download_cb(self, episode):
3402 logger.debug('extension_episode_download_cb(%s)', episode)
3403 self.download_episode_list(episodes=[episode])
3405 def main(options=None):
3406 gobject.threads_init()
3407 gobject.set_application_name('gPodder')
3409 for i in range(EpisodeListModel.PROGRESS_STEPS + 1):
3410 pixbuf = draw_cake_pixbuf(float(i) /
3411 float(EpisodeListModel.PROGRESS_STEPS))
3412 icon_name = 'gpodder-progress-%d' % i
3413 gtk.icon_theme_add_builtin_icon(icon_name, pixbuf.get_width(), pixbuf)
3415 gtk.window_set_default_icon_name('gpodder')
3416 gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
3418 try:
3419 dbus_main_loop = dbus.glib.DBusGMainLoop(set_as_default=True)
3420 gpodder.dbus_session_bus = dbus.SessionBus(dbus_main_loop)
3422 bus_name = dbus.service.BusName(gpodder.dbus_bus_name, bus=gpodder.dbus_session_bus)
3423 except dbus.exceptions.DBusException, dbe:
3424 logger.warn('Cannot get "on the bus".', exc_info=True)
3425 dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, \
3426 gtk.BUTTONS_CLOSE, _('Cannot start gPodder'))
3427 dlg.format_secondary_markup(_('D-Bus error: %s') % (str(dbe),))
3428 dlg.set_title('gPodder')
3429 dlg.run()
3430 dlg.destroy()
3431 sys.exit(0)
3433 gp = gPodder(bus_name, core.Core(UIConfig, model_class=Model))
3435 # Handle options
3436 if options.subscribe:
3437 util.idle_add(gp.subscribe_to_url, options.subscribe)
3439 if gpodder.osx:
3440 from gpodder.gtkui import macosx
3442 # Handle "subscribe to podcast" events from firefox
3443 macosx.register_handlers(gp)
3445 # Handle quit event
3446 if macapp is not None:
3447 macapp.connect('NSApplicationBlockTermination', gp.quit_cb)
3448 macapp.ready()
3450 gp.run()