Blob Blame History Raw
# upload_sync_controller
#
# Copyright (C) 2012-2017 Intel Corporation. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms and conditions of the GNU Lesser General Public License,
# version 2.1, as published by the Free Software Foundation.
#
# This program is distributed in the hope it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
# for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin St - Fifth Floor, Boston, MA 02110-1301 USA.
#
# Regis Merlino <regis.merlino@intel.com>
#

import ConfigParser
import os
import shutil
import urllib
import dbus
import gio
import glib

from mediaconsole import UPNP, MediaObject, Container, Device

class _UscUpnp(UPNP):
    def __init__(self):
        UPNP.__init__(self)

    def get_servers(self):
        return self._manager.GetServers()

class _UscDevice(Device):
    def __init__(self, path):
        Device.__init__(self, path)

    def create_container_in_any(self, name, type, child_types):
        return self._deviceIF.CreateContainerInAnyContainer(name, type,
                                                            child_types)

class _UscContainer(Container):
    def __init__(self, path):
        Container.__init__(self, path)

    def create_container(self, name, type, child_types):
        return self._containerIF.CreateContainer(name, type, child_types)

    def upload(self, name, file_path):
        return self._containerIF.Upload(name, file_path)

class UscError(Exception):
    """An Upload Sync Controller error."""

    def __init__(self, message):
        """
        message: description of the error
        """

        Exception.__init__(self, message)
        self.message = message

    def __str__(self):
        return 'UscError: ' + self.message

class _UscStore(object):
    REMOTE_ID_OPTION = 'remote_id'
    TYPE_OPTION = 'type'

    def __init__(self, root_path, server_id):
        if not os.path.exists(root_path):
            os.makedirs(root_path)

        self.__config_path = os.path.join(root_path, server_id + '.conf')

        self.__config = ConfigParser.ConfigParser()

    def initialize(self):
        self.__config.read(self.__config_path)

    def __write_config(self):
        with open(self.__config_path, 'wb') as configfile:
            self.__config.write(configfile)

    def remove(self):
        if os.path.exists(self.__config_path):
            os.remove(self.__config_path)

    def __parent_deleted(self, path, deleted_containers):
        for del_path in deleted_containers:
            if path.startswith(del_path):
                return True

        return False

    def remove_file(self, path):
        id = self.__config.get(path, self.REMOTE_ID_OPTION)
        print u'\tDeleting server object {0}'.format(id)
        try:
            obj = MediaObject(id)
            obj.delete()
        except:
            pass

        print u'\tDeleting local cache {0}'.format(path)
        self.__config.remove_section(path)

    def sync_deleted_files(self, path):
        deleted_containers = []

        sections = self.__config.sections()
        if not sections:
            return

        for path in sections:
            if self.__parent_deleted(path, deleted_containers):
                print u'\tDeleting local cache {0}'.format(path)
                self.__config.remove_section(path)
            elif not os.path.exists(path):
                if self.__config.get(path, self.TYPE_OPTION) == 'container':
                    deleted_containers.append(path + os.sep)

                self.remove_file(path)

        self.__write_config()

    def add_file(self, container, name, parent):
        new_path = os.path.join(parent, name)
        if os.path.isdir(new_path):
            if self.__config.has_section(new_path):
                id = self.__config.get(new_path, self.REMOTE_ID_OPTION)
            else:
                print u'\tCreating server container for {0}'.format(new_path)
                id = container.create_container(name, 'container', ['*'])
    
                print u'\tStoring cached data for {0}'.format(id)
                self.__config.add_section(new_path)
                self.__config.set(new_path, self.REMOTE_ID_OPTION, id)
                self.__config.set(new_path, self.TYPE_OPTION, 'container')

            new_container = _UscContainer(id)
            self.sync_added_files(new_container, new_path)
        elif not self.__config.has_section(new_path):
            print u'\tUploading file {0}'.format(new_path)
            (up, id) = container.upload(name, new_path)

            print u'\tStoring cached data for {0}'.format(id)
            self.__config.add_section(new_path)
            self.__config.set(new_path, self.REMOTE_ID_OPTION, id)
            self.__config.set(new_path, self.TYPE_OPTION, 'item')

    def sync_added_files(self, container, path):
        children = os.listdir(path)
        for child in children:
            self.add_file(container, child, path)

        self.__write_config()

    def set_root(self, path, id):
        self.__config.add_section(path)
        self.__config.set(path, self.REMOTE_ID_OPTION, id)
        self.__config.set(path, self.TYPE_OPTION, 'container')

        self.__write_config()

    def object_id_from_path(self, path):
        return self.__config.get(path, self.REMOTE_ID_OPTION)

    def rename_file(self, path1, path2):
        self.__config.add_section(path2)

        id = self.__config.get(path1, self.REMOTE_ID_OPTION)
        self.__config.set(path2, self.REMOTE_ID_OPTION, id)
        type = self.__config.get(path1, self.TYPE_OPTION)
        self.__config.set(path2, self.TYPE_OPTION, type)

        self.__config.remove_section(path1)

        self.__write_config()

