← Back to team overview

openlp-core team mailing list archive

[Merge] lp:~tomasgroth/openlp/remote-sync into lp:openlp

 

Tomas Groth has proposed merging lp:~tomasgroth/openlp/remote-sync into lp:openlp.

Requested reviews:
  OpenLP Core (openlp-core)

For more details, see:
https://code.launchpad.net/~tomasgroth/openlp/remote-sync/+merge/336573

This merge request is meant for initial feedback for this implementation of remote sync.

This implementation aims to support synchronizing songs, custom slides and services files, though only songs are currently supported.

The idea is to define a "framework" where it will relatively easy to add new backends. Currently there is one (mostly) working backend, which uses a simple folder structure (the folder could be placed in dropbox or SMB/NFS share). An untested backend that uses FTP is a simple extension of the folder based backend.

The main files to look at are:
openlp/plugins/remotesync/remotesyncplugin.py
openlp/plugins/remotesync/lib/backends/synchronizer.py
openlp/plugins/remotesync/lib/backends/foldersynchronizer.py

There are many todos:
 * Use Path Objects
 * Implement sync of custom slides
 * Implement sync services
 * Implement handling of detected conflicts.
-- 
Your team OpenLP Core is requested to review the proposed merge of lp:~tomasgroth/openlp/remote-sync into lp:openlp.
=== added directory 'openlp/plugins/remotesync'
=== added file 'openlp/plugins/remotesync/__init__.py'
--- openlp/plugins/remotesync/__init__.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/__init__.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2017 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+"""
+The :mod:`remotesync` module contains the Remote Sync plugin.  The remotesync plugin provides the ability to synchronize
+songs, custom slides and service-files between multiple OpenLP instances.
+"""

=== added directory 'openlp/plugins/remotesync/lib'
=== added file 'openlp/plugins/remotesync/lib/__init__.py'
--- openlp/plugins/remotesync/lib/__init__.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/lib/__init__.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2016 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+
+from .remotesynctab import RemoteSyncTab
+
+__all__ = ['RemoteSyncTab']

=== added directory 'openlp/plugins/remotesync/lib/backends'
=== added file 'openlp/plugins/remotesync/lib/backends/__init__.py'
--- openlp/plugins/remotesync/lib/backends/__init__.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/lib/backends/__init__.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2017 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################

