Beispiel #1
0
class Lycra(object):
    """
    This class does the main lyrics management.

    Args:
        config: MusicDB Configuration object.

    Raises:
        TypeError: when *config* is not of type :class:`~lib.cfg.musicdb.MusicDBConfig`
    """
    def __init__(self, config):

        if type(config) != MusicDBConfig:
            logging.error("Config-class of unknown type!")
            raise TypeError("config argument not of type MusicDBConfig")

        logging.debug("Crawler path is %s", CRAWLERPATH)

        self.config = config
        self.lycradb = LycraDatabase(self.config.lycra.dbpath)
        self.fs = Filesystem(CRAWLERPATH)
        self.crawlers = None

    def LoadCrawlers(self):
        """
        This method loads all crawlers inside the crawler directory.

        .. warning::

            Changes at crawler may not be recognized until the whole application gets restarted.
            Only new added crawler gets loaded.
            Already loaded crawler are stuck at Pythons module cache.

        Returns:
            ``None``
        """
        # Get a list of all modules
        crawlerfiles = self.fs.GetFiles(".")
        modulenames = [
            self.fs.GetFileName(x) for x in crawlerfiles
            if self.fs.GetFileExtension(x) == "py"
        ]
        if len(modulenames) == 0:
            logging.warning(
                "No modules found in \"%s\"! \033[1;30m(… but crawler cache is still usable.)",
                self.fs.AbsolutePath(CRAWLERPATH))
            self.crawlers = None
            return None

        # load all modules
        self.crawlers = []
        for modulename in modulenames:
            modfp, modpath, moddesc = imp.find_module(modulename,
                                                      [CRAWLERPATH])

            try:
                logging.debug("Loading %s …", str(modpath))
                module = imp.load_module(modulename, modfp, modpath, moddesc)
            except Exception as e:
                logging.error(
                    "Loading Crawler %s failed with error: %s! \033[1;30m(Ignoring this specific Crawler)",
                    str(e), str(modpath))
            finally:
                # Since we may exit via an exception, close fp explicitly.
                if modfp:
                    modfp.close()

            crawler = {}
            crawler["module"] = module
            crawler["modulename"] = modulename
            self.crawlers.append(crawler)

        if len(self.crawlers) == 0:
            logging.warning(
                "No crawler loaded from \"%s\"! \033[1;30m(… but crawler cache is still usable.)",
                self.fs.AbsolutePath(CRAWLERPATH))
            self.crawlers = None
        return None

    def RunCrawler(self, crawler, artistname, albumname, songname, songid):
        """
        This method runs a specific crawler.
        This crawler gets all information available to search for a specific songs lyric.

        This method is for class internal use.
        When using this class, call :meth:`~mdbapi.lycra.Lycra.CrawlForLyrics` instead of calling this method directly.
        Before calling this method, :meth:`~mdbapi.lycra.Lycra.LoadCrawlers` must be called.

        The crawler base class :class:`lib.crawlerapi.LycraCrawler` catches all exceptions so that they do not net to be executed in an try-except environment.

        Args:
            crawler (str): Name of the crawler. If it addresses the file ``lib/crawler/example.py`` the name is ``example``
            artistname (str): The name of the artist as stored in the MusicDatabase
            albumname (str): The name of the album as stored in the MusicDatabase
            songname (str): The name of the song as stored in the MusicDatabase
            songid (int): The ID of the song to associate the lyrics with the song

        Returns:
            ``None``
        """
        crawlerclass = getattr(crawler["module"], crawler["modulename"])
        crawlerentity = crawlerclass(self.lycradb)
        crawlerentity.Crawl(artistname, albumname, songname, songid)
        return None

    def CrawlForLyrics(self, artistname, albumname, songname, songid):
        """
        Loads all crawler from the crawler directory via :meth:`~mdbapi.lycra.Lycra.LoadCrawlers` 
        and runs them via :meth:`~mdbapi.lycra.Lycra.RunCrawler`.

        Args:
            artistname (str): The name of the artist as stored in the music database
            albumname (str): The name of the album as stored in the music database
            songname (str): The name of the song as stored in the music database
            songid (int): The ID of the song to associate the lyrics with the song

        Returns:
            ``False`` if something went wrong. Otherwise ``True``. (This is *no* indication that there were lyrics found!)
        """
        # Load / Reload crawlers
        try:
            self.LoadCrawlers()
        except Exception as e:
            logging.error(
                "Loading Crawlers failed with error \"%s\"! \033[1;30m(… but crawler cache is still usable.)",
                str(e))
            return False

        if not self.crawlers:
            return False

        for crawler in self.crawlers:
            self.RunCrawler(crawler, artistname, albumname, songname, songid)

        return True

    def GetLyrics(self, songid):
        """
        This method returns the lyrics of a song.
        See :meth:`lib.db.lycradb.LycraDatabase.GetLyricsFromCache`
        """
        return self.lycradb.GetLyricsFromCache(songid)
