my.gpodder.org Advanced API Support (bug 691)

This support is still very early, and some
bits and pieces are missing, but it works
for the most basic use cases.

New hard dependency on "mygpoclient", which
you can get from the following URL:

http://thpinfo.com/2010/mygpoclient/
This commit is contained in:
Thomas Perl 2010-01-19 23:43:59 +01:00
parent 8cbbea9b8b
commit b20357f832
11 changed files with 723 additions and 210 deletions

1
README
View File

@ -32,6 +32,7 @@
* python (>= 2.5)
* python-gtk2 (>= 2.12)
* python-feedparser
* python-mygpoclient (http://thpinfo.com/2010/mygpoclient/)
* python-dbus (optional, but highly recommended)
Additional dependencies for iPod synchronization support:

View File

@ -161,17 +161,10 @@
</object>
</child>
<child>
<object class="GtkAction" id="itemUploadToMygpo">
<property name="name">itemUploadToMygpo</property>
<property name="label" translatable="yes">Upload to my.gpodder.org</property>
<signal handler="on_upload_to_mygpo" name="activate"/>
</object>
</child>
<child>
<object class="GtkAction" id="itemDownloadFromMygpo">
<property name="name">itemDownloadFromMygpo</property>
<property name="label" translatable="yes">Download from my.gpodder.org</property>
<signal handler="on_download_from_mygpo" name="activate"/>
<object class="GtkAction" id="item_mygpo_settings">
<property name="name">item_mygpo_settings</property>
<property name="label" translatable="yes">my.gpodder.org Settings</property>
<signal handler="on_mygpo_settings_activate" name="activate"/>
</object>
</child>
<child>
@ -434,8 +427,7 @@
<separator/>
<menuitem action="itemMassUnsubscribe"/>
<separator/>
<menuitem action="itemUploadToMygpo"/>
<menuitem action="itemDownloadFromMygpo"/>
<menuitem action="item_mygpo_settings"/>
<menuitem action="item_goto_mygpo"/>
</menu>
<menu action="menuChannels">

View File

@ -89,16 +89,6 @@
<property name="label" translatable="yes">Export to OPML file</property>
<signal handler="on_itemExportChannels_activate" name="activate"/>
</object>
<object class="GtkAction" id="itemUploadToMygpo">
<property name="name">itemUploadToMygpo</property>
<property name="label" translatable="yes">Upload to my.gpodder.org</property>
<signal handler="on_upload_to_mygpo" name="activate"/>
</object>
<object class="GtkAction" id="itemDownloadFromMygpo">
<property name="name">itemDownloadFromMygpo</property>
<property name="label" translatable="yes">Download from my.gpodder.org</property>
<signal handler="on_download_from_mygpo" name="activate"/>
</object>
<object class="GtkRadioAction" id="item_view_podcasts_all">
<property name="name">item_view_podcasts_all</property>
<property name="label" translatable="yes">All</property>

View File

@ -162,17 +162,16 @@
</object>
</child>
<child>
<object class="GtkAction" id="itemUploadToMygpo">
<property name="name">itemUploadToMygpo</property>
<property name="label" translatable="yes">Upload to my.gpodder.org</property>
<signal handler="on_upload_to_mygpo" name="activate"/>
<object class="GtkAction" id="item_mygpo_settings">
<property name="name">item_mygpo_settings</property>
<property name="label" translatable="yes">my.gpodder.org Settings</property>
<signal handler="on_mygpo_settings_activate" name="activate"/>
</object>
</child>
<child>
<object class="GtkAction" id="itemDownloadFromMygpo">
<property name="name">itemDownloadFromMygpo</property>
<property name="label" translatable="yes">Download from my.gpodder.org</property>
<signal handler="on_download_from_mygpo" name="activate"/>
<object class="GtkAction" id="item_goto_mygpo">
<property name="label" translatable="yes">Go to my.gpodder.org</property>
<signal handler="on_goto_mygpo" name="activate"/>
</object>
</child>
<child>
@ -391,8 +390,8 @@
<menuitem action="item_import_from_file"/>
<menuitem action="itemExportChannels"/>
<separator/>
<menuitem action="itemUploadToMygpo"/>
<menuitem action="itemDownloadFromMygpo"/>
<menuitem action="item_mygpo_settings"/>
<menuitem action="item_goto_mygpo"/>
</menu>
<menu action="menuChannels">
<menuitem action="itemPlaySelected"/>

View File

@ -0,0 +1,248 @@
<?xml version="1.0"?>
<!--*- mode: xml -*-->
<interface>
<object class="GtkDialog" id="MygPodderSettings">
<property name="default_height">260</property>
<property name="default_width">320</property>
<property context="yes" name="title" translatable="yes">my.gPodder.org settings</property>
<property name="type_hint">dialog</property>
<property name="visible">True</property>
<child internal-child="vbox">
<object class="GtkVBox" id="vbox">
<property name="border_width">2</property>
<property name="visible">True</property>
<child>
<object class="GtkTable" id="table">
<property name="border_width">12</property>
<property name="column_spacing">6</property>
<property name="n_columns">3</property>
<property name="n_rows">9</property>
<property name="row_spacing">6</property>
<property name="visible">True</property>
<child>
<object class="GtkLabel" id="label_general">
<property context="yes" name="label" translatable="yes">&lt;b&gt;General&lt;/b&gt;</property>
<property name="use_markup">True</property>
<property name="visible">True</property>
<property name="xalign">0.0</property>
</object>
<packing>
<property name="right_attach">3</property>
<property name="y_options">fill</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="checkbutton_enable">
<property context="yes" name="label" translatable="yes">Enable synchronization of subscription list</property>
<property name="visible">True</property>
</object>
<packing>
<property name="bottom_attach">2</property>
<property name="right_attach">3</property>
<property name="top_attach">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_username">
<property context="yes" name="label" translatable="yes">Username:</property>
<property name="visible">True</property>
<property name="xalign">1.0</property>
</object>
<packing>
<property name="bottom_attach">3</property>
<property name="top_attach">2</property>
<property name="x_options">fill</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_password">
<property context="yes" name="label" translatable="yes">Password:</property>
<property name="visible">True</property>
<property name="xalign">1.0</property>
</object>
<packing>
<property name="bottom_attach">4</property>
<property name="top_attach">3</property>
<property name="x_options">fill</property>
</packing>
</child>
<child>
<object class="GtkHSeparator" id="hseparator">
<property name="visible">True</property>
</object>
<packing>
<property name="bottom_attach">5</property>
<property name="right_attach">3</property>
<property name="top_attach">4</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_device">
<property context="yes" name="label" translatable="yes">&lt;b&gt;Device configuration&lt;/b&gt;</property>
<property name="use_markup">True</property>
<property name="visible">True</property>
<property name="xalign">0.0</property>
</object>
<packing>
<property name="bottom_attach">6</property>
<property name="right_attach">3</property>
<property name="top_attach">5</property>
<property name="y_options">fill</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_uid">
<property context="yes" name="label" translatable="yes">UID:</property>
<property name="visible">True</property>
<property name="xalign">1.0</property>
</object>
<packing>
<property name="bottom_attach">7</property>
<property name="top_attach">6</property>
<property name="x_options">fill</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_caption">
<property context="yes" name="label" translatable="yes">Caption:</property>
<property name="visible">True</property>
<property name="xalign">1.0</property>
</object>
<packing>
<property name="bottom_attach">8</property>
<property name="top_attach">7</property>
<property name="x_options">fill</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_type">
<property context="yes" name="label" translatable="yes">Type:</property>
<property name="visible">True</property>
<property name="xalign">1.0</property>
</object>
<packing>
<property name="bottom_attach">9</property>
<property name="top_attach">8</property>
<property name="x_options">fill</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="entry_username">
<property name="visible">True</property>
</object>
<packing>
<property name="bottom_attach">3</property>
<property name="left_attach">1</property>
<property name="right_attach">3</property>
<property name="top_attach">2</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="entry_password">
<property name="visibility">False</property>
<property name="is_focus">True</property>
<property name="visible">True</property>
</object>
<packing>
<property name="bottom_attach">4</property>
<property name="left_attach">1</property>
<property name="right_attach">3</property>
<property name="top_attach">3</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="entry_uid">
<property name="visible">True</property>
</object>
<packing>
<property name="bottom_attach">7</property>
<property name="left_attach">1</property>
<property name="right_attach">2</property>
<property name="top_attach">6</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="entry_caption">
<property name="visible">True</property>
</object>
<packing>
<property name="bottom_attach">8</property>
<property name="left_attach">1</property>
<property name="right_attach">3</property>
<property name="top_attach">7</property>
</packing>
</child>
<child>
<object class="GtkComboBox" id="combo_type">
<property name="visible">True</property>
</object>
<packing>
<property name="bottom_attach">9</property>
<property name="left_attach">1</property>
<property name="right_attach">3</property>
<property name="top_attach">8</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<object class="GtkButton" id="button_list_uids">
<property context="yes" name="label" translatable="yes">List UIDs</property>
<property name="visible">True</property>
<property name="sensitive">False</property>
<signal handler="on_button_list_uids_clicked" name="clicked"/>
</object>
<packing>
<property name="bottom_attach">7</property>
<property name="left_attach">2</property>
<property name="right_attach">3</property>
<property name="top_attach">6</property>
<property name="x_options">fill</property>
<property name="y_options"></property>
</packing>
</child>
</object>
<packing>
<property name="position">2</property>
</packing>
</child>
<child internal-child="action_area">
<object class="GtkHButtonBox" id="action_area">
<property name="border_width">5</property>
<property name="layout_style">end</property>
<property name="spacing">6</property>
<property name="visible">True</property>
<child>
<object class="GtkButton" id="button_cancel">
<property name="label">gtk-cancel</property>
<property name="use_stock">True</property>
<property name="visible">True</property>
<signal handler="on_button_cancel_clicked" name="clicked"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
</packing>
</child>
<child>
<object class="GtkButton" id="button_save">
<property name="label">gtk-save</property>
<property name="use_stock">True</property>
<property name="visible">True</property>
<signal handler="on_button_save_clicked" name="clicked"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="pack_type">end</property>
</packing>
</child>
</object>
</child>
</object>
</interface>

View File

@ -41,6 +41,15 @@ except ImportError:
sys.exit(1)
del feedparser
try:
import mygpoclient
except ImportError:
print """
Error: Module "mygpoclient" not found. Please install "python-mygpoclient"
or download it from http://thpinfo.com/2010/mygpoclient/
"""
sys.exit(1)
del mygpoclient
# The User-Agent string for downloads
user_agent = 'gPodder/%s (+%s)' % (__version__, __url__)

View File

@ -232,16 +232,23 @@ gPodderSettings = {
'youtube_preferred_fmt_id': (int, 18,
('The preferred video format that should be downloaded from YouTube.')),
# Settings for my.gpodder.org
'my_gpodder_username': (str, '',
# my.gpodder.org general settings
'mygpo_username': (str, '',
("The user's gPodder web services username.")),
'my_gpodder_password': (str, '',
'mygpo_password': (str, '',
("The user's gPodder web services password.")),
'my_gpodder_autoupload': (bool, False,
("Upload the user's podcast list to the gPodder web services when "
"gPodder is closed.")),
'my_gpodder_service': (str, 'http://my.gpodder.org',
('The base URL of the my.gpodder.org service.')),
'mygpo_enabled': (bool, False,
("Synchronize subscriptions with the web service.")),
'mygpo_server': (str, 'my.gpodder.org',
('The hostname of the mygpo server in use.')),
# my.gpodder.org device-specific settings
'mygpo_device_uid': (str, '',
("The UID that is assigned to this installation.")),
'mygpo_device_caption': (str, '',
("The human-readable name of this installation.")),
'mygpo_device_type': (str, 'desktop',
("The type of the device gPodder is running on.")),
# Paned position
'paned_position': ( int, 200,

View File

@ -174,8 +174,8 @@ class gPodderPreferences(BuilderWidget):
self._config.videoplayer = new_value
def update_button_mygpo(self):
if self._config.my_gpodder_username:
self.button_mygpo.set_value(self._config.my_gpodder_username)
if self._config.mygpo_username:
self.button_mygpo.set_value(self._config.mygpo_username)
else:
self.button_mygpo.set_value(_('Not logged in'))

View File

@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
#
# gPodder - A media aggregator and podcast client
# Copyright (c) 2005-2010 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 <http://www.gnu.org/licenses/>.
#
# gpodder.gtkui.mygpodder- UI code for my.gpodder.org settings
# Thomas Perl <thpinfo.com>; 2010-01-19
import gtk
import gpodder
_ = gpodder.gettext
from gpodder.gtkui.interface.common import BuilderWidget
class MygPodderSettings(BuilderWidget):
# Valid types defined in mygpoclient.api.PodcastDevice
VALID_TYPES = (
('desktop', _('Desktop')),
('laptop', _('Laptop')),
('mobile', _('Mobile phone')),
('server', _('Server')),
('other', _('Other')),
)
# Columns IDs for the combo box model
C_ID, C_CAPTION = range(2)
def new(self):
active_index = 0
self._model = gtk.ListStore(str, str)
for index, data in enumerate(self.VALID_TYPES):
id, caption = data
if id == self.config.mygpo_device_type:
active_index = index
self._model.append(data)
self.combo_type.set_model(self._model)
cell = gtk.CellRendererText()
self.combo_type.pack_start(cell, True)
self.combo_type.add_attribute(cell, 'text', 1)
# Initialize the UI state with configuration settings
self.checkbutton_enable.set_active(self.config.mygpo_enabled)
self.entry_username.set_text(self.config.mygpo_username)
self.entry_password.set_text(self.config.mygpo_password)
self.entry_uid.set_text(self.config.mygpo_device_uid)
self.entry_caption.set_text(self.config.mygpo_device_caption)
self.combo_type.set_active(active_index)
def on_button_list_uids_clicked(self, button):
# FIXME: Not implemented yet
pass
def on_button_cancel_clicked(self, button):
# Ignore changed settings and close
self.main_window.destroy()
def on_button_save_clicked(self, button):
model = self.combo_type.get_model()
it = self.combo_type.get_active_iter()
device_type = model.get_value(it, self.C_ID)
# Update configuration and close
self.config.mygpo_enabled = self.checkbutton_enable.get_active()
self.config.mygpo_username = self.entry_username.get_text()
self.config.mygpo_password = self.entry_password.get_text()
self.config.mygpo_device_uid = self.entry_uid.get_text()
self.config.mygpo_device_caption = self.entry_caption.get_text()
self.config.mygpo_device_type = device_type
self.main_window.destroy()

View File

@ -18,6 +18,7 @@
#
import os
import cgi
import gtk
import gtk.gdk
import gobject
@ -88,6 +89,7 @@ from gpodder.gtkui.draw import draw_text_box_centered
from gpodder.gtkui.interface.common import BuilderWidget
from gpodder.gtkui.interface.common import TreeViewHelper
from gpodder.gtkui.interface.addpodcast import gPodderAddPodcast
from gpodder.gtkui.mygpodder import MygPodderSettings
if gpodder.ui.desktop:
from gpodder.gtkui.download import DownloadStatusModel
@ -468,10 +470,88 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.main_window.set_title(_('gPodder'))
hildon.hildon_gtk_window_take_screenshot(self.main_window, True)
# Set up the first instance of MygPoClient
self.mygpo_client = my.MygPoClient(self.config,
on_add_remove_podcasts=self.on_add_remove_podcasts_mygpo,
on_rewrite_url=self.on_rewrite_url_mygpo,
on_send_full_subscriptions=self.on_send_full_subscriptions)
util.idle_add(self.mygpo_client.schedule_podcast_sync)
# First-time users should be asked if they want to see the OPML
if not self.channels and not gpodder.ui.fremantle:
util.idle_add(self.on_itemUpdate_activate)
def on_add_remove_podcasts_mygpo(self, add_urls, remove_urls):
existing_urls = [c.url for c in self.channels]
# Columns for the episode selector window - just one...
columns = (
('description', None, None, _('Action')),
)
# A list of actions that have to be chosen from
actions = []
for url in add_urls:
if url not in existing_urls:
actions.append(my.Change(url, my.Change.ADD))
for url in remove_urls:
if url in existing_urls:
podcast_object = None
for podcast in self.channels:
if podcast.url == url:
podcast_object = podcast
break
actions.append(my.Change(url, my.Change.REMOVE, podcast))
def execute_podcast_actions(selected):
subscribe_list = [a.url for a in selected if a.change == a.ADD]
remove_list = [a.podcast for a in selected if a.change == a.REMOVE]
# Apply the accepted changes locally
self.add_podcast_list(subscribe_list)
self.remove_podcast_list(remove_list, confirm=False)
unselected = [a for a in actions if a not in selected]
add_urls = [a.url for a in unselected if a.change == a.REMOVE]
remove_urls = [a.url for a in unselected if a.change == a.ADD]
# Revert the declined changes on the server
self.mygpo_client.on_subscribe(add_urls)
self.mygpo_client.on_unsubscribe(remove_urls)
def ask():
# We're abusing the Episode Selector again ;) -- thp
gPodderEpisodeSelector(self.main_window, \
title=_('Confirm changes from my.gpodder.org'), \
instructions=_('Select the actions you want to carry out.'), \
episodes=actions, \
columns=columns, \
size_attribute=None, \
stock_ok_button=gtk.STOCK_APPLY, \
callback=execute_podcast_actions, \
_config=self.config)
if actions:
util.idle_add(ask)
def on_rewrite_url_mygpo(self, old_url, new_url):
# Called by the mygpo client if a local URL needs to be fixed
if not new_url:
return
for channel in self.channels:
if channel.url == old_url:
log('Updating URL of %s to %s', channel, new_url, sender=self)
channel.url = new_url
channel.save()
self.channel_list_changed = True
util.idle_add(self.update_episode_list_model)
return
def on_send_full_subscriptions(self):
# Send the full subscription list to the my.gpodder.org client
self.mygpo_client.on_subscribe([c.url for c in self.channels])
def on_podcast_selected(self, treeview, path, column):
# for Maemo 5's UI
model = treeview.get_model()
@ -2123,6 +2203,9 @@ class gPodder(BuilderWidget, dbus.service.Object):
error_messages.get(url, _('Unknown')))) for url in failed)
self.show_message(message, title, important=True)
# Upload subscription changes to my.gpodder.org
self.mygpo_client.on_subscribe(worked)
# If at least one podcast has been added, save and update all
if self.channel_list_changed:
self.save_channels_opml()
@ -2451,9 +2534,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
"""
if self.channels:
if self.save_channels_opml():
if self.config.my_gpodder_autoupload:
log('Uploading to my.gpodder.org on close', sender=self)
util.idle_add(self.on_upload_to_mygpo, None)
pass # FIXME: Add mygpo synchronization here
else:
self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'), important=True)
@ -2652,7 +2733,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
if self.channels:
self.update_feed_cache()
else:
gPodderWelcome(self.gPodder, center_on_widget=self.gPodder, show_example_podcasts_callback=self.on_itemImportChannels_activate, setup_my_gpodder_callback=self.on_download_from_mygpo)
gPodderWelcome(self.gPodder,
center_on_widget=self.gPodder,
show_example_podcasts_callback=self.on_itemImportChannels_activate,
setup_my_gpodder_callback=self.on_mygpo_settings_activate)
def download_episode_list_paused(self, episodes):
self.download_episode_list(episodes, True)
@ -2847,76 +2931,16 @@ class gPodder(BuilderWidget, dbus.service.Object):
gPodderPreferences(self.gPodder, _config=self.config, \
callback_finished=self.properties_closed, \
user_apps_reader=self.user_apps_reader, \
mygpo_login=lambda: self.require_my_gpodder_authentication(force_dialog=True))
mygpo_login=self.on_mygpo_settings_activate)
def on_itemDependencies_activate(self, widget):
gPodderDependencyManager(self.gPodder)
def require_my_gpodder_authentication(self, force_dialog=False):
if force_dialog or (not self.config.my_gpodder_username or not self.config.my_gpodder_password):
success, authentication = self.show_login_dialog(_('Login to my.gpodder.org'), _('Please enter your e-mail address and your password.'), username=self.config.my_gpodder_username, password=self.config.my_gpodder_password, username_prompt=_('E-Mail Address'), register_callback=lambda: util.open_website('http://my.gpodder.org/register'))
if success:
self.config.my_gpodder_username, self.config.my_gpodder_password = authentication
return True
else:
return False
return True
def on_goto_mygpo(self, widget):
client = my.MygPodderClient(self.config.my_gpodder_service, \
self.config.my_gpodder_username, \
self.config.my_gpodder_password)
client.open_website()
self.mygpo_client.open_website()
def on_download_from_mygpo(self, widget=None):
if self.require_my_gpodder_authentication():
client = my.MygPodderClient(self.config.my_gpodder_service, \
self.config.my_gpodder_username, self.config.my_gpodder_password)
opml_data = client.download_subscriptions()
if len(opml_data) > 0:
fp = open(gpodder.subscription_file, 'w')
fp.write(opml_data)
fp.close()
(added, skipped) = (0, 0)
i = opml.Importer(gpodder.subscription_file)
existing = [c.url for c in self.channels]
urls = [item['url'] for item in i.items if item['url'] not in existing]
skipped = len(i.items) - len(urls)
added = len(urls)
self.add_podcast_list(urls)
if added > 0:
d = {'added': added, 'skipped': skipped}
message = _('Added %(added)d new and skipped %(skipped)d existing subscriptions.')
self.show_message(message % d, _('Result of subscription download'), widget=self.treeChannels)
elif widget is not None:
self.show_message(_('Your local subscription list is up to date.'), _('Result of subscription download'), widget=self.treeChannels)
else:
self.config.my_gpodder_password = ''
self.on_download_from_mygpo(widget)
else:
self.show_message(_('Please set up your username and password first.'), _('Username and password needed'), important=True)
def on_upload_to_mygpo(self, widget):
if self.require_my_gpodder_authentication():
client = my.MygPodderClient(self.config.my_gpodder_service, \
self.config.my_gpodder_username, self.config.my_gpodder_password)
self.save_channels_opml()
success, messages = client.upload_subscriptions(gpodder.subscription_file)
if widget is not None:
if not success:
self.show_message('\n'.join(messages), _('Results of upload'), important=True)
self.config.my_gpodder_password = ''
self.on_upload_to_mygpo(widget)
else:
self.show_message('\n'.join(messages), _('Results of upload'), widget=self.treeChannels)
elif not success:
log('Upload to my.gpodder.org failed, but widget is None!', sender=self)
elif widget is not None:
self.show_message(_('Please set up your username and password first.'), _('Username and password needed'), important=True)
def on_mygpo_settings_activate(self, action=None):
settings = MygPodderSettings(self.main_window, config=self.config)
def on_itemAddChannel_activate(self, widget=None):
gPodderAddPodcast(self.gPodder, \
@ -2972,6 +2996,9 @@ class gPodder(BuilderWidget, dbus.service.Object):
progress = ProgressIndicator(title, info, parent=self.main_window)
def finish_deletion(select_url):
# Upload subscription list changes to the web service
self.mygpo_client.on_unsubscribe([c.url for c in channels])
# Re-load the channels and select the desired new channel
self.update_feed_cache(force_update=False, select_url_afterwards=select_url)
progress.on_finished()
@ -2995,7 +3022,11 @@ class gPodder(BuilderWidget, dbus.service.Object):
if len(channels) == 1:
# get the URL of the podcast we want to select next
position = self.channels.index(channel)
if channel in self.channels:
position = self.channels.index(channel)
else:
position = -1
if position == len(self.channels)-1:
# this is the last podcast, so select the URL
# of the item before this one (i.e. the "new last")
@ -3095,7 +3126,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.config.opml_url, \
self.add_podcast_list, \
self.on_itemAddChannel_activate, \
self.on_download_from_mygpo, \
self.on_mygpo_settings_activate, \
self.show_text_edit_dialog)
else:
dir = gPodderPodcastDirectory(self.main_window, _config=self.config, \

View File

@ -20,113 +20,260 @@
#
# my.py -- "my gPodder" service client
# Thomas Perl <thp@gpodder.org> 2008-12-08
# my.py -- mygpo Client Abstraction for gPodder
# Thomas Perl <thp@gpodder.org>; 2010-01-19
#
import gpodder
_ = gpodder.gettext
import atexit
import os
import threading
import time
from gpodder.liblogger import log
from gpodder import util
########################################################################
# Based on upload_test.py
# Copyright Michael Foord, 2004 & 2005.
# Released subject to the BSD License
# Please see http://www.voidspace.org.uk/documents/BSD-LICENSE.txt
# Scripts maintained at http://www.voidspace.org.uk/python/index.shtml
# E-mail fuzzyman@voidspace.org.uk
########################################################################
from mygpoclient import api
import urllib2
import mimetypes
import mimetools
import webbrowser
def encode_multipart_formdata(fields, files, BOUNDARY = '-----'+mimetools.choose_boundary()+'-----'):
""" Encodes fields and files for uploading.
fields is a sequence of (name, value) elements for regular form fields - or a dictionary.
files is a sequence of (name, filename, value) elements for data to be uploaded as files.
Return (content_type, body) ready for urllib2.Request instance
You can optionally pass in a boundary string to use or we'll let mimetools provide one.
"""
CRLF = '\r\n'
L = []
if isinstance(fields, dict):
fields = fields.items()
for (key, value) in fields:
L.append('--' + BOUNDARY)
L.append('Content-Disposition: form-data; name="%s"' % key)
L.append('')
L.append(value)
for (key, filename, value) in files:
filetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
L.append('--' + BOUNDARY)
L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename))
L.append('Content-Type: %s' % filetype)
L.append('')
L.append(value)
L.append('--' + BOUNDARY + '--')
L.append('')
body = CRLF.join(L)
content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
return content_type, body
def build_request(theurl, fields, files, txheaders=None):
"""Given the fields to set and the files to encode it returns a fully formed urllib2.Request object.
You can optionally pass in additional headers to encode into the opject. (Content-type and Content-length will be overridden if they are set).
fields is a sequence of (name, value) elements for regular form fields - or a dictionary.
files is a sequence of (name, filename, value) elements for data to be uploaded as files.
"""
content_type, body = encode_multipart_formdata(fields, files)
if not txheaders: txheaders = {}
txheaders['Content-type'] = content_type
txheaders['Content-length'] = str(len(body))
txheaders['User-agent'] = gpodder.user_agent
return urllib2.Request(theurl, body, txheaders)
try:
import simplejson as json
except ImportError:
import json
class MygPodderClient(object):
def __init__(self, service_uri, username, password):
self.service_uri = service_uri
self.username = username
self.password = password
class Change(object):
ADD, REMOVE = range(2)
def __init__(self, url, change, podcast=None):
self.url = url
self.change = change
self.podcast = podcast
@property
def description(self):
if self.change == self.ADD:
return _('Add %s') % self.url
else:
return _('Remove %s') % self.podcast.title
class Actions(object):
NONE = 0
SYNC_PODCASTS, \
UPLOAD_EPISODES, \
UPDATE_DEVICE = (1<<x for x in range(3))
class MygPoClient(object):
CACHE_FILE = 'mygpo.queue.json'
FLUSH_TIMEOUT = 10
FLUSH_RETRIES = 3
def __init__(self, config,
on_rewrite_url=lambda old_url, new_url: None,
on_add_remove_podcasts=lambda add_urls, remove_urls: None,
on_send_full_subscriptions=lambda: None):
self._cache = {'actions': Actions.NONE,
'add_podcasts': [],
'remove_podcasts': [],
'episodes': []}
self._config = config
self._client = None
# Callback for actions that need to be handled by the UI frontend
self._on_rewrite_url = on_rewrite_url
self._on_add_remove_podcasts = on_add_remove_podcasts
self._on_send_full_subscriptions = on_send_full_subscriptions
# Initialize the _client attribute and register with config
self.on_config_changed('mygpo_username')
assert self._client is not None
self._config.add_observer(self.on_config_changed)
# Initialize and load the local queue
self._cache_file = os.path.join(gpodder.home, self.CACHE_FILE)
try:
self._cache = json.loads(open(self._cache_file).read())
except Exception, e:
log('Cannot read cache file: %s', str(e), sender=self)
self._worker_thread = None
atexit.register(self._at_exit)
# Do the initial flush (in case any actions are queued)
self.flush()
def can_access_webservice(self):
return self._config.mygpo_enabled and self._config.mygpo_device_uid
def schedule_podcast_sync(self):
log('Scheduling podcast list sync', sender=self)
self.schedule(Actions.SYNC_PODCASTS)
def request_podcast_lists_in_cache(self):
if 'add_podcasts' not in self._cache:
self._cache['add_podcasts'] = []
if 'remove_podcasts' not in self._cache:
self._cache['remove_podcasts'] = []
def on_subscribe(self, urls):
self.request_podcast_lists_in_cache()
self._cache['add_podcasts'].extend(urls)
for url in urls:
if url in self._cache['remove_podcasts']:
self._cache['remove_podcasts'].remove(url)
self.schedule(Actions.SYNC_PODCASTS)
self.flush()
def on_unsubscribe(self, urls):
self.request_podcast_lists_in_cache()
self._cache['remove_podcasts'].extend(urls)
for url in urls:
if url in self._cache['add_podcasts']:
self._cache['add_podcasts'].remove(url)
self.schedule(Actions.SYNC_PODCASTS)
self.flush()
@property
def actions(self):
return self._cache.get('actions', Actions.NONE)
def _at_exit(self):
self._worker_proc(forced=True)
def _worker_proc(self, forced=False):
if not forced:
log('Worker thread waiting for timeout', sender=self)
time.sleep(self.FLUSH_TIMEOUT)
# Only work when enabled, UID set and allowed to work
if self.can_access_webservice() and \
(self._worker_thread is not None or forced):
self._worker_thread = None
log('Worker thread starting to work...', sender=self)
for retry in range(self.FLUSH_RETRIES):
if retry:
log('Retrying flush queue...', sender=self)
# Update the device first, so it can be created if new
if self.actions & Actions.UPDATE_DEVICE:
self.update_device()
if self.actions & Actions.SYNC_PODCASTS:
self.synchronize_subscriptions()
if self.actions & Actions.UPLOAD_EPISODES:
# TODO: Upload episode actions
pass
if not self.actions:
# No more pending actions. Ready to quit.
break
log('Flush completed (result: %d)', self.actions, sender=self)
self._dump_cache_to_file()
def _dump_cache_to_file(self):
try:
fp = open(self._cache_file, 'w')
fp.write(json.dumps(self._cache))
fp.close()
# FIXME: Atomic file write would be nice ;)
except Exception, e:
log('Cannot dump cache to file: %s', str(e), sender=self)
def flush(self):
if not self.actions:
return
if self._worker_thread is None:
self._worker_thread = threading.Thread(target=self._worker_proc)
self._worker_thread.setDaemon(True)
self._worker_thread.start()
else:
log('Flush already queued', sender=self)
def schedule(self, action):
if 'actions' not in self._cache:
self._cache['actions'] = 0
self._cache['actions'] |= action
self.flush()
def done(self, action):
if 'actions' not in self._cache:
self._cache['actions'] = 0
if action == Actions.SYNC_PODCASTS:
self._cache['add_podcasts'] = []
self._cache['remove_podcasts'] = []
self._cache['actions'] &= ~action
def on_config_changed(self, name=None, old_value=None, new_value=None):
if name in ('mygpo_username', 'mygpo_password', 'mygpo_server'):
self._client = api.MygPodderClient(self._config.mygpo_username,
self._config.mygpo_password, self._config.mygpo_server)
log('Reloading settings.', sender=self)
elif name.startswith('mygpo_device_'):
self.schedule(Actions.UPDATE_DEVICE)
if name == 'mygpo_device_uid':
# Reset everything because we have a new device ID
self._on_send_full_subscriptions()
self._cache['podcasts_since'] = 0
def synchronize_subscriptions(self):
try:
device_id = self._config.mygpo_device_uid
since = self._cache.get('podcasts_since', 0)
# Step 1: Pull updates from the server and notify the frontend
result = self._client.pull_subscriptions(device_id, since)
self._cache['podcasts_since'] = result.since
if result.add or result.remove:
log('Changes from server: add %d, remove %d', \
len(result.add), \
len(result.remove), \
sender=self)
self._on_add_remove_podcasts(result.add, result.remove)
# Step 2: Push updates to the server and rewrite URLs (if any)
add = list(set(self._cache.get('add_podcasts', [])))
remove = list(set(self._cache.get('remove_podcasts', [])))
if add or remove:
# Only do a push request if something has changed
result = self._client.update_subscriptions(device_id, add, remove)
self._cache['podcasts_since'] = result.since
for old_url, new_url in result.update_urls:
if new_url:
log('URL %s rewritten: %s', old_url, new_url, sender=self)
self._on_rewrite_url(old_url, new_url)
self.done(Actions.SYNC_PODCASTS)
return True
except Exception, e:
log('Cannot upload subscriptions: %s', str(e), sender=self, traceback=True)
return False
def update_device(self):
try:
log('Uploading device settings...', sender=self)
uid = self._config.mygpo_device_uid
caption = self._config.mygpo_device_caption
device_type = self._config.mygpo_device_type
self._client.update_device_settings(uid, caption, device_type)
log('Device settings uploaded.', sender=self)
self.done(Actions.UPDATE_DEVICE)
return True
except Exception, e:
log('Cannot update device %s: %s', uid, str(e), sender=self, traceback=True)
return False
def open_website(self):
webbrowser.open(self.service_uri, new=1)
def download_subscriptions(self):
theurl = self.service_uri+"/getlist"
args = {'username': self.username, 'password': self.password}
args = '&'.join(('%s=%s' % a for a in args.items()))
url = theurl + '?' + args
opml_data = util.urlopen(url).read()
return opml_data
def upload_subscriptions(self, filename):
theurl = self.service_uri+'/upload'
action = 'update-subscriptions'
fields = {'username': self.username, 'password': self.password, 'action': 'update-subscriptions', 'protocol': '0'}
opml_file = ('opml', 'subscriptions.opml', open(filename).read())
result = urllib2.urlopen(build_request(theurl, fields, [opml_file])).read()
messages = []
success = False
if '@GOTOMYGPODDER' in result:
self.open_website()
messages.append(_('Please have a look at the website for more information.'))
if '@SUCCESS' in result:
messages.append(_('Subscriptions uploaded.'))
success = True
elif '@AUTHFAIL' in result:
messages.append(_('Authentication failed.'))
elif '@PROTOERROR' in result:
messages.append(_('Protocol error.'))
else:
messages.append(_('Unknown response.'))
return success, messages
util.open_website('http://' + self._config.mygpo_server)