=== added file 'openlp/plugins/remotesync/lib/backends/foldersynchronizer.py'
--- openlp/plugins/remotesync/lib/backends/foldersynchronizer.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/lib/backends/foldersynchronizer.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,326 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2017 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+import datetime
+import os
+import glob
+import shutil
+
+import logging
+
+from openlp.plugins.remotesync.lib.backends.synchronizer import Synchronizer, SyncItemType, ConflictException, \
+    LockException, ConflictReason
+from openlp.plugins.remotesync.lib.db import RemoteSyncItem
+
+log = logging.getLogger(__name__)
+
+
+class FolderSynchronizer(Synchronizer):
+    """
+    The FolderSynchronizer uses xml-files in a simple holder structure for synchronizing data.
+    The folder-structure looks like this:
+    <base-folder>
+    +---songs
+    |   +---history
+    |   +---deleted
+    +---customs
+    |   +---history
+    |   +---deleted
+    +---services
+    The files are named after the uuid generated for each song or custom slide, the id of the 
+    OpenLP instance and the version of the song, like this: {uuid}={version}={computer_id}.xml
+    An example could be: bd5bc6c2-4fd2-4a42-925f-48d00de835ec=4=churchpc1.xml
+    When a file is updated a lock file is created to signal that the song is locked. The filename
+    of the lock file is: {uuid}.lock={computer_id}={version}
+    As part of the updating of the file, the old version is moved to the appropriate history folder.
+    When a song/custom slide is deleted, its file is moved to the history-folder, and an empty file
+    named as the items uuid is placed in the deleted-folder.
+    """
+
+    def __init__(self, manager, base_folder_path, pc_id):
+        """
+        Initilize the synchronizer
+        :param manager:
+        :type Manager:
+        :param base_folder_path:
+        :type str:
+        :param pc_id:
+        :type str:
+        """
+        super(FolderSynchronizer, self).__init__(manager)
+        self.base_folder_path = base_folder_path
+        self.pc_id = pc_id
+        self.song_folder_path = os.path.join(self.base_folder_path, 'songs')
+        self.song_history_folder_path = os.path.join(self.song_folder_path, 'history')
+        self.song_deleted_folder_path = os.path.join(self.song_folder_path, 'deleted')
+        self.custom_folder_path = os.path.join(self.base_folder_path, 'customs')
+        self.custom_history_folder_path = os.path.join(self.custom_folder_path, 'history')
+        self.custom_deleted_folder_path = os.path.join(self.custom_folder_path, 'deleted')
+        self.service_folder_path = os.path.join(self.base_folder_path, 'services')
+
+    def check_configuration(self):
+        return True
+
+    def check_connection(self):
+        return os.path.exists(self.base_folder_path) and os.path.exists(
+            self.song_history_folder_path) and os.path.exists(self.custom_folder_path)
+
+    def initialize_remote(self):
+        os.makedirs(self.song_history_folder_path, exist_ok=True)
+        os.makedirs(self.custom_folder_path, exist_ok=True)
+        os.makedirs(self.service_folder_path, exist_ok=True)
+
+    def _get_file_list(self, path, mask):
+        return glob.glob(os.path.join(path, mask))
+
+    def _remove_lock_file(self, lock_filename):
+        os.remove(lock_filename)
+
+    def _move_file(self, src, dst):
+        shutil.move(src, dst)
+
+    def _create_file(self, filename, file_content):
+        out_file = open(filename, 'wt')
+        out_file.write(file_content)
+        out_file.close()
+
+    def _read_file(self, filename):
+        in_file = open(filename, 'rt')
+        content = in_file.read()
+        in_file.close()
+        return content
+
+    def check_for_remote_changes(self):
+        """
+        Check for changes in the remote/shared folder.
+        :return: True if one or more songs was updated, otherwise False
+        """
+        updated = False
+        song_files = self._get_file_list(self.song_folder_path, '*.xml')
+        conflicts = []
+        for song_file in song_files:
+            # skip conflicting files
+            if song_file in conflicts:
+                continue
+            # Check if this song is already sync'ed
+            filename = os.path.basename(song_file)
+            filename_elements = filename.split('=', 1)
+            uuid = filename_elements[0]
+            file_version = filename_elements[1].replace('.xml', '')
+            # Detect if there are multiple files for the same song, which would mean that we have a conflict
+            files = []
+            for song_file2 in song_files:
+                if uuid in song_file2:
+                    files.append(song_file2)
+            # if more than one song file has the same uuid, then we have a conflict
+            if len(files) > 1:
+                # Add conflicting files to the "blacklist"
+                conflicts += files
+                # Mark song as conflicted!
+                self.mark_item_for_conflict(SyncItemType.Song, uuid, ConflictReason.MultipleRemoteEntries)
+            existing_item = self.get_sync_item(uuid, SyncItemType.Song)
+            song_id = existing_item.item_id if existing_item else None
+            # If we do not have a local version or if the remote version is different, then we update
+            if not existing_item or existing_item.version != file_version:
+                log.debug('Local version (%s) and file version (%s) mismatch - updated triggered!' % (
+                    existing_item.version, file_version))
+                log.debug('About to fetch song: %s %d' % (uuid, song_id))
+                try:
+                    self.fetch_song(uuid, song_id)
+                except ConflictException as ce:
+                    log.debug('Conflict detected while fetching song %d / %s!' % (song_id, uuid))
+                    self.mark_item_for_conflict(SyncItemType.Song, uuid, ce.reason)
+                    continue
+                updated = True
+            # TODO: Check for deleted files
+        return updated
+
+    def _check_for_lock_file(self, type, path, uuid, first_sync_attempt, prev_lock_id):
+        """
+        Check for lock file. Raises exception if a valid lock file is found. If an expired lock file is found
+        it is deleted.
+        :param type:
+        :type str:
+        :param path:
+        :type str:
+        :param uuid:
+        :type str:
+        :param first_sync_attempt:
+        :type datetime:
+        :param prev_lock_id:
+        :type str:
+        """
+        existing_lock_file = self._get_file_list(path, uuid + '.lock*')
+        if existing_lock_file:
+            log.debug('Found a lock file!')
+            current_lock_id = existing_lock_file[0].split('.lock=')[-1]
+            if first_sync_attempt:
+                # Have we seen this lock before?
+                if current_lock_id == prev_lock_id:
+                    # If the lock is more than 60 seconds old it is deleted
+                    delta = datetime.datetime.now() - first_sync_attempt
+                    if delta.total_seconds() > 60:
+                        # Remove expired lock
+                        self._remove_lock_file(existing_lock_file[0])
+                    else:
+                        # Lock is still valid, keep waiting
+                        raise LockException(type, uuid, current_lock_id, first_sync_attempt)
+                else:
+                    # New lock encountered, now we have to wait - again
+                    raise LockException(type, uuid, current_lock_id, datetime.datetime.now())
+            else:
+                # New lock encountered, now we have to wait for it
+                raise LockException(type, uuid, current_lock_id, datetime.datetime.now())        
+
+    def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
+        """
+        Sends a song to the shared folder. Does the following:
+        1. Check for an existing lock, raise LockException if one found.
+        2. Check if the song already exists on remote. If so, check if the latest version is available locally, raise
+           ConflictException if the remote version is not known locally. If the latest version is known, create a lock
+           file and move the existing file to the history folder. If the song does not exists already, just create a
+           lock file.
+        3. Place file with song in folder.
+        4. Delete lock file.
+        :param song: The song object to synchronize
+        :param song_uuid: The uuid of the song
+        :param last_known_version: The last known version of the song
+        :param first_sync_attempt: If the song has been attempted synchronized before,
+                                  this is the timestamp of the first sync attempt.
+        :param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
+                             prevented the synchronization.
+        :return: The new version.
+        """
+        # Check for lock file. Will raise exception on lock
+        self._check_for_lock_file(SyncItemType.Song, self.song_folder_path, song_uuid, first_sync_attempt, prev_lock_id)
+        # Check if song already exists
+        existing_song_files = self._get_file_list(self.song_folder_path, song_uuid + '*.xml')
+        counter = -1
+        if existing_song_files:
+            # Handle case with multiple files returned, which indicates a conflict!
+            if len(existing_song_files) > 1:
+                raise ConflictException(SyncItemType.Song, song_uuid, ConflictReason.MultipleRemoteEntries)
+            existing_file = os.path.basename(existing_song_files[0])
+            filename_elements = existing_file.split('=')
+            counter = int(filename_elements[1])
+            if last_known_version:
+                current_local_counter = int(last_known_version.split('=')[0])
+                # Check if we do have the latest version locally, if not we flag a conflict
+                if current_local_counter != counter:
+                    raise ConflictException(SyncItemType.Song, song_uuid, ConflictReason.VersionMismatch)
+            counter += 1
+            # Create lock file
+            lock_filename = '{path}.lock={pcid}={counter}'.format(path=os.path.join(self.song_folder_path, song_uuid),
+                                                                  pcid=self.pc_id, counter=counter)
+            self._create_file(lock_filename, '')
+            # Move old file to history folder
+            self._move_file(os.path.join(self.song_folder_path, existing_file), self.song_history_folder_path)
+        else:
+            # TODO: Check for missing (deleted) file
+            lock_filename = '{path}.lock={pcid}={counter}'.format(path=os.path.join(self.song_folder_path, song_uuid),
+                                                                  pcid=self.pc_id, counter=counter)
+            counter += 1
+            # Create lock file
+            self._create_file(lock_filename, '')
+        # Put xml in file
+        version = '{counter}={computer_id}'.format(counter=counter, computer_id=self.pc_id)
+        xml = self.open_lyrics.song_to_xml(song, version)
+        new_filename = os.path.join(self.song_folder_path, song_uuid + "=" + version + '.xml')
+        new_tmp_filename = new_filename + '-tmp'
+        self._create_file(new_tmp_filename, xml)
+        self._move_file(new_tmp_filename, new_filename)
+        # Delete lock file
+        self._remove_lock_file(lock_filename)
+        return version
+
+    def fetch_song(self, song_uuid, song_id):
+        """
+        Fetch a specific song from the shared folder
+        :param song_uuid: uuid of the song
+        :param song_id: song db id, None if song does not yet exists in the song db
+        :return: The song object
+        """
+        # Check for lock file - is this actually needed? should we create a read lock?
+        if self._get_file_list(self.song_folder_path, song_uuid + '.lock'):
+            log.debug('Found a lock file! Ignoring it for now.')
+        existing_song_files = self._get_file_list(self.song_folder_path, song_uuid + '*')
+        if existing_song_files:
+            # Handle case with multiple files returned, which indicates a conflict!
+            if len(existing_song_files) > 1:
+                raise ConflictException(SyncItemType.Song, song_uuid, ConflictReason.MultipleRemoteEntries)
+            existing_file = os.path.basename(existing_song_files[0])
+            filename_elements = existing_file.split('=', 1)
+            song_uuid = filename_elements[0]
+            version = filename_elements[1]
+            xml = self._read_file(existing_song_files[0])
+            song = self.open_lyrics.xml_to_song(xml, update_song_id=song_id)
+            sync_item = self.manager.get_object_filtered(RemoteSyncItem, RemoteSyncItem.uuid == song_uuid)
+            if not sync_item:
+                sync_item = RemoteSyncItem()
+                sync_item.type = SyncItemType.Song
+                sync_item.item_id = song.id
+                sync_item.uuid = song_uuid
+            sync_item.version = version
+            self.manager.save_object(sync_item, True)
+            return song
+        else:
+            return None
+
+    def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
+        """
+        Delete song from the remote location. Does the following:
+        1. Check for an existing lock, raise LockException if one found.
+        2. Create a lock file and move the existing file to the history folder.
+        3. Place a file in the deleted folder, named after the song uuid. 
+        4. Delete lock file.
+        :param song_uuid:
+        :type str:
+        :param first_del_attempt:
+        :type DateTime:
+        :param prev_lock_id:
+        :type str:
+        """
+        # Check for lock file. Will raise exception on lock
+        self._check_for_lock_file(SyncItemType.Song, self.song_folder_path, song_uuid, first_del_attempt, prev_lock_id)
+        # Move the song xml file to the history folder
+        existing_song_files = self._get_file_list(self.song_folder_path, song_uuid + '*.xml')
+        if existing_song_files:
+            # Handle case with multiple files returned, which indicates a conflict!
+            if len(existing_song_files) > 1:
+                raise ConflictException(SyncItemType.Song, song_uuid, ConflictReason.MultipleRemoteEntries)
+            existing_file = os.path.basename(existing_song_files[0])
+            # Move old file to deleted folder
+            self._move_file(os.path.join(self.song_folder_path, existing_file), self.song_history_folder_path)
+        # Create a file in the deleted-folder
+        delete_filename = os.path.join(self.song_deleted_folder_path, song_uuid)
+        self._create_file(delete_filename, '')
+
+    def send_custom(self, custom):
+        pass
+
+    def fetch_custom(self):
+        pass
+
+    def send_service(self, service):
+        pass
+
+    def fetch_service(self):
+        pass

