mygpo: Better integration + Fremantle UI

Replace the JSON-based persistence layer with
"minidb", a SQLite-based object persistence
layer, and make the UI request changes from
the API client when it thinks it fits best.

Also, add a Hildonized UI of the mygpo settings
dialog for Maemo 5.
This commit is contained in:
Thomas Perl 2010-01-28 17:39:10 +01:00
parent a06739bdbc
commit f5b6cebfad
9 changed files with 886 additions and 247 deletions

View File

@ -0,0 +1,226 @@
<?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>
<signal handler="on_delete_event" name="delete-event"/>
<child internal-child="vbox">
<object class="GtkVBox" id="vbox">
<property name="border_width">2</property>
<property name="visible">True</property>
<child>
<object class="HildonPannableArea" id="pannable_area">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="size-request-policy">HILDON_SIZE_REQUEST_CHILDREN</property>
<child>
<object class="GtkViewport" id="pannable_viewport">
<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="HildonCheckButton" id="checkbutton_enable">
<property context="yes" name="label" translatable="yes">Enable synchronization of subscription list</property>
<property name="visible">True</property>
<property name="arrangement">HILDON_BUTTON_ARRANGEMENT_HORIZONTAL</property>
<property name="size">HILDON_SIZE_FINGER_HEIGHT</property>
<signal handler="on_enabled_toggled" name="toggled"/>
</object>
<packing>
<property name="bottom_attach">2</property>
<property name="right_attach">3</property>
<property name="top_attach">1</property>
<property name="x_options">fill expand</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="HildonButton" id="button_overwrite">
<property name="label" translatable="yes">Replace list on server with local subscriptions</property>
<property name="visible">True</property>
<property name="arrangement">HILDON_BUTTON_ARRANGEMENT_HORIZONTAL</property>
<property name="size">HILDON_SIZE_FINGER_HEIGHT</property>
<signal handler="on_button_overwrite_clicked" name="clicked"/>
</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">Device ID:</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">Device Name:</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="HildonEntry" id="entry_username">
<property name="visible">True</property>
<signal handler="on_username_changed" name="changed"/>
</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="HildonEntry" id="entry_password">
<property name="visibility">False</property>
<property name="is_focus">True</property>
<property name="visible">True</property>
<signal handler="on_password_changed" name="changed"/>
</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="GtkLabel" id="label_uid_value">
<property name="visible">True</property>
<property name="xalign">0.0</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="HildonEntry" id="entry_caption">
<property name="visible">True</property>
<signal handler="on_device_caption_changed" name="changed"/>
</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>
<signal handler="on_device_type_changed" name="changed"/>
</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="HildonButton" id="button_list_uids">
<property context="yes" name="label" translatable="yes">Select device</property>
<property name="visible">True</property>
<property name="arrangement">HILDON_BUTTON_ARRANGEMENT_HORIZONTAL</property>
<property name="size">HILDON_SIZE_FINGER_HEIGHT | HILDON_SIZE_AUTO_WIDTH</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>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View File