Beispiel #2
0
class SongView(ListView, ButtonView):
    def __init__(self, config, albumpath, title, x, y, w, h):
        ListView.__init__(self, title, x, y, w, h)
        ButtonView.__init__(self, align="center")
        self.AddButton("↑", "Go up")
        self.AddButton("↓", "Go down")
        self.AddButton("c", "Clean name")
        self.AddButton("e", "Edit name")
        #self.AddButton("␣", "Toggle")
        #self.AddButton("↵", "Commit")
        #self.AddButton("␛", "Cancel")

        # elements are a tuple (original path, new path)

        self.cfg = config
        self.fs = Filesystem(self.cfg.music.path)
        self.albumpath = albumpath

        self.nameinput = FileNameInput()
        self.numberinput = TextInput()
        self.cdnuminput = TextInput()
        dialogh = 2 + 3
        self.dialog = Dialog("Rename Song", self.x, self.y + 1, self.w,
                             dialogh)
        self.dialog.AddInput("Song name:", self.nameinput, "Correct name only")
        self.dialog.AddInput("Song number:", self.numberinput,
                             "Song number only")
        self.dialog.AddInput("CD number:", self.cdnuminput,
                             "CD number or nothing")
        self.dialogmode = False

    def FindSongs(self):
        files = self.fs.GetFiles(self.albumpath, self.cfg.music.ignoresongs)
        songs = []
        # Only take audio files into account - ignore images and booklets
        for f in files:
            extension = self.fs.GetFileExtension(f)
            if extension in ["mp3", "flac", "m4a", "aac"]:
                songs.append((f, f))
        return songs

    def CleanFileNames(self):
        for index, element in enumerate(self.elements):
            origpath = element[0]
            path = element[1]
            directory = self.fs.GetDirectory(path)
            filename = self.fs.GetFileName(path)
            extension = self.fs.GetFileExtension(path)
            seg = self.FileNameSegments(filename)

            newfilename = filename[seg["number"]:seg["gap"]]
            newfilename += filename[seg["name"]:]
            newfilename = unicodedata.normalize("NFC", newfilename)

            newpath = os.path.join(directory, newfilename + "." + extension)
            self.elements[index] = (origpath, newpath)

    # no path, no file extension!
    # returns indices of name segments
    def FileNameSegments(self, filename):
        seg = {}

        # Start of song number
        m = re.search("\d", filename)
        if m:
            seg["number"] = m.start()
        else:
            seg["number"] = 0

        # End of song number (1 space is necessary)
        m = re.search("\s", filename[seg["number"]:])
        if m:
            seg["gap"] = seg["number"] + 1 + m.start()
        else:
            seg["gap"] = seg["number"] + 1

        # Find start of song name
        m = re.search("\w", filename[seg["gap"]:])
        if m:
            seg["name"] = seg["gap"] + m.start()
        else:
            seg["name"] = seg["gap"]

        return seg

    def UpdateUI(self):
        newsongs = self.FindSongs()
        self.SetData(newsongs)

    def onDrawElement(self, element, number, maxwidth):
        oldpath = element[0]
        path = element[1]
        width = maxwidth
        filename = self.fs.GetFileName(path)
        extension = self.fs.GetFileExtension(path)
        analresult = self.fs.AnalyseSongFileName(filename + "." + extension)

        # Render validation
        if not analresult:
            validation = "\033[1;31m ✘ "
        else:
            validation = "\033[1;32m ✔ "
        width -= 3

        # Render file name
        renderedname = ""
        width -= len(filename)
        seg = self.FileNameSegments(filename)
        renderedname += "\033[1;31m\033[4m" + filename[
            0:seg["number"]] + "\033[24m"
        renderedname += "\033[1;34m" + filename[seg["number"]:seg["gap"]]
        renderedname += "\033[1;31m\033[4m" + filename[
            seg["gap"]:seg["name"]] + "\033[24m"
        renderedname += "\033[1;34m" + filename[seg["name"]:]

        # Render file extension
        fileextension = "." + extension
        fileextension = fileextension[:width]
        fileextension = fileextension.ljust(width)
        return validation + "\033[1;34m" + renderedname + "\033[1;30m" + fileextension

    def Draw(self):
        if self.dialogmode == True:
            pass
        else:
            ListView.Draw(self)
            x = self.x + 1
            y = self.y + self.h - 1
            w = self.w - 2
            ButtonView.Draw(self, x, y, w)

    def HandleKey(self, key):
        if self.dialogmode == True:
            if key == "enter":  # Commit dialog inputs
                songname = self.nameinput.GetData()
                songnumber = self.numberinput.GetData()
                cdnumber = self.cdnuminput.GetData()

                element = self.dialog.oldelement
                path = element[
                    1]  # the editable path is 1, 0 is the original path
                directory = self.fs.GetDirectory(path)
                extension = self.fs.GetFileExtension(path)

                if len(songnumber) == 1:
                    songnumber = "0" + songnumber
                if cdnumber:
                    songnumber = cdnumber + "-" + songnumber

                newpath = os.path.join(
                    directory, songnumber + " " + songname + "." + extension)
                self.SetSelectedData((element[0], newpath))

                self.dialogmode = False
                self.Draw()  # show list view instead of dialog

            elif key == "escape":
                self.dialogmode = False
                self.dialog.oldname = None  # prevent errors by leaving a clean state
                self.Draw()  # show list view instead of dialog
                # reject changes

            else:
                self.dialog.HandleKey(key)

        else:
            if key == "up" or key == "down":
                ListView.HandleKey(self, key)

            elif key == "c":
                self.CleanFileNames()

            elif key == "e":  # edit name
                element = self.GetSelectedData()
                editpath = element[1]
                filename = self.fs.GetFileName(editpath)
                seg = self.FileNameSegments(filename)
                songnumber = filename[seg["number"]:seg["gap"]].strip()
                songname = filename[seg["name"]:].strip()

                if "-" in songnumber:
                    cdnumber = songnumber.split("-")[0].strip()
                    songnumber = songnumber.split("-")[1].strip()
                else:
                    cdnumber = ""

                self.nameinput.SetData(songname)
                self.numberinput.SetData(songnumber)
                self.cdnuminput.SetData(cdnumber)

                self.dialog.oldelement = element
                self.dialog.Draw()
                self.dialogmode = True
