Exemple #1
0
class UploadManager(object):
    """
    This class manages uploading content to the server MusicDB runs on.
    All data is stored in the uploads-directory configured in the MusicDB configuration.
    
    Args:
        config: :class:`~lib.cfg.musicdb.MusicDBConfig` object holding the MusicDB Configuration
        database: (optional) A :class:`~lib.db.musicdb.MusicDatabase` instance

    Raises:
        TypeError: When the arguments are not of the correct type.
    """
    def __init__(self, config, database):
        if type(config) != MusicDBConfig:
            raise TypeError("config argument not of type MusicDBConfig")
        if database != None and type(database) != MusicDatabase:
            raise TypeError(
                "database argument not of type MusicDatabase or None")

        self.db = database
        self.cfg = config
        self.uploadfs = Filesystem(self.cfg.uploads.path)
        self.musicfs = Filesystem(self.cfg.music.path)
        self.artworkfs = Filesystem(self.cfg.artwork.path)
        # TODO: check write permission of all directories
        self.fileprocessing = Fileprocessing(self.cfg.uploads.path)
        self.dbmanager = MusicDBDatabase(config, database)

        global Tasks
        if Tasks == None:
            self.LoadTasks()

    #####################################################################
    # Callback Function Management                                      #
    #####################################################################

    def RegisterCallback(self, function):
        """
        Register a callback function that reacts on Upload related events.
        For more details see the module description at the top of this document.

        Args:
            function: A function that shall be called on an event.

        Returns:
            *Nothing*
        """
        global Callbacks
        Callbacks.append(function)

    def RemoveCallback(self, function):
        """
        Removes a function from the list of callback functions.

        Args:
            function: A function that shall be called removed.

        Returns:
            *Nothing*
        """
        global Callbacks

        # Not registered? Then do nothing.
        if not function in Callbacks:
            logging.warning(
                "A Streaming Thread callback function should be removed, but did not exist in the list of callback functions!"
            )
            return

        Callbacks.remove(function)

    def NotifyClient(self, notification, task, message=None):
        """
        This method triggers a client-notification.

        There are three kind of notifications:

            * ``"ChunkRequest"``: A new chunk of data is requested
            * ``"StateUpdate"``: The state or annotations of an upload-task has been changed. See ``"state"`` value.
            * ``"InternalError"``: There is an internal error occurred during. See ``"message"`` value.

        The notification comes with the current status of the upload process.
        This includes the following keys - independent of the state of the upload:

            * uploadid: ID of the upload the notification is associated with
            * offset: Offset of the requested data in the source file
            * chunksize: The maximum chunk size
            * state: The current state of the upload task
            * message: ``null``/``None`` or a message from the server
            * uploadtask: The task dictionary itself
            * uploadslist: Except for ``ChunkRequest`` events, the WebSocket server append the result of :meth:`lib.ws.mdbwsi.MusicDBWebSocketInterface.GetUploads` to the notification

        *task* can be ``None`` in case the notification is meant to be an information that a given upload ID is invalid.

        Args:
            notification (str): Name of the notification
            task (dict): Task structure
            message (str): (optional) text message (like an error message) to the client

        Returns:
            *Nothing*

        Raises:
            ValueError: When notification has an unknown notification name
        """
        if not notification in [
                "ChunkRequest", "StateUpdate", "InternalError"
        ]:
            raise ValueError("Unknown notification \"%s\"" % (notification))

        status = {}
        if task != None:
            status["uploadid"] = task["id"]
            status["offset"] = task["offset"]  # offset of the data to request
            status[
                "chunksize"] = 4096 * 100  # Upload 400KiB (TODO: Make configurable)
            status["state"] = task["state"]
            status["uploadtask"] = task
        else:
            status["uploadid"] = None
            status["offset"] = None
            status["chunksize"] = None
            status["state"] = "notexisting"
            status["uploadtask"] = None

        status["message"] = message

        global Callbacks
        for callback in Callbacks:
            try:
                callback(notification, status)
            except Exception as e:
                logging.exception(
                    "A Upload Management event callback function crashed!")

    #####################################################################
    # State management                                                  #
    #####################################################################

    def SaveTask(self, task):
        """
        This method saves a task in the uploads directory under ``tasks/${Task ID}.json``

        Args:
            task (dict): The task to save

        Returns:
            *Nothing*
        """
        taskid = task["id"]
        data = json.dumps(task)
        path = self.cfg.uploads.path + "/tasks/" + taskid + ".json"

        if not self.uploadfs.IsDirectory("tasks"):
            logging.debug("tasks directory missing. Creating \"%s\"",
                          self.cfg.uploads.path + "/tasks")
            self.uploadfs.CreateSubdirectory("tasks")

        with open(path, "w+") as fd:
            fd.write(data)

        return

    def LoadTasks(self):
        """
        Loads all task from the JSON files inside the tasks-directory.
        The list of active tasks will be replaced by the loaded tasks.

        Returns:
            *Nothing*
        """
        logging.debug("Loading Upload-Tasks…")

        taskfilenames = self.uploadfs.ListDirectory("tasks")

        global Tasks
        Tasks = {}
        for taskfilename in taskfilenames:
            taskpath = self.cfg.uploads.path + "/tasks/" + taskfilename

            if self.uploadfs.GetFileExtension(taskpath) != "json":
                continue

            try:
                with open(taskpath) as fd:
                    task = json.load(fd)
            except Exception as e:
                logging.warning(
                    "Loading task file \"%s\" failed with error \"%s\". \033[1;30m(File will be ignored)",
                    str(taskpath), str(e))
                continue

            if "id" not in task:
                logging.warning(
                    "File \"%s\" is not a valid task (ID missing). \033[1;30m(File will be ignored)",
                    str(taskpath), str(e))
                continue

            Tasks[task["id"]] = task

        return

    #####################################################################
    # Management Functions                                              #
    #####################################################################

    def InitiateUpload(self, uploadid, mimetype, contenttype, filesize,
                       checksum, sourcefilename):
        """
        Initiates an upload of a file into a MusicDB managed file space.
        After calling this method, a notification gets triggered to request the first chunk of data from the clients.
        In case uploads are deactivated in the MusicDB Configuration, an ``"InternalError"`` Notification gets sent to the clients.

        Args:
            uploadid (str): Unique ID to identify the upload task 
            mimetype (str): MIME-Type of the file (example: ``"image/png"``)
            contenttype (str): Type of the content: (``"video"``, ``"album"``, ``"artwork"``)
            filesize (int): Size of the complete file in bytes
            checksum (str): SHA-1 check sum of the source file
            sourcefilename (str): File name (example: ``"test.png"``)

        Raises:
            TypeError: When one of the arguments has not the expected type
            ValueError: When *contenttype* does not have the expected values
        """
        if type(uploadid) != str:
            raise TypeError("Upload ID must be of type string")
        if type(mimetype) != str:
            raise TypeError("mime type must be of type string")
        if type(contenttype) != str:
            raise TypeError("content type must be of type string")
        if contenttype not in ["video", "album", "artwork"]:
            raise ValueError(
                "content type \"%s\" not valid. \"video\", \"album\" or \"artwork\" expected."
                % (str(contenttype)))
        if type(filesize) != int:
            raise TypeError("file size must be of type int")
        if filesize <= 0:
            raise ValueError("file size must be greater than 0")
        if type(checksum) != str:
            raise TypeError("Checksum must be of type string")
        if type(sourcefilename) != str:
            raise TypeError("Source file name must be of type string")

        if not self.cfg.uploads.allow:
            self.NotifyClient("InternalError", None, "Uploads deactivated")
            logging.warning(
                "Uploads not allowed! \033[1;30m(See MusicDB Configuration: [uploads]->allow)"
            )
            return

        fileextension = self.uploadfs.GetFileExtension(sourcefilename)
        destinationname = contenttype + "-" + checksum + "." + fileextension
        destinationpath = self.cfg.uploads.path + "/" + destinationname

        # TODO: Check if there is already a task with the given ID.
        # If this task is in waitforchunk state, the upload can be continued instead of restarting it.

        # Remove existing upload if destination path exists
        self.uploadfs.RemoveFile(
            destinationpath)  # Removes file when it exists

        # Create File
        with open(destinationpath, "w+b"):
            pass

        task = {}
        task["id"] = uploadid
        task["filesize"] = filesize
        task["offset"] = 0
        task["contenttype"] = contenttype
        task["mimetype"] = mimetype
        task["sourcefilename"] = sourcefilename
        task["sourcechecksum"] = checksum
        task["destinationpath"] = destinationpath
        task[
            "videofile"] = None  # Path to the video file in the music directory
        task["state"] = "waitforchunk"
        task["annotations"] = {}
        self.SaveTask(task)

        global Tasks
        Tasks[uploadid] = task

        self.NotifyClient("ChunkRequest", task)
        return

    def RequestRemoveUpload(self, uploadid):
        """
        This method triggers removing a specific upload.
        This includes the uploaded file as well as the upload task information and annotations.

        The upload task can be in any state.
        When the remove-operation is triggered, its state gets changed to ``"remove"``.

        Only the ``"remove"`` state gets set. Removing will be done by the Management Thread.

        Args:
            uploadid (str): ID of the upload-task

        Returns:
            ``True`` on success
        """
        try:
            task = self.GetTaskByID(uploadid)
        except Exception as e:
            logging.error(
                "Internal error while requesting a new chunk of data: %s",
                str(e))
            return False

        self.UpdateTaskState(task, "remove")
        return True

    def GetTaskByID(self, uploadid):
        """
        This method returns an existing task from the tasklist.
        The task gets identified by its ID aka Upload ID

        When the task does not exits, the clients get an ``"InternalError"`` notification.
        The tasks state is then ``"notexisting"``.

        Args:
            uploadid (str): ID of the upload-task

        Returns:
            A task dictionary

        Raises:
            TypeError: When *uploadid* is not a string
            ValueError: When *uploadid* is not a valid key in the Tasks-dictionary
        """
        if type(uploadid) != str:
            raise TypeError("Upload ID must be a string. Type was \"%s\"." %
                            (str(type(uploadid))))

        global Tasks
        if uploadid not in Tasks:
            self.NotifiyClient("InternalError", None, "Invalid Upload ID")
            raise ValueError("Upload ID \"%s\" not in Task Queue.",
                             str(uploadid))

        return Tasks[uploadid]

    def UpdateTaskState(self, task, state, errormessage=None):
        """
        This method updates and saves the state of an task.
        An ``"StateUpdate"`` notification gets send as well.

        If *errormessage* is not ``None``, the notification gets send as ``"InternalError"`` with the message

        Args:
            task (dict): Task object to update
            state (str): New state
            message (str): Optional message

        Returns:
            *Nothing*
        """
        task["state"] = state
        self.SaveTask(task)
        if errormessage:
            self.NotifyClient("InternalError", task, errormessage)
        else:
            self.NotifyClient("StateUpdate", task)
        return

    def NewChunk(self, uploadid, rawdata):
        """
        This method processes a new chunk received from the uploading client.

        Args:
            uploadid (str): Unique ID to identify the upload task
            rawdata (bytes): Raw data to append to the uploaded data

        Returns:
            ``False`` in case an error occurs. Otherwise ``True``.

        Raises:
            TypeError: When *rawdata* is not of type ``bytes``
        """
        if type(rawdata) != bytes:
            raise TypeError("raw data must be of type bytes. Type was \"%s\"" %
                            (str(type(rawdata))))

        try:
            task = self.GetTaskByID(uploadid)
        except Exception as e:
            logging.error(
                "Internal error while requesting a new chunk of data: %s",
                str(e))
            return False

        chunksize = len(rawdata)
        filepath = task["destinationpath"]

        try:
            with open(filepath, "ab") as fd:
                fd.write(rawdata)
        except Exception as e:
            logging.warning(
                "Writing chunk of uploaded data into \"%s\" failed: %s \033[1;30m(Upload canceled)",
                filepath, str(e))
            self.UpdateTaskState(
                task, "uploadfailed",
                "Writing data failed with error: \"%s\"" % (str(e)))
            return False

        task["offset"] += chunksize
        self.SaveTask(task)

        if task["offset"] >= task["filesize"]:
            # Upload complete
            self.UploadCompleted(task)
        else:
            # Get next chunk of data
            self.NotifyClient("ChunkRequest", task)
        return True

    def UploadCompleted(self, task):
        """
        This method continues the file management after an upload was completed.
        The following tasks were performed:

            * Checking the checksum of the destination file (SHA1) and compares it with the ``"sourcechecksum"`` from the *task*-dict.

        When the upload was successful, it notifies the clients with a ``"UploadComplete"`` notification.
        Otherwise with a ``"UploadFailed"`` one.

        Args:
            task (dict): The task that upload was completed

        Returns:
            ``True`` When the upload was successfully complete, otherwise ``False``
        """
        # Check checksum
        destchecksum = self.fileprocessing.Checksum(task["destinationpath"],
                                                    "sha1")
        if destchecksum != task["sourcechecksum"]:
            logging.error(
                "Upload Failed: \033[0;36m%s \e[1;30m(Checksum mismatch)",
                task["destinationpath"])
            self.UpdateTaskState(task, "uploadfailed", "Checksum mismatch")
            return False

        logging.info("Upload Complete: \033[0;36m%s", task["destinationpath"])
        self.UpdateTaskState(task, "uploadcomplete")
        # Now, the Management Thread takes care about post processing or removing no longer needed content
        return True

    def GetTasks(self):
        """
        Returns:
            The dictionary with all upload tasks
        """
        global Tasks
        return Tasks

    def PreProcessUploadedFile(self, task):
        """
        This method initiates pre-processing of an uploaded file.
        Depending on the *contenttype* different post processing methods are called:

            * ``"video"``: :meth:`~PreProcessVideo`
            * ``"artwork"``: :meth:`~PreProcessArtwork`

        The task must be in ``"uploadcomplete"`` state, otherwise nothing happens but printing an error message.
        If post processing was successful, the task state gets updated to ``"preprocessed"``.
        When an error occurred, the state will become ``"invalidcontent"``.

        Args:
            task (dict): the task object of an upload-task

        Returns:
            ``True`` on success, otherwise ``False``
        """
        if task["state"] != "uploadcomplete":
            logging.error(
                "task must be in \"uploadcomplete\" state for post processing. Actual state was \"%s\". \033[1;30m(Such a mistake should not happen. Anyway, the task won\'t be post process and nothing bad will happen.)",
                task["state"])
            return False

        # Perform post processing
        logging.debug("Preprocessing upload %s -> %s",
                      str(task["sourcefilename"]),
                      str(task["destinationpath"]))
        success = False
        if task["contenttype"] == "video":
            success = self.PreProcessVideo(task)
        elif task["contenttype"] == "artwork":
            success = self.PreProcessArtwork(task)
        else:
            logging.warning(
                "Unsupported content type of upload: \"%s\" \033[1;30m(Upload will be ignored)",
                str(task["contenttype"]))
            self.UpdateTaskState(task, "invalidcontent",
                                 "Unsupported content type")
            return False

        # Update task state
        if success == True:
            newstate = "preprocessed"
        else:
            newstate = "invalidcontent"

        self.UpdateTaskState(task, newstate)
        return success

    def PreProcessVideo(self, task):
        """

        Args:
            task (dict): the task object of an upload-task
        """
        meta = MetaTags()
        try:
            meta.Load(task["destinationpath"])
        except ValueError:
            logging.error(
                "The file \"%s\" uploaded as video to %s is not a valid video or the file format is not supported. \033[1;30m(File will be not further processed.)",
                task["sourcefilename"], task["destinationpath"])
            return False

        # Get all meta infos (for videos, this does not include any interesting information.
        # Maybe the only useful part is the Load-method to check if the file is supported by MusicDB
        #tags = meta.GetAllMetadata()
        #logging.debug(tags)
        return True

    def PreProcessArtwork(self, task):
        """

        Args:
            task (dict): the task object of an upload-task
        """
        origfile = task["destinationpath"]
        extension = self.uploadfs.GetFileExtension(origfile)
        jpegfile = origfile[:-len(extension)] + "jpg"
        if extension != "jpg":
            logging.debug(
                "Transcoding artwork file form %s (\"%s\") to JPEG (\"%s\")",
                extension, origfile, jpegfile)
            im = Image.open(origfile)
            im = im.convert("RGB")
            im.save(jpegfile, "JPEG", optimize=True, progressive=True)

        task["artworkfile"] = jpegfile
        return True

    def AnnotateUpload(self, uploadid, annotations):
        """
        This method can be used to add additional information to an upload.
        This can be done during or after the upload process.

        Args:
            uploadid (str): ID to identify the upload

        Returns:
            ``True`` on success, otherwise ``False``

        Raises:
            TypeError: When *uploadid* is not of type ``str``
            ValueError: When *uploadid* is not included in the Task Queue
        """
        try:
            task = self.GetTaskByID(uploadid)
        except Exception as e:
            logging.error(
                "Internal error while requesting a new chunk of data: %s",
                str(e))
            return False

        for key, item in annotations.items():
            task["annotations"][key] = item

        self.SaveTask(task)
        self.NotifyClient("StateUpdate", task)
        return True

    def IntegrateUploadedFile(self, uploadid, triggerimport):
        """
        This method integrated the uploaded files into the music directory.
        The whole file tree will be created following the MusicDB naming scheme.

        The upload task must be in ``preprocesses`` state. If not, nothing happens.

        When *triggerimport* is ``true``, the upload manager start importing the music.
        This happens asynchronously inside the Upload Manager Thread.

        Args:
            uploadid (str): ID to identify the upload

        Returns:
            ``True`` on success, otherwise ``False``

        Raises:
            TypeError: When *uploadid* is not of type ``str``
            ValueError: When *uploadid* is not included in the Task Queue
        """
        try:
            task = self.GetTaskByID(uploadid)
        except Exception as e:
            logging.error(
                "Internal error while requesting a new chunk of data: %s",
                str(e))
            return False

        if task["state"] != "preprocessed":
            logging.warning(
                "Cannot integrate an upload that is not in \"preprocessed\" state. Upload with ID \"%s\" was in \"%s\" state! \033[1;30m(Nothing will be done)",
                str(task["id"]), str(task["state"]))
            return

        # Perform post processing
        success = False
        if task["contenttype"] == "video":
            success = self.IntegrateVideo(task)
        elif task["contenttype"] == "artwork":
            success = True  # Importing artwork does not require the file at any specific place
        else:
            logging.warning(
                "Unsupported content type of upload: \"%s\" \033[1;30m(Upload will be ignored)",
                str(task["contenttype"]))
            self.UpdateTaskState(task, "integrationfailed",
                                 "Unsupported content type")
            return

        # Update task state
        if success == True:
            newstate = "integrated"
        else:
            newstate = "integrationfailed"
        self.UpdateTaskState(task, newstate)

        # Trigger import
        if success == False or triggerimport == False:
            return  # … but only if wanted, and previous step was successful

        self.UpdateTaskState(
            task,
            "startimport")  # The upload management thread will do the rest
        return

    def IntegrateVideo(self, task):
        """
        When an annotation needed for creating the video file path in the music directory is missing, ``False`` gets returned and an error message written into the log
        """
        uploadedfile = task["destinationpath"]  # uploaded file
        try:
            artistname = task["annotations"]["artistname"]
            releasedate = task["annotations"]["release"]
            videoname = task["annotations"]["name"]
        except KeyError as e:
            logging.error(
                "Collection video information for creating its path name failed with key-error for: %s \033[1;30m(Make sure all important annotations are given to that upload: name, artistname, release)",
                str(e))
            return False

        fileextension = self.uploadfs.GetFileExtension(uploadedfile)
        videofile = artistname + "/" + releasedate + " - " + videoname + "." + fileextension

        task["videofile"] = videofile
        logging.debug("Integrating upload %s -> %s", str(uploadedfile),
                      str(videofile))

        # Check if video file already exists
        if self.musicfs.Exists(videofile):
            logging.warning(
                "File \"%s\" already exists in the music directory! \033[1;30m(It will NOT be overwritten)",
                str(videofile))
            self.NotifyClient("InternalError", task,
                              "File already exists in the music directory")
            return False

        # Check if artist directory exists
        if not self.musicfs.IsDirectory(artistname):
            logging.info(
                "Artist directory for \"%s\" does not exist and will be created.",
                str(artistname))
            try:
                self.musicfs.CreateSubdirectory(artistname)
            except PermissionError:
                logging.error(
                    "Creating artist sub-directory \"%s\" failed! - Permission denied! \033[1;30m(MusicDB requires write permission to the music file tree)",
                    str(artistname))
                self.NotifyClient(
                    "InternalError", task,
                    "Creating artist directory failed - Permission denied")
                return False

        # Copy file, create Artist directory if not existing
        try:
            success = self.musicfs.CopyFile(uploadedfile, videofile)
        except PermissionError:
            logging.error(
                "Copying video file to \"%s\" failed! - Permission denied! \033[1;30m(MusicDB requires write permission to the music file tree)",
                str(videofile))
            self.NotifyClient("InternalError", task,
                              "Copying failed - Permission denied")
            return False

        if (success):
            logging.info("New video file at %s", str(videofile))
        else:
            logging.warning("Copying video file to \"%s\" failed!",
                            str(videofile))
        return success

    def ImportVideo(self, task):
        """
        Task state must be ``"startimport"`` and content type must be ``"video"``

        Returns:
            ``True`` on success.
        """
        # Check task state and type
        if task["state"] != "startimport":
            logging.warning(
                "Cannot import an upload that is not in \"startimport\" state. Upload with ID \"%s\" was in \"%s\" state! \033[1;30m(Nothing will be done)",
                str(task["id"]), str(task["state"]))
            return False

        success = False
        if task["contenttype"] != "video":
            logging.warning(
                "Video expected. Actual type of upload: \"%s\" \033[1;30m(No video will be imported)",
                str(task["contenttype"]))
            return False

        # Get important information
        try:
            artistname = task["annotations"]["artistname"]
            videopath = task["videofile"]
        except KeyError as e:
            logging.error(
                "Collecting video information for importing failed with key-error for: %s \033[1;30m(Make sure the artist name is annotated to the upload)",
                str(e))
            return False

        # Check if the artist already exists in the database - if not, add it
        artist = self.db.GetArtistByPath(artistname)
        if artist == None:
            logging.info("Importing new artist: \"%s\"", artistname)
            try:
                self.dbmanager.AddArtist(artistname)
            except Exception as e:
                logging.error(
                    "Importing artist \"%s\" failed with error: %s \033[1;30m(Video upload canceled)",
                    str(artistname), str(e))
                self.NotifyClient("InternalError", task,
                                  "Importing artist failed")
                return False
            artist = self.db.GetArtistByPath(artistname)

        # Import video
        try:
            success = self.dbmanager.AddVideo(videopath, artist["id"])
        except Exception as e:
            logging.error(
                "Importing video \"%s\" failed with error: %s \033[1;30m(Video upload canceled)",
                str(videopath), str(e))
            self.NotifyClient("InternalError", task, "Importing video failed")
            return False

        if not success:
            logging.error(
                "Importing video \"%s\" failed. \033[1;30m(Video upload canceled)",
                str(videopath), str(e))
            self.NotifyClient("InternalError", task, "Importing video failed")
            return False

        # Add origin information to database if annotated
        try:
            origin = task["annotations"]["origin"]
        except KeyError as e:
            pass
        else:
            video = self.db.GetVideoByPath(videopath)
            video["origin"] = origin
            self.db.WriteVideo(video)

        logging.info("Importing Video succeeded")
        return True

    def ImportVideoArtwork(self, task):
        """
        Returns:
            ``True`` on success
        """
        # Check task state and type
        if task["state"] != "importartwork":
            logging.warning(
                "Cannot import artwork that is not in \"importartwork\" state. Upload with ID \"%s\" was in \"%s\" state! \033[1;30m(Nothing will be done)",
                str(task["id"]), str(task["state"]))
            return False

        if task["contenttype"] != "video":
            logging.warning(
                "Video expected. Actual type of upload: \"%s\" \033[1;30m(No video will be imported)",
                str(task["contenttype"]))
            return False

        # Start generating the artworks
        videopath = task["videofile"]
        framemanager = VideoFrames(self.cfg, self.db)

        video = self.db.GetVideoByPath(videopath)
        if not video:
            logging.error(
                "Getting video \"%s\" from database failed. \033[1;30m(Artwork import canceled)",
                str(videopath), str(e))
            self.NotifyClient("InternalError", task,
                              "Video artwork import failed")
            return False

        retval = framemanager.UpdateVideoFrames(video)
        if retval == False:
            logging.error(
                "Generating video frames and preview failed for video \"%s\". \033[1;30m(Artwork import canceled)",
                str(videopath))
            self.NotifyClient("InternalError", task,
                              "Video artwork import failed")
            return False

        logging.info("Importing Video thumbnails and previews succeeded")
        return True

    def ImportArtwork(self, task):
        """
        Task state must be ``"startimport"`` and content type must be ``"artwork"``

        Returns:
            ``True`` on success.
        """
        # Check task state and type
        if task["state"] != "startimport":
            logging.warning(
                "Cannot import an upload that is not in \"startimport\" state. Upload with ID \"%s\" was in \"%s\" state! \033[1;30m(Nothing will be done)",
                str(task["id"]), str(task["state"]))
            return False

        success = False
        if task["contenttype"] != "artwork":
            logging.warning(
                "Album artwork expected. Actual type of upload: \"%s\" \033[1;30m(No artwork will be imported)",
                str(task["contenttype"]))
            return False

        # Get important information
        try:
            artistname = task["annotations"]["artistname"]
            albumname = task["annotations"]["albumname"]
            albumid = task["annotations"]["albumid"]
            sourcepath = task["artworkfile"]
        except KeyError as e:
            logging.error(
                "Collecting artwork information for importing failed with key-error for: %s \033[1;30m(Make sure the artist and album name is annotated as well as the album ID.)",
                str(e))
            return False

        # Import artwork
        awmanager = MusicDBArtwork(self.cfg, self.db)
        artworkname = awmanager.CreateArtworkName(artistname, albumname)
        success = awmanager.SetArtwork(albumid, sourcepath, artworkname)

        if not success:
            logging.error(
                "Importing artwork \"%s\" failed. \033[1;30m(Artwork upload canceled)",
                str(sourcepath))
            self.NotifyClient("InternalError", task,
                              "Importing artwork failed")
            return False

        # Add origin information to database if annotated
        try:
            origin = task["annotations"]["origin"]
        except KeyError as e:
            pass
        else:
            video = self.db.GetVideoByPath(videopath)
            video["origin"] = origin
            self.db.WriteVideo(video)

        logging.info("Importing Artwork succeeded")
        return True

    def RemoveTask(self, task):
        """
        ``tasks/${Task ID}.json``
        """
        logging.info(
            "Removing uploaded \"%s\" file and task \"%s\" information.",
            task["sourcefilename"], task["id"])
        datapath = task["destinationpath"]
        taskpath = "tasks/" + task["id"] + ".json"

        # if artwork, remove artworkfile as well
        if task["contenttype"] == "artwork":
            artworkfile = task["artworkfile"]
            logging.debug("Removing %s",
                          self.uploadfs.AbsolutePath(artworkfile))
            self.uploadfs.RemoveFile(artworkfile)

        logging.debug("Removing %s", self.uploadfs.AbsolutePath(datapath))
        self.uploadfs.RemoveFile(datapath)
        logging.debug("Removing %s", self.uploadfs.AbsolutePath(taskpath))
        self.uploadfs.RemoveFile(taskpath)
        return True