=== added file 'openlp/plugins/remotesync/lib/backends/ftpsynchronizer.py'
--- openlp/plugins/remotesync/lib/backends/ftpsynchronizer.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/lib/backends/ftpsynchronizer.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2017 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+
+import fnmatch
+from ftplib import FTP
+from io import TextIOBase
+
+from openlp.plugins.remotesync.lib.backends.foldersynchronizer import FolderSynchronizer
+
+
+class FtpSynchronizer(FolderSynchronizer):
+
+    def __init__(self, manager, base_folder_path, pc_id):
+        super(FtpSynchronizer, self).__init__(manager, base_folder_path, pc_id)
+        self.ftp = None
+
+    def check_configuration(self):
+        return True
+
+    def check_connection(self):
+        self.connect()
+        base_folder_content = self._get_file_list(self.base_folder_path, '*')
+        self.disconnect()
+
+    def initialize_remote(self):
+        self.connect()
+        self.ftp.mkd(self.song_history_folder_path)
+        self.ftp.mkd(self.custom_folder_path)
+        self.ftp.mkd(self.service_folder_path)
+        self.disconnect()
+
+    def connect(self):
+        # TODO: Also support FTP_TLS
+        self.ftp = FTP('123.server.ip', 'username','password')
+
+    def disconnect(self):
+        self.ftp.close()
+        self.ftp = None
+
+    def _get_file_list(self, path, mask):
+        file_list = self.ftp.nlst(path)
+        filtered_list = fnmatch.filter(file_list, mask)
+        return filtered_list
+
+    def _remove_lock_file(self, lock_filename):
+        self.ftp.remove(lock_filename)
+
+    def _move_file(self, src, dst):
+        self.ftp.move(src, dst)
+
+    def _create_file(self, filename, file_content):
+        text_stream = TextIOBase()
+        text_stream.write(file_content)
+        self.ftp.storbinary('STOR '+ filename, text_stream)
+
+    def _read_file(self, filename):
+        text_stream = TextIOBase()
+        self.ftp.retrbinary('RETR ' + filename, text_stream, 1024)
+        return text_stream.read()