@ -7,6 +7,7 @@
<property context="yes" name="title" translatable="yes">my.gPodder.org settings</property>
<property name="type_hint">dialog</property>
<property name="visible">True</property>
<signal handler="on_delete_event" name="delete-event"/>
<child internal-child="vbox">
<object class="GtkVBox" id="vbox">
<property name="border_width">2</property>
@ -19,26 +20,15 @@
<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>
<signal handler="on_enabled_toggled" name="toggled"/>
</object>
<packing>
<property name="bottom_attach">2</property>
<property name="right_attach">2</property>
<property name="right_attach">3</property>
<property name="top_attach">1</property>
</packing>
</child>
@ -68,7 +58,7 @@
</child>
<child>
<object class="GtkButton" id="button_overwrite">
<property name="label" translatable="yes">Upload subscription list now (overwrite on server)</property>
<property name="label" translatable="yes">Replace list on server with local subscriptions</property>
<property name="visible">True</property>
<signal handler="on_button_overwrite_clicked" name="clicked"/>
</object>
@ -94,7 +84,7 @@
</child>
<child>
<object class="GtkLabel" id="label_uid">
<property context="yes" name="label" translatable="yes">UID:</property>
<property context="yes" name="label" translatable="yes">Device ID:</property>
<property name="visible">True</property>
<property name="xalign">1.0</property>
</object>
@ -106,7 +96,7 @@
</child>
<child>
<object class="GtkLabel" id="label_caption">
<property context="yes" name="label" translatable="yes">Caption:</property>
<property context="yes" name="label" translatable="yes">Device Name:</property>
<property name="visible">True</property>
<property name="xalign">1.0</property>
</object>
@ -131,6 +121,7 @@
<child>
<object class="GtkEntry" id="entry_username">
<property name="visible">True</property>
<signal handler="on_username_changed" name="changed"/>
</object>
<packing>
<property name="bottom_attach">3</property>
@ -144,6 +135,7 @@
<property name="visibility">False</property>
<property name="is_focus">True</property>
<property name="visible">True</property>
<signal handler="on_password_changed" name="changed"/>
</object>
<packing>
<property name="bottom_attach">4</property>
@ -153,9 +145,9 @@
</packing>
</child>
<child>
<object class="GtkEntry" id="entry_uid">
<object class="GtkLabel" id="label_uid_value">
<property name="visible">True</property>
<signal handler="on_device_settings_changed" name="changed"/>
<property name="xalign">0.0</property>
</object>
<packing>
<property name="bottom_attach">7</property>
@ -167,7 +159,7 @@
<child>
<object class="GtkEntry" id="entry_caption">
<property name="visible">True</property>
<signal handler="on_device_settings_changed" name="changed"/>
<signal handler="on_device_caption_changed" name="changed"/>
</object>
<packing>
<property name="bottom_attach">8</property>
@ -179,7 +171,7 @@
<child>
<object class="GtkComboBox" id="combo_type">
<property name="visible">True</property>
<signal handler="on_device_settings_changed" name="changed"/>
<signal handler="on_device_type_changed" name="changed"/>
</object>
<packing>
<property name="bottom_attach">9</property>
@ -216,30 +208,17 @@
<property name="spacing">6</property>
<property name="visible">True</property>
<child>
<object class="GtkButton" id="button_cancel">
<property name="label">gtk-cancel</property>
<object class="GtkButton" id="button_close">
<property name="label">gtk-close</property>
<property name="use_stock">True</property>
<property name="visible">True</property>
<signal handler="on_button_cancel_clicked" name="clicked"/>
<signal handler="on_button_close_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>

View File