Exemple #2
0
class MusicCache(object):
    """
    Args:
        config: MusicDB configuration object
        database: MusicDB database

    Raises:
        TypeError: when *config* or *database* not of type :class:`~lib.cfg.musicdb.MusicDBConfig` or :class:`~lib.db.musicdb.MusicDatabase`
    """
    def __init__(self, config, database):
        if type(config) != MusicDBConfig:
            print(
                "\033[1;31mFATAL ERROR: Config-class of unknown type!\033[0m")
            raise TypeError("config argument not of type MusicDBConfig")
        if type(database) != MusicDatabase:
            print(
                "\033[1;31mFATAL ERROR: Database-class of unknown type!\033[0m"
            )
            raise TypeError("database argument not of type MusicDatabase")

        self.db = database
        self.cfg = config
        self.fs = Filesystem(self.cfg.music.cache)
        self.fileprocessor = Fileprocessing(self.cfg.music.cache)
        self.artworkcache = ArtworkCache(self.cfg.artwork.path)

    def GetAllCachedFiles(self):
        """
        This method returns three lists of paths of all files inside the cache.
        The tree lists are the following:

            #. All artist directories
            #. All album paths
            #. All song paths

        Returns:
            A tuple of three lists: (Artist-Paths, Album-Paths, Song-Paths)

        Example:

            .. code-block:: python

                (artists, albums, songs) = cache.GetAllCachedFiles()

                print("Albums in cache:")
                for path in albums:
                    name = musicdb.GetAlbumByPath(path)["name"]
                    print(" * " + name)

                print("Files in cache:")
                for path in songs:
                    print(" * " + path)
        """
        # Get all files from the cache
        artistpaths = self.fs.ListDirectory()
        albumpaths = self.fs.GetSubdirectories(artistpaths)
        songpaths = self.fs.GetFiles(albumpaths)

        return artistpaths, albumpaths, songpaths

    def RemoveOldArtists(self, cartistpaths, mdbartists):
        """
        This method removes all cached artists when they are not included in the artist list from the database.

        ``cartistpaths`` must be a list of artist directories with the artist ID as directory name.
        From these paths, a list of available artist ids is made and compared to the artist ids from the list of artists returned by the database (stored in ``mdbartists``)

        Is there a path/ID in ``cartistpaths`` that is not in the ``mdbartists`` list, the directory gets removed.
        The pseudo code can look like this:

            .. code-block:: python

                for path in cartistpaths:
                    if int(path) not in [mdbartists["id"]]:
                        self.fs.RemoveDirectory(path)

        Args:
            cartistpaths (list): a list of artist directories in the cache
            mdbartists (list): A list of artist rows from the Music Database

        Returns:
            *Nothing*
        """
        artistids = [artist["id"] for artist in mdbartists]
        cachedids = [int(path) for path in cartistpaths]

        for cachedid in cachedids:
            if cachedid not in artistids:
                self.fs.RemoveDirectory(str(cachedid))

    def RemoveOldAlbums(self, calbumpaths, mdbalbums):
        """
        This method compares the available album paths from the cache with the entries from the Music Database.
        If there are albums that do not match the database entries, then the cached album will be removed.

        Args:
            calbumpaths (list): a list of album directories in the cache (scheme: "ArtistID/AlbumID")
            mdbalbums (list): A list of album rows from the Music Database

        Returns:
            *Nothing*
        """
        # create "artistid/albumid" paths
        validpaths = [
            os.path.join(str(album["artistid"]), str(album["id"]))
            for album in mdbalbums
        ]

        for cachedpath in calbumpaths:
            if cachedpath not in validpaths:
                self.fs.RemoveDirectory(cachedpath)

    def RemoveOldSongs(self, csongpaths, mdbsongs):
        """
        This method compares the available song paths from the cache with the entries from the Music Database.
        If there are songs that do not match the database entries, then the cached song will be removed.

        Args:
            csongpaths (list): a list of song files in the cache (scheme: "ArtistID/AlbumID/SongID:Checksum.mp3")
            mdbsongs (list): A list of song rows from the Music Database

        Returns:
            *Nothing*
        """
        # create song paths
        validpaths = []
        for song in mdbsongs:
            path = self.GetSongPath(song)
            if path:
                validpaths.append(path)

        for cachedpath in csongpaths:
            if cachedpath not in validpaths:
                self.fs.RemoveFile(cachedpath)

    def GetSongPath(self, mdbsong, absolute=False):
        """
        This method returns a path following the naming scheme for cached songs (``ArtistID/AlbumID/SongID:Checksum.mp3``).
        It is not guaranteed that the file actually exists.

        Args:
            mdbsong: Dictionary representing a song entry form the Music Database
            absolute: Optional argument that can be set to ``True`` to get an absolute path, not relative to the cache directory.

        Returns:
            A (possible) path to the cached song (relative to the cache directory, ``absolute`` got not set to ``True``).
            ``None`` when there is no checksum available. The checksum is part of the file name.
        """
        # It can happen, that there is no checksum for a song.
        # For example when an old installation of MusicDB got not updated properly.
        # Better check if the checksum is valid to avoid any further problems.
        if len(mdbsong["checksum"]) != 64:
            logging.error(
                "Invalid checksum of song \"%s\": %s \033[1;30m(64 hexadecimal digit SHA265 checksum expected. Try \033[1;34mmusicdb repair --checksums \033[1;30mto fix the problem.)",
                mdbsong["path"], mdbsong["checksum"])
            return None

        path = os.path.join(str(mdbsong["artistid"]),
                            str(mdbsong["albumid"]))  # ArtistID/AlbumID
        path = os.path.join(path,
                            str(mdbsong["id"]))  # ArtistID/AlbumID/SongID
        path += ":" + mdbsong[
            "checksum"] + ".mp3"  # ArtistID/AlbumID/SongID:Checksum.mp3

        if absolute:
            path = self.fs.AbsolutePath(path)

        return path

    def Add(self, mdbsong):
        """
        This method checks if the song exists in the cache.
        When it doesn't, the file will be created (this may take some time!!).

        This process is done in the following steps:

            #. Check if song already cached. If it does, the method returns with ``True``
            #. Create directory tree if it does not exist. (``ArtistID/AlbumID/``)
            #. Convert song to mp3 (320kbp/s) and write it into the cache.
            #. Update ID3 tags. (ID3v2.3.0, 500x500 pixel artworks)

        Args:
            mdbsong: Dictionary representing a song entry form the Music Database

        Returns:
            ``True`` on success, otherwise ``False``
        """
        path = self.GetSongPath(mdbsong)
        if not path:
            return False

        # check if file exists, and create it when not.
        if self.fs.IsFile(path):
            return True

        # Create directory if not exists
        directory = os.path.join(str(mdbsong["artistid"]),
                                 str(mdbsong["albumid"]))  # ArtistID/AlbumID
        if not self.fs.IsDirectory(directory):
            self.fs.CreateSubdirectory(directory)

        # Create new mp3
        srcpath = os.path.join(self.cfg.music.path, mdbsong["path"])
        dstpath = self.fs.AbsolutePath(path)
        retval = self.fileprocessor.ConvertToMP3(srcpath, dstpath)
        if retval == False:
            logging.error("Converting %s to %s failed!", srcpath, dstpath)
            return False
        os.sync()

        # Optimize new mp3
        mdbalbum = self.db.GetAlbumById(mdbsong["albumid"])
        mdbartist = self.db.GetArtistById(mdbsong["artistid"])

        try:
            relartworkpath = self.artworkcache.GetArtwork(
                mdbalbum["artworkpath"], "500x500")
        except Exception as e:
            logging.error(
                "Getting artwork from cache failed with exception: %s!",
                str(e))
            logging.error("   Artwork: %s, Scale: %s", mdbalbum["artworkpath"],
                          "500x500")
            return False

        absartworkpath = os.path.join(self.cfg.artwork.path, relartworkpath)

        retval = self.fileprocessor.OptimizeMP3Tags(
            mdbsong,
            mdbalbum,
            mdbartist,
            srcpath=path,
            dstpath=path,
            absartworkpath=absartworkpath,
            forceID3v230=True)
        if retval == False:
            logging.error("Optimizing %s failed!", path)
            return False

        return True