=== added file 'openlp/plugins/remotesync/lib/backends/synchronizer.py'
--- openlp/plugins/remotesync/lib/backends/synchronizer.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/lib/backends/synchronizer.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,182 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2017 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+
+from sqlalchemy.sql import and_
+
+from openlp.core.common.settings import Settings
+from openlp.core.common.registry import Registry
+from openlp.plugins.remotesync.lib.db import RemoteSyncItem, ConflictItem
+from openlp.plugins.songs.lib.openlyricsxml import OpenLyrics
+
+
+class ConflictReason:
+    """
+    Conflict reason type definitions
+    """
+    MultipleRemoteEntries = 'MultipleRemoteEntries'
+    VersionMismatch = 'VersionMismatch'
+
+
+class ConflictException(Exception):
+    """
+    Exception thrown in case of conflicts
+    """
+    def __init__(self, type, uuid, reason):
+        self.type = type
+        self.uuid = uuid
+        self.reason = reason
+
+
+class LockException(Exception):
+    """
+    Exception thrown in case of a locked item
+    """
+    def __init__(self, type, uuid, lock_id, first_attempt):
+        self.type = type
+        self.uuid = uuid
+        self.lock_id = lock_id
+        self.first_attempt = first_attempt
+
+
+class SyncItemType:
+    """
+    Sync item type definitions
+    """
+    Song = 'song'
+    Custom = 'custom'
+
+class SyncItemAction:
+    """
+    Sync item Action definitions
+    """
+    Update = 'update'
+    Delete = 'delete'
+
+
+class Synchronizer(object):
+    """
+    The base class used for synchronization.
+    Any Synchronizer implementation must override the functions needed to actually synchronize songs, custom slides
+    and services.
+    """
+
+    def __init__(self, manager):
+        self.manager = manager
+        self.song_manager = Registry().get('songs_manager')
+        self.open_lyrics = OpenLyrics(Registry().get('songs_manager'))
+
+    def connect(self):
+        pass
+
+    def disconnect(self):
+        pass
+
+    def get_sync_item(self, uuid, type):
+        item = self.manager.get_object_filtered(RemoteSyncItem, and_(RemoteSyncItem.uuid == uuid,
+                                                                     RemoteSyncItem.type == type))
+        if item:
+            return item
+        else:
+            return None
+
+    def mark_item_for_conflict(self, type, uuid, reason):
+        """
+        Marks item as having a conflict
+        :param type: Type of the item
+        :param uuid: The uuid of the item
+        :param reason: The reason for the conflict
+        """
+        # Check if it is already marked with a conflict
+        item = self.manager.get_object_filtered(ConflictItem, and_(ConflictItem.uuid == uuid,
+                                                                   ConflictItem.conflict_reason == reason))
+        if not item:
+            item = ConflictItem()
+            item.type = type
+            item.uuid = uuid
+            item.conflict_reason = reason
+            self.manager.save_object(item)
+
+    def check_configuration(self):
+        return False
+
+    def check_connection(self):
+        return False
+
+    def initialize_remote(self):
+        pass
+
+    def check_for_remote_changes(self):
+        pass
+
+    def send_song(self, song, song_uuid, last_known_version, first_sync_attempt, prev_lock_id):
+        """
+        Sends a song to the remote location
+        :param song: The song object to synchronize
+        :param song_uuid: The uuid of the song
+        :param last_known_version: The last known version of the song
+        :param first_sync_attempt: If the song has been attempted synchronized before,
+                                  this is the timestamp of the first sync attempt.
+        :param prev_lock_id: If the song has been attempted synchronized before, this is the id of the lock that
+                             prevented the synchronization.
+        :return: The new version.
+        """
+        pass
+
+    def fetch_song(self, song_uuid, song_id):
+        """
+        Fetch a specific song from the remote location
+        :param song_uuid: uuid of the song
+        :param song_id: song db id, None if song does not yet exists in the song db
+        :return: The song object
+        """
+        pass
+
+    def delete_song(self, song_uuid, first_del_attempt, prev_lock_id):
+        """
+        Delete song from the remote location
+        :param song_uuid:
+        :type str:
+        :param first_del_attempt:
+        :type DateTime:
+        :param prev_lock_id:
+        :type str:
+        """
+        pass
+        
+    def send_custom(self, custom):
+        pass
+
+    def fetch_custom(self):
+        pass
+
+    def delete_custom(self):
+        pass
+
+    def send_service(self, service):
+        pass
+
+    def fetch_service(self):
+        pass
+
+    def serialize_custom(custom):
+        j_data = dict()
+        return j_data

=== added file 'openlp/plugins/remotesync/lib/backends/webservicesynchronizer.py'
--- openlp/plugins/remotesync/lib/backends/webservicesynchronizer.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/lib/backends/webservicesynchronizer.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2017 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+
+import requests
+
+from openlp.core.common import Settings, registry
+from openlp.core.lib.db import Manager
+from openlp.plugins.remotesync.lib.backends.synchronizer import Synchronizer
+from openlp.plugins.songs.lib.db import init_schema, Song
+
+
+class WebServiceSynchronizer(Synchronizer):
+    baseurl = 'http://localhost:8000/'
+    auth_token = 'afd9a4aa979534edf0015f7379cd6d61a52f9e10'
+
+    def __init__(self, *args, **kwargs):
+        port = kwargs['port']
+        address = kwargs['address']
+        self.auth_token = kwargs['auth_token']
+        self.base_url = '{}:{}/'.format(address, port)
+        self.manager = registry.Registry().get('songs_manager')
+        registry.Registry().register('remote_synchronizer', self)
+
+    @staticmethod
+    def _handle(response, expected_status_code=200):
+        if response.status_code != expected_status_code:
+            print('whoops got {} expected {}'.format(response.status_code, expected_status_code))
+        return response
+
+    def _get(self, url):
+        return self._handle(requests.get(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)}),
+                            200)
+
+    def _post(self, url, data, return_code):
+        return self._handle(requests.post(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)},
+                                          json=data), return_code)
+
+    def _put(self, url, data):
+        return self._handle(requests.put(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)},
+                                         data=data))
+
+    def _delete(self, url):
+        return self._handle(requests.delete(url, headers={'Authorization': 'access_token {}'.format(self.auth_token)}))
+
+    def check_connection(self):
+        return False
+
+    def send_song(self, song):
+        self._post('http://localhost:8000/songs/', song, 201)
+
+    def receive_songs(self):
+        self._get('http://localhost:8000/songs/')
+
+    def send_all_songs(self):
+        for song in self.manager.get_all_objects(Song):
+            self._post('http://localhost:8000/songs/', self.open_lyrics.song_to_xml(song), 201)

=== added file 'openlp/plugins/remotesync/lib/db.py'
--- openlp/plugins/remotesync/lib/db.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/lib/db.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2017 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+"""
+The :mod:`db` module provides the database and schema that is the backend for
+the Custom plugin
+"""
+from sqlalchemy import Column, Table, types
+from sqlalchemy.orm import mapper
+
+from openlp.core.lib.db import BaseModel, init_db
+
+
+class RemoteSyncItem(BaseModel):
+    """
+    RemosteSync model
+    """
+    pass
+
+
+class SyncQueueItem(BaseModel):
+    """
+    SyncQueue model
+    """
+    pass
+
+
+class ConflictItem(BaseModel):
+    """
+    Conflict model
+    """
+    pass
+
+
+def init_schema(url):
+    """
+    Setup the custom database connection and initialise the database schema
+
+    :param url:  The database to setup
+    """
+    session, metadata = init_db(url)
+
+    remote_sync_table = Table('remote_sync_map', metadata,
+                              Column('item_id', types.Integer(), primary_key=True),
+                              Column('type', types.Unicode(64), primary_key=True),
+                              Column('uuid', types.Unicode(36), nullable=False),
+                              Column('version', types.Unicode(64), nullable=False),
+                              )
+
+    sync_queue_table = Table('sync_queue_table', metadata,
+                             Column('item_id', types.Integer(), primary_key=True, nullable=False),
+                             Column('type', types.Unicode(64), primary_key=True, nullable=False),
+                             Column('action', types.Unicode(32)),
+                             Column('lock_id', types.Unicode(128)),
+                             Column('first_attempt', types.DateTime()),
+                             )
+
+    conflicts_table = Table('conflicts_table', metadata,
+                            Column('type', types.Unicode(64), primary_key=True, nullable=False),
+                            Column('uuid', types.Unicode(36), nullable=False),
+                            Column('conflict_reason', types.Unicode(64), nullable=False),
+                            )
+
+    mapper(RemoteSyncItem, remote_sync_table)
+    mapper(SyncQueueItem, sync_queue_table)
+    mapper(ConflictItem, conflicts_table)
+
+    metadata.create_all(checkfirst=True)
+    return session