@ -243,9 +243,9 @@ gPodderSettings = {
('The hostname of the mygpo server in use.')),
# my.gpodder.org device-specific settings
'mygpo_device_uid': (str, '',
'mygpo_device_uid': (str, util.get_hostname(),
("The UID that is assigned to this installation.")),
'mygpo_device_caption': (str, '',
'mygpo_device_caption': (str, _('gPodder on %s') % util.get_hostname(),
("The human-readable name of this installation.")),
'mygpo_device_type': (str, 'desktop',
("The type of the device gPodder is running on.")),

View File

@ -170,7 +170,8 @@ class GtkBuilderWidget(object):
is refered using self.vbox_dialog in the code.
"""
for widget in self.builder.get_objects():
if not hasattr(widget, 'get_name'):
# Just to be safe - every widget from the builder is buildable
if not isinstance(widget, gtk.Buildable):
continue
if isinstance(widget, gtk.ScrolledWindow):
@ -180,12 +181,11 @@ class GtkBuilderWidget(object):
widget_name = gtk.Buildable.get_name(widget)
widget_api_name = '_'.join(re.findall(tokenize.Name, widget_name))
widget.set_name(widget_api_name)
if hasattr(self, widget_api_name):
raise AttributeError("instance %s already has an attribute %s" % (self,widget_api_name))
else:
setattr(self, widget_api_name, widget)
@property
def main_window(self):
"""Returns the main window of this GtkBuilderWidget"""

View File

@ -127,17 +127,47 @@ class MygPodderSettings(BuilderWidget):
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.label_uid_value.set_label(self.config.mygpo_device_uid)
self.entry_caption.set_text(self.config.mygpo_device_caption)
self.combo_type.set_active(active_index)
self.button_overwrite.set_sensitive(True)
if gpodder.ui.fremantle:
self.checkbutton_enable.set_name('HildonButton-finger')
self.button_overwrite.set_name('HildonButton-finger')
self.button_list_uids.set_name('HildonButton-finger')
def on_device_settings_changed(self, widget):
self.button_overwrite.set_sensitive(False)
# Disable mygpo sync while the dialog is open
self._enable_mygpo = self.config.mygpo_enabled
self.config.mygpo_enabled = False
def on_enabled_toggled(self, widget):
# Only update indirectly (see on_delete_event)
self._enable_mygpo = widget.get_active()
def on_username_changed(self, widget):
self.config.mygpo_username = widget.get_text()
def on_password_changed(self, widget):
self.config.mygpo_password = widget.get_text()
def on_device_caption_changed(self, widget):
self.config.mygpo_device_caption = widget.get_text()
def on_device_type_changed(self, widget):
model = widget.get_model()
it = widget.get_active_iter()
device_type = model.get_value(it, self.C_ID)
self.config.mygpo_device_type = device_type
def on_button_overwrite_clicked(self, button):
threading.Thread(target=self.mygpo_client.force_fresh_upload).start()
title = _('Replace subscription list on server')
message = _('Remote podcasts that have not been added locally will be removed on the server. Continue?')
if self.show_confirmation(message, title):
def thread_proc():
self.config.mygpo_enabled = True
self.on_send_full_subscriptions()
self.config.mygpo_enabled = False
threading.Thread(target=thread_proc).start()
def on_button_list_uids_clicked(self, button):
indicator = ProgressIndicator(_('Downloading device list'),
@ -145,15 +175,34 @@ class MygPodderSettings(BuilderWidget):
False, self.main_window)
def thread_proc():
devices = self.mygpo_client.get_devices()
try:
devices = self.mygpo_client.get_devices()
except Exception, e:
indicator.on_finished()
def show_error(e):
if str(e):
message = str(e)
else:
message = e.__class__.__name__
self.show_message(message,
_('Error getting list'),
important=True)
util.idle_add(show_error, e)
return
indicator.on_finished()
def ui_callback(devices):
model = DeviceList(devices)
dialog = DeviceBrowser(model, self.main_window)
result = dialog.get_selected()
if result is not None:
uid, caption, device_type = result
self.entry_uid.set_text(uid)
# Update config and label with new UID
self.config.mygpo_device_uid = uid
self.label_uid_value.set_label(uid)
self.entry_caption.set_text(caption)
for index, data in enumerate(self.VALID_TYPES):
d_type, d_name = data
@ -164,22 +213,13 @@ class MygPodderSettings(BuilderWidget):
threading.Thread(target=thread_proc).start()
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
def on_delete_event(self, widget, event):
# Re-enable mygpo sync if the user has selected it
self.config.mygpo_enabled = self._enable_mygpo
# Flush settings for mygpo client now
self.mygpo_client.flush(now=True)
def on_button_close_clicked(self, button):
self.on_delete_event(self.main_window, None)
self.main_window.destroy()

View File

@ -389,12 +389,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
# load list of user applications for audio playback
self.user_apps_reader = UserAppsReader(['audio', 'video'])
def read_apps():
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)
threading.Thread(target=read_apps).start()
threading.Thread(target=self.user_apps_reader.read).start()
# Set the "Device" menu item for the first time
if gpodder.ui.desktop:
@ -471,17 +466,20 @@ class gPodder(BuilderWidget, dbus.service.Object):
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)
self.mygpo_client = my.MygPoClient(self.config)
# Do the initial sync with the web service
util.idle_add(self.mygpo_client.flush, True)
# 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):
def on_add_remove_podcasts_mygpo(self):
actions = self.mygpo_client.get_received_actions()
if not actions:
return False
existing_urls = [c.url for c in self.channels]
# Columns for the episode selector window - just one...
@ -490,63 +488,80 @@ class gPodder(BuilderWidget, dbus.service.Object):
)
# A list of actions that have to be chosen from
actions = []
changes = []
for url in add_urls:
if url not in existing_urls:
actions.append(my.Change(url, my.Change.ADD))
# Actions that are ignored (already carried out)
ignored = []
for url in remove_urls:
if url in existing_urls:
for action in actions:
if action.is_add and action.url not in existing_urls:
changes.append(my.Change(action))
elif action.is_remove and action.url in existing_urls:
podcast_object = None
for podcast in self.channels:
if podcast.url == url:
if podcast.url == action.url:
podcast_object = podcast
break
actions.append(my.Change(url, my.Change.REMOVE, podcast))
changes.append(my.Change(action, podcast_object))
else:
log('Ignoring action: %s', action, sender=self)
ignored.append(action)
# Confirm all ignored changes
self.mygpo_client.confirm_received_actions(ignored)
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]
add_list = [c.action.url for c in selected if c.action.is_add]
remove_list = [c.podcast for c in selected if c.action.is_remove]
# Apply the accepted changes locally
self.add_podcast_list(subscribe_list)
self.add_podcast_list(add_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)
# All selected items are now confirmed
self.mygpo_client.confirm_received_actions(c.action for c in selected)
# Revert the changes on the server
rejected = [c.action for c in changes if c not in selected]
self.mygpo_client.reject_received_actions(rejected)
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, \
episodes=changes, \
columns=columns, \
size_attribute=None, \
stock_ok_button=gtk.STOCK_APPLY, \
callback=execute_podcast_actions, \
_config=self.config)
if actions:
# There are some actions that need the user's attention
if changes:
util.idle_add(ask)
return True
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
# We have no remaining actions - no selection happens
return False
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 rewrite_urls_mygpo(self):
# Check if we have to rewrite URLs since the last add
rewritten_urls = self.mygpo_client.get_rewritten_urls()
for rewritten_url in rewritten_urls:
if not rewritten_url.new_url:
continue
for channel in self.channels:
if channel.url == rewritten_url.old_url:
log('Updating URL of %s to %s', channel, \
rewritten_url.new_url, sender=self)
channel.url = rewritten_url.new_url
channel.save()
self.channel_list_changed = True
util.idle_add(self.update_episode_list_model)
break
def on_send_full_subscriptions(self):
# Send the full subscription list to the my.gpodder.org client
@ -559,8 +574,14 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.mygpo_client.set_subscriptions([c.url for c in self.channels])
util.idle_add(self.show_message, _('List uploaded successfully.'))
except Exception, e:
util.idle_add(self.show_message, str(e), \
_('Error while uploading'), important=True)
def show_error(e):
message = str(e)
if not message:
message = e.__class__.__name__
self.show_message(message, \
_('Error while uploading'), \
important=True)
util.idle_add(show_error, e)
util.idle_add(indicator.on_finished)
@ -2220,6 +2241,9 @@ class gPodder(BuilderWidget, dbus.service.Object):
# If at least one podcast has been added, save and update all
if self.channel_list_changed:
# Fix URLs if mygpo has rewritten them
self.rewrite_urls_mygpo()
self.save_channels_opml()
# If only one podcast was added, select it after the update
@ -2447,7 +2471,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.channel_list_changed = True
self.update_podcast_list_model(select_url=select_url_afterwards)
return
# Fix URLs if mygpo has rewritten them
self.rewrite_urls_mygpo()
self.updating_feed_cache = True
if channels is None:
@ -2742,6 +2769,11 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.update_feed_cache(channels=[self.active_channel])
def on_itemUpdate_activate(self, widget=None):
# Check if we have outstanding subscribe/unsubscribe actions
if self.on_add_remove_podcasts_mygpo():
log('Update cancelled (received server changes)', sender=self)
return
if self.channels:
self.update_feed_cache()
else:
@ -2954,7 +2986,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
def on_mygpo_settings_activate(self, action=None):
settings = MygPodderSettings(self.main_window, \
config=self.config, \
mygpo_client=self.mygpo_client)
mygpo_client=self.mygpo_client, \
on_send_full_subscriptions=self.on_send_full_subscriptions)
def on_itemAddChannel_activate(self, widget=None):
gPodderAddPodcast(self.gPodder, \
@ -3350,6 +3383,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
def _on_auto_update_timer(self):
log('Auto update timer fired.', sender=self)
self.update_feed_cache(force_update=True)
# Ask web service for sub changes (if enabled)
self.mygpo_client.flush()
return True
def on_treeDownloads_row_activated(self, widget, *args):

164
src/gpodder/minidb.py Normal file
View File

@ -0,0 +1,164 @@
#!/usr/bin/python
# -*- 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.minidb - A simple SQLite store for Python objects
# Thomas Perl, 2010-01-28
# based on: "ORM wie eine Kirchenmaus - a very poor ORM implementation
# by thp, 2009-11-29 (thpinfo.com/about)"
# For Python 2.5, we need to request the "with" statement
from __future__ import with_statement
import sqlite3.dbapi2 as sqlite
import threading
class Store(object):
def __init__(self, filename=':memory:'):
self.db = sqlite.connect(filename, check_same_thread=False)
self.lock = threading.RLock()
def _schema(self, class_):
return class_.__name__, list(sorted(class_.__slots__))
def _set(self, o, slot, value):
setattr(o, slot, o.__class__.__slots__[slot](value))
def commit(self):
with self.lock:
self.db.commit()
def close(self):
with self.lock:
self.db.close()
def _register(self, class_):
with self.lock:
table, slots = self._schema(class_)
cur = self.db.execute('PRAGMA table_info(%s)' % table)
available = cur.fetchall()
if available:
available = [row[1] for row in available]
missing_slots = (s for s in slots if s not in available)
for slot in missing_slots:
self.db.execute('ALTER TABLE %s ADD COLUMN %s TEXT' % (table,
slot))
else:
self.db.execute('CREATE TABLE %s (%s)' % (table,
', '.join('%s TEXT'%s for s in slots)))
def update(self, o, **kwargs):
self.remove(o)
for k, v in kwargs.items():
setattr(o, k, v)
self.save(o)
def save(self, o):
if hasattr(o, '__iter__'):
for child in o:
self.save(child)
return
with self.lock:
self._register(o.__class__)
table, slots = self._schema(o.__class__)
values = [str(getattr(o, slot)) for slot in slots]
self.db.execute('INSERT INTO %s (%s) VALUES (%s)' % (table,
', '.join(slots), ', '.join('?'*len(slots))), values)
def remove(self, o):
if hasattr(o, '__iter__'):
for child in o:
self.remove(child)
return
with self.lock:
self._register(o.__class__)
table, slots = self._schema(o.__class__)
values = [getattr(o, slot) for slot in slots]
self.db.execute('DELETE FROM %s WHERE %s' % (table,
' AND '.join('%s=?'%s for s in slots)), values)
def load(self, class_, **kwargs):
with self.lock:
self._register(class_)
table, slots = self._schema(class_)
sql = 'SELECT %s FROM %s' % (', '.join(slots), table)
if kwargs:
sql += ' WHERE %s' % (' AND '.join('%s=?' % k for k in kwargs))
try:
cur = self.db.execute(sql, kwargs.values())
except Exception, e:
print sql
raise
def apply(row):
o = class_.__new__(class_)
for attr, value in zip(slots, row):
self._set(o, attr, value)
return o
return [apply(row) for row in cur.fetchall()]
def get(self, class_, **kwargs):
result = self.load(class_, **kwargs)
if result:
return result[0]
else:
return None
if __name__ == '__main__':
class Person(object):
__slots__ = {'username': str, 'id': int}
def __init__(self, username, id):
self.username = username
self.id = id
def __repr__(self):
return '<Person "%s" (%d)>' % (self.username, self.id)
m = Store()
m.save(Person('User %d' % x, x*20) for x in range(50))
p = m.get(Person, id=200)
print p
m.remove(p)
p = m.get(Person, id=200)
# Remove some persons again (deletion by value!)
m.remove(Person('User %d' % x, x*20) for x in range(40))
class Person(object):
__slots__ = {'username': str, 'id': int, 'mail': str}
def __init__(self, username, id, mail):
self.username = username
self.id = id
self.mail = mail
def __repr__(self):
return '<Person "%s" (%s)>' % (self.username, self.mail)
# A schema update takes place here
m.save(Person('User %d' % x, x*20, 'user@home.com') for x in range(50))
print m.load(Person)

View File

@ -35,6 +35,7 @@ import time
from gpodder.liblogger import log
from gpodder import util
from gpodder import minidb
# Append gPodder's user agent to mygpoclient's user agent
import mygpoclient
@ -42,112 +43,203 @@ mygpoclient.user_agent += ' ' + gpodder.user_agent
from mygpoclient import api
try:
import simplejson as json
except ImportError:
import json
class Change(object):
# Database model classes
class SinceValue(object):
__slots__ = {'host': str, 'device_id': str, 'category': int, 'since': int}
# Possible values for the "category" field
PODCASTS, EPISODES = range(2)
def __init__(self, host, device_id, category, since=0):
self.host = host
self.device_id = device_id
self.category = category
self.since = since
class SubscribeAction(object):
__slots__ = {'action_type': int, 'url': str}
# Possible values for the "action_type" field
ADD, REMOVE = range(2)
def __init__(self, url, change, podcast=None):
def __init__(self, action_type, url):
self.action_type = action_type
self.url = url
self.change = change
@property
def is_add(self):
return self.action_type == self.ADD
@property
def is_remove(self):
return self.action_type == self.REMOVE
@classmethod
def add(cls, url):
return cls(cls.ADD, url)
@classmethod
def remove(cls, url):
return cls(cls.REMOVE, url)
@classmethod
def undo(cls, action):
if action.is_add:
return cls(cls.REMOVE, action.url)
elif action.is_remove:
return cls(cls.ADD, action.url)
raise ValueError('Cannot undo action: %r' % action)
# New entity name for "received" actions
class ReceivedSubscribeAction(SubscribeAction): pass
class UpdateDeviceAction(object):
__slots__ = {'device_id': str, 'caption': str, 'device_type': str}
def __init__(self, device_id, caption, device_type):
self.device_id = device_id
self.caption = caption
self.device_type = device_type
class EpisodeAction(object):
__slots__ = {'podcast_url': str, 'episode_url': str, 'device_id': str,
'action': str, 'timestamp': int, 'position': str}
def __init__(self, podcast_url, episode_url, device_id, \
action, timestamp, position):
self.podcast_url = podcast_url
self.episode_url = episode_url
self.device_id = device_id
self.action = action
self.timestamp = timestamp
self.position = position
# New entity name for "received" actions
class ReceivedEpisodeAction(EpisodeAction): pass
class RewrittenUrl(object):
__slots__ = {'old_url': str, 'new_url': str}
def __init__(self, old_url, new_url):
self.old_url = old_url
self.new_url = new_url
# End Database model classes
# Helper class for displaying changes in the UI
class Change(object):
def __init__(self, action, podcast=None):
self.action = action
self.podcast = podcast
@property
def description(self):
if self.change == self.ADD:
return _('Add %s') % self.url
if self.action.is_add:
return _('Add %s') % self.action.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'
STORE_FILE = 'mygpo.queue.sqlite'
FLUSH_TIMEOUT = 60
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': []}
def __init__(self, config):
self._store = minidb.Store(os.path.join(gpodder.home, self.STORE_FILE))
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')
self.on_config_changed()
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._config.add_observer(self.on_config_changed)
self._worker_thread = None
atexit.register(self._at_exit)
# Do the initial flush (in case any actions are queued)
def get_rewritten_urls(self):
"""Returns a list of rewritten URLs for uploads
This should be called regularly. Every object returned
should be merged into the database, and the old_url
should be updated to new_url in every podcdast.
"""
rewritten_urls = self._store.load(RewrittenUrl)
self._store.remove(rewritten_urls)
return rewritten_urls
def get_received_actions(self):
"""Returns a list of ReceivedSubscribeAction objects
The list might be empty. All these actions have to
be processed. The user should confirm which of these
actions should be taken, the reest should be rejected.
Use confirm_received_actions and reject_received_actions
to return and finalize the actions received by this
method in order to not receive duplicate actions.
"""
return self._store.load(ReceivedSubscribeAction)
def confirm_received_actions(self, actions):
"""Confirm that a list of actions has been processed
The UI should call this with a list of actions that
have been accepted by the user and processed by the
podcast backend.
"""
# Simply remove the received actions from the queue
self._store.remove(actions)
def reject_received_actions(self, actions):
"""Reject (undo) a list of ReceivedSubscribeAction objects
The UI should call this with a list of actions that
have been rejected by the user. A reversed set of
actions will be uploaded to the server so that the
state on the server matches the state on the client.
"""
# Create "undo" actions for received subscriptions
self._store.save(SubscribeAction.undo(a) for a in actions)
self.flush()
# After we've handled the reverse-actions, clean up
self._store.remove(actions)
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 force_fresh_upload(self):
self._on_send_full_subscriptions()
def set_subscriptions(self, urls):
log('Uploading (overwriting) subscriptions...')
self._client.put_subscriptions(self._config.mygpo_device_uid, urls)
log('Subscription upload done.')
if self.can_access_webservice():
log('Uploading (overwriting) subscriptions...')
self._client.put_subscriptions(self._config.mygpo_device_uid, urls)
log('Subscription upload done.')
else:
raise Exception('Webservice access not enabled')
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)
# Cancel previously-inserted "remove" actions
self._store.remove(SubscribeAction.remove(url) for url in urls)
# Insert new "add" actions
self._store.save(SubscribeAction.add(url) for url in urls)
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)
# Cancel previously-inserted "add" actions
self._store.remove(SubscribeAction.add(url) for url in urls)
# Insert new "remove" actions
self._store.save(SubscribeAction.remove(url) for url in urls)
self.flush()
@property
@ -156,9 +248,14 @@ class MygPoClient(object):
def _at_exit(self):
self._worker_proc(forced=True)
self._store.commit()
self._store.close()
def _worker_proc(self, forced=False):
if not forced:
# Store the current contents of the queue database
self._store.commit()
log('Worker thread waiting for timeout', sender=self)
time.sleep(self.FLUSH_TIMEOUT)
@ -166,121 +263,202 @@ class MygPoClient(object):
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):
must_retry = False
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()
for action in self._store.load(UpdateDeviceAction):
if self.update_device(action):
self._store.remove(action)
else:
must_retry = True
if self.actions & Actions.SYNC_PODCASTS:
self.synchronize_subscriptions()
# Upload podcast subscription actions
actions = self._store.load(SubscribeAction)
if self.synchronize_subscriptions(actions):
self._store.remove(actions)
else:
must_retry = True
if self.actions & Actions.UPLOAD_EPISODES:
# TODO: Upload episode actions
pass
# Upload episode actions
actions = self._store.load(EpisodeAction)
if self.synchronize_episodes(actions):
self._store.remove(actions)
else:
must_retry = True
if not self.actions:
if not must_retry:
# No more pending actions. Ready to quit.
break
log('Flush completed (result: %d)', self.actions, sender=self)
self._dump_cache_to_file()
log('Worker thread finished.', sender=self)
else:
log('Worker thread may not execute (disabled).', sender=self)
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)
# Store the current contents of the queue database
self._store.commit()
def flush(self):
if not self.actions:
def flush(self, now=False):
if not self.can_access_webservice():
log('Flush requested, but sync disabled.', sender=self)
return
if self._worker_thread is None:
self._worker_thread = threading.Thread(target=self._worker_proc)
if self._worker_thread is None or now:
if now:
log('Flushing NOW.', sender=self)
else:
log('Flush requested.', sender=self)
self._worker_thread = threading.Thread(target=self._worker_proc, args=[now])
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
log('Flush requested, already waiting.', sender=self)
def on_config_changed(self, name=None, old_value=None, new_value=None):
if name in ('mygpo_username', 'mygpo_password', 'mygpo_server'):
if name in ('mygpo_username', 'mygpo_password', 'mygpo_server') \
or self._client is None:
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
threading.Thread(target=self.force_fresh_upload).start()
self._cache['podcasts_since'] = 0
# Remove all previous device update actions
self._store.remove(self._store.load(UpdateDeviceAction))
# Insert our new update action
action = UpdateDeviceAction(self._config.mygpo_device_uid, \
self._config.mygpo_device_caption, \
self._config.mygpo_device_type)
self._store.save(action)
def synchronize_episodes(self, actions):
log('Info: Episode sync disabled at the moment.', sender=self)
return True
log('Starting episode status sync.', sender=self)
def convert_to_api(action):
return api.EpisodeAction(action.podcast_url, \
action.episode_url, action.action, \
action.device_id, action.timestamp, \
action.position)
def convert_from_api(action):
return ReceivedEpisodeAction(action.podcast, \
action.episode, action.device, \
action.action, action.timestamp, \
action.position)
def synchronize_subscriptions(self):
try:
host = self._config.mygpo_server
device_id = self._config.mygpo_device_uid
since = self._cache.get('podcasts_since', 0)
# Load the "since" value from the database
since_o = self._store.get(SinceValue, host=host, \
device_id=device_id, \
category=SinceValue.EPISODES)
# Use a default since object for the first-time case
if since_o is None:
since_o = SinceValue(host, device_id, SinceValue.EPISODES)
# Step 1: Download Episode actions
changes = self._client.download_episode_actions(since_o.since, \
device_id=device_id)
received_actions = [convert_from_api(a) for a in changes.actions]
self._store.save(received_actions)
# Save the "since" value for later use
self._store.update(since_o, since=changes.since)
# Convert actions to the mygpoclient format for uploading
episode_actions = [convert_to_api(a) for a in actions]
# Upload the episodes and retrieve the new "since" value
since = self._client.upload_episode_actions(episode_actions)
# Update the "since" value of the episodes
self._store.update(since_o, since)
# Actions have been uploaded to the server - remove them
self._store.remove(actions)
log('Episode actions have been uploaded to the server.', sender=self)
return True
except Exception, e:
log('Cannot upload episode actions: %s', str(e), sender=self, traceback=True)
return False
def synchronize_subscriptions(self, actions):
log('Starting subscription sync.', sender=self)
try:
host = self._config.mygpo_server
device_id = self._config.mygpo_device_uid
# Load the "since" value from the database
since_o = self._store.get(SinceValue, host=host, \
device_id=device_id, \
category=SinceValue.PODCASTS)
# Use a default since object for the first-time case
if since_o is None:
since_o = SinceValue(host, device_id, SinceValue.PODCASTS)
# 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)
result = self._client.pull_subscriptions(device_id, since_o.since)
# Update the "since" value in the database
self._store.update(since_o, since=result.since)
# Store received actions for later retrieval (and in case we
# have outdated actions in the database, simply remove them)
for url in result.add:
log('Received add action: %s', url, sender=self)
self._store.remove(ReceivedSubscribeAction.remove(url))
self._store.save(ReceivedSubscribeAction.add(url))
for url in result.remove:
log('Received remove action: %s', url, sender=self)
self._store.remove(ReceivedSubscribeAction.add(url))
self._store.save(ReceivedSubscribeAction.remove(url))
# 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', [])))
actions = self._store.load(SubscribeAction)
add = [a.url for a in actions if a.is_add]
remove = [a.url for a in actions if a.is_remove]
if add or remove:
log('Uploading: +%d / -%d', len(add), len(remove), sender=self)
# Only do a push request if something has changed
result = self._client.update_subscriptions(device_id, add, remove)
self._cache['podcasts_since'] = result.since
# Update the "since" value in the database
self._store.update(since_o, since=result.since)
# Store URL rewrites for later retrieval by GUI
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)
log('Rewritten URL: %s', new_url, sender=self)
self._store.save(RewrittenUrl(old_url, new_url))
self.done(Actions.SYNC_PODCASTS)
# Actions have been uploaded to the server - remove them
self._store.remove(actions)
log('All actions have been uploaded to the server.', sender=self)
return True
except Exception, e:
log('Cannot upload subscriptions: %s', str(e), sender=self, traceback=True)
return False
def update_device(self):
def update_device(self, action):
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)
self._client.update_device_settings(action.device_id, \
action.caption, action.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)

View File

@ -38,6 +38,7 @@ import platform
import glob
import stat
import shlex
import socket
import re
import subprocess
@ -1357,3 +1358,17 @@ def run_external_command(command_line):
threading.Thread(target=open_process, args=(command_line,)).start()
def get_hostname():
"""Return the hostname of this computer
This can be implemented in a different way on each
platform and should yield a unique-per-user device ID.
"""
nodename = platform.node()
if nodename:
return nodename
# Fallback - but can this give us "localhost"?
return socket.gethostname()