class UscController(object):
    """An Upload Sync Controller sample app.

    The Upload Sync Controller propagates changes in a local folder to media
    servers (DMS/M-DMS) to be added to their list of available content.
    Media servers must expose the 'content-synchronization' capability to
    be managed by this controller.

    The four main methods are servers(), track(), add_server() and start_sync().
    * servers() lists the media servers available on the network
    * track() is used to set the local folder that is to be synchronized
    * add_server() is used to add a media server to the list of servers that
      are to be synchronized
    * start_sync() launches the servers synchronisation from the local storage

    Sample usage:
    >>> controller.servers()
    >>> controller.track('/home/user/local_folder')
    >>> controller.add_server('/com/intel/dLeynaServer/server/0')
    >>> controller.start_sync()
    """

    CONFIG_PATH = os.path.join(os.environ['HOME'],
                               '.config/upload-sync-controller.conf')
    ROOT_CONTAINER_ID_OPTION = 'root_container_id'
    DATA_PATH_SECTION = '__paths__'
    DATA_PATH_OPTION = 'data_path'
    TRACK_PATH_OPTION = 'track_path'

    def __init__(self, rel_path = None):
        """
        rel_path: if provided, contains the relative local database path,
                  from the user's HOME directory.
                  If not provided, the local database path will be
                  '$HOME/upload-sync-controller'
        """

        self.__upnp = _UscUpnp()

        self.__config = ConfigParser.ConfigParser()
        self.__config.read(UscController.CONFIG_PATH)
        
        if rel_path:
            self.__set_data_path(rel_path)
        elif not self.__config.has_section(UscController.DATA_PATH_SECTION):
            self.__set_data_path('upload-sync-controller')
            
        self.__store_path = self.__config.get(UscController.DATA_PATH_SECTION,
                                              UscController.DATA_PATH_OPTION)
        try:
            self.__track_path = self.__config.get(
                                              UscController.DATA_PATH_SECTION,
                                              UscController.TRACK_PATH_OPTION)
        except:
            self.__track_path = None

    def __write_config(self):
        with open(UscController.CONFIG_PATH, 'wb') as configfile:
            self.__config.write(configfile)

    def __set_data_path(self, rel_path):
        data_path = os.environ['HOME'] + os.sep + rel_path

        if not self.__config.has_section(UscController.DATA_PATH_SECTION):
            self.__config.add_section(UscController.DATA_PATH_SECTION)

        self.__config.set(UscController.DATA_PATH_SECTION,
                          UscController.DATA_PATH_OPTION, data_path)

        self.__write_config()

    def __check_trackable(self, server):
        try:
            try:
                srt = server.get_prop('ServiceResetToken')
            except:
                raise UscError("'ServiceResetToken' variable not supported")

            try:
                dlna_caps = server.get_prop('DLNACaps')
                if not 'content-synchronization' in dlna_caps:
                    raise
            except:
                raise UscError("'content-synchronization' cap not supported")

            try:
                search_caps = server.get_prop('SearchCaps')
                if not [x for x in search_caps if 'ObjectUpdateID' in x]:
                    raise
                if not [x for x in search_caps if 'ContainerUpdateID' in x]:
                    raise
            except:
                raise UscError("'objectUpdateID' search cap not supported")

            return srt
        except UscError as err:
            print err
            return None

    def __need_sync(self, servers):
        for item in servers:
            device = _UscDevice(item)
            uuid = device.get_prop('UDN')

            if self.__config.has_section(uuid):
                yield device, uuid, self.__config.has_option(uuid,
                                    UscController.ROOT_CONTAINER_ID_OPTION)

    def __remove_monitor(self, path):
        for item in self.__monitor_list.keys():
            if item.startswith(path):
                print u'\tStop monitoring {0}'.format(path)
                del self.__monitor_list[item]

    def __add_monitor(self, path):
        print u'\tStart monitoring {0}'.format(path)
        gfile = gio.File(path)
        monitor = gfile.monitor_directory(gio.FILE_MONITOR_SEND_MOVED, None)
        monitor.connect("changed", self.__directory_changed) 
        self.__monitor_list[path] = monitor

    def __monitor_directory(self, path):
        self.__add_monitor(path)
        children = os.listdir(path)
        for child in children:
            new_path = os.path.join(path, child)
            if os.path.isdir(new_path):
                self.__monitor_directory(new_path)

    def __start_monitoring(self):
        self.__monitor_list = {}
        self.__monitor_directory(self.__track_path)

        print u'Type ctrl-c to stop.'
        try:
            main_loop = glib.MainLoop()
            main_loop.run()
        except:
            print u'Monitoring stopped.'

    def __directory_changed(self, monitor, file1, file2, evt_type):
        print
        if evt_type == gio.FILE_MONITOR_EVENT_CREATED:
            (parent, name) = os.path.split(file1.get_path())
            for store in self.__store_list:
                parent_id = store.object_id_from_path(parent)
                container = _UscContainer(parent_id)
                store.add_file(container, name, parent)

            if os.path.isdir(file1.get_path()):
                self.__monitor_directory(file1.get_path())
        elif evt_type == gio.FILE_MONITOR_EVENT_MOVED:
            (parent1, name1) = os.path.split(file1.get_path())
            (parent2, name2) = os.path.split(file2.get_path())
            renamed = (parent1 == parent2)

            if file1.get_path() in self.__monitor_list:
                self.__remove_monitor(file1.get_path())

            for store in self.__store_list:
                object_id = store.object_id_from_path(file1.get_path())
                obj = MediaObject(object_id)
                dlna_managed = obj.get_prop('DLNAManaged')

                if renamed and dlna_managed['ChangeMeta']:
                    print u'\tRenaming {0} to {1}'.format(name1, name2)

                    props = {'DisplayName' : name2}
                    obj.update(props, [])

                    store.rename_file(file1.get_path(), file2.get_path())
                else:
                    store.remove_file(file1.get_path())

                    parent_id = store.object_id_from_path(parent2)
                    container = _UscContainer(parent_id)

                    store.add_file(container, name2, parent2)

            if os.path.isdir(file2.get_path()):
                self.__monitor_directory(file2.get_path())
        elif evt_type == gio.FILE_MONITOR_EVENT_DELETED:
            if file1.get_path() in self.__monitor_list:
                self.__remove_monitor(file1.get_path())
            
            for store in self.__store_list:
                store.remove_file(file1.get_path())
        else:
            return

        print u'Type ctrl-c to stop.'

    def track(self, track_path):
        """Sets the local folder that is to be synchronized."""
        
        self.__track_path = track_path
        self.__config.set(UscController.DATA_PATH_SECTION,
                          UscController.TRACK_PATH_OPTION, track_path)

        self.__write_config()

    def start_sync(self):
        """Performs a synchronization for all the media servers.

        Displays some progress information during the process.
        """

        if not self.__track_path:
            print u'Error: track path is not set'
            return

        self.__store_list = []
        print u'Syncing...'
        for device, uuid, init in self.__need_sync(self.__upnp.get_servers()):
            store = _UscStore(self.__store_path, uuid)
            store.initialize()
            self.__store_list.append(store)

            if not init:
                print u'Performing initial sync for server {0}:'.format(uuid)
                root = device.create_container_in_any('usc_root', 'container',
                                                      ['image'])
                self.__config.set(uuid, UscController.ROOT_CONTAINER_ID_OPTION,
                                  root)
                self.__write_config()
                store.set_root(self.__track_path, root)
            else:
                print u'Performing normal sync for server {0}:'.format(uuid)
                root = self.__config.get(uuid,
                                         UscController.ROOT_CONTAINER_ID_OPTION)

            print u'\tRoot container is {0}'.format(root)
            root_container = _UscContainer(root)
            store.sync_deleted_files(self.__track_path)
            store.sync_added_files(root_container, self.__track_path)
            print u'Done.'

        if len(self.__store_list) == 0:
            print u'Nothing to do, stopping.'
            return

        print u'Now monitoring local changes...'
        self.__start_monitoring()

    def servers(self):
        """Displays media servers available on the network.

        Displays media servers information as well as the synchronized status.
        """

        print u'Running servers:'

        for item in self.__upnp.get_servers():
            try:
                server = Container(item)
                try:
                    folder_name = server.get_prop('FriendlyName')
                except Exception:
                    folder_name = server.get_prop('DisplayName')
                device = Device(item)
                dev_uuid = device.get_prop('UDN')
                dev_path = device.get_prop('Path')

                print u'{0:<25}  Synchronized({2})  {3}  {1}'.format(
                                            folder_name,
                                            dev_path,
                                            self.__config.has_section(dev_uuid),
                                            dev_uuid)

            except dbus.exceptions.DBusException as err:
                print u'Cannot retrieve properties for ' + item
                print str(err).strip()[:-1]

    def synchronized(self):
        """Displays the list of servers currently synchronized."""

        print u'Synchronized servers:'

        for name in self.__config.sections():
            if name != UscController.DATA_PATH_SECTION:
                print u'{0:<30}'.format(name)

    def add_server(self, server_path):
        """Adds a media server to the controller's list.

        server_path: d-bus path for the media server
        """

        server = Device(server_path)
        server_uuid = server.get_prop('UDN')
        
        if not self.__config.has_section(server_uuid):
            srt = self.__check_trackable(server)
            if srt != None:
                self.__config.add_section(server_uuid)
                self.__write_config()
            else:
                print u"Sorry, the server {0} has no such capability and " \
                        "will not be synchronized.".format(server_path)

    def __remove_server(self, server_uuid):
        try:
            root = self.__config.get(server_uuid,
                                     UscController.ROOT_CONTAINER_ID_OPTION)
            MediaObject(root).delete()
        except:
            pass
        self.__config.remove_section(server_uuid)
        
        store = _UscStore(self.__store_path, server_uuid)
        store.remove()

    def remove_server(self, server_path):
        """Removes a media server from the controller's list.

        Also removes the server side synchronized file and folders.
        server_path: d-bus path for the media server
        """

        server = Device(server_path)
        server_uuid = server.get_prop('UDN')

        if self.__config.has_section(server_uuid):
            self.__remove_server(server_uuid)

        self.__write_config()

    def reset(self):
        """Removes all media servers from the controller's list
        
        Also removes the server side synchronized file and folders.
        """

        for name in self.__config.sections():
            if name != UscController.DATA_PATH_SECTION:
                self.__remove_server(name)

        self.__write_config()

if __name__ == '__main__':
    print u'An Upload Sync Controller sample app.'
    print
    controller = UscController()
    controller.servers()
    print
    print u'"controller" instance is ready for use.'
    print u'Type "help(UscController)" for more details and usage samples.'