=== added file 'openlp/plugins/remotesync/lib/remotesynctab.py'
--- openlp/plugins/remotesync/lib/remotesynctab.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/lib/remotesynctab.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,151 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2017 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+
+import os.path
+
+from PyQt5 import QtCore, QtGui, QtNetwork, QtWidgets
+
+from openlp.core.common.settings import Settings
+from openlp.core.common.registry import Registry
+from openlp.core.common.applocation import AppLocation
+from openlp.core.common.i18n import translate
+from openlp.core.lib import SettingsTab, build_icon
+
+
+class RemoteSyncTab(SettingsTab):
+    """
+    RemoteSyncTab is the RemoteSync settings tab in the settings dialog.
+    """
+    def __init__(self, parent, title, visible_title, icon_path):
+        super(RemoteSyncTab, self).__init__(parent, title, visible_title, icon_path)
+
+    def setupUi(self):
+        self.setObjectName('RemoteSyncTab')
+        super(RemoteSyncTab, self).setupUi()
+        self.server_settings_group_box = QtWidgets.QGroupBox(self.left_column)
+        self.server_settings_group_box.setObjectName('server_settings_group_box')
+        self.server_settings_layout = QtWidgets.QFormLayout(self.server_settings_group_box)
+        self.server_settings_layout.setObjectName('server_settings_layout')
+        self.address_label = QtWidgets.QLabel(self.server_settings_group_box)
+        self.address_label.setObjectName('address_label')
+        self.address_edit = QtWidgets.QLineEdit(self.server_settings_group_box)
+        self.address_edit.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
+        self.address_edit.setValidator(QtGui.QRegExpValidator(QtCore.QRegExp('\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}'),
+                                       self))
+        self.address_edit.setObjectName('address_edit')
+        self.server_settings_layout.addRow(self.address_label, self.address_edit)
+        self.port_label = QtWidgets.QLabel(self.server_settings_group_box)
+        self.port_label.setObjectName('port_label')
+        self.port_spin_box = QtWidgets.QSpinBox(self.server_settings_group_box)
+        self.port_spin_box.setMaximum(32767)
+        self.port_spin_box.setObjectName('port_spin_box')
+        self.server_settings_layout.addRow(self.port_label, self.port_spin_box)
+        self.left_layout.addWidget(self.server_settings_group_box)
+        self.auth_token_label = QtWidgets.QLabel(self.server_settings_group_box)
+        self.auth_token_label.setObjectName('auth_token_label')
+        self.auth_token = QtWidgets.QLineEdit(self.server_settings_group_box)
+        self.auth_token.setObjectName('auth_token')
+        self.server_settings_layout.addRow(self.auth_token_label, self.auth_token)
+        self.left_layout.addWidget(self.server_settings_group_box)
+
+        self.actions_group_box = QtWidgets.QGroupBox(self.left_column)
+        self.actions_group_box.setObjectName('actions_group_box')
+        self.actions_layout = QtWidgets.QFormLayout(self.actions_group_box)
+        self.actions_layout.setObjectName('actions_layout')
+
+        self.send_songs_btn = QtWidgets.QPushButton(self.actions_group_box)
+        self.send_songs_btn.setObjectName('send_songs_btn')
+        self.send_songs_btn.clicked.connect(self.on_send_songs_clicked)
+
+        self.receive_songs_btn = QtWidgets.QPushButton(self.actions_group_box)
+        self.receive_songs_btn.setObjectName('receive_songs_btn')
+        self.receive_songs_btn.clicked.connect(self.on_receive_songs_clicked)
+        self.actions_layout.addRow(self.send_songs_btn, self.receive_songs_btn)
+        self.left_layout.addWidget(self.actions_group_box)
+
+        self.remote_statistics_group_box = QtWidgets.QGroupBox(self.left_column)
+        self.remote_statistics_group_box.setObjectName('remote_statistics_group_box')
+        self.remote_statistics_layout = QtWidgets.QFormLayout(self.remote_statistics_group_box)
+        self.remote_statistics_layout.setObjectName('remote_statistics_layout')
+        self.update_policy_label = QtWidgets.QLabel(self.remote_statistics_group_box)
+        self.update_policy_label.setObjectName('update_policy_label')
+        self.update_policy = QtWidgets.QLabel(self.remote_statistics_group_box)
+        self.update_policy.setObjectName('update_policy')
+        self.remote_statistics_layout.addRow(self.update_policy_label, self.update_policy)
+        self.last_sync_label = QtWidgets.QLabel(self.remote_statistics_group_box)
+        self.last_sync_label.setObjectName('last_sync_label')
+        self.last_sync = QtWidgets.QLabel(self.remote_statistics_group_box)
+        self.last_sync.setObjectName('last_sync')
+        self.remote_statistics_layout.addRow(self.last_sync_label, self.last_sync)
+        self.left_layout.addWidget(self.remote_statistics_group_box)
+
+        self.left_layout.addStretch()
+        self.right_column.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
+        self.right_layout.addStretch()
+
+    def retranslateUi(self):
+        self.server_settings_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Server Settings'))
+        self.actions_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Actions'))
+        self.remote_statistics_group_box.setTitle(translate('RemotePlugin.RemoteSyncTab', 'Remote Statistics'))
+        self.address_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Remote server ip address:'))
+        self.port_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Port number:'))
+        self.auth_token_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Auth Token:'))
+        self.receive_songs_btn.setText(translate('RemotePlugin.RemoteSyncTab', 'Receive Songs'))
+        self.send_songs_btn.setText(translate('RemotePlugin.RemoteSyncTab', 'Send Songs'))
+        self.update_policy_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Update Policy:'))
+        self.last_sync_label.setText(translate('RemotePlugin.RemoteSyncTab', 'Last Sync:'))
+
+    def load(self):
+        """
+        Load the configuration and update the server configuration if necessary
+        """
+        #self.port_spin_box.setValue(Settings().value(self.settings_section + '/port'))
+        #self.address_edit.setText(Settings().value(self.settings_section + '/ip address'))
+        #self.auth_token.setText(Settings().value(self.settings_section + '/auth token'))
+        pass
+
+    def save(self):
+        """
+        Save the configuration and update the server configuration if necessary
+        """
+        #Settings().setValue(self.settings_section + '/port', self.port_spin_box.value())
+        #Settings().setValue(self.settings_section + '/ip address', self.address_edit.text())
+        #Settings().setValue(self.settings_section + '/auth token', self.auth_token.text())
+        self.generate_icon()
+
+    def on_send_songs_clicked(self):
+        Registry().execute('synchronize_to_remote')
+        #self.remote_synchronizer.send_all_songs()
+
+    def on_receive_songs_clicked(self):
+        Registry().execute('synchronize_from_remote')
+        #self.remote_synchronizer.receive_songs()
+
+    def generate_icon(self):
+        """
+        Generate icon for main window
+        """
+        self.remote_sync_icon.hide()
+        icon = QtGui.QImage(':/remote/network_server.png')
+        icon = icon.scaled(80, 80, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
+        self.remote_sync_icon.setPixmap(QtGui.QPixmap.fromImage(icon))
+        self.remote_sync_icon.show()

=== added file 'openlp/plugins/remotesync/remotesyncplugin.py'
--- openlp/plugins/remotesync/remotesyncplugin.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/remotesync/remotesyncplugin.py	2018-01-24 20:33:29 +0000
@@ -0,0 +1,300 @@
+# -*- coding: utf-8 -*-
+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
+
+###############################################################################
+# OpenLP - Open Source Lyrics Projection                                      #
+# --------------------------------------------------------------------------- #
+# Copyright (c) 2008-2017 OpenLP Developers                                   #
+# --------------------------------------------------------------------------- #
+# This program 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; version 2 of the License.                              #
+#                                                                             #
+# This program 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, write to the Free Software Foundation, Inc., 59  #
+# Temple Place, Suite 330, Boston, MA 02111-1307 USA                          #
+###############################################################################
+"""
+The RemoteSync plugin makes it possible to synchronize songs, custom slides and service files.
+There is currently 2 different Synchronizer backends: FolderSynchronizer and FtpSynchronizer.
+When synchronizing there is 3 things to do:
+  1. Pull updates from the remote.
+  2. Push updates to the remote.
+  3. Handle conflicts.
+"""
+import logging
+import uuid
+from sqlalchemy.sql import and_
+from PyQt5 import QtWidgets, QtCore
+
+from openlp.core.common.settings import Settings
+from openlp.core.common.registry import Registry
+from openlp.core.lib import Plugin, StringContent, translate, build_icon
+from openlp.core.lib.db import Manager
+from openlp.plugins.remotesync.lib.backends.synchronizer import SyncItemType, SyncItemAction, ConflictException, LockException
+from openlp.plugins.songs.lib.db import Song
+
+from openlp.plugins.remotesync.lib import RemoteSyncTab
+from openlp.plugins.remotesync.lib.backends.foldersynchronizer import FolderSynchronizer
+from openlp.plugins.remotesync.lib.db import init_schema, SyncQueueItem, RemoteSyncItem
+
+log = logging.getLogger(__name__)
+
+__default_settings__ = {
+    'remotesync/db type': 'sqlite',
+    'remotesync/db username': '',
+    'remotesync/db password': '',
+    'remotesync/db hostname': '',
+    'remotesync/db database': '',
+    'remotesync/type': 'folder',  # folder or ftp
+    'remotesync/folder path': '/tmp/openlp_remote_sync',
+    'remotesync/folder pc id': 'firstpc',
+    'remotesync/ftp host': 'ftp.openlp.io',
+    'remotesync/ftp port': '21',
+    'remotesync/ftp ssl': False,
+    'remotesync/ftp username': 'username',
+    'remotesync/ftp password': 'password',
+}
+
+
+class RemoteSyncPlugin(Plugin):
+    log.info('RemoteSync Plugin loaded')
+
+    def __init__(self):
+        """
+        remotes constructor
+        """
+        super(RemoteSyncPlugin, self).__init__('remotesync', __default_settings__, settings_tab_class=RemoteSyncTab)
+        self.manager = Manager('remotesync', init_schema)
+        self.icon_path = ':/plugins/plugin_remote.png'
+        self.icon = build_icon(self.icon_path)
+        self.weight = -1
+        self.synchronizer = None
+
+    def initialise(self):
+        """
+        Initialise the remotesync plugin
+        """
+        log.debug('initialise')
+        super(RemoteSyncPlugin, self).initialise()
+        if not hasattr(self, 'remote_sync_icon'):
+            self.remote_sync_icon = QtWidgets.QLabel(self.main_window.status_bar)
+            size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
+            size_policy.setHorizontalStretch(0)
+            size_policy.setVerticalStretch(0)
+            size_policy.setHeightForWidth(self.remote_sync_icon.sizePolicy().hasHeightForWidth())
+            self.remote_sync_icon.setSizePolicy(size_policy)
+            self.remote_sync_icon.setFrameShadow(QtWidgets.QFrame.Plain)
+            self.remote_sync_icon.setLineWidth(1)
+            self.remote_sync_icon.setScaledContents(True)
+            self.remote_sync_icon.setFixedSize(20, 20)
+            self.remote_sync_icon.setObjectName('remote_sync_icon')
+            self.main_window.status_bar.insertPermanentWidget(2, self.remote_sync_icon)
+            self.settings_tab.remote_sync_icon = self.remote_sync_icon
+        # TODO: Generate a pc id
+        self.settings_tab.generate_icon()
+        sync_type = Settings().value('remotesync/type')
+        if sync_type == 'folder':
+            self.synchronizer = FolderSynchronizer(self.manager, Settings().value('remotesync/folder path'),
+                                                   Settings().value('remotesync/folder pc id'))
+        else:
+            self.synchronizer = None
+        if not self.synchronizer.check_connection():
+            self.synchronizer.initialize_remote()
+        # TODO: register delete functions
+        Registry().register_function('song_changed', self.queue_song_for_sync)
+        Registry().register_function('custom_changed', self.queue_custom_for_sync)
+        Registry().register_function('service_changed', self.save_service)
+        Registry().register_function('synchronize_to_remote', self.push_to_remote)
+        Registry().register_function('synchronize_from_remote', self.pull_from_remote)
+        Registry().register_function('song_deleted', self.queue_song_for_deletion)
+        self.startup_check()
+        # Set a timer to start the processing of the queue in 10 seconds
+        QtCore.QTimer.singleShot(10000, self.synchronize)
+
+    def finalise(self):
+        log.debug('finalise')
+        super(RemoteSyncPlugin, self).finalise()
+
+    @staticmethod
+    def about():
+        """
+        Information about this plugin
+        """
+        about_text = translate('RemoteSyncPlugin', '<strong>RemoteSync Plugin</strong>'
+                                                   '<br />The remotesync plugin provides the ability to synchronize '
+                                                   'songs, custom slides and service-files between multiple OpenLP '
+                                                   'instances.')
+        return about_text
+
+    def set_plugin_text_strings(self):
+        """
+        Called to define all translatable texts of the plugin
+        """
+        # Name PluginList
+        self.text_strings[StringContent.Name] = {
+            'singular': translate('RemoteSyncPlugin', 'RemoteSync', 'name singular'),
+            'plural': translate('RemoteSyncPlugin', 'RemotesSync', 'name plural')
+        }
+        # Name for MediaDockManager, SettingsManager
+        self.text_strings[StringContent.VisibleName] = {
+            'title': translate('RemoteSyncPlugin', 'RemoteSync', 'container title')
+        }
+
+    def startup_check(self):
+        """
+        Run through all songs and custom slides to see if they have been synchronized. Queue them if they have not
+        """
+        song_manager = Registry().get('songs_manager')
+        all_songs = song_manager.get_all_objects(Song)
+        for song in all_songs:
+            # TODO: Check that songs actually exists remotely - should we delete if not?
+            synced_songs = self.manager.get_object_filtered(RemoteSyncItem,
+                                                            and_(RemoteSyncItem.type == SyncItemType.Song,
+                                                                 RemoteSyncItem.item_id == song.id))
+            if not synced_songs:
+                self.queue_song_for_sync(song.id)
+        # TODO: Also check custom slides
+
+    def synchronize(self):
+        """
+        Synchronize by first pulling data from remote and then pushing local changes to the remote
+        """
+        self.synchronizer.connect()
+        self.pull_from_remote()
+        self.push_to_remote()
+        self.synchronizer.disconnect()
+        # Set a timer to start the synchronization again in 10 minutes.
+        QtCore.QTimer.singleShot(600000, self.synchronize)
+
+    def push_to_remote(self):
+        """
+        Run through the queue and push songs and custom slides to remote
+        """
+        queue_items = self.manager.get_all_objects(SyncQueueItem)
+        song_manager = Registry().get('songs_manager')
+        for queue_item in queue_items:
+            if queue_item.type == SyncItemType.Song:
+                if queue_item.action == SyncItemAction.Update:
+                    song = song_manager.get_object(Song, queue_item.item_id)
+                    # If song has not been sync'ed before we generate a uuid
+                    sync_item = self.manager.get_object_filtered(RemoteSyncItem,
+                                                                 and_(RemoteSyncItem.type == SyncItemType.Song,
+                                                                      RemoteSyncItem.item_id == song.id))
+                    if not sync_item:
+                        sync_item = RemoteSyncItem()
+                        sync_item.type = SyncItemType.Song
+                        sync_item.item_id = song.id
+                        sync_item.uuid = str(uuid.uuid4())
+                    # Synchronize the song
+                    try:
+                        version = self.synchronizer.send_song(song, sync_item.uuid, sync_item.version,
+                                                              queue_item.first_attempt, queue_item.lock_id)
+                    except ConflictException:
+                        log.debug('Conflict detected for song %d / %s' % (sync_item.item_id, sync_item.uuid))
+                        # TODO: Store the conflict in the DB and turn on the conflict icon
+                        continue
+                    except LockException as le:
+                        # Store the lock time in the DB and keep it in the queue
+                        log.debug('Lock detected for song %d / %s' % (sync_item.item_id, sync_item.uuid))
+                        queue_item.first_attempt = le.first_attempt
+                        queue_item.lock_id = le.lock_id
+                        self.manager.save_object(queue_item)
+                        continue
+                    sync_item.version = version
+                    # Save the RemoteSyncItem so we know which version we have locally
+                    self.manager.save_object(sync_item, True)
+                elif queue_item.action == SyncItemAction.Delete:
+                    # Delete the song
+                    try:
+                        version = self.synchronizer.delete_song(sync_item.uuid, sync_item.version,
+                                                                queue_item.first_attempt, queue_item.lock_id)
+                    except ConflictException:
+                        log.debug('Conflict detected for song %d / %s' % (sync_item.item_id, sync_item.uuid))
+                        # TODO: Store the conflict in the DB and turn on the conflict icon
+                        continue
+                    except LockException as le:
+                        # Store the lock time in the DB and keep it in the queue
+                        log.debug('Lock detected for song %d / %s' % (sync_item.item_id, sync_item.uuid))
+                        queue_item.first_attempt = le.first_attempt
+                        queue_item.lock_id = le.lock_id
+                        self.manager.save_object(queue_item)
+                        continue
+                    
+                # Delete the SyncQueueItem from the queue since the synchronization is now complete
+                self.manager.delete_all_objects(SyncQueueItem, and_(SyncQueueItem.item_id == queue_item.item_id,
+                                                                        SyncQueueItem.type == SyncItemType.Song))
+
+            elif queue_item.type == SyncItemType.Custom:
+                # TODO: Handle custom slides
+                pass
+
+    def queue_song_for_sync(self, song_id):
+        """
+        Put song in queue to be sync'ed
+        :param song_id:
+        """
+        # First check that the song isn't already in the queue
+        queue_item = self.manager.get_object_filtered(SyncQueueItem, and_(SyncQueueItem.item_id == song_id,
+                                                                          SyncQueueItem.type == SyncItemType.Song))
+        if not queue_item:
+            queue_item = SyncQueueItem()
+            queue_item.item_id = song_id
+            queue_item.type = SyncItemType.Song
+            queue_item.action = SyncItemAction.Update
+            self.manager.save_object(queue_item, True)
+
+    def queue_custom_for_sync(self, custom_id):
+        """
+        Put custom slide in queue to be sync'ed
+        :param custom_id:
+        """
+        # First check that the custom slide isn't already in the queue
+        queue_item = self.manager.get_object_filtered(SyncQueueItem, and_(SyncQueueItem.item_id == custom_id,
+                                                                          SyncQueueItem.type == SyncItemType.Custom))
+        if not queue_item:
+            queue_item = SyncQueueItem()
+            queue_item.item_id = custom_id
+            queue_item.type = SyncItemType.Custom
+            queue_item.action = SyncItemAction.Update
+            self.manager.save_object(queue_item, True)
+
+    def queue_song_for_deletion(self, song_id):
+        """
+        Put song in queue to be deleted
+        :param song_id:
+        """
+        queue_item = SyncQueueItem()
+        queue_item.item_id = song_id
+        queue_item.type = SyncItemType.Song
+        queue_item.action = SyncItemAction.Delete
+        self.manager.save_object(queue_item, True)
+
+    def queue_custom_for_deletion(self, custom_id):
+        """
+        Put custom slide in queue to be deleted
+        :param custom_id:
+        """
+        queue_item = SyncQueueItem()
+        queue_item.item_id = custom_id
+        queue_item.type = SyncItemType.Song
+        queue_item.action = SyncItemAction.Delete
+        self.manager.save_object(queue_item, True)
+
+    def pull_from_remote(self):
+        updated = self.synchronizer.check_for_remote_changes()
+        if updated:
+            Registry().execute('songs_load_list')
+
+    def save_service(self, service_item):
+        pass
+
+    def handle_conflicts(self):
+        # (Re)use the duplicate song UI to let the user manually handle the conflicts. Also allow for batch
+        # processing, where either local or remote always wins.
+        pass

=== modified file 'openlp/plugins/songs/forms/editsongform.py'
--- openlp/plugins/songs/forms/editsongform.py	2017-12-29 09:15:48 +0000
+++ openlp/plugins/songs/forms/editsongform.py	2018-01-24 20:33:29 +0000
@@ -1096,3 +1096,4 @@
         clean_song(self.manager, self.song)
         self.manager.save_object(self.song)
         self.media_item.auto_select_id = self.song.id
+        Registry().execute('song_changed', self.song.id)

=== modified file 'openlp/plugins/songs/lib/db.py'
--- openlp/plugins/songs/lib/db.py	2017-12-29 09:15:48 +0000
+++ openlp/plugins/songs/lib/db.py	2018-01-24 20:33:29 +0000
@@ -263,6 +263,9 @@
         * theme_name
         * search_title
         * search_lyrics
+        * created_date
+        * last_modified
+        * temporary
 
     **songs_songsbooks Table**
         This is a mapping table between the *songs* and the *song_books* tables. It has the following columns:

=== modified file 'openlp/plugins/songs/lib/mediaitem.py'
--- openlp/plugins/songs/lib/mediaitem.py	2017-12-29 09:15:48 +0000
+++ openlp/plugins/songs/lib/mediaitem.py	2018-01-24 20:33:29 +0000
@@ -514,6 +514,7 @@
                 item_id = item.data(QtCore.Qt.UserRole)
                 delete_song(item_id, self.plugin)
                 self.main_window.increment_progress_bar()
+                Registry().execute('song_deleted', self.song.id)
             self.main_window.finished_progress_bar()
             self.application.set_normal_cursor()
             self.on_search_text_button_clicked()

=== modified file 'openlp/plugins/songs/lib/openlyricsxml.py'
--- openlp/plugins/songs/lib/openlyricsxml.py	2017-12-29 09:15:48 +0000
+++ openlp/plugins/songs/lib/openlyricsxml.py	2018-01-24 20:33:29 +0000
@@ -227,7 +227,7 @@
         self.manager = manager
         FormattingTags.load_tags()
 
-    def song_to_xml(self, song):
+    def song_to_xml(self, song, version=None):
         """
         Convert the song to OpenLyrics Format.
         """
@@ -256,6 +256,9 @@
                 'verseOrder', properties, song.verse_order.lower())
         if song.ccli_number:
             self._add_text_to_element('ccliNo', properties, song.ccli_number)
