# -*- coding: utf-8 -*-
#
# gPodder - A media aggregator and podcast client
# Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
#
# gPodder is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# gPodder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
import os
import gtk
import gtk.gdk
import gobject
import pango
import sys
import shutil
import subprocess
import glob
import time
import urllib
import urllib2
import tempfile
import collections
import threading
from xml.sax import saxutils
import gpodder
try:
import dbus
import dbus.service
import dbus.mainloop
import dbus.glib
except ImportError:
# Mock the required D-Bus interfaces with no-ops (ugly? maybe.)
class dbus:
class SessionBus:
def __init__(self, *args, **kwargs):
pass
class glib:
class DBusGMainLoop:
pass
class service:
@staticmethod
def method(interface):
return lambda x: x
class BusName:
def __init__(self, *args, **kwargs):
pass
class Object:
def __init__(self, *args, **kwargs):
pass
from gpodder import feedcore
from gpodder import util
from gpodder import opml
from gpodder import sync
from gpodder import download
from gpodder import my
from gpodder.liblogger import log
_ = gpodder.gettext
try:
from gpodder import trayicon
have_trayicon = True
except Exception, exc:
log('Warning: Could not import gpodder.trayicon.', traceback=True)
log('Warning: This probably means your PyGTK installation is too old!')
have_trayicon = False
from gpodder.model import PodcastChannel
from gpodder.dbsqlite import Database
from gpodder.gtkui.model import PodcastListModel
from gpodder.gtkui.model import EpisodeListModel
from gpodder.gtkui.config import UIConfig
from gpodder.gtkui.download import DownloadStatusModel
from gpodder.gtkui.services import CoverDownloader
from gpodder.gtkui.widgets import SimpleMessageArea
from gpodder.gtkui.desktopfile import UserAppsReader
from gpodder.gtkui.interface.common import BuilderWidget
from gpodder.gtkui.interface.channel import gPodderChannel
from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
if gpodder.interface == gpodder.GUI:
from gpodder.gtkui.interface.preferences import gPodderPreferences
from gpodder.gtkui.interface.syncprogress import gPodderSyncProgress
from gpodder.gtkui.interface.deviceplaylist import gPodderDevicePlaylist
else:
from gpodder.gtkui.maemo.preferences import gPodderDiabloPreferences as gPodderPreferences
from gpodder.gtkui.interface.shownotes import gPodderShownotes
from gpodder.gtkui.interface.podcastdirectory import gPodderPodcastDirectory
from gpodder.gtkui.interface.episodeselector import gPodderEpisodeSelector
from gpodder.gtkui.interface.dependencymanager import gPodderDependencyManager
from gpodder.gtkui.interface.welcome import gPodderWelcome
if gpodder.interface == gpodder.MAEMO:
import hildon
class gPodder(BuilderWidget, dbus.service.Object):
finger_friendly_widgets = ['btnCancelFeedUpdate', 'label2', 'labelDownloads', 'btnCleanUpDownloads']
ENTER_URL_TEXT = _('Enter podcast URL...')
APPMENU_ACTIONS = ('itemUpdate', 'itemDownloadAllNew', 'itemPreferences')
TREEVIEW_WIDGETS = ('treeAvailable', 'treeChannels', 'treeDownloads')
def __init__(self, bus_name, config):
dbus.service.Object.__init__(self, object_path=gpodder.dbus_gui_object_path, bus_name=bus_name)
self.db = Database(gpodder.database_file)
self.config = config
BuilderWidget.__init__(self, None)
def new(self):
if gpodder.interface == gpodder.MAEMO:
# Maemo-specific changes to the UI
gpodder.icon_file = gpodder.icon_file.replace('.svg', '.png')
self.app = hildon.Program()
gtk.set_application_name('gPodder')
self.window = hildon.Window()
self.window.connect('delete-event', self.on_gPodder_delete_event)
self.window.connect('window-state-event', self.window_state_event)
self.itemUpdateChannel.set_visible(True)
# Remove old toolbar from its parent widget
self.toolbar.get_parent().remove(self.toolbar)
toolbar = gtk.Toolbar()
toolbar.set_style(gtk.TOOLBAR_BOTH_HORIZ)
self.btnUpdateFeeds.get_parent().remove(self.btnUpdateFeeds)
self.btnUpdateFeeds = gtk.ToolButton(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_SMALL_TOOLBAR), _('Update all'))
self.btnUpdateFeeds.set_is_important(True)
self.btnUpdateFeeds.connect('clicked', self.on_itemUpdate_activate)
toolbar.insert(self.btnUpdateFeeds, -1)
self.btnUpdateFeeds.show_all()
self.btnUpdateSelectedFeed = gtk.ToolButton(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_SMALL_TOOLBAR), _('Update selected'))
self.btnUpdateSelectedFeed.set_is_important(True)
self.btnUpdateSelectedFeed.connect('clicked', self.on_itemUpdateChannel_activate)
toolbar.insert(self.btnUpdateSelectedFeed, -1)
self.btnUpdateSelectedFeed.show_all()
self.toolFeedUpdateProgress = gtk.ToolItem()
self.pbFeedUpdate.reparent(self.toolFeedUpdateProgress)
self.toolFeedUpdateProgress.set_expand(True)
toolbar.insert(self.toolFeedUpdateProgress, -1)
self.toolFeedUpdateProgress.hide()
self.btnCancelFeedUpdate = gtk.ToolButton(gtk.STOCK_CLOSE)
self.btnCancelFeedUpdate.connect('clicked', self.on_btnCancelFeedUpdate_clicked)
toolbar.insert(self.btnCancelFeedUpdate, -1)
self.btnCancelFeedUpdate.hide()
self.toolbarSpacer = gtk.SeparatorToolItem()
self.toolbarSpacer.set_draw(False)
self.toolbarSpacer.set_expand(True)
toolbar.insert(self.toolbarSpacer, -1)
self.toolbarSpacer.show()
self.wNotebook.set_show_tabs(False)
self.tool_downloads = gtk.ToggleToolButton(gtk.STOCK_GO_DOWN)
self.tool_downloads.connect('toggled', self.on_tool_downloads_toggled)
self.tool_downloads.set_label(_('Downloads'))
self.tool_downloads.set_is_important(True)
toolbar.insert(self.tool_downloads, -1)
self.tool_downloads.show_all()
self.toolPreferences = gtk.ToolButton(gtk.STOCK_PREFERENCES)
self.toolPreferences.connect('clicked', self.on_itemPreferences_activate)
toolbar.insert(self.toolPreferences, -1)
self.toolPreferences.show()
self.toolQuit = gtk.ToolButton(gtk.STOCK_QUIT)
self.toolQuit.connect('clicked', self.on_gPodder_delete_event)
toolbar.insert(self.toolQuit, -1)
self.toolQuit.show()
# Add and replace toolbar with our new one
toolbar.show()
self.window.add_toolbar(toolbar)
self.toolbar = toolbar
self.app.add_window(self.window)
self.vMain.reparent(self.window)
self.gPodder = self.window
# Reparent the main menu
menu = gtk.Menu()
for child in self.mainMenu.get_children():
child.get_parent().remove(child)
menu.append(self.set_finger_friendly(child))
menu.append(self.set_finger_friendly(self.itemQuit.create_menu_item()))
if hasattr(hildon, 'AppMenu'):
# Maemo 5 - use the new AppMenu with Buttons
self.appmenu = hildon.AppMenu()
for action_name in self.APPMENU_ACTIONS:
action = getattr(self, action_name)
b = gtk.Button('')
action.connect_proxy(b)
self.appmenu.append(b)
b = gtk.Button(_('Classic menu'))
b.connect('clicked', lambda b: menu.popup(None, None, None, 1, 0))
self.appmenu.append(b)
self.window.set_app_menu(self.appmenu)
else:
# Maemo 4 - just "reparent" the menu to the hildon window
self.window.set_menu(menu)
self.mainMenu.destroy()
self.window.show()
# do some widget hiding
self.itemTransferSelected.set_visible(False)
self.item_email_subscriptions.set_visible(False)
self.menuView.set_visible(False)
# get screen real estate
self.hboxContainer.set_border_width(0)
# Offer importing of videocenter podcasts
if os.path.exists(os.path.expanduser('~/videocenter')):
self.item_upgrade_from_videocenter.set_visible(True)
self.gPodder.connect('key-press-event', self.on_key_press)
self.bluetooth_available = util.bluetooth_available()
if gpodder.win32:
# FIXME: Implement e-mail sending of list in win32
self.item_email_subscriptions.set_sensitive(False)
if self.config.show_url_entry_in_podcast_list:
self.hboxAddChannel.show()
if not gpodder.interface == gpodder.MAEMO and not self.config.show_toolbar:
self.toolbar.hide()
self.config.add_observer(self.on_config_changed)
self.default_entry_text_color = self.entryAddChannel.get_style().text[gtk.STATE_NORMAL]
self.entryAddChannel.connect('focus-in-event', self.entry_add_channel_focus)
self.entryAddChannel.connect('focus-out-event', self.entry_add_channel_unfocus)
self.entry_add_channel_unfocus(self.entryAddChannel, None)
self.uar = None
self.tray_icon = None
self.episode_shownotes_window = None
self.download_status_model = DownloadStatusModel()
self.download_queue_manager = download.DownloadQueueManager(self.config)
self.fullscreen = False
self.minimized = False
self.gPodder.connect('window-state-event', self.window_state_event)
self.show_hide_tray_icon()
self.itemShowToolbar.set_active(self.config.show_toolbar)
self.itemShowDescription.set_active(self.config.episode_list_descriptions)
self.config.connect_gtk_window(self.gPodder, 'main_window')
self.config.connect_gtk_paned( 'paned_position', self.channelPaned)
self.config.connect_gtk_spinbutton('max_downloads', self.spinMaxDownloads)
self.config.connect_gtk_togglebutton('max_downloads_enabled', self.cbMaxDownloads)
self.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
self.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
# Then the amount of maximum downloads changes, notify the queue manager
changed_cb = lambda spinbutton: self.download_queue_manager.spawn_and_retire_threads()
self.spinMaxDownloads.connect('value-changed', changed_cb)
self.default_title = None
if gpodder.__version__.rfind('git') != -1:
self.set_title('gPodder %s' % gpodder.__version__)
else:
title = self.gPodder.get_title()
if title is not None:
self.set_title(title)
else:
self.set_title(_('gPodder'))
gtk.about_dialog_set_url_hook(lambda dlg, link, data: util.open_website(link), None)
# Set up podcast channel tree view widget
self.treeChannels.set_enable_search(True)
self.treeChannels.set_search_column(PodcastListModel.C_TITLE)
self.treeChannels.set_headers_visible(False)
iconcolumn = gtk.TreeViewColumn('')
iconcell = gtk.CellRendererPixbuf()
iconcolumn.pack_start(iconcell, False)
iconcolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_COVER)
self.treeChannels.append_column(iconcolumn)
namecolumn = gtk.TreeViewColumn('')
namecell = gtk.CellRendererText()
namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
namecolumn.pack_start(namecell, True)
namecolumn.add_attribute(namecell, 'markup', PodcastListModel.C_DESCRIPTION)
iconcell = gtk.CellRendererPixbuf()
iconcell.set_property('xalign', 1.0)
namecolumn.pack_start(iconcell, False)
namecolumn.add_attribute(iconcell, 'pixbuf', PodcastListModel.C_PILL)
namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
self.treeChannels.append_column(namecolumn)
self.cover_downloader = CoverDownloader()
# Generate list models for podcasts and their episodes
self.podcast_list_model = PodcastListModel(self.config.podcast_list_icon_size, self.cover_downloader)
self.treeChannels.set_model(self.podcast_list_model)
self.episode_list_model = EpisodeListModel()
self.treeAvailable.set_model(self.episode_list_model)
# enable alternating colors hint
self.treeAvailable.set_rules_hint( True)
self.treeChannels.set_rules_hint( True)
# connect to tooltip signals
try:
self.treeChannels.set_property('has-tooltip', True)
self.treeChannels.connect('query-tooltip', self.treeview_channels_query_tooltip)
self.treeAvailable.set_property('has-tooltip', True)
self.treeAvailable.connect('query-tooltip', self.treeview_episodes_query_tooltip)
except:
log('I cannot set has-tooltip/query-tooltip (need at least PyGTK 2.12)', sender = self)
self.last_tooltip_channel = None
self.last_tooltip_episode = None
self.podcast_list_can_tooltip = True
self.episode_list_can_tooltip = True
self.currently_updating = False
# Add our context menu to treeAvailable
if gpodder.interface == gpodder.MAEMO:
self.treeview_available_buttonpress = (0, 0)
self.treeAvailable.connect('button-press-event', self.treeview_button_savepos)
self.treeAvailable.connect('button-release-event', self.treeview_button_pressed)
self.treeview_channels_buttonpress = (0, 0)
self.treeChannels.connect('button-press-event', self.treeview_channels_button_pressed)
self.treeChannels.connect('button-release-event', self.treeview_channels_button_released)
else:
self.treeAvailable.connect('button-press-event', self.treeview_button_pressed)
self.treeChannels.connect('button-press-event', self.treeview_channels_button_pressed)
self.treeDownloads.connect('button-press-event', self.treeview_downloads_button_pressed)
iconcell = gtk.CellRendererPixbuf()
if gpodder.interface == gpodder.MAEMO:
iconcell.set_fixed_size(-1, 52)
status_column_label = ''
else:
status_column_label = _('Status')
iconcolumn = gtk.TreeViewColumn(status_column_label, iconcell, pixbuf=EpisodeListModel.C_STATUS_ICON)
namecell = gtk.CellRendererText()
namecell.set_property('ellipsize', pango.ELLIPSIZE_END)
namecolumn = gtk.TreeViewColumn(_('Episode'), namecell, markup=EpisodeListModel.C_DESCRIPTION)
namecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
namecolumn.set_resizable(True)
namecolumn.set_expand(True)
sizecell = gtk.CellRendererText()
sizecolumn = gtk.TreeViewColumn(_('Size'), sizecell, text=EpisodeListModel.C_FILESIZE_TEXT)
releasecell = gtk.CellRendererText()
releasecolumn = gtk.TreeViewColumn(_('Released'), releasecell, text=EpisodeListModel.C_PUBLISHED_TEXT)
for itemcolumn in (iconcolumn, namecolumn, sizecolumn, releasecolumn):
itemcolumn.set_reorderable(gpodder.interface != gpodder.MAEMO)
self.treeAvailable.append_column(itemcolumn)
if gpodder.interface == gpodder.MAEMO:
# Due to screen space contraints, we
# hide these columns here by default
self.column_size = sizecolumn
self.column_released = releasecolumn
self.column_released.set_visible(False)
self.column_size.set_visible(False)
# enable search in treeavailable
self.treeAvailable.set_search_equal_func( self.treeAvailable_search_equal)
# on Maemo 5, we need to set hildon-ui-mode of TreeView widgets to 1
if gpodder.interface == gpodder.MAEMO:
HUIM = 'hildon-ui-mode'
if HUIM in [p.name for p in gobject.list_properties(gtk.TreeView)]:
for treeview_name in self.TREEVIEW_WIDGETS:
treeview = getattr(self, treeview_name)
treeview.set_property(HUIM, 1)
# enable multiple selection support
if gpodder.interface == gpodder.MAEMO:
self.treeAvailable.get_selection().set_mode(gtk.SELECTION_SINGLE)
else:
self.treeAvailable.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
if hasattr(self.treeDownloads, 'set_rubber_banding'):
# Available in PyGTK 2.10 and above
self.treeDownloads.set_rubber_banding(True)
# columns and renderers for "download progress" tab
# First column: [ICON] Episodename
column = gtk.TreeViewColumn(_('Episode'))
cell = gtk.CellRendererPixbuf()
if gpodder.interface == gpodder.MAEMO:
cell.set_property('stock-size', gtk.ICON_SIZE_DIALOG)
else:
cell.set_property('stock-size', gtk.ICON_SIZE_MENU)
column.pack_start(cell, expand=False)
column.add_attribute(cell, 'stock-id', \
DownloadStatusModel.C_ICON_NAME)
cell = gtk.CellRendererText()
cell.set_property('ellipsize', pango.ELLIPSIZE_END)
column.pack_start(cell, expand=True)
column.add_attribute(cell, 'text', DownloadStatusModel.C_NAME)
column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
column.set_resizable(True)
column.set_expand(True)
self.treeDownloads.append_column(column)
# Second column: Progress
column = gtk.TreeViewColumn(_('Progress'), gtk.CellRendererProgress(),
value=DownloadStatusModel.C_PROGRESS, \
text=DownloadStatusModel.C_PROGRESS_TEXT)
self.treeDownloads.append_column(column)
# Third column: Size
if gpodder.interface != gpodder.MAEMO:
column = gtk.TreeViewColumn(_('Size'), gtk.CellRendererText(),
text=DownloadStatusModel.C_SIZE_TEXT)
self.treeDownloads.append_column(column)
# Fourth column: Speed
column = gtk.TreeViewColumn(_('Speed'), gtk.CellRendererText(),
text=DownloadStatusModel.C_SPEED_TEXT)
self.treeDownloads.append_column(column)
# Fifth column: Status
column = gtk.TreeViewColumn(_('Status'), gtk.CellRendererText(),
text=DownloadStatusModel.C_STATUS_TEXT)
self.treeDownloads.append_column(column)
# After we've set up most of the window, show it :)
if not gpodder.interface == gpodder.MAEMO:
self.gPodder.show()
if self.config.start_iconified:
self.iconify_main_window()
if self.tray_icon and self.config.minimize_to_tray:
self.tray_icon.set_visible(False)
self.cover_downloader.register('cover-available', self.cover_download_finished)
self.cover_downloader.register('cover-removed', self.cover_file_removed)
self.treeDownloads.set_model(self.download_status_model)
self.download_tasks_seen = set()
self.download_list_update_enabled = False
self.last_download_count = 0
#Add Drag and Drop Support
flags = gtk.DEST_DEFAULT_ALL
targets = [ ('text/plain', 0, 2), ('STRING', 0, 3), ('TEXT', 0, 4) ]
actions = gtk.gdk.ACTION_DEFAULT | gtk.gdk.ACTION_COPY
self.treeChannels.drag_dest_set( flags, targets, actions)
self.treeChannels.connect( 'drag_data_received', self.drag_data_received)
# Subscribed channels
self.active_channel = None
self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
self.channel_list_changed = True
self.update_podcasts_tab()
# load list of user applications for audio playback
self.user_apps_reader = UserAppsReader(['audio', 'video'])
threading.Thread(target=self.read_apps).start()
# Set the "Device" menu item for the first time
self.update_item_device()
# Last folder used for saving episodes
self.folder_for_saving_episodes = None
# Now, update the feed cache, when everything's in place
self.btnUpdateFeeds.show()
self.updating_feed_cache = False
self.feed_cache_update_cancelled = False
self.update_feed_cache(force_update=self.config.update_on_startup)
# Look for partial file downloads
partial_files = glob.glob(os.path.join(self.config.download_dir, '*', '*.partial'))
# Message area
self.message_area = None
resumable_episodes = []
if len(partial_files) > 0:
for f in partial_files:
correct_name = f[:-len('.partial')] # strip ".partial"
log('Searching episode for file: %s', correct_name, sender=self)
found_episode = False
for c in self.channels:
for e in c.get_all_episodes():
if e.local_filename(create=False, check_only=True) == correct_name:
log('Found episode: %s', e.title, sender=self)
resumable_episodes.append(e)
found_episode = True
if found_episode:
break
if found_episode:
break
if not found_episode:
log('Partial file without episode: %s', f, sender=self)
util.delete_file(f)
if len(resumable_episodes):
self.download_episode_list_paused(resumable_episodes)
self.message_area = SimpleMessageArea(_('There are unfinished downloads from your last session.\nPick the ones you want to continue downloading.'))
self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
self.message_area.show_all()
self.wNotebook.set_current_page(1)
self.clean_up_downloads(delete_partial=False)
else:
self.clean_up_downloads(delete_partial=True)
# Start the auto-update procedure
self.auto_update_procedure(first_run=True)
# Delete old episodes if the user wishes to
if self.config.auto_remove_old_episodes:
old_episodes = self.get_old_episodes()
if len(old_episodes) > 0:
self.delete_episode_list(old_episodes, confirm=False)
self.updateComboBox()
# First-time users should be asked if they want to see the OPML
if len(self.channels) == 0:
util.idle_add(self.on_itemUpdate_activate)
def enable_download_list_update(self):
if not self.download_list_update_enabled:
gobject.timeout_add(1500, self.update_downloads_list)
self.download_list_update_enabled = True
def on_btnCleanUpDownloads_clicked(self, button):
model = self.download_status_model
all_tasks = [(gtk.TreeRowReference(model, row.path), row[0]) for row in model]
changed_episode_urls = []
for row_reference, task in all_tasks:
if task.status in (task.DONE, task.CANCELLED, task.FAILED):
model.remove(model.get_iter(row_reference.get_path()))
try:
# We don't "see" this task anymore - remove it;
# this is needed, so update_episode_list_icons()
# below gets the correct list of "seen" tasks
self.download_tasks_seen.remove(task)
except KeyError, key_error:
log('Cannot remove task from "seen" list: %s', task, sender=self)
changed_episode_urls.append(task.url)
# Tell the task that it has been removed (so it can clean up)
task.removed_from_list()
# Tell the podcasts tab to update icons for our removed podcasts
self.update_episode_list_icons(changed_episode_urls)
# Update the tab title and downloads list
self.update_downloads_list()
def on_tool_downloads_toggled(self, toolbutton):
if toolbutton.get_active():
self.wNotebook.set_current_page(1)
else:
self.wNotebook.set_current_page(0)
def update_downloads_list(self):
try:
model = self.download_status_model
downloading, failed, finished, queued, others = 0, 0, 0, 0, 0
total_speed, total_size, done_size = 0, 0, 0
# Keep a list of all download tasks that we've seen
download_tasks_seen = set()
# Remember the progress and speed for the episode that
# has been opened in the episode shownotes dialog (if any)
if self.episode_shownotes_window is not None:
episode_window_episode = self.episode_shownotes_window.episode
episode_window_progress = 0.0
episode_window_speed = 0.0
else:
episode_window_episode = None
# Do not go through the list of the model is not (yet) available
if model is None:
model = ()
for row in model:
self.download_status_model.request_update(row.iter)
task = row[self.download_status_model.C_TASK]
speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
total_size += size
done_size += size*progress
if episode_window_episode is not None and \
episode_window_episode.url == task.url:
episode_window_progress = progress
episode_window_speed = speed
download_tasks_seen.add(task)
if status == download.DownloadTask.DOWNLOADING:
downloading += 1
total_speed += speed
elif status == download.DownloadTask.FAILED:
failed += 1
elif status == download.DownloadTask.DONE:
finished += 1
elif status == download.DownloadTask.QUEUED:
queued += 1
else:
others += 1
# Remember which tasks we have seen after this run
self.download_tasks_seen = download_tasks_seen
text = [_('Downloads')]
if downloading + failed + finished + queued > 0:
s = []
if downloading > 0:
s.append(_('%d downloading') % downloading)
if failed > 0:
s.append(_('%d failed') % failed)
if finished > 0:
s.append(_('%d done') % finished)
if queued > 0:
s.append(_('%d queued') % queued)
text.append(' (' + ', '.join(s)+')')
self.labelDownloads.set_text(''.join(text))
if gpodder.interface == gpodder.MAEMO:
sum = downloading + failed + finished + queued + others
if sum:
self.tool_downloads.set_label(_('Downloads (%d)') % sum)
else:
self.tool_downloads.set_label(_('Downloads'))
title = [self.default_title]
# We have to update all episodes/channels for which the status has
# changed. Accessing task.status_changed has the side effect of
# re-setting the changed flag, so we need to get the "changed" list
# of tuples first and split it into two lists afterwards
changed = [(task.url, task.podcast_url) for task in \
self.download_tasks_seen if task.status_changed]
episode_urls = [episode_url for episode_url, channel_url in changed]
channel_urls = [channel_url for episode_url, channel_url in changed]
count = downloading + queued
if count > 0:
if count == 1:
title.append( _('downloading one file'))
elif count > 1:
title.append( _('downloading %d files') % count)
if total_size > 0:
percentage = 100.0*done_size/total_size
else:
percentage = 0.0
total_speed = util.format_filesize(total_speed)
title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
if self.tray_icon is not None:
# Update the tray icon status and progress bar
self.tray_icon.set_status(self.tray_icon.STATUS_DOWNLOAD_IN_PROGRESS, title[1])
self.tray_icon.draw_progress_bar(percentage/100.)
elif self.last_download_count > 0:
if self.tray_icon is not None:
# Update the tray icon status
self.tray_icon.set_status()
self.tray_icon.downloads_finished(self.download_tasks_seen)
if gpodder.interface == gpodder.MAEMO:
hildon.hildon_banner_show_information(self.gPodder, None, 'gPodder: %s' % _('All downloads finished'))
log('All downloads have finished.', sender=self)
if self.config.cmd_all_downloads_complete:
util.run_external_command(self.config.cmd_all_downloads_complete)
self.last_download_count = count
self.gPodder.set_title(' - '.join(title))
self.update_episode_list_icons(episode_urls)
if self.episode_shownotes_window is not None and \
self.episode_shownotes_window.gPodderShownotes.get_property('visible'):
self.episode_shownotes_window.download_status_changed(episode_urls)
self.episode_shownotes_window.download_status_progress(episode_window_progress, episode_window_speed)
self.play_or_download()
if channel_urls:
self.updateComboBox(only_these_urls=channel_urls)
if not self.download_queue_manager.are_queued_or_active_tasks():
self.download_list_update_enabled = False
return self.download_list_update_enabled
except Exception, e:
log('Exception happened while updating download list.', sender=self, traceback=True)
self.show_message('%s\n\n%s' % (_('Please report this problem and restart gPodder:'), str(e)), _('Unhandled exception'))
# We return False here, so the update loop won't be called again,
# that's why we require the restart of gPodder in the message.
return False
def entry_add_channel_focus(self, widget, event):
widget.modify_text(gtk.STATE_NORMAL, self.default_entry_text_color)
if widget.get_text() == self.ENTER_URL_TEXT:
widget.set_text('')
def entry_add_channel_unfocus(self, widget, event):
if widget.get_text() == '':
widget.set_text(self.ENTER_URL_TEXT)
widget.modify_text(gtk.STATE_NORMAL, gtk.gdk.color_parse('#aaaaaa'))
def on_config_changed(self, name, old_value, new_value):
if name == 'show_toolbar' and gpodder.interface != gpodder.MAEMO:
if new_value:
self.toolbar.show()
else:
self.toolbar.hide()
elif name == 'episode_list_descriptions' and gpodder.interface != gpodder.MAEMO:
self.updateTreeView()
elif name == 'show_url_entry_in_podcast_list':
if new_value:
self.hboxAddChannel.show()
else:
self.hboxAddChannel.hide()
def read_apps(self):
time.sleep(3) # give other parts of gpodder a chance to start up
self.user_apps_reader.read()
util.idle_add(self.user_apps_reader.get_applications_as_model, 'audio', False)
util.idle_add(self.user_apps_reader.get_applications_as_model, 'video', False)
def treeview_episodes_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
# With get_bin_window, we get the window that contains the rows without
# the header. The Y coordinate of this window will be the height of the
# treeview header. This is the amount we have to subtract from the
# event's Y coordinate to get the coordinate to pass to get_path_at_pos
(x_bin, y_bin) = treeview.get_bin_window().get_position()
y -= x_bin
y -= y_bin
(path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
if not self.episode_list_can_tooltip or (column is not None and column != treeview.get_columns()[0]):
self.last_tooltip_episode = None
return False
if path is not None:
model = treeview.get_model()
iter = model.get_iter(path)
url = model.get_value(iter, EpisodeListModel.C_URL)
description = model.get_value(iter, EpisodeListModel.C_DESCRIPTION_STRIPPED)
if self.last_tooltip_episode is not None and self.last_tooltip_episode != url:
self.last_tooltip_episode = None
return False
self.last_tooltip_episode = url
if len(description) > 400:
description = description[:398]+'[...]'
tooltip.set_text(description)
return True
self.last_tooltip_episode = None
return False
def podcast_list_allow_tooltips(self):
self.podcast_list_can_tooltip = True
def episode_list_allow_tooltips(self):
self.episode_list_can_tooltip = True
def treeview_channels_query_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
(path, column, rx, ry) = treeview.get_path_at_pos( x, y) or (None,)*4
if not self.podcast_list_can_tooltip or (column is not None and column != treeview.get_columns()[0]):
self.last_tooltip_channel = None
return False
if path is not None:
model = treeview.get_model()
iter = model.get_iter(path)
url = model.get_value(iter, PodcastListModel.C_URL)
channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
if self.last_tooltip_channel is not None and self.last_tooltip_channel != channel:
self.last_tooltip_channel = None
return False
self.last_tooltip_channel = channel
channel.request_save_dir_size()
diskspace_str = util.format_filesize(channel.save_dir_size, 0)
error_str = model.get_value(iter, PodcastListModel.C_ERROR)
if error_str:
error_str = _('Feedparser error: %s') % saxutils.escape(error_str.strip())
error_str = '%s' % error_str
table = gtk.Table(rows=3, columns=3)
table.set_row_spacings(5)
table.set_col_spacings(5)
table.set_border_width(5)
heading = gtk.Label()
heading.set_alignment(0, 1)
heading.set_markup('%s\n%s' % (saxutils.escape(channel.title), saxutils.escape(channel.url)))
table.attach(heading, 0, 1, 0, 1)
size_info = gtk.Label()
size_info.set_alignment(1, 1)
size_info.set_justify(gtk.JUSTIFY_RIGHT)
size_info.set_markup('%s\n%s' % (diskspace_str, _('disk usage')))
table.attach(size_info, 2, 3, 0, 1)
table.attach(gtk.HSeparator(), 0, 3, 1, 2)
if len(channel.description) < 500:
description = channel.description
else:
pos = channel.description.find('\n\n')
if pos == -1 or pos > 500:
description = channel.description[:498]+'[...]'
else:
description = channel.description[:pos]
description = gtk.Label(description)
if error_str:
description.set_markup(error_str)
description.set_alignment(0, 0)
description.set_line_wrap(True)
table.attach(description, 0, 3, 2, 3)
table.show_all()
tooltip.set_custom(table)
return True
self.last_tooltip_channel = None
return False
def update_m3u_playlist_clicked(self, widget):
self.active_channel.update_m3u_playlist()
self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'))
def treeview_downloads_button_pressed(self, treeview, event):
if event.button == 1:
# Catch left mouse button presses, and if we there is no
# path at the given position, deselect all items
(x, y) = (int(event.x), int(event.y))
(path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,)*4
if path is None:
treeview.get_selection().unselect_all()
# Use right-click for the Desktop version and left-click for Maemo
if (event.button == 1 and gpodder.interface == gpodder.MAEMO) or \
(event.button == 3 and gpodder.interface == gpodder.GUI):
(x, y) = (int(event.x), int(event.y))
(path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,)*4
paths = []
# Did the user right-click into a selection?
selection = treeview.get_selection()
if selection.count_selected_rows() and path:
(model, paths) = selection.get_selected_rows()
if path not in paths:
# We have right-clicked, but not into the
# selection, assume we don't want to operate
# on the selection
paths = []
# No selection or right click not in selection:
# Select the single item where we clicked
if not paths and path:
treeview.grab_focus()
treeview.set_cursor( path, column, 0)
(model, paths) = (treeview.get_model(), [path])
# We did not find a selection, and the user didn't
# click on an item to select -- don't show the menu
if not paths:
return True
selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
def make_menu_item(label, stock_id, tasks, status):
# This creates a menu item for selection-wide actions
def for_each_task_set_status(tasks, status):
changed_episode_urls = []
for row_reference, task in tasks:
if status is not None:
if status == download.DownloadTask.QUEUED:
# Only queue task when its paused/failed/cancelled
if task.status in (task.PAUSED, task.FAILED, task.CANCELLED):
self.download_queue_manager.add_task(task)
self.enable_download_list_update()
elif status == download.DownloadTask.CANCELLED:
# Cancelling a download only allows when paused/downloading/queued
if task.status in (task.QUEUED, task.DOWNLOADING, task.PAUSED):
task.status = status
elif status == download.DownloadTask.PAUSED:
# Pausing a download only when queued/downloading
if task.status in (task.DOWNLOADING, task.QUEUED):
task.status = status
else:
# We (hopefully) can simply set the task status here
task.status = status
else:
# Remove the selected task - cancel downloading/queued tasks
if task.status in (task.QUEUED, task.DOWNLOADING):
task.status = task.CANCELLED
model.remove(model.get_iter(row_reference.get_path()))
# Remember the URL, so we can tell the UI to update
try:
# We don't "see" this task anymore - remove it;
# this is needed, so update_episode_list_icons()
# below gets the correct list of "seen" tasks
self.download_tasks_seen.remove(task)
except KeyError, key_error:
log('Cannot remove task from "seen" list: %s', task, sender=self)
changed_episode_urls.append(task.url)
# Tell the task that it has been removed (so it can clean up)
task.removed_from_list()
# Tell the podcasts tab to update icons for our removed podcasts
self.update_episode_list_icons(changed_episode_urls)
# Update the tab title and downloads list
self.update_downloads_list()
return True
item = gtk.ImageMenuItem(label)
item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
item.connect('activate', lambda item: for_each_task_set_status(tasks, status))
# Determine if we should disable this menu item
for row_reference, task in tasks:
if status == download.DownloadTask.QUEUED:
if task.status not in (download.DownloadTask.PAUSED, \
download.DownloadTask.FAILED, \
download.DownloadTask.CANCELLED):
item.set_sensitive(False)
break
elif status == download.DownloadTask.CANCELLED:
if task.status not in (download.DownloadTask.PAUSED, \
download.DownloadTask.QUEUED, \
download.DownloadTask.DOWNLOADING):
item.set_sensitive(False)
break
elif status == download.DownloadTask.PAUSED:
if task.status not in (download.DownloadTask.QUEUED, \
download.DownloadTask.DOWNLOADING):
item.set_sensitive(False)
break
elif status is None:
if task.status not in (download.DownloadTask.CANCELLED, \
download.DownloadTask.FAILED, \
download.DownloadTask.DONE):
item.set_sensitive(False)
break
return self.set_finger_friendly(item)
menu = gtk.Menu()
item = gtk.ImageMenuItem(_('Episode details'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
if len(selected_tasks) == 1:
row_reference, task = selected_tasks[0]
episode = task.episode
item.connect('activate', lambda item: self.show_episode_shownotes(episode))
else:
item.set_sensitive(False)
menu.append(item)
menu.append(gtk.SeparatorMenuItem())
menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED))
menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED))
menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED))
menu.append(gtk.SeparatorMenuItem())
menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None))
if gpodder.interface == gpodder.MAEMO:
# Because we open the popup on left-click for Maemo,
# we also include a non-action to close the menu
menu.append(gtk.SeparatorMenuItem())
item = gtk.ImageMenuItem(_('Close this menu'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
menu.append(self.set_finger_friendly(item))
menu.show_all()
menu.popup(None, None, None, event.button, event.time)
return True
def treeview_channels_button_pressed( self, treeview, event):
if gpodder.interface == gpodder.MAEMO:
self.treeview_channels_buttonpress = (event.x, event.y)
return True
if event.button == 3:
( x, y ) = ( int(event.x), int(event.y) )
( path, column, rx, ry ) = treeview.get_path_at_pos( x, y) or (None,)*4
paths = []
# Did the user right-click into a selection?
selection = treeview.get_selection()
if selection.count_selected_rows() and path:
( model, paths ) = selection.get_selected_rows()
if path not in paths:
# We have right-clicked, but not into the
# selection, assume we don't want to operate
# on the selection
paths = []
# No selection or right click not in selection:
# Select the single item where we clicked
if not len( paths) and path:
treeview.grab_focus()
treeview.set_cursor( path, column, 0)
( model, paths ) = ( treeview.get_model(), [ path ] )
# We did not find a selection, and the user didn't
# click on an item to select -- don't show the menu
if not len( paths):
return True
menu = gtk.Menu()
item = gtk.ImageMenuItem( _('Open download folder'))
item.set_image( gtk.image_new_from_icon_name( 'folder-open', gtk.ICON_SIZE_MENU))
item.connect('activate', lambda x: util.gui_open(self.active_channel.save_dir))
menu.append( item)
item = gtk.ImageMenuItem( _('Update Feed'))
item.set_image( gtk.image_new_from_icon_name( 'gtk-refresh', gtk.ICON_SIZE_MENU))
item.connect('activate', self.on_itemUpdateChannel_activate )
item.set_sensitive( not self.updating_feed_cache )
menu.append( item)
item = gtk.ImageMenuItem(_('Update M3U playlist'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_REFRESH, gtk.ICON_SIZE_MENU))
item.connect('activate', self.update_m3u_playlist_clicked)
menu.append(item)
if self.active_channel.link:
item = gtk.ImageMenuItem(_('Visit website'))
item.set_image(gtk.image_new_from_icon_name('web-browser', gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: util.open_website(self.active_channel.link))
menu.append(item)
if self.active_channel.channel_is_locked:
item = gtk.ImageMenuItem(_('Allow deletion of all episodes'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
item.connect('activate', self.on_channel_toggle_lock_activate)
menu.append(self.set_finger_friendly(item))
else:
item = gtk.ImageMenuItem(_('Prohibit deletion of all episodes'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
item.connect('activate', self.on_channel_toggle_lock_activate)
menu.append(self.set_finger_friendly(item))
menu.append( gtk.SeparatorMenuItem())
item = gtk.ImageMenuItem(gtk.STOCK_EDIT)
item.connect( 'activate', self.on_itemEditChannel_activate)
menu.append( item)
item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
item.connect( 'activate', self.on_itemRemoveChannel_activate)
menu.append( item)
menu.show_all()
# Disable tooltips while we are showing the menu, so
# the tooltip will not appear over the menu
self.podcast_list_can_tooltip = False
menu.connect('deactivate', lambda menushell: self.podcast_list_allow_tooltips())
menu.popup( None, None, None, event.button, event.time)
return True
def on_itemClose_activate(self, widget):
if self.tray_icon is not None:
if gpodder.interface == gpodder.MAEMO:
self.gPodder.set_property('visible', False)
else:
self.iconify_main_window()
else:
self.on_gPodder_delete_event(widget)
def cover_file_removed(self, channel_url):
"""
The Cover Downloader calls this when a previously-
available cover has been removed from the disk. We
have to update our model to reflect this change.
"""
self.podcast_list_model.delete_cover_by_url(channel_url)
def cover_download_finished(self, channel_url, pixbuf):
"""
The Cover Downloader calls this when it has finished
downloading (or registering, if already downloaded)
a new channel cover, which is ready for displaying.
"""
self.podcast_list_model.add_cover_by_url(channel_url, pixbuf)
def save_episode_as_file(self, episode):
if episode.was_downloaded(and_exists=True):
folder = self.folder_for_saving_episodes
copy_from = episode.local_filename(create=False)
assert copy_from is not None
copy_to = episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)
(result, folder) = self.show_copy_dialog(src_filename=copy_from, dst_filename=copy_to, dst_directory=folder)
self.folder_for_saving_episodes = folder
def copy_episodes_bluetooth(self, episodes):
episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
def convert_and_send_thread(episodes, notify):
for episode in episodes:
filename = episode.local_filename(create=False)
assert filename is not None
destfile = os.path.join(tempfile.gettempdir(), \
util.sanitize_filename(episode.sync_filename(self.config.custom_sync_name_enabled, self.config.custom_sync_name)))
(base, ext) = os.path.splitext(filename)
if not destfile.endswith(ext):
destfile += ext
try:
shutil.copyfile(filename, destfile)
util.bluetooth_send_file(destfile)
except:
log('Cannot copy "%s" to "%s".', filename, destfile, sender=self)
notify(_('Error converting file.'), _('Bluetooth file transfer'))
util.delete_file(destfile)
threading.Thread(target=convert_and_send_thread, args=[episodes_to_copy, self.notification]).start()
def treeview_button_savepos(self, treeview, event):
if gpodder.interface == gpodder.MAEMO and event.button == 1:
self.treeview_available_buttonpress = (event.x, event.y)
return True
def treeview_channels_button_released(self, treeview, event):
if gpodder.interface == gpodder.MAEMO and event.button == 1:
selection = self.treeChannels.get_selection()
pathatpos = self.treeChannels.get_path_at_pos(int(event.x), int(event.y))
if self.currently_updating:
log('do not handle press while updating', sender=self)
return True
if pathatpos is None:
return False
else:
ydistance = int(abs(event.y-self.treeview_channels_buttonpress[1]))
xdistance = int(event.x-self.treeview_channels_buttonpress[0])
if ydistance < 30:
(path, column, x, y) = pathatpos
selection.select_path(path)
self.treeChannels.set_cursor(path)
self.treeChannels.grab_focus()
# Emulate the cursor changed signal to force an update
self.on_treeChannels_cursor_changed(self.treeChannels)
return True
def get_device_name(self):
if self.config.device_type == 'ipod':
return _('iPod')
elif self.config.device_type in ('filesystem', 'mtp'):
return _('MP3 player')
else:
return '(unknown device)'
def treeview_button_pressed( self, treeview, event):
if gpodder.interface == gpodder.MAEMO:
ydistance = int(abs(event.y-self.treeview_available_buttonpress[1]))
xdistance = int(event.x-self.treeview_available_buttonpress[0])
selection = self.treeAvailable.get_selection()
pathatpos = self.treeAvailable.get_path_at_pos(int(event.x), int(event.y))
if pathatpos is None:
# No item at the current cursor position
return False
elif ydistance < 30:
# Item under the cursor, and no scrolling done
(path, column, x, y) = pathatpos
selection.select_path(path)
self.treeAvailable.set_cursor(path)
self.treeAvailable.grab_focus()
if self.config.maemo_enable_gestures and xdistance > 70:
self.on_playback_selected_episodes(None)
return True
elif self.config.maemo_enable_gestures and xdistance < -70:
self.on_shownotes_selected_episodes(None)
return True
else:
# Scrolling has been done
return True
# Use right-click for the Desktop version and left-click for Maemo
if (event.button == 1 and gpodder.interface == gpodder.MAEMO) or \
(event.button == 3 and gpodder.interface == gpodder.GUI):
( x, y ) = ( int(event.x), int(event.y) )
( path, column, rx, ry ) = treeview.get_path_at_pos( x, y) or (None,)*4
paths = []
# Did the user right-click into a selection?
selection = self.treeAvailable.get_selection()
if selection.count_selected_rows() and path:
( model, paths ) = selection.get_selected_rows()
if path not in paths:
# We have right-clicked, but not into the
# selection, assume we don't want to operate
# on the selection
paths = []
# No selection or right click not in selection:
# Select the single item where we clicked
if not len( paths) and path:
treeview.grab_focus()
treeview.set_cursor( path, column, 0)
( model, paths ) = ( treeview.get_model(), [ path ] )
# We did not find a selection, and the user didn't
# click on an item to select -- don't show the menu
if not len( paths):
return True
episodes = self.get_selected_episodes()
any_locked = any(e.is_locked for e in episodes)
any_played = any(e.is_played for e in episodes)
one_is_new = any(e.state == gpodder.STATE_NORMAL and not e.is_played for e in episodes)
menu = gtk.Menu()
(can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
if open_instead_of_play:
item = gtk.ImageMenuItem(gtk.STOCK_OPEN)
else:
item = gtk.ImageMenuItem(gtk.STOCK_MEDIA_PLAY)
item.set_sensitive(can_play)
item.connect('activate', self.on_playback_selected_episodes)
menu.append(self.set_finger_friendly(item))
if not can_cancel:
item = gtk.ImageMenuItem(_('Download'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_GO_DOWN, gtk.ICON_SIZE_MENU))
item.set_sensitive(can_download)
item.connect('activate', self.on_download_selected_episodes)
menu.append(self.set_finger_friendly(item))
else:
item = gtk.ImageMenuItem(gtk.STOCK_CANCEL)
item.connect('activate', lambda w: self.on_treeDownloads_row_activated(self.toolCancel))
menu.append(self.set_finger_friendly(item))
item = gtk.ImageMenuItem(gtk.STOCK_DELETE)
item.set_sensitive(can_delete)
item.connect('activate', self.on_btnDownloadedDelete_clicked)
menu.append(self.set_finger_friendly(item))
if one_is_new:
item = gtk.ImageMenuItem(_('Do not download'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: self.mark_selected_episodes_old())
menu.append(self.set_finger_friendly(item))
elif can_download:
item = gtk.ImageMenuItem(_('Mark as new'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_ABOUT, gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: self.mark_selected_episodes_new())
menu.append(self.set_finger_friendly(item))
# Ok, this probably makes sense to only display for downloaded files
if can_play and not can_download:
menu.append( gtk.SeparatorMenuItem())
item = gtk.ImageMenuItem(_('Save to disk'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_SAVE_AS, gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: [self.save_episode_as_file(e) for e in episodes])
menu.append(self.set_finger_friendly(item))
if self.bluetooth_available:
item = gtk.ImageMenuItem(_('Send via bluetooth'))
item.set_image(gtk.image_new_from_icon_name('bluetooth', gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: self.copy_episodes_bluetooth(episodes))
menu.append(self.set_finger_friendly(item))
if can_transfer:
item = gtk.ImageMenuItem(_('Transfer to %s') % self.get_device_name())
item.set_image(gtk.image_new_from_icon_name('multimedia-player', gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: self.on_sync_to_ipod_activate(w, episodes))
menu.append(self.set_finger_friendly(item))
if can_play:
menu.append( gtk.SeparatorMenuItem())
if any_played:
item = gtk.ImageMenuItem(_('Mark as unplayed'))
item.set_image( gtk.image_new_from_stock( gtk.STOCK_CANCEL, gtk.ICON_SIZE_MENU))
item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, False))
menu.append(self.set_finger_friendly(item))
else:
item = gtk.ImageMenuItem(_('Mark as played'))
item.set_image( gtk.image_new_from_stock( gtk.STOCK_APPLY, gtk.ICON_SIZE_MENU))
item.connect( 'activate', lambda w: self.on_item_toggle_played_activate( w, False, True))
menu.append(self.set_finger_friendly(item))
if any_locked:
item = gtk.ImageMenuItem(_('Allow deletion'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, False))
menu.append(self.set_finger_friendly(item))
else:
item = gtk.ImageMenuItem(_('Prohibit deletion'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_DIALOG_AUTHENTICATION, gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: self.on_item_toggle_lock_activate( w, False, True))
menu.append(self.set_finger_friendly(item))
menu.append(gtk.SeparatorMenuItem())
# Single item, add episode information menu item
item = gtk.ImageMenuItem(_('Episode details'))
item.set_image(gtk.image_new_from_stock( gtk.STOCK_INFO, gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: self.show_episode_shownotes(episodes[0]))
menu.append(self.set_finger_friendly(item))
# If we have it, also add episode website link
if episodes[0].link and episodes[0].link != episodes[0].url:
item = gtk.ImageMenuItem(_('Visit website'))
item.set_image(gtk.image_new_from_icon_name('web-browser', gtk.ICON_SIZE_MENU))
item.connect('activate', lambda w: util.open_website(episodes[0].link))
menu.append(self.set_finger_friendly(item))
if gpodder.interface == gpodder.MAEMO:
# Because we open the popup on left-click for Maemo,
# we also include a non-action to close the menu
menu.append(gtk.SeparatorMenuItem())
item = gtk.ImageMenuItem(_('Close this menu'))
item.set_image(gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU))
menu.append(self.set_finger_friendly(item))
menu.show_all()
# Disable tooltips while we are showing the menu, so
# the tooltip will not appear over the menu
self.episode_list_can_tooltip = False
menu.connect('deactivate', lambda menushell: self.episode_list_allow_tooltips())
menu.popup( None, None, None, event.button, event.time)
return True
def set_title(self, new_title):
self.default_title = new_title
self.gPodder.set_title(new_title)
def update_selected_episode_list_icons(self):
"""
Updates the status icons in the episode list
"""
selection = self.treeAvailable.get_selection()
(model, paths) = selection.get_selected_rows()
for path in paths:
iter = model.get_iter(path)
self.episode_list_model.update_by_iter(iter, \
self.episode_is_downloading, \
self.config.episode_list_descriptions and gpodder.interface != gpodder.MAEMO)
def update_episode_list_icons(self, urls):
"""
Updates the status icons in the episode list
Only update the episodes that have an URL in
the "urls" iterable object (e.g. a list of URLs)
"""
if self.active_channel is None or not urls:
return
self.episode_list_model.update_by_urls(urls, \
self.episode_is_downloading, \
self.config.episode_list_descriptions and gpodder.interface != gpodder.MAEMO)
def clean_up_downloads(self, delete_partial=False):
# Clean up temporary files left behind by old gPodder versions
temporary_files = glob.glob('%s/*/.tmp-*' % self.config.download_dir)
if delete_partial:
temporary_files += glob.glob('%s/*/*.partial' % self.config.download_dir)
for tempfile in temporary_files:
util.delete_file(tempfile)
# Clean up empty download folders and abandoned download folders
download_dirs = glob.glob(os.path.join(self.config.download_dir, '*'))
for ddir in download_dirs:
if os.path.isdir(ddir) and False: # FIXME not db.channel_foldername_exists(os.path.basename(ddir)):
globr = glob.glob(os.path.join(ddir, '*'))
if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
shutil.rmtree(ddir, ignore_errors=True)
def streaming_possible(self):
return self.config.player and self.config.player != 'default'
def playback_episodes_for_real(self, episodes):
groups = collections.defaultdict(list)
for episode in episodes:
# Mark episode as played in the database
episode.mark(is_played=True)
file_type = episode.file_type()
if file_type == 'video' and self.config.videoplayer and \
self.config.videoplayer != 'default':
player = self.config.videoplayer
elif file_type == 'audio' and self.config.player and \
self.config.player != 'default':
player = self.config.player
else:
player = 'default'
filename = episode.local_filename(create=False)
if filename is None or not os.path.exists(filename):
filename = episode.url
groups[player].append(filename)
# Open episodes with system default player
if 'default' in groups:
for filename in groups['default']:
log('Opening with system default: %s', filename, sender=self)
util.gui_open(filename)
del groups['default']
# For each type now, go and create play commands
for group in groups:
for command in util.format_desktop_command(group, groups[group]):
log('Executing: %s', repr(command), sender=self)
subprocess.Popen(command)
def playback_episodes(self, episodes):
if gpodder.interface == gpodder.MAEMO:
if len(episodes) == 1:
text = _('Opening %s') % saxutils.escape(episodes[0].title)
else:
text = _('Opening %d episodes') % len(episodes)
banner = hildon.hildon_banner_show_animation(self.gPodder, None, text)
def destroy_banner_later(banner):
banner.destroy()
return False
gobject.timeout_add(5000, destroy_banner_later, banner)
episodes = [e for e in episodes if \
e.was_downloaded(and_exists=True) or self.streaming_possible()]
try:
self.playback_episodes_for_real(episodes)
except Exception, e:
log('Error in playback!', sender=self, traceback=True)
self.show_message( _('Please check your media player settings in the preferences dialog.'), _('Error opening player'))
self.update_selected_episode_list_icons()
self.updateComboBox(only_selected_channel=True)
def treeAvailable_search_equal( self, model, column, key, iter, data = None):
if model is None:
return True
key = key.lower()
for column in (EpisodeListModel.C_TITLE, EpisodeListModel.C_DESCRIPTION_STRIPPED):
value = model.get_value( iter, column).lower()
if value.find( key) != -1:
return False
return True
def change_menu_item(self, menuitem, icon=None, label=None):
if icon is not None:
menuitem.set_property('stock-id', icon)
if label is not None:
menuitem.label = label
def play_or_download(self):
if self.wNotebook.get_current_page() > 0:
return
( can_play, can_download, can_transfer, can_cancel, can_delete ) = (False,)*5
( is_played, is_locked ) = (False,)*2
open_instead_of_play = False
selection = self.treeAvailable.get_selection()
if selection.count_selected_rows() > 0:
(model, paths) = selection.get_selected_rows()
for path in paths:
episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
if episode.file_type() not in ('audio', 'video'):
open_instead_of_play = True
if episode.was_downloaded():
can_play = episode.was_downloaded(and_exists=True)
can_delete = True
is_played = episode.is_played
is_locked = episode.is_locked
if not can_play:
can_download = True
else:
if self.episode_is_downloading(episode):
can_cancel = True
else:
can_download = True
can_download = can_download and not can_cancel
can_play = self.streaming_possible() or (can_play and not can_cancel and not can_download)
can_transfer = can_play and self.config.device_type != 'none' and not can_cancel and not can_download
if open_instead_of_play:
if gpodder.interface != gpodder.MAEMO:
self.toolPlay.set_stock_id(gtk.STOCK_OPEN)
can_transfer = False
else:
if gpodder.interface != gpodder.MAEMO:
self.toolPlay.set_stock_id(gtk.STOCK_MEDIA_PLAY)
self.toolPlay.set_sensitive( can_play)
self.toolDownload.set_sensitive( can_download)
self.toolTransfer.set_sensitive( can_transfer)
self.toolCancel.set_sensitive( can_cancel)
self.item_cancel_download.set_sensitive(can_cancel)
self.itemDownloadSelected.set_sensitive(can_download)
self.itemOpenSelected.set_sensitive(can_play)
self.itemPlaySelected.set_sensitive(can_play)
self.itemDeleteSelected.set_sensitive(can_play and not can_download)
self.item_toggle_played.set_sensitive(can_play)
self.item_toggle_lock.set_sensitive(can_play)
self.itemOpenSelected.set_visible(open_instead_of_play)
self.itemPlaySelected.set_visible(not open_instead_of_play)
if can_play:
if is_played:
self.change_menu_item(self.item_toggle_played, gtk.STOCK_CANCEL, _('Mark as unplayed'))
else:
self.change_menu_item(self.item_toggle_played, gtk.STOCK_APPLY, _('Mark as played'))
if is_locked:
self.change_menu_item(self.item_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Allow deletion'))
else:
self.change_menu_item(self.item_toggle_lock, gtk.STOCK_DIALOG_AUTHENTICATION, _('Prohibit deletion'))
return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
def on_cbMaxDownloads_toggled(self, widget, *args):
self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
def on_cbLimitDownloads_toggled(self, widget, *args):
self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
def episode_new_status_changed(self, urls):
self.updateComboBox()
self.update_episode_list_icons(urls)
def updateComboBox(self, selected_url=None, only_selected_channel=False, only_these_urls=None):
selection = self.treeChannels.get_selection()
(model, iter) = selection.get_selected()
if only_selected_channel:
# very cheap! only update selected channel
if iter and self.active_channel is not None:
model.update_by_iter(iter)
elif not self.channel_list_changed:
# we can keep the model, but have to update some
if only_these_urls is None:
# still cheaper than reloading the whole list
iter = model.get_iter_first()
while iter is not None:
model.update_by_iter(iter)
iter = model.iter_next(iter)
else:
# ok, we got a bunch of urls to update
model.update_by_urls(only_these_urls)
else:
if model and iter and selected_url is None:
# Get the URL of the currently-selected podcast
selected_url = model.get_value(iter, 0)
# Update the podcast list model with new channels
self.podcast_list_model.set_channels(self.channels)
try:
selected_path = (0,)
# Find the previously-selected URL in the new
# model if we have an URL (else select first)
if selected_url is not None:
pos = model.get_iter_first()
while pos is not None:
url = model.get_value(pos, 0)
if url == selected_url:
selected_path = model.get_path(pos)
break
pos = model.iter_next(pos)
self.treeChannels.get_selection().select_path(selected_path)
except:
log( 'Cannot set selection on treeChannels', sender = self)
self.on_treeChannels_cursor_changed( self.treeChannels)
self.channel_list_changed = False
def episode_is_downloading(self, episode):
"""Returns True if the given episode is being downloaded at the moment"""
if episode is None:
return False
return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
def on_episode_list_model_updated(self, banner=None):
if banner is not None:
banner.destroy()
self.treeAvailable.columns_autosize()
self.play_or_download()
self.currently_updating = False
def updateTreeView(self):
if self.channels and self.active_channel is not None:
if gpodder.interface == gpodder.MAEMO:
banner = hildon.hildon_banner_show_animation(self.gPodder, None, _('Loading episodes for %s') % saxutils.escape(self.active_channel.title))
else:
banner = None
self.currently_updating = True
self.episode_list_model.update_from_channel(self.active_channel, \
self.episode_is_downloading, \
self.config.episode_list_descriptions and gpodder.interface != gpodder.MAEMO, \
lambda: self.on_episode_list_model_updated(banner))
else:
self.episode_list_model.clear()
def drag_data_received(self, widget, context, x, y, sel, ttype, time):
(path, column, rx, ry) = self.treeChannels.get_path_at_pos( x, y) or (None,)*4
dnd_channel = None
if path is not None:
model = self.treeChannels.get_model()
iter = model.get_iter(path)
url = model.get_value(iter, 0)
for channel in self.channels:
if channel.url == url:
dnd_channel = channel
break
result = sel.data
rl = result.strip().lower()
if (rl.endswith('.jpg') or rl.endswith('.png') or rl.endswith('.gif') or rl.endswith('.svg')) and dnd_channel is not None:
self.cover_downloader.replace_cover(dnd_channel, result)
else:
self.add_new_channel(result)
def add_new_channel(self, result=None, ask_download_new=True, quiet=False, block=False, authentication_tokens=None):
result = util.normalize_feed_url(result)
(scheme, rest) = result.split('://', 1)
if not result:
cute_scheme = saxutils.escape(scheme)+'://'
title = _('%s URLs are not supported') % cute_scheme
message = _('gPodder does not understand the URL you supplied.')
self.show_message( message, title)
return
for old_channel in self.channels:
if old_channel.url == result:
log( 'Channel already exists: %s', result)
# Select the existing channel in combo box
for i in range( len( self.channels)):
if self.channels[i] == old_channel:
self.treeChannels.get_selection().select_path( (i,))
self.on_treeChannels_cursor_changed(self.treeChannels)
break
self.show_message( _('You have already subscribed to this podcast: %s') % (
saxutils.escape( old_channel.title), ), _('Already added'))
return
waitdlg = gtk.MessageDialog(self.gPodder, 0, gtk.MESSAGE_INFO, gtk.BUTTONS_NONE)
waitdlg.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
waitdlg.set_title(_('Downloading episode list'))
waitdlg.set_markup('%s' % waitdlg.get_title())
waitdlg.format_secondary_text(_('Downloading episode information for %s') % result)
waitpb = gtk.ProgressBar()
if block:
waitdlg.vbox.add(waitpb)
waitdlg.show_all()
waitdlg.set_response_sensitive(gtk.RESPONSE_CANCEL, False)
self.entryAddChannel.set_text(_('Downloading feed...'))
self.entryAddChannel.set_sensitive(False)
self.btnAddChannel.set_sensitive(False)
args = (result, self.add_new_channel_finish, authentication_tokens, ask_download_new, quiet, waitdlg)
thread = threading.Thread( target=self.add_new_channel_proc, args=args )
thread.start()
while block and thread.isAlive():
while gtk.events_pending():
gtk.main_iteration( False)
waitpb.pulse()
time.sleep(0.1)
def add_new_channel_proc( self, url, callback, authentication_tokens, *callback_args):
log( 'Adding new channel: %s', url)
channel = error = None
try:
channel = PodcastChannel.load(self.db, url=url, create=True,
authentication_tokens=authentication_tokens,
max_episodes=self.config.max_episodes_per_feed,
download_dir=self.config.download_dir)
except feedcore.AuthenticationRequired, e:
error = e
except feedcore.WifiLogin, e:
error = e
except Exception, e:
log('Error adding channel: %s', e, traceback=True, sender=self)
util.idle_add( callback, channel, url, error, *callback_args )
def save_channels_opml(self):
exporter = opml.Exporter(gpodder.subscription_file)
return exporter.write(self.channels)
def add_new_channel_finish( self, channel, url, error, ask_download_new, quiet, waitdlg):
if channel is not None:
self.channels.append( channel)
self.channel_list_changed = True
self.save_channels_opml()
if not quiet:
# download changed channels and select the new episode in the UI afterwards
self.update_feed_cache(force_update=False, select_url_afterwards=channel.url)
try:
(username, password) = util.username_password_from_url(url)
except ValueError, ve:
self.show_message(_('The following error occured while trying to get authentication data from the URL:') + '\n\n' + ve.message, _('Error getting authentication data'))
(username, password) = (None, None)
log('Error getting authentication data from URL: %s', url, traceback=True)
if username and self.show_confirmation( _('You have supplied %s as username and a password for this feed. Would you like to use the same authentication data for downloading episodes?') % ( saxutils.escape( username), ), _('Password authentication')):
channel.username = username
channel.password = password
log('Saving authentication data for episode downloads..', sender = self)
channel.save()
# We need to update the channel list otherwise the authentication
# data won't show up in the channel editor.
# TODO: Only updated the newly added feed to save some cpu cycles
self.channels = PodcastChannel.load_from_db(self.db, self.config.download_dir)
self.channel_list_changed = True
if ask_download_new:
new_episodes = channel.get_new_episodes(downloading=self.episode_is_downloading)
if len(new_episodes):
self.new_episodes_show(new_episodes)
elif isinstance(error, feedcore.AuthenticationRequired):
response, auth_tokens = self.UsernamePasswordDialog(
_('Feed requires authentication'), _('Please enter your username and password.'))
if response:
self.add_new_channel( url, authentication_tokens=auth_tokens )
elif isinstance(error, feedcore.WifiLogin):
if self.show_confirmation(_('The URL you are trying to add redirects to the website %s. Do you want to visit the website to login now?') % saxutils.escape(error.data), _('Website redirection detected')):
util.open_website(error.data)
if self.show_confirmation(_('Please login to the website now. Should I retry subscribing to the podcast at %s?') % saxutils.escape(url), _('Retry adding channel')):
self.add_new_channel(url)
else:
# Ok, the URL is not a channel, or there is some other
# error - let's see if it's a web page or OPML file...
handled = False
try:
data = urllib2.urlopen(url).read().lower()
if '' in data:
# This looks like an OPML feed
self.on_item_import_from_file_activate(None, url)
handled = True
elif '