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:
parent
a06739bdbc
commit
f5b6cebfad
|
@ -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"><b>Device configuration</b></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>
|
|
@ -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"><b>General</b></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>
|
||||
|
|
|
@ -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.")),
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in New Issue