Beispiel #3
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
Beispiel #4
0
class MetaTags(object):
    """
    This class can be used to access the metadata of the music files.

    Args:
        root (str): All paths used in this class are relative to this path. Default is ``"/"``.
    """
    def __init__(self, root="/"):
        self.fs = Filesystem(root)
        self.file = None  # the mutagenx file handler/object
        self.ftype = None  # contains the identifier of the filetype (m4a, mp3, flac)
        self.extension = None  # contains the actual file extension
        self.path = None  # contains the full path of the file for debugging

    def Load(self, path):
        """
        Supported file extensions:

            * For MPEG4: ``mp4``, ``aac``, ``m4a``
            * For MPEG3: ``mp3``, ``MP3``
            * For FLAC: ``flac``

        Args:
            path (str): path to the song file that shall be loaded

        Returns:
            *Nothing*

        Raises:
            TypeError: If path is not a string
            ValueError: If file not exist or file cannot be read.
            ValueError: If the file extension or file format is not supported
        """
        logging.debug("Analysing file from %s", path)
        if type(path) != str:
            raise TypeError("Path must be a string!")

        # do some filename-management
        self.path = self.fs.AbsolutePath(path)
        if not self.fs.IsFile(self.path):
            raise ValueError("File \"%s\" does not exist" % (self.path))

        # remenber the path for debugging
        self.extension = self.fs.GetFileExtension(self.path)

        # normalize the extension
        if self.extension in ["mp4", "aac", "m4a"]:
            self.ftype = "m4a"
        elif self.extension == "flac":
            self.ftype = "flac"
        elif self.extension in ["mp3", "MP3"]:
            self.ftype = "mp3"
        else:
            self.path = None
            raise ValueError("Unsupported file-extension \"%s\" of \"%s\"" %
                             (self.extension, path))

        logging.debug("Loading file of type %s from \"%s\"", self.ftype,
                      self.path)

        # open the file
        if self.ftype == "flac":
            self.file = FLAC(self.path)
        elif self.ftype == "mp3":
            self.file = MP3(self.path)
        elif self.ftype == "m4a":
            self.file = MP4(self.path)
        else:
            self.path = None
            raise ValueError("Unsupported file-type %s" % (self.ftype))

    def GetAllMetadata(self):
        """
        This method collects as much information as possible from a file.
        The information gets stored in a dictionary.

            * ``song``: :meth:`~lib.metatags.MetaTags.GetSongname`
            * ``album``: :meth:`~lib.metatags.MetaTags.GetAlbumname`
            * ``artist``: :meth:`~lib.metatags.MetaTags.GetArtistname`
            * ``releaseyear``: :meth:`~lib.metatags.MetaTags.GetReleaseyear`
            * ``cdnumber``: :meth:`~lib.metatags.MetaTags.GetCDNumber`
            * ``songnumber``: :meth:`~lib.metatags.MetaTags.GetTracknumber`
            * ``origin``: :meth:`~lib.metatags.MetaTags.GetOrigin`
            * ``playtime``: :meth:`~lib.metatags.MetaTags.GetPlaytime`
            * ``bitrate``: :meth:`~lib.metatags.MetaTags.GetBitrate`
            * ``lyrics``: :meth:`~lib.metatags.MetaTags.GetLyrics`
        """
        metadata = {}
        metadata["song"] = self.GetSongname()
        metadata["album"] = self.GetAlbumname()
        metadata["artist"] = self.GetArtistname()
        metadata["releaseyear"] = self.GetReleaseyear()
        metadata["cdnumber"] = self.GetCDNumber()
        metadata["songnumber"] = self.GetTracknumber()
        metadata["origin"] = self.GetOrigin()
        metadata["playtime"] = self.GetPlaytime()
        metadata["bitrate"] = self.GetBitrate()
        metadata["lyrics"] = self.GetLyrics()
        return metadata

    def StoreArtwork(self, imgfilename):
        """
        This method stores an artwork from the metadata into a file.
        If there is no artwork in the metadata, ``False`` gets returned.

        If the file already exists, it gets overwritten.

        Args:
            imgfilename (str): Absolute path to an image file to store the image at

        Returns:
            ``True`` on success, otherwise ``False``
        """
        try:
            if self.ftype == "mp3":
                # Source: http://stackoverflow.com/questions/6171565/how-do-i-read-album-artwork-using-python
                artwork = self.file[
                    "APIC:"]  # access APIC frame and grab the image
                with open(imgfilename, "wb") as img:
                    img.write(artwork.data)
                return True

            elif self.ftype == "m4a":
                artwork = self.file[b"covr"][0]
                with open(imgfilename, "wb") as img:
                    img.write(artwork)
                return True

            elif self.ftype == "flac":
                artwork = self.file.pictures[0].data
                with open(imgfilename, "wb") as img:
                    img.write(artwork)
                return True

        except KeyError:
            logging.debug("File \"%s\" does not have a Cover-Image", self.path)
        except Exception as e:
            logging.warning(
                "Storing artwork to \"\033[0;33m%s\033[1;33m\" failed with error \"%s\"!",
                imgfilename, e)

        return False

    def GetSongname(self):
        """
        This method returns the name of a song

        Returns:
            The song name as string, or ``None`` if entry does not exist
        """
        try:
            if self.ftype == "m4a":
                return self.file[b"\xa9nam"][0]

            elif self.ftype == "mp3":
                name = self.file["TIT2"][0]

                # check if the unicodes were read wrong
                try:
                    name = name.encode("latin-1").decode("utf-8")
                except:
                    pass
                return name

            elif self.ftype == "flac":
                return self.file["Title"][0]

        except KeyError:
            logging.debug("File \"%s\" does not have a songname", self.path)
            return None

        return None

    def GetAlbumname(self):
        """
        This method returns the name of the album

        Returns:
            The album name as string, or ``None`` if entry does not exist
        """
        if self.ftype == "m4a":
            if b"\xa9alb" in self.file:
                return self.file[b"\xa9alb"][0]
            else:
                logging.debug("File \"%s\" does not have an albumname",
                              self.path)
                return None

        elif self.ftype == "mp3":
            # some songs dont have this tag
            if "TALB" in self.file:
                name = self.file["TALB"][0]
            else:
                logging.debug("File \"%s\" does not have an albumname",
                              self.path)
                return None

            # check if the unicodes were read wrong
            try:
                name = name.encode("latin-1").decode("utf-8")
            except:
                pass

            return name

        elif self.ftype == "flac":
            return self.file["Album"][0]

        logging.debug("File \"%s\" does not have an albumname", self.path)
        return None

    def GetArtistname(self):
        """
        This method returns the name of the artist

        Returns:
            The artist name as string, or ``None`` if entry does not exist
        """
        try:
            if self.ftype == "m4a":
                return self.file[b"\xa9ART"][0]

            elif self.ftype == "mp3":
                name = self.file["TPE1"][0]
                # check if the unicodes were read wrong
                try:
                    name = name.encode("latin-1").decode("utf-8")
                except:
                    pass
                return name

            elif self.ftype == "flac":
                return self.file["Artist"][0]

        except KeyError:
            logging.debug("File \"%s\" does not have an artistname", self.path)
            return None

        return None

    def GetReleaseyear(self):
        """
        This method returns the release year

        Returns:
            The release year as integer, or ``0`` if entry does not exist
        """
        date = 0
        try:
            if self.ftype == "m4a":
                date = self.file[b"\xa9day"][0]
                date = date.split("-")[0]  # get just the year

            elif self.ftype == "mp3":
                if not "TDRC" in self.file:
                    return int(date)

                date = self.file["TDRC"][0]
                date = date.text

                try:
                    date = date.split("-")[0]
                except:
                    pass

            elif self.ftype == "flac":
                date = self.file["Date"][0]
                date = date.split("-")[0]  # get just the year

        except KeyError:
            logging.debug("File \"%s\" does not have a release year",
                          self.path)
            return None

        try:
            date = int(date)
        except ValueError:
            logging.debug("File \"%s\" has a malformed date value!", self.path)
            data = 0
        return date

    def GetCDNumber(self):
        """
        This method returns the CD Number.
        The CD number is only stored in MP4 metadata.
        For all other formats, this method always returns ``0``

        Returns:
            The CD number as integer, or ``0`` if entry does not exist
        """
        number = 0
        if self.ftype == "m4a":
            try:
                number = self.file[b"disk"][0][0]
            except KeyError as e:
                pass
        elif self.ftype == "mp3":
            return 0
        elif self.ftype == "flac":
            return 0

        return int(number)

    def GetTracknumber(self):
        """
        This method returns the track number.

        Returns:
            The track number as integer, or ``0`` if entry does not exist
        """
        number = 0
        try:
            if self.ftype == "m4a":
                number = self.file[b"trkn"][0][0]

            elif self.ftype == "mp3":
                number = self.file["TRCK"][0]

            elif self.ftype == "flac":
                number = self.file["Tracknumber"][0]

        except KeyError:
            logging.debug("File \"%s\" does not have a tracknumber!",
                          self.path)
            return 0

        try:
            number = number.split("/")[0]
        except:
            pass

        # mutagenx has problems with unicode. In some cases, the tracknumber was not read correctly
        # that's sooooo embarrassing for a python3 lib
        try:
            number = int(number)
        except:
            logging.debug("File \"%s\" has a malformated tracknumber!",
                          self.path)
            number = 0
        return number

    def GetOrigin(self):
        """
        This method tries to determine where the file come from.
        The following origins can be detected: 

            * ``"iTunes"``
            * ``"bandcamp"``
            * ``"music163"`` aka 网易云音乐
            * ``"CD"`` as fallback for unknown *flac* files
            * ``"internet"`` as fallback for any other unknown files

        Returns:
            Name of the origin as string
        """
        # check m4a
        if self.ftype == "m4a":
            if b"----:com.apple.iTunes:iTunNORM" in self.file:
                return "iTunes"
            if b"----:com.apple.iTunes:iTunSMPB" in self.file:
                return "iTunes"
            if b"apID" in self.file:
                return "iTunes"

            if b"\xa9cmt" in self.file:
                comment = self.file[b"\xa9cmt"][0]
                comment = comment.lower()
                index = comment.find("bandcamp")
                return "bandcamp"

        # Check mp3
        elif self.ftype == "mp3":
            # usually, music.163 uses the TPUB frame …
            try:
                if self.file["TPUB"][0] == "网易云音乐":
                    return "music163"
            except KeyError:
                pass
            # … but not always :( - There is a secound way: COMM contains a key
            try:
                if "COMM::\'eng\'" in self.file:
                    if "163 key" in self.file["COMM::\'eng\'"][0]:
                        return "music163"
                    elif "bandcamp" in self.file["COMM::\'eng\'"][0]:
                        return "bandcamp"
            except KeyError:
                pass

        # Check flac
        elif self.ftype == "flac":
            try:
                comments = self.file["Comment"]
                for comment in comments:
                    if "bandcamp" in comment:
                        return "bandcamp"
            except:
                pass

            return "CD"

        # "No" origin? So it's from the internet
        return "internet"

    def GetLyrics(self):
        """
        This method tries to get lyrics from the metadata.
        If on lyrics were found, ``None`` gets returned.

        Returns:
            The lyrics as string, or ``None`` if entry does not exist
        """
        if self.ftype == "m4a":
            return None

        elif self.ftype == "flac":
            try:
                lyrics = self.file[b"\xa9lyr"][0]
            except:
                return None

        elif self.ftype == "mp3":
            try:
                lyrics = self.file.tags.getall('USLT')[0].text
                if type(lyrics) == str and len(lyrics) > 0:
                    return lyrics
            except:
                return None

        return None

    def GetPlaytime(self):
        """
        This method tries to determine the playtime of a song.
        It first calls :meth:`~lib.metatags.MetaTags.AnalysePlaytime` which gets the playtime direct form file
        using ``ffprobe``.
        Only if this method fails, the playtime gets read from the meta data.

        Returns:
            playtime in second, or ``0`` if there is no way to get the time
        """
        time = 0

        if self.ftype in ["m4a", "mp3", "flac"]:
            try:
                analtime = round(self.AnalysePlaytime())
            except:
                analtime = None

            # never trust metadata, if we got duration, reading the metadata is not needed anymore
            if analtime:
                time = analtime
            else:
                try:
                    time = round(self.file.info.length)
                except:
                    time = 0

        return int(time)

    def AnalysePlaytime(self):
        """
        Analyses the playtime of a file using ``ffprobe``.

        The corresponding command line is the following:

            .. code-block:: bash

                ffprobe -v error -show_entries format=duration -print_format default=noprint_wrappers=1:nokey=1 $PATH

        Returns:
            The duration in seconds (as float) or ``None`` if the analysis fails
        """
        process = [
            "ffprobe", "-v", "error", "-show_entries", "format=duration",
            "-print_format", "default=noprint_wrappers=1:nokey=1", self.path
        ]

        logging.debug("Running duration analysis: %s", str(process))
        try:
            retval = subprocess.check_output(process)
            logging.debug("Analysis returned %s", str(retval))
            retval = float(retval)
        except Exception as e:
            logging.error("Error \"%s\" while executing: %s", str(e),
                          str(process))
            return None

        logging.debug("Analysis returned duration of %fs", retval)
        return retval

    def GetBitrate(self):
        """
        This method returns the bitrate of the file.

        Returns:
            bitrate as integer or ``0``
        """
        bitrate = 0

        if self.ftype in ["m4a", "mp3", "flac"]:
            try:
                bitrate = self.file.info.bitrate
            except:
                bitrate = 0

        return int(bitrate)