+        # Add a custom version
+        if version:
+            self._add_text_to_element('version', properties, version)
         if song.authors_songs:
             authors = etree.SubElement(properties, 'authors')
             for author_song in song.authors_songs:
@@ -368,7 +371,7 @@
         end_tags.reverse()
         return ''.join(start_tags), ''.join(end_tags)
 
-    def xml_to_song(self, xml, parse_and_temporary_save=False):
+    def xml_to_song(self, xml, parse_and_temporary_save=False, update_song_id=None):
         """
         Create and save a song from OpenLyrics format xml to the database. Since we also export XML from external
         sources (e. g. OpenLyrics import), we cannot ensure, that it completely conforms to the OpenLyrics standard.
@@ -390,7 +393,10 @@
         # Formatting tags are new in OpenLyrics 0.8
         if float(song_xml.get('version')) > 0.7:
             self._process_formatting_tags(song_xml, parse_and_temporary_save)
-        song = Song()
+        if update_song_id:
+            song = self.manager.get_object(Song, update_song_id)
+        else:
+            song = Song()
         # Values will be set when cleaning the song.
         song.search_lyrics = ''
         song.verse_order = ''

=== modified file 'openlp/plugins/songs/songsplugin.py'
--- openlp/plugins/songs/songsplugin.py	2017-12-29 09:15:48 +0000
+++ openlp/plugins/songs/songsplugin.py	2018-01-24 20:33:29 +0000
@@ -90,6 +90,7 @@
         """
         super(SongsPlugin, self).__init__('songs', __default_settings__, SongMediaItem, SongsTab)
         self.manager = Manager('songs', init_schema, upgrade_mod=upgrade)
+        Registry().register('songs_manager', self.manager)
         self.weight = -10
         self.icon_path = ':/plugins/plugin_songs.png'
         self.icon = build_icon(self.icon_path)


Follow ups