Exemple #3
0
class MusicAI(object):
    """

    Args:
        config: Instance of :class:`lib.cfg.musicdb.MusicDBConfig`.

    Raises:
        TypeError: If *config* is of an invalid type
    """
    def __init__(self, config):
        if type(config) != MusicDBConfig:
            logging.critical(
                "FATAL ERROR: Config-class of unknown type \"%s\"!",
                str(type(config)))
            raise TypeError()

        self.config = config
        self.fs = Filesystem(self.config.music.path)
        self.genrelist = self.config.musicai.genrelist
        self.modelname = self.config.musicai.modelname
        self.modelfile = self.config.musicai.modelpath + "/" + self.modelname + ".DCNN.tfl"

    def LoadModel(self, modelfilepath):
        """
        This method creates a neural network model and loads the trained data from the model in ``modelfilepath`` if the file ``modelfilepath + ".index"`` exists.
        The model will be created to run on the GPU if the related configuration is set to ``True``, otherwise the model will run on the CPU.
        Currently only ``/gpu:0`` and ``/cpu:0`` are supported.
        This method also resets *TensorFlows* and *TFLearn* internals by calling ``tensorflow.reset_default_graph()`` and ``tflearn.config.init_graph()``.
        The model can then be saved by :meth:`~mdbapi.musicai.MusicAI.SaveModel`

        If model creation fails, an exception gets raised.

        .. warning::

            The model data can become invalid if the code for creating the model (:meth:`~mdbapi.musicai.MusicAI.CreateModel`) changes the architecture of the model.

        Args:
            modelfilepath (str): Path where the models data are stored

        Returns:
            The TFLearn-model

        Raises:
            tensorflow.errors.InternalError: When creating the model failes.
        """
        # Reset tensorflow
        tensorflow.reset_default_graph()
        tflearn.config.init_graph()
        model = None

        # Create Model
        if self.config.musicai.usegpu:
            device = "/gpu:0"
        else:
            device = "/cpu:0"

        logging.debug("Create Model for device %s" % (device))

        try:
            with tensorflow.device(device):
                model = self.CreateModel()
        except tensorflow.errors.InternalError as e:
            ename = str(type(e).__name__)
            logging.exception(
                "Creating Tensorflow Model for device \"%s\" failed with error \"%s\""
                % (str(device), ename))
            print(
                "\033[1;31mCreating Tensorflow Model for device \"%s\" failed with error \"%s\" \033[1;30m(My be related to Out-Of-Memory error for GPU usage)\033[0m"
                % (str(device), ename))
            raise (e)

        # Load weights/biases if available
        if self.fs.Exists(modelfilepath + ".index"):
            model.load(modelfilepath)

        return model

    def SaveModel(self, model, modelfilepath):
        """
        This method stores the configuration (weights, biases, …) in ``modelfilepath``.
        *TFLearn* creates multiple files with ``modelfilepath`` as suffix.

        The model can then be loaded by :meth:`~mdbapi.musicai.MusicAI.LoadModel`

        .. warning::

            The model data can become invalid if the code for creating the model (:meth:`~mdbapi.musicai.MusicAI.CreateModel`) changes the architecture of the model.

        Args:
            model: The *TFLearn* model of the neural network
            modelfilepath (str): The path and filename-(prefix) where the data of the model will be saved

        Returns:
            *Nothing*
        """
        model.save(modelfilepath)

    def GetStatistics(self):
        """
        This method returns statistics of the sizes of the training and feature sets.

        Returns:
            A dictionary with statistics or ``None`` if there is no training set

        Example:

            .. code-block:: python

                stats = musicai.GetStatistics()
                print("Set Size: ", stats["setsize"])
                print("Number of Songs in Set: ", stats["numofsongs"])

        """
        trainingfilepath = self.config.musicai.modelpath + "/" + self.modelname + ".h5"
        if not self.fs.Exists(trainingfilepath):
            return None

        trainingfile = HDF5Storage(trainingfilepath)
        # Check if the song was already added to the trainingset
        try:
            songset = trainingfile.ReadDataset("songids")
            outputset = trainingfile.ReadDataset("outputs")
        except AssertionError:
            trainingfile.Close()
            return None

        stats = {}
        stats["setsize"] = outputset.shape[0]
        stats["numofsongs"] = songset.shape[0]
        return stats

    # This MUST BE THREADSAFE to process multiple files at once
    # Accessing own database is allowed
    # returns file to spetrogram on success or None if something failed
    def CreateSpectrogram(self, songid, songpath):
        """
        This method creates the spectrograms used for the feature set.
        If the spectrogram already exists, it will skip the process.
        The spectrogram is a png-image.

        .. warning::

            This method must be thread safe to process multiple files at once

        This method represents the following shell commands:

        .. code-block:: bash

            # decode song to wave-file
            ffmpeg -v quiet -y -i $songpath "$songid-stereo.wav"

            # make mono wave file out of the stereo wave file
            sox "$songid-stereo.wav" "$songid-mono.wav" remix 1,2

            # create spectrogram out of the mono wave file
            sox "$songid-mono.wav" -n spectrogram -y $slizesize -X 50 -m -r -o "$songid.png"

        The temporary wave files are stored in the *musicai-tmppath*.
        They will be removed if the spectrogram was created successfully.
        Otherwise they will be kept.
        The spectrogram in the *musicai-specpath*.
        The slicesize denotes the height of the spectrogram which basically means the granularity of frequencies.
        This value gets increased by 1 to cover the offset (f=0).
        One column in the spectrogram represents 50ms of the song.
        All those variables can be set in the MusicDB Configuration.

        .. warning::

            Before changing the slice-size read the ``sox`` manpages for the ``-y`` parameter carefully.

        Args:
            songid: The song ID is used to create temporary files
            songpath (str): The path to the song that shall be analyzed

        Returns:
            path to the spectrogram
        """
        # prepare pathes
        sourcefile = self.fs.AbsolutePath(songpath)
        wavefile = self.config.musicai.tmppath + "/" + str(
            songid) + "-stereo.wav"
        monofile = self.config.musicai.tmppath + "/" + str(
            songid) + "-mono.wav"
        spectrogram = self.config.musicai.specpath + "/" + str(songid) + ".png"

        # we are already done
        if self.fs.Exists(spectrogram):
            return spectrogram

        # create wave-file
        # ffmpeg -i audio.aac stereo.wav
        if not self.fs.Exists(wavefile):
            process = [
                "ffmpeg",
                "-v",
                "quiet",  # don't be verbose
                "-y",  # overwrite existing file
                "-i",
                sourcefile,
                wavefile
            ]
            try:
                self.fs.Execute(process)
            except Exception as e:
                logging.error("Error \"%s\" while executing: %s", str(e),
                              str(process))
                return None

        # create mono-file
        # sox stereo.wav mono.wav remix 1,2
        if not self.fs.Exists(monofile):
            process = ["sox", wavefile, monofile, "remix", "1,2"]
            try:
                self.fs.Execute(process)
            except Exception as e:
                logging.error("Error \"%s\" while executing: %s", str(e),
                              str(process))
                return None

        # create spectrogram
        # sox mono.wav -n spectrogram -Y 200 -X 50 -m -r -o mono.png
        if not self.fs.Exists(spectrogram):
            process = [
                "sox", monofile, "-n", "spectrogram", "-y",
                str(self.config.musicai.slicesize + 1), "-X", "50", "-m", "-r",
                "-o", spectrogram
            ]
            try:
                self.fs.Execute(process)
            except Exception as e:
                logging.error("Error \"%s\" while executing: %s", str(e),
                              str(process))
                return None

        # remove tempfiles - Keep spectrogram because it looks so cool. Maybe it finds its place in the UI
        self.fs.RemoveFile(wavefile)
        self.fs.RemoveFile(monofile)
        return spectrogram

    # This MUST BE THREADSAFE to process multiple files at once
    # Accessing own database is allowed
    # returns True on success
    def CreateFeatureset(self, songid, songpath):
        """
        This function generates the feature set for the MusicAI.
        The features are generated by the following steps:

            #. First step is to create the spectrogram calling :meth:`~mdbapi.musicai.MusicAI.CreateSpectrogram`.
            #. Next it takes slices from the resulting image and converts it into a normalized *numpy* array.
            #. The begin and end of a song will be chopped of and gets ignored.

        A slicesize can be defind in the MusicDB Configuration under musicai->slicesize. **Be careful with this configuration and check the influence to other methods in this class!**
        The first 10 and the last 10 slices will be skipped to avoid unnecessary much intro/outro-data-noise.
        
        The resulting data (a feature) is a *numpy* 3D (slicesize, slicesize, 1) matrix of type float in range of 0.0 to 1.0.
        This matrix of all features of a song gets stored in a HDF5 file under ``$spectrograms/$SongID.h5``

        Args:
            songid: ID of the song the feature set belongs to
            songpath (str): path to the song that shall be analyzed

        Returns:
            ``True`` on success, otherwise ``False``

        Example:

            .. code-block:: python

                musicai = MusicAI("./musicdb.ini")
                musicai.CreateFeatureset(mdbsong["id"], mdbsong["path"]):
        """
        # Create Spectrogram
        spectrogram = self.CreateSpectrogram(songid, songpath)
        if not spectrogram:
            return False

        # Open it and make raw data out of the image
        image = Image.open(spectrogram)
        width, height = image.size
        slicesize = self.config.musicai.slicesize
        numslices = int(width / slicesize) - 20  # \_ skip slices (intro+outro)
        startoffset = 10 * slicesize  # /

        if numslices <= 0:
            logging.warning(
                "song %s too small! \033[1;30m(do not use for featureset)" %
                (songpath))
            return False

        # create data
        dataset = numpy.zeros((numslices, slicesize, slicesize, 1))
        for i in range(numslices):
            # Crop into slices
            startpixel = i * slicesize + startoffset
            imageslice = image.crop(
                (startpixel, 0, startpixel + slicesize, slicesize))

            # Make numpy-arrays out of it
            data = numpy.asarray(imageslice,
                                 dtype=numpy.uint8)  # image -> ndarray
            data = data / 255.0  # [0 … 255] -> [0.0 … 1.0] (and makes float)
            data = data.reshape((slicesize, slicesize, 1))  # X² -> X³

            # Store the feature dataset
            dataset[i] = data

        # Open storage for the features
        featurefilepath = self.config.musicai.specpath + "/" + str(
            songid) + ".h5"
        featurefile = HDF5Storage(featurefilepath)
        featurefile.WriteDataset("featureset", dataset)
        featurefile.Close()
        return True

    def HasFeatureset(self, songid):
        """
        This method checks if a song has already a feature set.
        If there is a HDF5 file in the spectrogram directors the method assumes that this file contains the feature set.
        The file gets not read.
        The png encoded spectrogram is not the final feature set and so its existence is not relevant for this method.

        Args:
            songid: ID of the song that shall be checked

        Returns:
            ``True`` if the songs feature set is available, otherwise ``False``
        """
        featurefilepath = self.config.musicai.specpath + "/" + str(
            songid) + ".h5"
        if self.fs.Exists(featurefilepath):
            return True
        return False

    def AddSongToTrainingset(self, songid, genre):
        """
        This method can be used to add a song to the available training sets.
        To do so, first the feature set must be created.

        The feature set file gets read and a genre vector generated.
        The resultuing two sets (*inputs* = feature set and *outputs* = genre vector) will be stored in the training file.

        The **inputs** are a HDF5 dataset handler shaping a numpy-array of size *n* of input matrices:
        ``(n, slicesize, slicesize, 1)``

        The **outputs** are a HDF5 dataset handler shaping a numpy-array of size *n* of genre-vectors formatted as shown in :meth:`mdbapi.musicai.MusicAI.GetGenreMatrix`:
        ``(n, 4, 1)``

        The genre name should be the same as the related genre tag is named in the database.
        It also must be part of the list of genres this AI works with.

        Args:
            songid: ID of the song that shall be used for training
            genre (str): Lower case name of the genre (as stored in the database) the song belongs to

        Returns:
            ``True`` if the song has a feature set so that it was added to the database. Otherwise ``False``

        Example:

            .. code-block:: python

                musicai = MusicAI("./musicdb.ini")
                musicai.CreateFeatureset(mdbsong["id"], mdbsong["path"]):
                musicai.AddSongToTrainingset(mdbsong["id"], "metal")

        """
        if not genre in self.genrelist:
            logging.error("The genre \"%s\" is not in the genrelist: \"%s\"!",
                          genre, str(self.genrelist))
            return False

        if not self.HasFeatureset(songid):
            logging.waring("Song with id %s does not have a featureset",
                           str(songid))
            return False

        featurefilepath = self.config.musicai.specpath + "/" + str(
            songid) + ".h5"
        trainingfilepath = self.config.musicai.modelpath + "/" + self.modelname + ".h5"

        featurefile = HDF5Storage(featurefilepath)
        trainingfile = HDF5Storage(trainingfilepath)

        # Check if the song was already added to the trainingset
        try:
            songids = trainingfile.ReadDataset("songids")
        except AssertionError:
            songids = []

        if songid in songids:
            logging.waring("Song with id %s does already exist in trainingset",
                           str(songid))
            featurefile.Close()
            trainingfile.Close()
            return False

        # Read Featureset
        featureset = featurefile.ReadDataset("featureset")  # read features
        setsize = featureset.shape[0]  # determin size of the workingset
        genrevector = self.GetGenreMatrix()[genre]  # get genre vector
        genrevector = numpy.array(genrevector)  # \_ create outputs-set
        genreset = numpy.tile(genrevector, (setsize, 1))  # /
        songidset = numpy.tile(numpy.array(songid), (1, 1))

        # Write trainingset
        trainingfile.WriteDataset("inputs", featureset)
        trainingfile.WriteDataset("outputs", genreset)
        trainingfile.WriteDataset("songids", songidset)

        featurefile.Close()
        trainingfile.Close()
        return True

    def CreateModel(self):
        """
        This method creates and returns the following deep convolutional neural network.

        The architecture of the network is close to the one provided the following blog article:
        
            Julien Despois, *"Finding the genre of a song with Deep Learning -- A.I. Odyyssey part. 1"*, Nov. 2016, internet (non-scientific source), https://chatbotslife.com/finding-the-genre-of-a-song-with-deep-learning-da8f59a61194

        Inside the method there are some configurable variables to easily change some basic settings of the network:

            * ``useflatten = True``: Create a flatten-layer between the convolutional part and the fully connected end
            * ``keepprob = 0.5``: Probability of an input gets propagated through the dropout-layer
            * ``activation = "elu"``: Name of the activation function used for the convolutional networks as used by *tensorflow*
            * ``weightinit = "Xavier"``: Initialisation function for the convolutional layers
            * ``weightdecay = 0.001``
            * ``learningrate = 0.001``

        The reason for using ``"elu"`` as activation function can be found in the following paper:

            Djork-Arné Clevert, Thomas Unterthiner and Sepp Hochreiter, *"Fast and Accurate Deep Network Learning by Exponential Linear Units (ELUs)"*, Nov. 2015, CoRR (scientific source), https://arxiv.org/abs/1511.07289


        Returns:
            The created TFLearn-model

        """
        slicesize = self.config.musicai.slicesize  # the slice size of the spectrograms define the size of the input layer
        genrecount = len(
            self.config.musicai.genrelist
        )  # number of genres and so the number of output neurons

        # Some general architechture configuration
        useflatten = True  # Use a flatten layer afrer the convolutional layers
        keepprob = 0.5  # configures the dropout layer
        activation = "elu"  # elu? warum nicht das übliche relu? -> Weil besser: https://arxiv.org/abs/1511.07289
        weightinit = "Xavier"  # Initialisation function for the convolutional layers
        weightdecay = 0.001
        learningrate = 0.001

        convnet = input_data(shape=[None, slicesize, slicesize, 1],
                             name='input')

        convnet = conv_2d(convnet,
                          64,
                          2,
                          activation=activation,
                          weights_init=weightinit,
                          weight_decay=weightdecay)
        convnet = max_pool_2d(convnet, 2)

        convnet = conv_2d(convnet,
                          128,
                          2,
                          activation=activation,
                          weights_init=weightinit,
                          weight_decay=weightdecay)
        convnet = max_pool_2d(convnet, 2)

        convnet = conv_2d(convnet,
                          256,
                          2,
                          activation=activation,
                          weights_init=weightinit,
                          weight_decay=weightdecay)
        convnet = max_pool_2d(convnet, 2)

        convnet = conv_2d(convnet,
                          512,
                          2,
                          activation=activation,
                          weights_init=weightinit,
                          weight_decay=weightdecay)
        convnet = max_pool_2d(convnet, 2)

        if useflatten:
            convnet = flatten(convnet)

        convnet = fully_connected(convnet,
                                  1024,
                                  activation=activation,
                                  weight_decay=weightdecay)
        convnet = dropout(convnet, keepprob)

        convnet = fully_connected(convnet,
                                  genrecount,
                                  activation='softmax',
                                  weight_decay=weightdecay)
        convnet = regression(convnet,
                             optimizer='rmsprop',
                             loss='categorical_crossentropy',
                             learning_rate=learningrate)

        model = tflearn.DNN(
            convnet,
            tensorboard_verbose=3,  # many nice graphs inside tensorboard
            tensorboard_dir=self.config.musicai.logpath)

        return model

    def GetGenreMatrix(self):
        r"""
        This method returns a dictionary with the categorization vector for each genre.
        When training a neural network for categorizing, each element of the result-vector is mapped to one category.
        This static mapping is returned by this method.
        The matrix gets dynamic generated out of the configuration of MusicDB.
        The configured genre list gets just transformed into the matirx.

        For example, the mapping for the genrelist ``Metal, NDH, Gothic, Elector`` is the following:

        .. math::

            \begin{pmatrix}
                \text{Metal}\\
                \text{NDH}\\
                \text{Gothic}\\
                \text{Electro}
            \end{pmatrix}

        The format of the mapping is a dictionary with each genre as key.
        Each entry in the dictionary is a vector with the related cell set to 1.0 and the other cells to 0.0. For *Gothic* music, the vector would look like this:

        .. math::

            \text{Gothic} = \begin{pmatrix}
                0.0\\
                0.0\\
                1.0\\
                0.0
            \end{pmatrix}

        Returns:
            A dictionary with expected-prediction-vectors for training
        """

        matrix = {}
        for index, genre in enumerate(self.genrelist):
            matrix[genre] = [0.0] * len(self.genrelist)
            matrix[genre][index] = 1.0

        return matrix

    def HasTrainingset(self, songid):
        """
        This method checks if a song is used for training - if there exists a training set.
        The check is done in two steps:

            #. Does the training set file for the model exist.
            #. Is the song ID listed in the set of training songs in the training set file.

        Args:
            songid: ID of the song that shall be checked

        Returns:
            ``True`` if there exists a training set, otherwise ``False``
        """
        trainingfilepath = self.config.musicai.modelpath + "/" + self.modelname + ".h5"
        if not self.fs.Exists(trainingfilepath):
            return False

        trainingfile = HDF5Storage(trainingfilepath)
        # Check if the song was already added to the trainingset
        try:
            songids = trainingfile.ReadDataset("songids")
        except AssertionError:
            trainingfile.Close()
            return False

        # if the song is listed in this list, it exists in the trainingset
        if songid in songids:
            trainingfile.Close()
            return True

        return False

    def PerformTraining(self):
        """
        This method performs the training of the neural network.
        The training is done in several steps:

            #. Loading the model using :meth:`~mdbapi.musicai.MusicAI.LoadModel`
            #. Loading the training set from file
            #. Performing the training itself. This can be canceled by pressing ctrl-c.
            #. Saving new model state by calling :meth:`~mdbapi.musicai.MusicAI.SaveModel`

        The size of the training set is determined by the MusicDB Configuration.

        Each run gets logged in the log-directory that can be configured in the MusicDB Configuration.
        The name of each run is the module-name extended with the date (YY-MM-DD) and time (HH:MM) of the training.
        These logs can be used for visualization using TensorBoard.

        Returns:
            ``True`` on success, otherwise ``False``
        """
        # shorten config
        runid = self.modelname + " " + datetime.datetime.now().strftime(
            "%Y-%m-%d %H:%M")
        batchsize = self.config.musicai.batchsize
        epoch = self.config.musicai.epoch

        # load model
        print("\033[1;34mLoading model … \033[0m")
        model = self.LoadModel(self.modelfile)

        # getting features
        print("\033[1;34mLoading training data … \033[0;36m")
        trainingfilepath = self.config.musicai.modelpath + "/" + self.modelname + ".h5"
        trainingfile = HDF5Storage(trainingfilepath)
        try:
            inputset = trainingfile.ReadDataset("inputs")
            outputset = trainingfile.ReadDataset("outputs")
        except AssertionError as e:
            logging.exception("Reading the Trainingset failed!")
            print("\033[1;31m … failed!\033[0m")
            trainingfile.Close()
            return False

        # run training
        print("\033[1;34mRunning training process … \033[0m")
        try:
            model.fit(inputset,
                      outputset,
                      n_epoch=epoch,
                      batch_size=batchsize,
                      shuffle=True,
                      validation_set=0.01,
                      snapshot_step=100,
                      show_metric=True,
                      run_id=runid)
        except KeyboardInterrupt as e:
            logging.info(
                "Training canceled by user. \033[1;30m(Progress will not be saved)\033[0m"
            )
            print(
                "\033[1;33m … canceled by user. \033[1;30m(Progress will not be saved)\033[0m"
            )
            trainingfile.Close()
            return False

        # save model
        print("\033[1;34mSaving Model … \033[0m")
        self.SaveModel(model, self.modelfile)
        trainingfile.Close()
        return True

    def PerformPrediction(self, songid):
        r"""
        This method starts a prediction of the genre for a song addressed by its song ID.
        It returns a vector of confidence for the genres as described in :meth:`~mdbapi.musicai.MusicAI.GetGenreMatrix`.
        The confidence is the average of all features in the set.
        The featureset gets loaded from the related HDF5 file in the spectrogram directory configured
        in the MusicDB Configuration.
        A feature set can be created using the :meth:`~mdbapi.musicai.MusicAI.CreateFeatureset`
        If the file does not exist ``None`` gets returned.

        The result is calculated the following way from the set *P* of predictions based on a feature
        for each genre in the set of genres *G*.

        .. math::

            p_{g} = \frac{1}{\lvert P \rvert}\sum_{i}^{\lvert P \rvert}{p_{i}} \qquad p_{i} \in P; \; g \in G

        Args:
            songid: The ID of the song that shall be categorized

        Returns:
            A confidence-vector for the genres that were predicted, or ``None`` if an error occurs

        Example:
            
            .. code-block:: python

                # Create a feature set if there is none yet
                if not musicai.HasFeatureset(mdbsong["id"]):
                    musicai.CreateFeatureset(mdbsong["id"], mdbsong["path"])

                # Perform prediction
                confidence = musicai.PerformPrediction(mdbsong["id"])

                if confidence == None:
                    print("Prediction Failed! No featureset available?")
                    return

                # Print results
                # The order of the Genre-List is important! The mapping (index, genre) must be correct!
                for index, genre in enumerate(config.musicai.genrelist):
                    print("%.2f: %s" % (confidence[index], genre))

        """
        if not self.HasFeatureset(songid):
            logging.warning("Song with id %s does not have a featureset",
                            str(songid))
            return None

        # load features
        featurefilepath = self.config.musicai.specpath + "/" + str(
            songid) + ".h5"
        featurefile = HDF5Storage(featurefilepath)
        try:
            featureset = featurefile.ReadDataset("featureset")
        except AssertionError as e:
            logging.error("Reading Featureset failed with error %s!", str(e))
            featureset = None
            featurefile.Close()

        if featureset == None:
            return None

        # load model
        model = self.LoadModel(self.modelfile)

        # run prediction
        predictionset = model.predict(featureset)
        featurefile.Close()

        # accumulate results
        numofgenres = len(self.genrelist)
        prediction = [0.0] * numofgenres

        for entry in predictionset:
            for i in range(numofgenres):
                prediction[i] += entry[i]

        for i in range(numofgenres):
            prediction[i] /= len(predictionset)

        return prediction