Beispiel #5
0
class MetaTags(object):
    """
    This class can be used to access the metadata of the music files.

    Args:
        root (str): All paths used in this class are relative to this path. Default is ``"/"``.
    """
    def __init__(self, root="/"):
        self.fs = Filesystem(root)
        self.file = None  # the mutagenx file handler/object
        self.ftype = None  # contains the identifier of the filetype (m4a, mp3, flac)
        self.extension = None  # contains the actual file extension
        self.path = None  # contains the full path of the file for debugging

    def Load(self, path):
        """
        Supported file extensions for audio files:

            * For MPEG4: ``aac``, ``m4a``
            * For MPEG3: ``mp3``, ``MP3``
            * For FLAC: ``flac``

        Supported file extensions for video files:

            * For MPEG4: ``m4v`` (``mp4``)
            * For WebM: ``webm``

        The corner case of an ``mp4`` file gets not handled as video because it is not clear if it shall be handled as audio
        or video file.
        A warning gets written into the log and a ``ValueError`` exception raised.

        Args:
            path (str): path to the song file that shall be loaded

        Returns:
            *Nothing*

        Raises:
            TypeError: If path is not a string
            ValueError: If file not exist or file cannot be read.
            ValueError: If the file extension or file format is not supported
        """
        logging.debug("Analysing file from %s", path)
        if type(path) != str:
            raise TypeError("Path must be a string!")

        # do some filename-management
        self.path = self.fs.AbsolutePath(path)
        if not self.fs.IsFile(self.path):
            raise ValueError("File \"%s\" does not exist" % (self.path))

        # remember the path for debugging
        self.extension = self.fs.GetFileExtension(self.path)

        # normalize the extension
        if self.extension in ["mp4"]:
            logging.warning(
                "A file with extension \"mp4\" shall be loaded. It will be loaded as video."
            )
            self.ftype = "m4v"
        elif self.extension in ["webm"]:
            self.ftype = "webm"
        elif self.extension in ["aac", "m4a"]:
            self.ftype = "m4a"
        elif self.extension in ["m4v"]:
            self.ftype = "m4v"
        elif self.extension == "flac":
            self.ftype = "flac"
        elif self.extension in ["mp3", "MP3"]:
            self.ftype = "mp3"
        else:
            self.path = None
            raise ValueError("Unsupported file extension \"%s\" of \"%s\"" %
                             (self.extension, path))

        logging.debug("Loading file of type %s from \"%s\"", self.ftype,
                      self.path)

        # open the file
        if self.ftype == "flac":
            self.file = FLAC(self.path)
        elif self.ftype == "mp3":
            self.file = MP3(self.path)
        elif self.ftype == "m4a":
            self.file = MP4(self.path)
        elif self.ftype == "m4v":
            self.file = MP4(self.path)
        elif self.ftype == "webm":
            logging.warning("WebM only partially supported!")
            self.file = None
        else:
            self.path = None
            raise ValueError("Unsupported file-type %s" % (self.ftype))

    def GetAllMetadata(self):
        """
        This method collects as much information as possible from a file.
        The information gets stored in a dictionary.

            * ``song``: :meth:`~lib.metatags.MetaTags.GetSongname`
            * ``album``: :meth:`~lib.metatags.MetaTags.GetAlbumname`
            * ``artist``: :meth:`~lib.metatags.MetaTags.GetArtistname`
            * ``releaseyear``: :meth:`~lib.metatags.MetaTags.GetReleaseyear`
            * ``origin``: :meth:`~lib.metatags.MetaTags.GetOrigin`
            * ``playtime``: :meth:`~lib.metatags.MetaTags.GetPlaytime`

        Additional information for audio files
            * ``cdnumber``: :meth:`~lib.metatags.MetaTags.GetCDNumber`
            * ``songnumber``: :meth:`~lib.metatags.MetaTags.GetTracknumber`
            * ``lyrics``: :meth:`~lib.metatags.MetaTags.GetLyrics`
            * ``bitrate``: :meth:`~lib.metatags.MetaTags.GetBitrate`

        Additional information for video files
            * ``codec``: :meth:`~lib.metatags.MetaTags.GetVideoCodec`
            * ``xresolution``, ``yresolution``: :meth:`~lib.metatags.MetaTags.GetVideoResolution`
        """
        metadata = {}

        metadata["song"] = self.GetSongname()
        metadata["album"] = self.GetAlbumname()
        metadata["artist"] = self.GetArtistname()
        metadata["releaseyear"] = self.GetReleaseyear()
        metadata["origin"] = self.GetOrigin()
        metadata["playtime"] = self.GetPlaytime()

        if self.ftype in ["flac", "mp3", "m4a"]:
            metadata["cdnumber"] = self.GetCDNumber()
            metadata["songnumber"] = self.GetTracknumber()
            metadata["bitrate"] = self.GetBitrate()
            metadata["lyrics"] = self.GetLyrics()

        elif self.ftype in ["m4v", "webm"]:
            metadata["codec"] = self.GetVideoCodec()
            x, y = self.GetVideoResolution()
            metadata["xresolution"] = x
            metadata["yresolution"] = y

        return metadata

    def StoreArtwork(self, imgfilename):
        """
        This method stores an artwork from the metadata into a file.
        If there is no artwork in the metadata, ``False`` gets returned.

        If the file already exists, it gets overwritten.

        Args:
            imgfilename (str): Absolute path to an image file to store the image at

        Returns:
            ``True`` on success, otherwise ``False``
        """
        try:
            if self.ftype == "mp3":
                # Source: http://stackoverflow.com/questions/6171565/how-do-i-read-album-artwork-using-python
                #artwork = self.file["APIC:"] # access APIC frame and grab the image
                # The suggested API seems to be broken.
                # This is why I go deeper into the mutagen-classes to get the image:
                artwork = self.file.tags.getall("APIC")[0]
                with open(imgfilename, "wb") as img:
                    img.write(artwork.data)
                return True

            elif self.ftype == "m4a":
                artwork = self.file[b"covr"][0]
                with open(imgfilename, "wb") as img:
                    img.write(artwork)
                return True

            elif self.ftype == "flac":
                artwork = self.file.pictures[0].data
                with open(imgfilename, "wb") as img:
                    img.write(artwork)
                return True

        except KeyError:
            logging.debug("File \"%s\" does not have a Cover-Image", self.path)
        except Exception as e:
            logging.warning(
                "Storing artwork to \"\033[0;33m%s\033[1;33m\" failed with error \"%s\"!",
                imgfilename, e)

        return False

    def GetSongname(self):
        """
        This method returns the name of a song

        Returns:
            The song name as string, or ``None`` if entry does not exist
        """
        try:
            if self.ftype == "m4a" or self.ftype == "m4v":
                return self.file[b"\xa9nam"][0]

            elif self.ftype == "mp3":
                name = self.file["TIT2"][0]

                # check if the unicodes were read wrong
                try:
                    name = name.encode("latin-1").decode("utf-8")
                except:
                    pass
                return name

            elif self.ftype == "flac":
                return self.file["Title"][0]

        except KeyError:
            logging.debug("File \"%s\" does not have a songname", self.path)
            return None

        return None

    def GetAlbumname(self):
        """
        This method returns the name of the album

        Returns:
            The album name as string, or ``None`` if entry does not exist
        """
        if self.ftype == "m4a" or self.ftype == "m4v":
            if b"\xa9alb" in self.file:
                return self.file[b"\xa9alb"][0]
            else:
                logging.debug("File \"%s\" does not have an albumname",
                              self.path)
                return None

        elif self.ftype == "mp3":
            # some songs dont have this tag
            if "TALB" in self.file:
                name = self.file["TALB"][0]
            else:
                logging.debug("File \"%s\" does not have an albumname",
                              self.path)
                return None

            # check if the unicodes were read wrong
            try:
                name = name.encode("latin-1").decode("utf-8")
            except:
                pass

            return name

        elif self.ftype == "flac":
            return self.file["Album"][0]

        logging.debug("File \"%s\" does not have an albumname", self.path)
        return None

    def GetArtistname(self):
        """
        This method returns the name of the artist

        Returns:
            The artist name as string, or ``None`` if entry does not exist
        """
        try:
            if self.ftype == "m4a" or self.ftype == "m4v":
                return self.file[b"\xa9ART"][0]

            elif self.ftype == "mp3":
                name = self.file["TPE1"][0]
                # check if the unicodes were read wrong
                try:
                    name = name.encode("latin-1").decode("utf-8")
                except:
                    pass
                return name

            elif self.ftype == "flac":
                return self.file["Artist"][0]

        except KeyError:
            logging.debug("File \"%s\" does not have an artistname", self.path)
            return None

        return None

    def GetReleaseyear(self):
        """
        This method returns the release year

        Returns:
            The release year as integer, or ``0`` if entry does not exist
        """
        date = 0
        try:
            if self.ftype == "m4a" or self.ftype == "m4v":
                date = self.file[b"\xa9day"][0]
                date = date.split("-")[0]  # get just the year

            elif self.ftype == "mp3":
                if not "TDRC" in self.file:
                    return int(date)

                date = self.file["TDRC"][0]
                date = date.text

                try:
                    date = date.split("-")[0]
                except:
                    pass

            elif self.ftype == "flac":
                date = self.file["Date"][0]
                date = date.split("-")[0]  # get just the year

        except KeyError:
            logging.debug("File \"%s\" does not have a release year",
                          self.path)
            return None

        try:
            date = int(date)
        except ValueError:
            logging.debug("File \"%s\" has a malformed date value!", self.path)
            data = 0
        return date

    def GetCDNumber(self):
        """
        This method returns the CD Number.
        The CD number is only read from MP4 and MP3 metadata.
        For all other formats, this method always returns ``0``

        Returns:
            The CD number as integer, or ``0`` if entry does not exist
        """
        number = 0
        if self.ftype == "m4a":
            try:
                number = self.file[b"disk"][0][0]
            except KeyError as e:
                number = 0

        elif self.ftype == "mp3":
            try:
                # Possible formats: "x/y" or "x"
                number = self.file["TPOS"][0].split("/")[0]
            except KeyError as e:
                number = 0

        elif self.ftype == "flac":
            return 0

        return int(number)

    def GetTracknumber(self):
        """
        This method returns the track number.

        Returns:
            The track number as integer, or ``0`` if entry does not exist
        """
        number = 0
        try:
            if self.ftype == "m4a":
                number = self.file[b"trkn"][0][0]

            elif self.ftype == "mp3":
                number = self.file["TRCK"][0]

            elif self.ftype == "flac":
                number = self.file["Tracknumber"][0]

        except KeyError:
            logging.debug("File \"%s\" does not have a tracknumber!",
                          self.path)
            return 0

        try:
            number = number.split("/")[0]
        except:
            pass

        # mutagenx has problems with unicode. In some cases, the tracknumber was not read correctly
        # that's sooooo embarrassing for a python3 lib
        try:
            number = int(number)
        except:
            logging.debug("File \"%s\" has a malformated tracknumber!",
                          self.path)
            number = 0
        return number

    def GetOrigin(self):
        """
        This method tries to determine where the file come from.
        The following origins can be detected: 

            * ``"iTunes"``
            * ``"bandcamp"``
            * ``"Amazon"``
            * ``"Google"``
            * ``"music163"`` aka 网易云音乐
            * ``"CD"`` as fallback for unknown *flac* files
            * ``"internet"`` as fallback for any other unknown files

        Returns:
            Name of the origin as string
        """
        # check m4a
        if self.ftype == "m4a" or self.ftype == "m4v":
            if b"----:com.apple.iTunes:iTunNORM" in self.file:
                return "iTunes"
            if b"----:com.apple.iTunes:iTunSMPB" in self.file:
                return "iTunes"
            if b"apID" in self.file:
                return "iTunes"

            if b"\xa9cmt" in self.file:
                comment = self.file[b"\xa9cmt"][0]
                comment = comment.lower()
                index = comment.find("bandcamp")
                return "bandcamp"

        # Check mp3
        elif self.ftype == "mp3":
            # usually, music.163 uses the TPUB frame …
            try:
                if self.file["TPUB"][0] == "网易云音乐":
                    return "music163"
            except KeyError:
                pass
            # … but not always :( - There is a second way: COMM contains a key
            try:
                if "COMM::\'eng\'" in self.file:
                    if "163 key" in self.file["COMM::\'eng\'"][0]:
                        return "music163"
                    elif "bandcamp" in self.file["COMM::\'eng\'"][0]:
                        return "bandcamp"
                    elif "Amazon" in self.file["COMM::\'eng\'"][0]:
                        return "Amazon"
            except KeyError:
                pass
            # … there is also a third way to identify the origin
            try:
                priv = self.file.tags.getall("PRIV")[0].owner
                if priv.split("/")[0] == "Google":
                    return "Google"
            except:
                pass

        # Check flac
        elif self.ftype == "flac":
            try:
                comments = self.file["Comment"]
                for comment in comments:
                    if "bandcamp" in comment:
                        return "bandcamp"
            except:
                pass

            return "CD"

        # "No" origin? So it's from the internet
        return "internet"

    def GetLyrics(self):
        """
        This method tries to get lyrics from the metadata.
        If on lyrics were found, ``None`` gets returned.

        Returns:
            The lyrics as string, or ``None`` if entry does not exist
        """
        if self.ftype == "m4a":
            return None

        elif self.ftype == "flac":
            try:
                lyrics = self.file[b"\xa9lyr"][0]
            except:
                return None

        elif self.ftype == "mp3":
            try:
                lyrics = self.file.tags.getall('USLT')[0].text
                if type(lyrics) == str and len(lyrics) > 0:
                    return lyrics
            except:
                return None

        return None

    def GetPlaytime(self):
        """
        This method tries to determine the playtime of a song.
        It first calls :meth:`~lib.metatags.MetaTags.AnalysePlaytime` which gets the playtime direct form file
        using ``ffprobe``.
        Only if this method fails, the playtime gets read from the meta data.

        Returns:
            playtime in second, or ``0`` if there is no way to get the time
        """
        time = 0

        if self.ftype in ["m4a", "m4v", "mp3", "flac", "webm"]:
            try:
                analtime = round(self.AnalysePlaytime())
            except:
                analtime = None

            # never trust metadata, if we got duration, reading the metadata is not needed anymore
            if analtime:
                time = analtime
            else:
                try:
                    time = round(self.file.info.length)
                except:
                    time = 0

        return int(time)

    def AnalysePlaytime(self):
        """
        Analyses the playtime of a file using ``ffprobe``.

        The corresponding command line is the following:

            .. code-block:: bash

                ffprobe -v error -show_entries format=duration -print_format default=noprint_wrappers=1:nokey=1 $PATH

        Returns:
            The duration in seconds (as float) or ``None`` if the analysis fails
        """
        process = [
            "ffprobe", "-v", "error", "-show_entries", "format=duration",
            "-print_format", "default=noprint_wrappers=1:nokey=1", self.path
        ]

        logging.debug("Running duration analysis: %s", str(process))
        try:
            retval = subprocess.check_output(process)
            logging.debug("Analysis returned %s", str(retval))
            retval = float(retval)
        except Exception as e:
            logging.error("Error \"%s\" while executing: %s", str(e),
                          str(process))
            return None

        logging.debug("Analysis returned duration of %fs", retval)
        return retval

    def GetVideoCodec(self):
        """
        Tries to identify the video codec of a video file.

        The corresponding command line is the following:

            .. code-block:: bash

                ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 $PATH

        Returns:
            A string with the identified codec like ``"h264"`` or ``None``
        """
        # via https://stackoverflow.com/questions/2869281/how-to-determine-video-codec-of-a-file-with-ffmpeg
        process = [
            "ffprobe", "-v", "error", "-select_streams", "v:0",
            "-show_entries", "stream=codec_name", "-of",
            "default=noprint_wrappers=1:nokey=1", self.path
        ]

        logging.debug("Running codec analysis: %s", str(process))
        try:
            retval = subprocess.check_output(process)
            logging.debug("Analysis returned %s", str(retval))
            retval = retval.decode("utf-8")
            retval = retval.strip()
        except Exception as e:
            logging.error("Error \"%s\" while executing: %s", str(e),
                          str(process))
            return None

        logging.debug("Analysis returned the codec %s", retval)
        return retval

    def GetVideoResolution(self):
        """
        Tries to identify the video resolution of a video file.
        There may be differences between the given resolution and the actual resolution of the video in the video player.
        This is due to the Sample Aspect Ratio (SAR).
        This method considers the SAR by returning the width multiplied by this ratio:

        .. math::

            width_{correct} = width_{meta} \cdot SAR

        The height will not be changed.

        The corresponding command line is the following:

        .. code-block:: bash

            ffprobe -v error -select_streams v:0 -show_entries stream=width,height,sample_aspect_ratio -of csv=s=x:p=0 $PATH

        Returns:
            A tuple ``(width,height)`` with the identified resolution like ``(1920, 1080)`` or ``None``
        """
        # via https://stackoverflow.com/questions/684015/how-can-i-get-the-resolution-width-and-height-for-a-video-file-from-a-linux-co
        process = [
            "ffprobe", "-v", "error", "-select_streams", "v:0",
            "-show_entries", "stream=width,height,sample_aspect_ratio", "-of",
            "csv=s=x:p=0", self.path
        ]

        logging.debug("Running resolution analysis: %s", str(process))
        try:
            retval = subprocess.check_output(process)
            logging.debug("Analysis returned %s", str(retval))
            retval = retval.decode("utf-8")
            x, y, sar = retval.split("x")
            x = int(x)
            y = int(y)
            sar_x, sar_y = sar.split(":")
            sar_x = float(sar_x)
            sar_y = float(sar_y)
        except Exception as e:
            logging.error("Error \"%s\" while executing: %s", str(e),
                          str(process))
            return None

        sar = sar_x / sar_y
        w = int(x * sar)
        h = int(y)

        logging.debug("Analysis returned the resolution %i x %i", w, h)
        return w, h

    def GetBitrate(self):
        """
        This method returns the bitrate of the file.

        Returns:
            bitrate as integer or ``0``
        """
        bitrate = 0

        if self.ftype in ["m4a", "mp3", "flac"]:
            try:
                bitrate = self.file.info.bitrate
            except:
                bitrate = 0

        return int(bitrate)