コード例 #1
0
ファイル: artwork.py プロジェクト: siddhantchimankar/musicdb
    def __init__(self, config, database):

        if type(config) != MusicDBConfig:
            raise TypeError("Config-class of unknown type")
        if type(database) != MusicDatabase:
            raise TypeError("Database-class of unknown type")

        self.db = database
        self.cfg = config
        self.fs = Filesystem()
        self.musicroot = Filesystem(self.cfg.music.path)
        self.artworkroot = Filesystem(self.cfg.artwork.path)

        # Define the prefix that must be used by the WebUI and server to access the artwork files
        # -> $PREFIX/$Artworkname.jpg
        self.manifestawprefix = "artwork"

        # Check if all paths exist that have to exist
        pathlist = []
        pathlist.append(self.cfg.music.path)
        pathlist.append(self.cfg.artwork.path)
        pathlist.append(self.cfg.artwork.manifesttemplate)

        for path in pathlist:
            if not self.fs.Exists(path):
                raise ValueError("Path \"" + path + "\" does not exist.")

        # Instantiate dependent classes
        self.meta = MetaTags(self.cfg.music.path)
        self.awcache = ArtworkCache(self.cfg.artwork.path)
コード例 #2
0
    def __init__(self, config, database):

        if type(config) != MusicDBConfig:
            raise TypeError("Config-class of unknown type")
        if type(database) != MusicDatabase:
            raise TypeError("Database-class of unknown type")

        self.db = database
        self.cfg = config
        self.fs = Filesystem()
        self.musicroot = Filesystem(self.cfg.music.path)
        self.framesroot = Filesystem(self.cfg.videoframes.path)
        self.metadata = MetaTags(self.cfg.music.path)
        self.maxframes = self.cfg.videoframes.frames
        self.previewlength = self.cfg.videoframes.previewlength
        self.scales = self.cfg.videoframes.scales

        # Check if all paths exist that have to exist
        pathlist = []
        pathlist.append(self.cfg.music.path)
        pathlist.append(self.cfg.videoframes.path)

        for path in pathlist:
            if not self.fs.Exists(path):
                raise ValueError("Path \"" + path + "\" does not exist.")
コード例 #3
0
ファイル: add.py プロジェクト: whiteblue3/musicdb
    def GetAlbumMetadata(self, albumpath):
        # get all songs from the albums
        songpaths = self.fs.GetFiles(
            albumpath,
            self.cfg.music.ignoresongs)  # ignores also all directories
        metatags = MetaTags(self.cfg.music.path)

        # Try to load a file
        for songpath in songpaths:
            try:
                metatags.Load(songpath)
            except ValueError:
                continue
            break
        else:
            return None

        metadata = metatags.GetAllMetadata()
        return metadata
コード例 #4
0
ファイル: uploadmanager.py プロジェクト: rstemmer/musicdb
    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
コード例 #5
0
    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     = Fileprocessing(self.cfg.music.path)
        self.meta   = MetaTags(self.cfg.music.path)

        # -rw-rw-r--
        self.filepermissions= stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH
        # drwxrwxr-x
        self.dirpermissions = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH                 

        # read lists with files and directories that shall be ignored by the scanner
        self.ignoreartists = self.cfg.music.ignoreartists
        self.ignorealbums  = self.cfg.music.ignorealbums
        self.ignoresongs   = self.cfg.music.ignoresongs
コード例 #6
0
class MusicDBDatabase(object):
    """
    This class supports the following features

        * File management
            * :meth:`~FindLostPaths`: Check if all paths in the *songs*, *albums* and *artists* table are valid.
            * :meth:`~FindNewPaths`: Check if there are new songs, albums or artists in the music collection that are not in the database.
            * :meth:`~FixAttributes`: Change the access mode and ownership of the music to match the configutation
            * :meth:`~AnalysePath`: Extract song information from its file path
            * :meth:`~TyrAnalysePathFor`: Check if the given path is valid for an artist, album or song
        * Database management
            * :meth:`~AddArtist`, :meth:`~AddAlbum`, :meth:`~AddSong`: Adds a new artist, album or song to the database
            * :meth:`~UpdateArtist`, :meth:`~UpdateAlbum`, :meth:`~UpdateSong`: Updates a artist, album or song path in the database
            * :meth:`~RemoveArtist`, :meth:`~RemoveAlbum`, :meth:`~RemoveSong`: Removes a artist, album or song from the database
        * Add information into the database
            * :meth:`~AddLyricsFromFile`: Read lyrics from the meta data of a song file into the database
            * :meth:`~UpdateChecksum`: Calculates and adds the checksum of a song file into the database
        * Other
            * :meth:`~UpdateServerCache`: Signals the WebSocket Server to refresh its internal caches

    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     = Fileprocessing(self.cfg.music.path)
        self.meta   = MetaTags(self.cfg.music.path)

        # -rw-rw-r--
        self.filepermissions= stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH
        # drwxrwxr-x
        self.dirpermissions = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH                 

        # read lists with files and directories that shall be ignored by the scanner
        self.ignoreartists = self.cfg.music.ignoreartists
        self.ignorealbums  = self.cfg.music.ignorealbums
        self.ignoresongs   = self.cfg.music.ignoresongs



    def FindLostPaths(self):
        """
        This method checks all artist, album and song entries if the paths to their related directories and files are still valid.
        Entries with invalid paths gets returned in three lists: ``artists, albums, songs``

        This method does not check if the song is cached in the Song Cache.

        Returns:
            A three lists of database entries with invalid paths. Empty lists if there is no invalid entrie.
        """
        lostartists = []
        lostalbums  = []
        lostsongs   = []

        # Check Artists
        artists = self.db.GetAllArtists()
        for artist in artists:
            if not self.fs.IsDirectory(artist["path"]):
                lostartists.append(artist)

        # Check Albums
        albums = self.db.GetAllAlbums()
        for album in albums:
            if not self.fs.IsDirectory(album["path"]):
                lostalbums.append(album)

        # Check Songs
        songs = self.db.GetAllSongs()
        for song in songs:
            if not self.fs.IsFile(song["path"]):
                lostsongs.append(song)

        return lostartists, lostalbums, lostsongs



    def FindNewPaths(self):
        """
        This method searches inside the music directory for valid artist, album and song paths.
        If those paths are not in the database, they will be returned.
        So this method returns three lists: ``artistpaths, albumpaths, songpaths``.
        Each representing an artist, album or song that is not known by the database yet.
        Files and directories in the configured ignore-list will be ignored.

        If a new directory was found, the subdirectories will not be added!
        So for a new album, the new songs are implicite and not listed in the new-songs-list.

        This method is very optimistic. It will also list empty directories.
        The user may want to check if the results of this method are valid for him.

        Further more this method is error tolerant. This means, if in the database is an invalid entry,
        this does not lead to errors. For example, if an album path gets renamed, this path will be returned.
        It does not lead to an error that the old path is still in the database.

        Returns:
            A three lists of paths that are valid but unknown by the database. Empty lists if there is no invalid entrie.
        """
        newartists = []
        newalbums  = []
        newsongs   = []

        # Check Artists
        artists          = self.db.GetAllArtists()
        knownartistpaths = [artist["path"] for artist in artists if self.fs.IsDirectory(artist["path"])]
        artistpaths      = self.fs.GetSubdirectories(None, self.ignoreartists)

        for path in artistpaths:
            path = self.fs.RemoveRoot(path)
            if path not in knownartistpaths:
                newartists.append(path)

        # Check Albums
        albums          = self.db.GetAllAlbums()
        knownalbumpaths = [album["path"] for album in albums if self.fs.IsDirectory(album["path"])]
        albumpaths      = self.fs.GetSubdirectories(knownartistpaths, self.ignorealbums)
        
        for path in albumpaths:
            if path not in knownalbumpaths:
                newalbums.append(path)

        # Check Songs
        songs           = self.db.GetAllSongs()
        knownsongpaths  = [song["path"] for song in songs if self.fs.IsFile(song["path"])]
        songpaths       = self.fs.GetFiles(knownalbumpaths, self.ignoresongs)

        for path in songpaths:

            # check if this is really an audio file
            extension = self.fs.GetFileExtension(path)
            if extension not in ["mp4", "aac", "m4a", "mp3", "flac", "MP3"]:
                continue

            if path not in knownsongpaths:
                newsongs.append(path)

        return newartists, newalbums, newsongs




    def FixAttributes(self, path):
        """
        This method changes the access permissions and ownership of a file or directory.
        Only the addressed files or directory's permissions gets changed, not their parents.

            * File permissions: ``rw-rw-r--``
            * Directory permissions: ``rwxrwxr-x``
            * Ownership as configured in the settings: ``[music]->owner``:``[music]->group``

        Args:
            path (str): Path to an artist, album or song, relative to the music directory

        Returns:
            *Nothing*

        Raises:
            ValueError if path is neither a file nor a directory.
        """
        # check if file or dir permissions must be used
        if self.fs.IsDirectory(path):
            permissions = self.dirpermissions
        elif self.fs.IsFile(path):
            permissions = self.filepermissions
        else:
            raise ValueError("Path \""+str(path)+"\" is not a directory or file")

        # change attributes and ownership
        self.fs.SetAttributes(path, self.cfg.music.owner, self.cfg.music.group, permissions)
        


    def AnalysePath(self, path):
        """
        This method analyses a path to a song and extracts all the information encoded in the path.
        The path must consist of three parts: The artist directory, the album directory and the song file.

        A valid path has one the following structures: 
        
            * ``{artistname}/{albumrelease} - {albumname}/{songnumber} {songname}.{extension}``
            * ``{artistname}/{albumrelease} - {albumname}/{cdnumber}-{songnumber} {songname}.{extension}``

        The returned dictionary holds all the extracted information.
        In case there is no *cdnumber*, this entry is ``1``.
        The names can have all printable Unicode characters and of cause spaces.

        If an error occurs because the path does not follow the scheme, ``None`` gets returned.
        This method does not check if the path exists!

        Args:
            path (str): A path of a song including artist and album directory.

        Returns:
            On success, a dictionary with information about the artist, album and song.
            Otherwise ``None`` gets returned.
        """
        result = {}

        # separate the artist album and song name stored in the filesystem
        try:
            [artist, album, song] = path.split("/")[-3:]
        except:
            logging.warning("Analysing \"%s\" failed!", path)
            logging.warning("path cannot be split into three parts: {artist}/{album}/{song}")
            return None

        # analyse the artist-infos
        result["artist"] = artist

        # analyse the album-infos
        albuminfos = self.fs.AnalyseAlbumDirectoryName(album)
        if albuminfos == None:
            logging.warning("Analysing \"%s\" failed!", path)
            logging.warning("Unexpected album directory name. Expecting \"{year} - {name}\"")
            return None

        result["release"] = albuminfos["release"]
        result["album"]   = albuminfos["name"]

        # analyse the song-infos
        try:
            songname   = song.split(" ")[1:]
            songnumber = song.split(" ")[0]

            try:
                [cdnumber, songnumber] = songnumber.split("-")
            except:
                cdnumber = 1

            songnumber = int(songnumber)
            cdnumber   = int(cdnumber)
            songname   = " ".join(songname)
            extension  = os.path.splitext(songname)[1][1:]  # get extension without leading "."
            songname   = os.path.splitext(songname)[0]      # remove extension
        except:
            logging.warning("Analysing \"%s\" failed!", path)
            logging.warning("Unexpected song file name. Expected \"[{cdnumber}-]{songnumber} {songname}.{ending}\".")
            return None

        result["song"]       = songname
        result["songnumber"] = songnumber
        result["cdnumber"]   = cdnumber
        result["extension"]  = extension

        return result



    def TryAnalysePathFor(self, target="all", path=None):
        """
        This method checks if a path is valid for a specific target.

        The check is done in the following steps:

            #. Get all song paths
            #. Apply an information extraction on all found song paths using :meth:`~mdbapi.database.MusicDBDatabase.AnalysePath`

        This guarantees, that all files are valid to process with MusicDB.

        Args:
            target (str): Optional, default value is ``"all"``. One of the following targets: ``"all"``, ``"artist"``, ``"album"`` or ``"song"``
            path (str): Optional, default value is ``None``. Path to an artist, album or song. If target is ``"all"``, path can be ``None``.

        Returns:
            ``True`` If the path is valid for the given target. Otherwise ``False`` gets returned.

        Raises:
            ValueError: when *target* is not ``"all"``, ``"artist"``, ``"album"`` or ``"song"``
        """
        if path == None and target != "all":
            logging.error("Path to check if it is a valid for %s may not be None!", target)
            return False

        # Get all song paths
        try:
            if target == "all":
                artistpaths = self.fs.GetSubdirectories(path,        self.ignoreartists)
                albumpaths  = self.fs.GetSubdirectories(artistpaths, self.ignorealbums)
                songpaths   = self.fs.GetFiles(albumpaths, self.ignoresongs)

            elif target == "artist":
                albumpaths  = self.fs.GetSubdirectories(path, self.ignorealbums)
                songpaths   = self.fs.GetFiles(albumpaths, self.ignoresongs)

            elif target == "album":
                songpaths   = self.fs.GetFiles(path, self.ignoresongs)

            elif target == "song":
                songpaths   = [path]

            else:
                raise ValueError("target not in {all, artist, album, song}")

        except Exception as e:
            logging.error("FATAL ERROR: The given path (\"%s\") was not a valid %s-path!\033[1;30m (%s)", path, target, str(e))
            return False

        n = len(songpaths)
        if n < 1:
            logging.error("No songs in %s-path (%s)", target, path)
            return False

        for songpath in songpaths:
            if not os.path.exists(songpath):
                logging.error("The song path %s does not exist.", songpath)
                return False

        # Scan all songpathes - if they are not analysable, give an error
        for songpath in songpaths:
            result = self.AnalysePath(songpath)
            if result == False:
                logging.error("Invalid path: " + songpath)
                return False

        return True



    def AddArtist(self, artistpath):
        """
        The *AddArtist* method adds a new artist to the database.
        This is done in the following steps:

            #. Check if the artist path is inside the music root directory
            #. Check if the artist is already in the database
            #. Extract the artist name from the path
            #. Set directory attributes and ownership using :meth:`~mdbapi.database.MusicDBDatabase.FixAttributes`
            #. Add artist to database
            #. Call :meth:`~mdbapi.database.MusicDBDatabase.AddAlbum` for all subdirectories of the artistpath. (Except for the directory-names in the *ignorealbum* list)

        Args:
            artistpath (str): Absolute or relative (to the music directory) path to the artist that shall be added to the database.

        Returns:
            ``None``

        Raises:
            ValueError: If the path does not address a directory
            ValueError: If artist is already in the database
        """
        # remove the leading part to the music directory
        try:
            artistpath = self.fs.RemoveRoot(artistpath) # remove the path to the musicdirectory
        except ValueError:
            pass

        if not self.fs.IsDirectory(artistpath):
            raise ValueError("Artist path " + artistpath + " is not a directory!")

        # Check if the artist already exists in the database
        artist = self.db.GetArtistByPath(artistpath)
        if artist != None:
            raise ValueError("Artist \"" + artist["name"] + "\" does already exist in the database.")
        
        # The filesystem is always right - Survivor of the "tag over fs"-apocalypse - THE FS IS _ALWAYS_ RIGHT!
        artistname = os.path.basename(artistpath)

        # fix attributes to fit in mdb environment before adding it to the database
        try:
            self.FixAttributes(artistpath)
        except Exception as e:
            logging.warning("Fixing file attributes failed with error: %s \033[1;30m(leaving permissions as they are)",
                    str(e))

        # add artist to database
        self.db.AddArtist(artistname, artistpath)
        artist = self.db.GetArtistByPath(artistpath)

        # Add all albums to the artist
        albumpaths = self.fs.GetSubdirectories(artistpath, self.ignorealbums)
        for albumpath in albumpaths:
            self.AddAlbum(albumpath, artist["id"])

        return None



    def UpdateArtist(self, artistid, newpath):
        """
        This method updates an already existing artist entry in the database.

        Updates information are:

            * path
            * name

        All albums and songs of this artist will also be updated using
        :meth:`~UpdateAlbum` and :meth:`~UpdateSong`.

        Args:
            artistid (int): ID of the artist entry that shall be updated
            newpath (str): Relative path to the new artist

        Returns:
            ``None``
        """
        try:
            newpath = self.fs.RemoveRoot(newpath) # remove the path to the musicdirectory
        except:
            pass

        artist     = self.db.GetArtistById(artistid)
        artist["path"] = newpath
        artist["name"] = os.path.basename(newpath)
        self.db.WriteArtist(artist)

        albums = self.db.GetAlbumsByArtistId(artistid)
        for album in albums:
            albumpath    = album["path"]
            albumpath    = albumpath.split(os.sep)
            albumpath[0] = newpath
            albumpath    = os.sep.join(albumpath)
            if self.fs.IsDirectory(albumpath):
                self.UpdateAlbum(album["id"], albumpath)

        return None



    def AddAlbum(self, albumpath, artistid=None):
        """
        This method adds an album to the database in the following steps:

            #. Check if the album already exists in the database
            #. Get all songs of the album by getting all files inside the directory except those on the *ignoresongs*-list.
            #. Load the metadata from one of those songs using :meth:`lib.metatags.MetaTags.GetAllMetadata`
            #. Analyze the path of one of those songs using :meth:`~mdbapi.database.MusicDBDatabase.AnalysePath`
            #. If *artistid* is not given as parameter, it gets read from the database identifying the artist by its path.
            #. Get modification date using :meth:`~lib.filesystem.FileSystem.GetModificationDate`
            #. Set directory attributes and ownership using :meth:`~mdbapi.database.MusicDBDatabase.FixAttributes`
            #. Create new entry for the new album in the database and get the default values
            #. Add each song of the album to the database by calling :meth:`~mdbapi.database.MusicDBDatabase.AddSong`
            #. Write all collected information of the album into the database

        If adding the songs to the database raises an exception, that song gets skipped.
        The *numofsongs* value for the album is the number of actual existing songs for this album in the database.
        It is save to add the failed song later by using the :meth:`~mdbapi.database.MusicDBDatabase.AddSong` method.

        Args:
            albumpath (str): Absolute path, or path relative to the music root directory, to the album that shall be added to the database.
            artistid (int): Optional, default value is ``None``. The ID of the artist this album belongs to.

        Returns:
            ``None``

        Raises:
            ValueError: If the path does not address a directory
            ValueError: If album already exists in the database
            AssertionError: If there is no artist for this album in the database
            AssertionError: If loading metadata from one of the song files failed

        """
        # remove the leading part to the music directory (it may be already removed)
        try:
            albumpath = self.fs.RemoveRoot(albumpath) # remove the path to the musicdirectory
        except ValueError:
            pass

        if not self.fs.IsDirectory(albumpath):
            raise ValueError("Artist path " + artistpath + " is not a directory!")

        # Check if the album already exists in the database
        album = self.db.GetAlbumByPath(albumpath)
        if album != None:
            raise ValueError("Album \"" + album["name"] + "\" does already exist in the database.")

        # This album dictionary gets filled with all kind of information during this method.
        # At the end they are written into the database
        album = {}
        album["name"]       = None
        album["path"]       = albumpath

        # get all songs from the albums - this is important to collect all infos for the album entry
        paths = self.fs.GetFiles(albumpath, self.ignoresongs) # ignores also all directories

        # remove files that are not music
        songpaths = []
        for path in paths:
            if self.fs.GetFileExtension(path) in ["mp3", "m4a", "aac", "flac"]:
                songpaths.append(path)

        # analyse the first one for the album-entry
        self.meta.Load(songpaths[0])
        tagmeta = self.meta.GetAllMetadata()
        fsmeta  = self.AnalysePath(songpaths[0])
        if fsmeta == None:
            raise AssertionError("Analysing path \"%s\" failed!", songpaths[0])
        moddate = self.fs.GetModificationDate(albumpath)

        # usually, the filesystem is always right, but in case of iTunes, the meta data are
        # FIX: NO! THEY ARE NOT! - THE FILESYSTEM IS _ALWAYS_ RIGHT!
        album["name"]    = fsmeta["album"]
        album["release"] = fsmeta["release"]
        album["origin"]  = tagmeta["origin"]
        album["added"]   = moddate
        
        if artistid == None:
            # the artistname IS the path, because thats how the fsmeta data came from
            artist = self.db.GetArtistByPath(fsmeta["artist"])
            if artist == None:
                raise AssertionError("Artist for the album \"" + album["name"] + "\" is not avaliable in the database.")
            artistid = artist["id"]

        # fix attributes to fit in mdb environment before adding it to the database
        try:
            self.FixAttributes(albumpath)
        except Exception as e:
            logging.warning("Fixing file attributes failed with error: %s \033[1;30m(leaving permissions as they are)",
                    str(e))

        # Add Album to database
        self.db.AddAlbum(artistid, album["name"], album["path"])

        # read the new database entry to get defaults - this is needed by the WriteAlbum-method
        entry = self.db.GetAlbumByPath(album["path"])
        album["id"]         = entry["id"]
        album["artworkpath"]= entry["artworkpath"]
        album["bgcolor"]    = entry["bgcolor"]
        album["fgcolor"]    = entry["fgcolor"]
        album["hlcolor"]    = entry["hlcolor"]

        # update the album entry
        album["artistid"]   = artistid

        # now add all the albums songs to the database
        for songpath in songpaths:
            try:
                self.AddSong(songpath, artistid, album["id"])
            except Exception as e:
                logging.exception("CRITICAL ERROR! Adding a song to the new added album \"%s\" failed with the exception \"%s\"! \033[1;30m(ignoring that song (%s) and continue with next)", str(album["name"]), str(e), str(songpath))

        # get some final information after adding the songs
        songs = self.db.GetSongsByAlbumId(album["id"])
        numofcds = 0
        for song in songs:
            if song["cd"] > numofcds:
                numofcds = song["cd"]

        album["numofsongs"] = len(songs)
        album["numofcds"]   = numofcds

        self.db.WriteAlbum(album)
        return None



    def UpdateAlbum(self, albumid, newpath):
        """
        This method updates an already existing album entry in the database.
        So in case some information in the filesystem were changed (renaming, new files, …) the database gets updated.
        The following steps will be done to do this:

            #. Update the *path* entry of the album to the new path
            #. Reading a song file inside the directory to load meta data
            #. Analyse the path to collect further information from the filesystem
            #. Update database entry for the album with the new collected information

        Updates information are:

            * path
            * name
            * release date
            * origin

        All albums and songs of this artist will also be updated using
        :meth:`~UpdateSong`.

        Args:
            albumid (int): ID of the album entry that shall be updated
            newpath (str): Relative path to the new album

        Returns:
            ``None``

        Raises:
            AssertionError: When the new path is invalid
        """
        album = self.db.GetAlbumById(albumid)
        try:
            newpath = self.fs.RemoveRoot(newpath) # remove the path to the musicdirectory
        except:
            pass

        album["path"] = newpath

        # get all songs from the albums - this is important to collect all infos for the album entry
        songpaths = self.fs.GetFiles(newpath, self.ignoresongs) # ignores also all directories

        # analyse the first one for the album-entry
        self.meta.Load(songpaths[0])
        tagmeta = self.meta.GetAllMetadata()
        fsmeta  = self.AnalysePath(songpaths[0])
        if fsmeta == None:
            raise AssertionError("Analysing path \"%s\" failed!", songpaths[0])

        album["name"]    = fsmeta["album"]
        album["release"] = fsmeta["release"]
        album["origin"]  = tagmeta["origin"]
        self.db.WriteAlbum(album)

        songs = self.db.GetSongsByAlbumId(albumid)
        for song in songs:
            songpath    = song["path"]
            songpath    = songpath.split(os.sep)    # [artist, album, song]
            songpath.pop(0)                         # [album, song]         // remove old artist
            songpath[0] = newpath                   # [artist/album, song]  // add new artist/album string
            songpath    = os.sep.join(songpath)

            # If it does not work, it does not matter.
            # This can be fixed by the user later.
            # It may faile because not only the albumname changed,
            # but also the files inside
            if self.fs.IsFile(songpath):
                self.UpdateSong(song["id"], songpath)

        return None



    def AddSong(self, songpath, artistid=None, albumid=None):
        """
        This method adds a song to the MusicDB database.
        To do so, the following steps were done:

            #. Check if the song already exists in the database
            #. Load the metadata from the song using :meth:`lib.metatags.MetaTags.GetAllMetadata`
            #. Analyze the path of one of the song using :meth:`~mdbapi.database.MusicDBDatabase.AnalysePath`
            #. If *artistid* is not given as parameter, it gets read from the database identifying the artist by its path.
            #. If *albumid* is not given as parameter, it gets read from the database identifying the album by its path.
            #. Set file attributes and ownership using :meth:`~mdbapi.database.MusicDBDatabase.FixAttributes`
            #. Add song to database
            #. If the parameter *albumid* was ``None`` the *numofsongs* entry of the determined album gets incremented
            #. If there are lyrics in the song file, they get also inserted into the database

        In case the album ID is set, this method assumes that its database entry gets managed by the :meth:`~mdbapi.database.MusicDBDatabase.AddAlbum` method.
        So, nothing will be changed regarding the album.
        If album ID was ``None``, this method also updates the album-entry, namely the *numofsongs* value gets incremented.

        Args:
            songpath (str): Absolute path, or path relative to the music root directory, to the song that shall be added to the database.
            artistid (int): Optional, default value is ``None``. The ID of the artist this song belongs to.
            albumid (int): Optional, default value is ``None``. The ID of the album this song belongs to.

        Returns:
            ``None``

        Raises:
            ValueError: If song already exists in the database
            AssertionError: If analyzing the path fails
            AssertionError: If there is no album for this song in the database
        """
        # do some checks
        # remove the root-path to the music directory
        try:
            songpath = self.fs.RemoveRoot(songpath) # remove the path to the musicdirectory
        except:
            pass

        # Check if the song already exists in the database
        song = self.db.GetSongByPath(songpath)
        if song != None:
            raise ValueError("Song \"" + song["name"] + "\" does already exist in the database.")

        # Get all information from the songpath and its meta data
        try:
            self.meta.Load(songpath)
        except Exception:
            logging.debug("Metadata of file %s cannot be load. Assuming this is not a song file!", str(songpath))
            # Ignore this file, it is not a valid song file
            return None

        tagmeta = self.meta.GetAllMetadata()
        fsmeta  = self.AnalysePath(songpath)
        if fsmeta == None:
            raise AssertionError("Invalid path-format: " + songpath)

        # Collect all data needed for the song-entry (except the song ID)
        # Remember! The filesystem is always right
        song = {}
        song["artistid"]    = artistid # \_ In case they are None yet, they will be updated later in the code
        song["albumid"]     = albumid  # /
        song["path"]        = songpath
        song["number"]      = fsmeta["songnumber"]
        song["cd"]          = fsmeta["cdnumber"]
        song["disabled"]    = 0
        song["playtime"]    = tagmeta["playtime"]
        song["bitrate"]     = tagmeta["bitrate"]
        song["likes"]       = 0
        song["dislikes"]    = 0
        song["qskips"]      = 0
        song["qadds"]       = 0
        song["qremoves"]    = 0
        song["favorite"]    = 0
        song["qrndadds"]    = 0
        song["lyricsstate"] = SONG_LYRICSSTATE_EMPTY
        song["checksum"]    = self.fs.Checksum(songpath)
        song["lastplayed"]  = 0

        # FIX: THE FILESYSTEM IS _ALWAYS_ RIGHT! - WHAT THE F**K!
        song["name"] = fsmeta["song"] 

        # artistid may be not given by the arguments of this method.
        # In this case, it must be searched in the database
        if artistid == None:
            artist = self.db.GetArtistByPath(fsmeta["artist"])
            if artist == None:
                raise AssertionError("Artist for the song \"" + songpath + "\" is not avaliable in the database.")

            song["artistid"] = artist["id"]

        if albumid == None:
            # reconstruct the album path out of the songs meta-information from the filesystem
            # so it is easy to find the right album by just comparing the pathes
            albumpath = fsmeta["artist"] + "/" + str(fsmeta["release"]) + " - " + fsmeta["album"]

            # find the album from the given artist to that the song belongs
            albums = self.db.GetAlbumsByArtistId(song["artistid"])
            for album in albums:
                if album["path"] == albumpath:
                    song["albumid"] = album["id"]
                    break
            else:
                raise AssertionError("The album for the song \"" + songpath + "\" is not avaliable in the database.")
            # if the albumid was unknown, the numofsongs was not updated before.
            # So the next section is necessary to determin the new numofsongs
            # (Will not be written to DB yet, only if AddFullSong at the end succeeds)
            newalbumentry   = self.db.GetAlbumById(song["albumid"])
            songlist        = self.db.GetSongsByAlbumId(song["albumid"])
            newalbumentry["numofsongs"] = len(songlist) + 1
        else:
            newalbumentry   = None  # there is no update for the album-entry

        # fix attributes to fit in mdb environment before adding it to the database
        try:
            self.FixAttributes(songpath)
        except Exception as e:
            logging.warning("Fixing file attributes failed with error: %s \033[1;30m(leaving permissions as they are)",
                    str(e))

        # add to database
        retval = self.db.AddFullSong(song)
        if retval == False:
            raise AssertionError("Adding song %s failed!", song["path"])

        if newalbumentry:
            self.db.WriteAlbum(newalbumentry)

        # Add lyrics for this song
        if tagmeta["lyrics"] != None:
            try:
                self.db.SetLyrics(song["id"], tagmeta["lyrics"], SONG_LYRICSSTATE_FROMFILE)
            except Exception as e:
                logging.warning("Adding lyrics for song %s failed with error \"%s\". \033[1;30m(Does not break anything)",
                        song["name"], str(e))

        return None



    def UpdateSong(self, songid, newpath):
        """
        This method updates a song entry and parts of the related album entry.
        The following steps will be done to do this:

            #. Update the *path* entry of the album to the new path
            #. Reading the song files meta data
            #. Analyse the path to collect further information from the filesystem
            #. Update database entry with the new collected information

        Updates information are:

            * path
            * name
            * song number
            * cd number
            * playtime
            * bitrate
            * checksum

        Further more the following album information get updted:

            * numofsongs
            * numofcds

        Args:
            songid (int): ID of the song entry that shall be updated
            newpath (str): Relative path to the new album

        Returns:
            ``None``

        Raises:
            AssertionError: When the new path is invalid
            Exception: When loading the meta data failes
        """
        try:
            newpath = self.fs.RemoveRoot(newpath) # remove the path to the musicdirectory
        except:
            pass
        song     = self.db.GetSongById(songid)
        songpath = newpath

        # Get all information from the songpath and its meta data
        try:
            self.meta.Load(songpath)
        except Exception as e:
            logging.excpetion("Metadata of file %s cannot be load. Error: %s", str(songpath), str(e))
            raise e

        tagmeta = self.meta.GetAllMetadata()
        fsmeta  = self.AnalysePath(songpath)
        if fsmeta == None:
            raise AssertionError("Invalid path-format: " + songpath)

        # Remember! The filesystem is always right
        song["path"]     = songpath
        song["name"]     = fsmeta["song"] 
        song["number"]   = fsmeta["songnumber"]
        song["cd"]       = fsmeta["cdnumber"]
        song["playtime"] = tagmeta["playtime"]
        song["bitrate"]  = tagmeta["bitrate"]
        song["checksum"] = self.fs.Checksum(songpath)

        self.db.WriteSong(song)

        # Fix album information
        album = self.db.GetAlbumById(song["albumid"])
        songs = self.db.GetSongsByAlbumId(album["id"])
        numofcds = 0
        for song in songs:
            if song["cd"] > numofcds:
                numofcds = song["cd"]

        album["numofsongs"] = len(songs)
        album["numofcds"]   = numofcds
        self.db.WriteAlbum(album)
        return None



    def RemoveSong(self, songid):
        """
        This method removed a song from the database.
        The file gets not touched, and so it does not matter if it even exists.
        All related information will also be removed.

        .. warning::

            This is not a *"set the deleted flag"* method.
            The data gets actually removed from the database.
            No recovery possible!

        Args:
            songid (int): ID of the song that shall be removed from database

        Return:
            ``None``
        """
        tracker = TrackerDatabase(self.cfg.tracker.dbpath)

        # remove from music.db
        self.db.RemoveSong(songid)
        # remove from tracker.db
        tracker.RemoveSong(songid)

        return None


    def RemoveAlbum(self, albumid):
        """
        This method removes an album from the database.
        The files gets not touched, and so it does not matter if they even exists.
        All related information will also be removed.

        .. warning::

            For all songs in this album, the :meth:`~RemoveSong` method gets called!

        .. warning::

            This is not a *"set the deleted flag"* method.
            The data gets actually removed from the database.
            No recovery possible!

        Args:
            albumid (int): ID of the album that shall be removed from database

        Return:
            ``None``
        """
        songs = self.db.GetSongsByAlbumId(albumid)
        for song in songs:
            self.RemoveSong(song["id"])
        self.db.RemoveAlbum(albumid)
        return None


    def RemoveArtist(self, artistid):
        """
        This method removes an artist from the database.
        The files gets not touched, and so it does not matter if they even exists.
        All related information will also be removed.

        .. warning::

            For all artists albums, the :meth:`~RemoveAlbum` method gets called!

        .. warning::

            This is not a *"set the deleted flag"* method.
            The data gets actually removed from the database.
            No recovery possible!

        Args:
            artistid (int): ID of the artist that shall be removed from database

        Return:
            ``None``
        """
        albums = self.db.GetAlbumsByArtistId(artistid)
        for album in albums:
            self.RemoveAlbum(album["id"])
        self.db.RemoveArtist(artistid)
        return None



    def UpdateChecksum(self, songpath):
        """
        This method can be used to add or update the checksum of the file of a song into the database.
        The method does not care if there is already a checksum.
        
        .. note::

            :meth:`~AddSong` and :meth:`~UpdateSong` also update the checksum.
            So after calling that methods, calling this ``AddChecksum`` method is not necessary.

        Args:
            songpath (str): Absolute song path, or relative to the music root directory.

        Returns:
            ``True`` on success, otherwise ``False``
        """
        # remove the root-path to the music directory
        try:
            songpath = self.fs.RemoveRoot(songpath)
        except ValueError:
            # if RemoveRoot raises an ValueError, this only means that song path is already a relative path
            pass
        except Exception as e:
            logging.error("Invalid song path: %s. \033[1;30m(No checksum will be added)", str(e))
            return False


        # Check if the song exists in the database
        song = self.db.GetSongByPath(songpath)
        if song == None:
            logging.warning("There is no song with file \"%s\" in the database! \033[1;30m(No checksum will be added)", songpath)
            return False

        # Add new checksum
        song["checksum"] = self.fs.Checksum(songpath)
        self.db.WriteSong(song)
        return True



    def AddLyricsFromFile(self, songpath):
        """
        This method can be used to add lyrics from the file of a song into the database.
        It tries to load the metadata from the song file.
        If that succeeds, the song entry gets loaded from the database and the lyrics state of that entry gets checked.
        The lyrics from the file gets stored in the song database in case the current lyrics state is *empty*.
        Otherwise the files lyrics get rejected.

        This method returns ``True`` when *new* lyrics were added. If there already exist lyrics ``False`` gets returned.

        Args:
            songpath (str): Absolute song path, or relative to the music root directory.

        Returns:
            ``True`` on success, otherwise ``False``
        """
        # remove the root-path to the music directory
        try:
            songpath = self.fs.RemoveRoot(songpath)
        except ValueError:
            # if RemoveRoot raises an ValueError, this only means that song path is already a relative path
            pass
        except Exception as e:
            logging.error("Invalid song path: %s. \033[1;30m(No lyrics will be loaded)", str(e))
            return False

        # Get all information from the song path and its meta data
        try:
            self.meta.Load(songpath)
        except Exception as e:
            logging.warning("Loading songs metadata failed with error: %s. \033[1;30m(No lyrics will be loaded)", str(e))
            return False

        # read all meta-tags
        tagmeta = self.meta.GetAllMetadata()

        if tagmeta["lyrics"] == None:
            # No lyrics - no warning because this is common
            return False

        # Check if the song exists in the database
        song = self.db.GetSongByPath(songpath)
        if song == None:
            logging.warning("There is no song with file \"%s\" in the database! \033[1;30m(No lyrics will be added)", songpath)
            return False

        # check if there are already lyrics, if yes, cancel
        if song["lyricsstate"] != SONG_LYRICSSTATE_EMPTY:
            logging.warning("Song \"%s\" has already lyrics. They will NOT be overwritten!", song["name"])
            return False
        
        # store lyrics and update lyricsstate
        self.db.SetLyrics(song["id"], tagmeta["lyrics"], SONG_LYRICSSTATE_FROMFILE)
        return True



    def UpdateSongPath(self, newsongpath, oldsongpath):
        """
        .. warning::

            This method is deprecated!
            It will be removed in April 2019!

        This method can be used to update a song.

        The new song file must be named as necessary for MusicDB.
        It is OK when the songs file name changes, as long as the cd and track number are equal to the old file.

        The following steps will be done:

            #. Get database entry from old song
            #. Analyse new songs metatags and file name
            #. Check if it is a valid replacement
            #. Replace files (and creating a backup of the old song)
            #. Try to set file attributes
            #. Update database

        .. attention::

            If something went wrong, ``False`` gets returned.
            Then only the new file must be replaced by the backup file.
            The database is still consistent.

        .. warning::

            A single CD song cannot be updated to a multi CD one

            The old and new song must have the same track number

        Args:
            newsongpath (str): Absolute path to the new song
            oldsongpath (str): Absolute path to the current song

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

        Example:

            .. code-block:: python
                
                # replace an old mp3 by a new flac file
                db.UpdateSongPath("/tmp/downloads/23 Is Everywhere.flac", "/data/music/Illuminati/2023 - Sheeple/23 Is Everwhere.mp3")
        """
        logging.warning("DEPRECATED! Will be removed in April 2019!")

        if type(newsongpath) != str or type(oldsongpath) != str:
            logging.error("Arguments of wrong type. Strings were expected")
            return False

        # Get song database entry
        logging.debug("Getting old song database entry …")
        try:
            relsongpath = self.fs.RemoveRoot(oldsongpath)
        except ValueError:
            logging.warning("The path \"%s\" is not inside the music root directory! \033[1;30m(Doing nothing)", oldsongpath)
            return False

        song = self.db.GetSongByPath(relsongpath)
        if not song:
            logging.warning("The song with path \"%s\" does not exist in the database. \033[1;30m(Doing nothing)", relsongpath)
            return False

        # Analyse new song
        logging.debug("Analysing new song …")
        newmetadata = MetaTags("/")
        try:
            print("source: " + newsongpath)
            newmetadata.Load(newsongpath)
        except Exception as e:
            logging.warning("Loading meta data from file \"%s\" failed with error: %s! \033[1;30m(Doing nothing)", newsongpath, str(e))
            return False
        tagmeta = newmetadata.GetAllMetadata()
        
        songfilename = newsongpath.split("/")[-1]
        fsmeta  = self.fs.AnalyseSongFileName(songfilename)
        if fsmeta == None:
            logging.warning("Analysing song file name \"%s\" failed! \033[1;30m(Doing nothing)", songfilename)
            return False

        # Check if this is a valid replacement
        logging.debug("Checking if replacement is valid …")
        if song["cd"]     != fsmeta["cdnumber"]:
            logging.warning("Old CD number (%s) is not equal to the new CD number (%s)! \033[1;30m(Doing nothing)", 
                    str(song["cd"]),
                    str(fsmeta["cdnumber"]))
            return False

        if song["number"] != fsmeta["number"]:
            logging.warning("Old song number (%s) is not equal to the new song number (%s)! \033[1;30m(Doing nothing)", 
                    str(song["number"]),
                    str(fsmeta["number"]))
            return False


        # Replace files
        logging.debug("Replacing song file …")
        albumpath      = self.db.GetAlbumById(song["albumid"])["path"]
        relbackup      = relsongpath + ".bak"
        newrelsongpath = albumpath + "/" + os.path.basename(newsongpath)

        logging.debug(" mv \"%s\" \"%s\"", relsongpath, relbackup)
        try:
            self.fs.MoveFile(relsongpath, relbackup)
        except Exception as e:
            logging.warning("Creating backup failed! \033[1;30m(Doning nothing)")
            return False

        logging.debug(" cp \"%s\" \"%s\"", newsongpath, newrelsongpath)
        try:
            self.fs.CopyFile(newsongpath, newrelsongpath)
        except Exception as e:
            logging.error("Copying new song fail failed! Trying to restore backup. Check if file \"%s\" is back in place!", oldsongpath)
            self.fs.MoveFile(relbackup, relsongpath)
            return False

        # Update database
        logging.debug("Updating database …")
        song["path"]     = newrelsongpath
        song["playtime"] = tagmeta["playtime"]
        song["bitrate"]  = tagmeta["bitrate"]
        song["name"]     = fsmeta["name"] 

        try:
            self.FixAttributes(newsongpath)
        except Exception as e:
            logging.warning("Fixing file attributes failed with error: %s \033[1;30m(leaving permissions as they are)",
                    str(e))

        # write to database
        self.db.WriteSong(song)
        return True



    def UpdateServerCache(self):
        """
        This method signals the MusicDB Websocket Server to update its caches by writing ``refresh`` into its named pipe.
        This should always be called when there are new artists, albums or songs added to the database.

        Returns:
            *Nothing*
        """
        pipe = NamedPipe(self.cfg.server.fifofile)
        pipe.WriteLine("refresh")
コード例 #7
0
    def UpdateSongPath(self, newsongpath, oldsongpath):
        """
        .. warning::

            This method is deprecated!
            It will be removed in April 2019!

        This method can be used to update a song.

        The new song file must be named as necessary for MusicDB.
        It is OK when the songs file name changes, as long as the cd and track number are equal to the old file.

        The following steps will be done:

            #. Get database entry from old song
            #. Analyse new songs metatags and file name
            #. Check if it is a valid replacement
            #. Replace files (and creating a backup of the old song)
            #. Try to set file attributes
            #. Update database

        .. attention::

            If something went wrong, ``False`` gets returned.
            Then only the new file must be replaced by the backup file.
            The database is still consistent.

        .. warning::

            A single CD song cannot be updated to a multi CD one

            The old and new song must have the same track number

        Args:
            newsongpath (str): Absolute path to the new song
            oldsongpath (str): Absolute path to the current song

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

        Example:

            .. code-block:: python
                
                # replace an old mp3 by a new flac file
                db.UpdateSongPath("/tmp/downloads/23 Is Everywhere.flac", "/data/music/Illuminati/2023 - Sheeple/23 Is Everwhere.mp3")
        """
        logging.warning("DEPRECATED! Will be removed in April 2019!")

        if type(newsongpath) != str or type(oldsongpath) != str:
            logging.error("Arguments of wrong type. Strings were expected")
            return False

        # Get song database entry
        logging.debug("Getting old song database entry …")
        try:
            relsongpath = self.fs.RemoveRoot(oldsongpath)
        except ValueError:
            logging.warning("The path \"%s\" is not inside the music root directory! \033[1;30m(Doing nothing)", oldsongpath)
            return False

        song = self.db.GetSongByPath(relsongpath)
        if not song:
            logging.warning("The song with path \"%s\" does not exist in the database. \033[1;30m(Doing nothing)", relsongpath)
            return False

        # Analyse new song
        logging.debug("Analysing new song …")
        newmetadata = MetaTags("/")
        try:
            print("source: " + newsongpath)
            newmetadata.Load(newsongpath)
        except Exception as e:
            logging.warning("Loading meta data from file \"%s\" failed with error: %s! \033[1;30m(Doing nothing)", newsongpath, str(e))
            return False
        tagmeta = newmetadata.GetAllMetadata()
        
        songfilename = newsongpath.split("/")[-1]
        fsmeta  = self.fs.AnalyseSongFileName(songfilename)
        if fsmeta == None:
            logging.warning("Analysing song file name \"%s\" failed! \033[1;30m(Doing nothing)", songfilename)
            return False

        # Check if this is a valid replacement
        logging.debug("Checking if replacement is valid …")
        if song["cd"]     != fsmeta["cdnumber"]:
            logging.warning("Old CD number (%s) is not equal to the new CD number (%s)! \033[1;30m(Doing nothing)", 
                    str(song["cd"]),
                    str(fsmeta["cdnumber"]))
            return False

        if song["number"] != fsmeta["number"]:
            logging.warning("Old song number (%s) is not equal to the new song number (%s)! \033[1;30m(Doing nothing)", 
                    str(song["number"]),
                    str(fsmeta["number"]))
            return False


        # Replace files
        logging.debug("Replacing song file …")
        albumpath      = self.db.GetAlbumById(song["albumid"])["path"]
        relbackup      = relsongpath + ".bak"
        newrelsongpath = albumpath + "/" + os.path.basename(newsongpath)

        logging.debug(" mv \"%s\" \"%s\"", relsongpath, relbackup)
        try:
            self.fs.MoveFile(relsongpath, relbackup)
        except Exception as e:
            logging.warning("Creating backup failed! \033[1;30m(Doning nothing)")
            return False

        logging.debug(" cp \"%s\" \"%s\"", newsongpath, newrelsongpath)
        try:
            self.fs.CopyFile(newsongpath, newrelsongpath)
        except Exception as e:
            logging.error("Copying new song fail failed! Trying to restore backup. Check if file \"%s\" is back in place!", oldsongpath)
            self.fs.MoveFile(relbackup, relsongpath)
            return False

        # Update database
        logging.debug("Updating database …")
        song["path"]     = newrelsongpath
        song["playtime"] = tagmeta["playtime"]
        song["bitrate"]  = tagmeta["bitrate"]
        song["name"]     = fsmeta["name"] 

        try:
            self.FixAttributes(newsongpath)
        except Exception as e:
            logging.warning("Fixing file attributes failed with error: %s \033[1;30m(leaving permissions as they are)",
                    str(e))

        # write to database
        self.db.WriteSong(song)
        return True
コード例 #8
0
ファイル: metadata.py プロジェクト: whiteblue3/musicdb
 def __init__(self, config, database):
     MetaTags.__init__(self)
コード例 #9
0
ファイル: artwork.py プロジェクト: siddhantchimankar/musicdb
class MusicDBArtwork(object):
    """
    Args:
        config: MusicDB configuration object
        database: MusicDB database

    Raises:
        TypeError: if config or database are not of the correct type
        ValueError: If one of the working-paths set in the config file does not exist
    """
    def __init__(self, config, database):

        if type(config) != MusicDBConfig:
            raise TypeError("Config-class of unknown type")
        if type(database) != MusicDatabase:
            raise TypeError("Database-class of unknown type")

        self.db = database
        self.cfg = config
        self.fs = Filesystem()
        self.musicroot = Filesystem(self.cfg.music.path)
        self.artworkroot = Filesystem(self.cfg.artwork.path)

        # Define the prefix that must be used by the WebUI and server to access the artwork files
        # -> $PREFIX/$Artworkname.jpg
        self.manifestawprefix = "artwork"

        # Check if all paths exist that have to exist
        pathlist = []
        pathlist.append(self.cfg.music.path)
        pathlist.append(self.cfg.artwork.path)
        pathlist.append(self.cfg.artwork.manifesttemplate)

        for path in pathlist:
            if not self.fs.Exists(path):
                raise ValueError("Path \"" + path + "\" does not exist.")

        # Instantiate dependent classes
        self.meta = MetaTags(self.cfg.music.path)
        self.awcache = ArtworkCache(self.cfg.artwork.path)

    def GetArtworkFromFile(self, album, tmpawfile):
        """
        This method tries to get an artwork from the metadata of the first song of an album.
        With the first song, the first one in the database related to the album is meant.
        The metadata gets loaded and the artwork stored to a temporary file using the method
        :meth:`lib.metatags.MetaTags.StoreArtwork`.

        Args:
            album: Album entry from the MusicDB Database
            tmpawfile (str): Temporary artwork path (incl filename) to which the artwork shall be written

        Returns:
            ``True`` on success, otherwise ``False``
        """
        # Load the first files metadata
        songs = self.db.GetSongsByAlbumId(album["id"])
        firstsong = songs[0]

        self.meta.Load(firstsong["path"])
        retval = self.meta.StoreArtwork(tmpawfile)
        return retval

    def SetArtwork(self, albumid, artworkpath, artworkname):
        """
        This method sets a new artwork for an album.
        It does the following things:

            #. Copy the artwork from *artworkpath* to the artwork root directory under the name *artworkname*
            #. Create scaled Versions of the artwork by calling :meth:`lib.cache.ArtworkCache.GetArtwork` for each resolution.
            #. Update entry in the database

        All new creates files ownership will be set to ``[music]->owner:[music]->group`` and gets the permission ``rw-rw-r--``

        Args:
            albumid: ID of the Album that artwork shall be set
            artworkpath (str, NoneType): The absolute path of an artwork that shall be added to the database. If ``None`` the method assumes that the default artwork shall be set. *artworkname* will be ignored in this case.
            artworkname (str): The relative path of the final artwork.

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

        Examples:
            
            .. code-block:: python

                # Copy from metadata extracted temporary artwork to the artwork directory
                self.SetArtwork(albumid, "/tmp/musicdbtmpartwork.jpg", "Artist - Album.jpg")

                # Copy a user-defined artwork to the artwork directory
                self.SetArtwork(albumid, "/home/username/downloads/fromzeintanetz.jpg", "Artist - Album.jpg")

                # Set the default artwork
                self.SetArtwork(albumid, None, any)
        """
        if artworkpath:
            abssrcpath = self.fs.AbsolutePath(artworkpath)
            absdstpath = self.artworkroot.AbsolutePath(artworkname)

            # Copy file
            logging.debug("Copying file from \"%s\" to \"%s\"", abssrcpath,
                          absdstpath)
            self.artworkroot.CopyFile(abssrcpath, absdstpath)

            # Set permissions to -rw-rw-r--
            try:
                self.artworkroot.SetAttributes(
                    artworkname, self.cfg.music.owner, self.cfg.music.group,
                    stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP
                    | stat.S_IROTH)
            except Exception as e:
                logging.warning(
                    "Setting artwork file attributes failed with error %s. \033[1;30m(Leaving them as they are)",
                    str(e))

        if not self.artworkroot.Exists(artworkname):
            logging.error(
                "Artwork \"%s\" does not exist but was expected to exist!",
                artworkname)
            return False

        # Scale file
        # convert edge-size to resolution
        # [10, 20, 30] -> ["10x10", "20x20", "30x30"]
        resolutions = [str(s) + "x" + str(s) for s in self.cfg.artwork.scales]

        for resolution in resolutions:
            relpath = self.awcache.GetArtwork(artworkname, resolution)

            if not self.artworkroot.Exists(relpath):
                logging.error(
                    "Artwork \"%s\" does not exist but was expected to exist!",
                    relpath)
                return False

            # Set permissions to -rw-rw-r--
            try:
                self.artworkroot.SetAttributes(
                    relpath, self.cfg.music.owner, self.cfg.music.group,
                    stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP
                    | stat.S_IROTH)
            except Exception as e:
                logging.warning(
                    "Setting artwork file attributes failed with error %s. \033[1;30m(Leaving them as they are)",
                    str(e))

        # Update database entry
        self.db.SetArtwork(albumid, artworkname)

        return True

    @staticmethod
    def CreateArtworkName(artistname, albumname):
        """
        This method creates the name for an artwork regarding the following schema:
        ``$Artistname - $Albumname.jpg``.
        If there is a ``/`` in the name, it gets replaced by ``∕`` (U+2215, DIVISION SLASH)

        Args:
            artistname (str): Name of an artist
            albumname (str): Name of an album

        Returns:
            valid artwork filename
        """
        artistname = artistname.replace("/", "∕")
        albumname = albumname.replace("/", "∕")
        imagename = artistname + " - " + albumname + ".jpg"
        return imagename

    def UpdateAlbumArtwork(self, album, artworkpath=None):
        """
        This method updates the artwork path entry of an album and the artwork files in the artwork directory.
        If a specific artwork shall be forced to use, *artworkpath* can be set to this artwork file.
        Following the concept *The Filesystem Is Always Right* and *Do Not Trust Metadata*, the user specified artwork path has higher priority.
        Metadata will only be processed if *artworkpath* is ``None``

        So an update takes place if *at least one* of the following condition is true:

            #. The database entry points to ``default.jpg``
            #. *artworkpath* is not ``None``
            #. If the database entry points to a nonexistent file

        Args:
            album: An Album Entry from the MusicDB Database
            artworkpath (str, NoneType): Absolute path of an artwork that shall be used as album artwork. If ``None`` the Method tries to extract the artwork from the meta data of an albums song.

        Returns:
            ``True`` If either the update was successful or there was no update necessary.
            ``False`` If the update failed. Reasons can be an invalid *artworkpath*-Argument
        """
        # Create relative artwork path
        artist = self.db.GetArtistById(album["artistid"])
        imagename = self.CreateArtworkName(artist["name"], album["name"])

        # Check if there is no update necessary
        dbentry = album["artworkpath"]
        if dbentry != "default.jpg" and artworkpath == None:
            if self.artworkroot.IsFile(
                    dbentry
            ):  # If the file does not extist, it must be updated!
                return True

        # Check if the user given artworkpath is valid
        if artworkpath and not self.fs.IsFile(artworkpath):
            logging.error(
                "The artworkpath that shall be forces is invalid (\"%s\")! \033[1;30m(Artwork update will be canceled)",
                str(artworkpath))
            return False

        # If there is no suggested artwork, try to get one from the meta data
        # In case this failes, use the default artwork
        if not artworkpath:
            artworkpath = "/tmp/musicdbtmpartwork.jpg"  # FIXME: Hardcoded usually sucks
            retval = self.GetArtworkFromFile(album, artworkpath)
            if not retval:
                imagename = "default.jpg"
                artworkpath = None

        # Set new artwork
        logging.info("Updating artwork for album \"%s\" to \"%s\" at \"%s\".",
                     album["name"], imagename, artworkpath)
        retval = self.SetArtwork(album["id"], artworkpath, imagename)
        return retval

    def GenerateAppCacheManifest(self):
        """
        This method creates a manifest file for web browsers.
        Creating is done in two steps.
        First the template given in the configuration gets copied.
        Second the paths of all artworks get append to the file.
        Also, those of the scaled versions (as given in the config file).

        Returns:
            *Nothing*

        Raises:
            PermissonError: When there is no write access to the manifest file
        """
        # copy manifest template
        template = open(self.cfg.artwork.manifesttemplate, "r")
        manifest = open(self.cfg.artwork.manifest, "w")

        for line in template:
            manifest.write(line)

        template.close()

        # and append all artworkd
        albums = self.db.GetAllAlbums()
        awpaths = [album["artworkpath"] for album in albums]
        resolutions = [str(s) + "x" + str(s) for s in self.cfg.artwork.scales]
        resolutions.append(".")
        for resolution in resolutions:
            for awpath in awpaths:
                path = os.path.join(self.manifestawprefix, resolution)
                path = os.path.join(path, awpath)
                manifest.write(path + "\n")

        manifest.close()
コード例 #10
0
ファイル: add.py プロジェクト: whiteblue3/musicdb
    def RunImportProcess(self, data):
        # show tasks
        self.cli.ClearScreen()
        self.cli.SetCursor(0, 0)

        # rename songs
        for song in data["songs"]:
            oldpath = song[0]
            newpath = song[1]
            if oldpath == newpath:
                continue

            self.cli.PrintText(
                "\033[1;34mRename Song: \033[0;31m%s\033[1;34m -> \033[0;32m%s\n"
                % (song[0], song[1]))
            self.fs.MoveFile(oldpath, newpath)

        # rename album
        oldalbumpath = data["oldalbumpath"]
        newalbumpath = os.path.join(
            data["artistname"],
            data["releasedate"] + " - " + data["albumname"])
        if oldalbumpath != newalbumpath:
            self.cli.PrintText(
                "\033[1;34mRename Album:  \033[0;31m%s\033[1;34m -> \033[0;32m%s\n"
                % (oldalbumpath, newalbumpath))
            self.fs.MoveDirectory(oldalbumpath, newalbumpath)

        # rename artist
        oldartistpath = self.fs.GetDirectory(oldalbumpath)
        newartistpath = data["artistname"]
        if oldartistpath != newartistpath:
            self.cli.PrintText(
                "\033[1;34mRename Artist: \033[0;31m%s\033[1;34m -> \033[0;32m%s\n"
                % (oldartistpath, newartistpath))
            self.fs.MoveDirectory(oldartistpath, newartistpath)

        # import
        artist = self.db.GetArtistByPath(newartistpath)
        if not artist:
            self.cli.PrintText("\033[1;34mAdd new artist \033[0;36m%s\n" %
                               (newartistpath))
            self.db.AddArtist(newartistpath, newartistpath)
            artist = self.db.GetArtistByPath(newartistpath)

        if not artist:
            self.cli.PrintText(
                "\033[1;31mAdding artist failed! \033[1;30m(Retry the import workflow and check the names of the files in the file system)\033[0m"
            )
            return
        else:
            self.cli.PrintText("\033[1;34mImport album \033[0;36m%s\n" %
                               (newalbumpath))
            self.AddAlbum(newalbumpath, artist["id"])

        # set origin
        album = self.db.GetAlbumByPath(newalbumpath)
        if not album:
            self.cli.PrintText(
                "\033[1;31mImporting album failed! \033[1;30m(Retry the import workflow and check the names of the files in the file system)\033[0m"
            )
            return
        elif album["origin"] != data["origin"]:
            self.cli.PrintText("\033[1;34mSet Origin \033[0;36m%s\n" %
                               (data["origin"]))
            album["origin"] = data["origin"]
            self.db.WriteAlbum(album)
        self.cli.PrintText("\033[1;32mImporting album succeeded!\n")

        # process
        if data["runartwork"]:
            self.cli.PrintText("\033[1;37mRun Artwork Import\n")
            artwork = MusicDBArtwork(self.cfg, self.db)
            artwork.UpdateAlbumArtwork(album)

        if data["runlyrics"]:
            self.cli.PrintText("\033[1;37mRun Lyrics Import\n")
            metadata = MetaTags(self.cfg.music.path)

            for songtuple in data["songs"]:
                songpath = songtuple[1]
                song = self.db.GetSongByPath(songpath)
                if not song:
                    continue
                songid = song["id"]

                metadata.Load(songpath)
                lyrics = metadata.GetLyrics()
                if lyrics:
                    self.db.SetLyrics(songid, lyrics,
                                      SONG_LYRICSSTATE_FROMFILE)

        if data["runmusicai"]:
            self.cli.PrintText("\033[1;37mRun MusicAI\n")
            absalbumpath = self.fs.AbsolutePath(newalbumpath)
            musicai = musicai_module(self.cfg, self.db)
            mdbsongs = musicai.GetSongsFromPath(absalbumpath)
            if not mdbsongs:
                self.cli.PrintText(
                    "\033[1;31mNo songs to analyze found in %s! \033[1;30m\033[0m"
                    % (absalbumpath))
                return

            musicai.GenerateFeatureset(mdbsongs)
            prediction = musicai.PerformPrediction(mdbsongs)
            musicai.StorePrediction(prediction)
コード例 #11
0
ファイル: add.py プロジェクト: rstemmer/musicdb
    def RunImportProcess(self, data):
        # show tasks
        self.cli.ClearScreen()
        self.cli.SetCursor(0, 0)

        # rename songs
        for song in data["songs"]:
            oldpath = song[0]
            newpath = song[1]
            if oldpath == newpath:
                continue

            self.cli.PrintText(
                "\033[1;34mRename Song: \033[0;31m%s\033[1;34m -> \033[0;32m%s\n"
                % (song[0], song[1]))
            self.fs.MoveFile(oldpath, newpath)

        # rename album
        oldalbumpath = data["oldalbumpath"]
        newalbumpath = os.path.join(
            data["artistname"],
            data["releasedate"] + " - " + data["albumname"])
        if oldalbumpath != newalbumpath:
            self.cli.PrintText(
                "\033[1;34mRename Album:  \033[0;31m%s\033[1;34m -> \033[0;32m%s\n"
                % (oldalbumpath, newalbumpath))
            self.fs.MoveDirectory(oldalbumpath, newalbumpath)

        # rename artist
        oldartistpath = self.fs.GetDirectory(oldalbumpath)
        newartistpath = data["artistname"]
        if oldartistpath != newartistpath:
            self.cli.PrintText(
                "\033[1;34mRename Artist: \033[0;31m%s\033[1;34m -> \033[0;32m%s\n"
                % (oldartistpath, newartistpath))
            self.fs.MoveDirectory(oldartistpath, newartistpath)

        # import
        artist = self.db.GetArtistByPath(newartistpath)
        if not artist:
            self.cli.PrintText("\033[1;34mAdd new artist \033[0;36m%s\n" %
                               (newartistpath))
            self.db.AddArtist(newartistpath, newartistpath)
            artist = self.db.GetArtistByPath(newartistpath)

        if not artist:
            self.cli.PrintText(
                "\033[1;31mAdding artist failed! \033[1;30m(Retry the import workflow and check the names of the files in the file system)\033[0m"
            )
            return
        else:
            self.cli.PrintText("\033[1;34mImport album \033[0;36m%s\n" %
                               (newalbumpath))
            try:
                self.AddAlbum(newalbumpath, artist["id"])
            except Exception as e:
                self.cli.PrintText(
                    "\033[1;31mImporting album failed with exception %s!\033[1;30m (Nothing bad happened, just try to solve the issue and repeat. Were all Paths and file names valid?)\n"
                    % (str(e)))

        # set origin
        album = self.db.GetAlbumByPath(newalbumpath)
        if not album:
            self.cli.PrintText(
                "\033[1;31mImporting album failed! \033[1;30m(Retry the import workflow and check the names of the files in the file system)\033[0m"
            )
            return
        elif album["origin"] != data["origin"]:
            self.cli.PrintText("\033[1;34mSet Origin \033[0;36m%s\n" %
                               (data["origin"]))
            album["origin"] = data["origin"]
            self.db.WriteAlbum(album)
        self.cli.PrintText("\033[1;32mImporting album succeeded!\n")

        # process
        if data["runartwork"]:
            self.cli.PrintText("\033[1;37mRun Artwork Import\n")
            artwork = MusicDBArtwork(self.cfg, self.db)
            artwork.UpdateAlbumArtwork(album)

        if data["runlyrics"]:
            self.cli.PrintText("\033[1;37mRun Lyrics Import\n")
            metadata = MetaTags(self.cfg.music.path)

            for songtuple in data["songs"]:
                songpath = songtuple[1]
                song = self.db.GetSongByPath(songpath)
                if not song:
                    continue
                songid = song["id"]

                metadata.Load(songpath)
                lyrics = metadata.GetLyrics()
                if lyrics:
                    self.db.SetLyrics(songid, lyrics,
                                      SONG_LYRICSSTATE_FROMFILE)
コード例 #12
0
class VideoFrames(object):
    """
    This class implements the concept described above.
    The most important method is :meth:`~UpdateVideoFrames` that generates all frames and previews for a given video.

    Args:
        config: MusicDB configuration object
        database: MusicDB database

    Raises:
        TypeError: if config or database are not of the correct type
        ValueError: If one of the working-paths set in the config file does not exist
    """
    def __init__(self, config, database):

        if type(config) != MusicDBConfig:
            raise TypeError("Config-class of unknown type")
        if type(database) != MusicDatabase:
            raise TypeError("Database-class of unknown type")

        self.db = database
        self.cfg = config
        self.fs = Filesystem()
        self.musicroot = Filesystem(self.cfg.music.path)
        self.framesroot = Filesystem(self.cfg.videoframes.path)
        self.metadata = MetaTags(self.cfg.music.path)
        self.maxframes = self.cfg.videoframes.frames
        self.previewlength = self.cfg.videoframes.previewlength
        self.scales = self.cfg.videoframes.scales

        # Check if all paths exist that have to exist
        pathlist = []
        pathlist.append(self.cfg.music.path)
        pathlist.append(self.cfg.videoframes.path)

        for path in pathlist:
            if not self.fs.Exists(path):
                raise ValueError("Path \"" + path + "\" does not exist.")

    def CreateFramesDirectoryName(self, artistname, videoname):
        """
        This method creates the name for a frames directory regarding the following schema:
        ``$Artistname/$Videoname``.
        If there is a ``/`` in the name, it gets replaced by ``∕`` (U+2215, DIVISION SLASH)

        Args:
            artistname (str): Name of an artist
            videoname (str): Name of a video

        Returns:
            valid frames sub directory name for a video
        """
        artistname = artistname.replace("/", "∕")
        videoname = videoname.replace("/", "∕")
        dirname = artistname + "/" + videoname
        return dirname

    def CreateFramesDirectory(self, artistname, videoname):
        """
        This method creates the directory that contains all frames and previews for a video.
        The ownership of the created directory will be the music user and music group set in the configuration file.
        The permissions will be set to ``rwxrwxr-x``.
        If the directory already exists, only the attributes will be updated.

        Args:
            artistname (str): Name of an artist
            videoname (str): Name of a video

        Returns:
            The name of the directory.
        """
        # Determine directory name
        dirname = self.CreateFramesDirectoryName(artistname, videoname)

        # Create directory if it does not yet exist
        if self.framesroot.IsDirectory(dirname):
            logging.debug("Frame directory \"%s\" already exists.", dirname)
        else:
            self.framesroot.CreateSubdirectory(dirname)

        # Set permissions to -rwxrwxr-x
        try:
            self.framesroot.SetAttributes(
                dirname, self.cfg.music.owner, self.cfg.music.group,
                stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP
                | stat.S_IWGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
        except Exception as e:
            logging.warning(
                "Setting frames sub directory attributes failed with error %s. \033[1;30m(Leaving them as they are)",
                str(e))

        return dirname

    def GenerateFrames(self, dirname, videopath):
        """
        This method creates all frame files, including scaled frames, from a video.
        After generating the frames, animations can be generated via :meth:`~GeneratePreviews`.

        To generate the frames, ``ffmpeg`` is used in the following way:

        .. code-block:: bash

            ffmpeg -ss $time -i $videopath -vf scale=iw*sar:ih -vframes 1 $videoframes/$dirname/frame-xx.jpg

        ``videopath`` and ``dirname`` are the parameters of this method.
        ``videoframes`` is the root directory for the video frames as configured in the MusicDB Configuration file.
        ``time`` is a moment in time in the video at which the frame gets selected.
        This value gets calculated depending of the videos length and amount of frames that shall be generated.
        The file name of the frames will be ``frame-xx.jpg`` where ``xx`` represents the frame number.
        The number is decimal, has two digits and starts with 01.

        The scale solves the differences between the Display Aspect Ratio (DAR) and the Sample Aspect Ratio (SAR).
        By using a scale of image width multiplied by the SAR, the resulting frame has the same ratio as the video in the video player.

        The total length of the video gets determined by :meth:`~lib.metatags.MetaTags.GetPlaytime`

        When there are already frames existing, nothing will be done.
        This implies that it is mandatory to remove existing frames manually when there are changes in the configuration.
        For example when increasing or decreasing the amount of frames to consider for the animation.
        The method will return ``True`` in this case, because there are frames existing.

        Args:
            dirname (str): Name/Path of the directory to store the generated frames
            videopath (str): Path to the video that gets processed

        Returns:
            ``True`` on success, otherwise ``False``
        """
        # Determine length of the video in seconds
        try:
            self.metadata.Load(videopath)
            videolength = self.metadata.GetPlaytime()
        except Exception as e:
            logging.exception(
                "Generating frames for video \"%s\" failed with error: %s",
                videopath, str(e))
            return False

        slicelength = videolength / (self.maxframes + 1)
        sliceoffset = slicelength / 2

        for framenumber in range(self.maxframes):
            # Calculate time point of the frame in seconds
            #moment = (videolength / self.maxframes) * framenumber
            moment = sliceoffset + slicelength * framenumber

            # Define destination path
            framename = "frame-%02d.jpg" % (framenumber + 1)
            framepath = dirname + "/" + framename

            # Only create frame if it does not yet exist
            if not self.framesroot.Exists(framepath):
                # create absolute paths for FFMPEG
                absframepath = self.framesroot.AbsolutePath(framepath)
                absvideopath = self.musicroot.AbsolutePath(videopath)

                # Run FFMPEG - use absolute paths
                process = [
                    "ffmpeg",
                    "-ss",
                    str(moment),
                    "-i",
                    absvideopath,
                    "-vf",
                    "scale=iw*sar:ih",  # Make sure the aspect ration is correct
                    "-vframes",
                    "1",
                    absframepath
                ]
                logging.debug("Getting frame via %s", str(process))
                try:
                    self.fs.Execute(process)
                except Exception as e:
                    logging.exception(
                        "Generating frame for video \"%s\" failed with error: %s",
                        videopath, str(e))
                    return False

            # Scale down the frame
            self.ScaleFrame(dirname, framenumber + 1)

        return True

    def ScaleFrame(self, dirname, framenumber):
        """
        This method creates a scaled version of the existing frames for a video.
        The aspect ration of the frame will be maintained.
        In case the resulting aspect ratio differs from the source file,
        the borders of the source frame will be cropped in the scaled version.

        If a scaled version exist, it will be skipped.

        The scaled JPEG will be stored with optimized and progressive settings.

        Args:
            dirname (str): Name of the directory where the frames are stored at (relative)
            framenumber (int): Number of the frame that will be scaled

        Returns:
            *Nothing*
        """
        sourcename = "frame-%02d.jpg" % (framenumber)
        sourcepath = dirname + "/" + sourcename
        abssourcepath = self.framesroot.AbsolutePath(sourcepath)

        for scale in self.scales:
            width, height = map(int, scale.split("x"))
            scaledframename = "frame-%02d (%d×%d).jpg" % (framenumber, width,
                                                          height)
            scaledframepath = dirname + "/" + scaledframename

            # In case the scaled version already exists, nothing will be done
            if self.framesroot.Exists(scaledframepath):
                continue

            absscaledframepath = self.framesroot.AbsolutePath(scaledframepath)

            size = (width, height)
            frame = Image.open(abssourcepath)
            frame.thumbnail(size, Image.BICUBIC)
            frame.save(absscaledframepath,
                       "JPEG",
                       optimize=True,
                       progressive=True)
        return

    def GeneratePreviews(self, dirname):
        """
        This method creates all preview animations (.webp), including scaled versions, from frames.
        The frames can be generated via :meth:`~GenerateFrames`.

        In case there is already a preview file, the method returns ``True`` without doing anything.

        Args:
            dirname (str): Name/Path of the directory to store the generated frames

        Returns:
            ``True`` on success, otherwise ``False``
        """
        # Create original sized preview
        framepaths = []
        for framenumber in range(self.maxframes):
            framename = "frame-%02d.jpg" % (framenumber + 1)
            framepath = dirname + "/" + framename
            framepaths.append(framepath)
        previewpath = dirname + "/preview.webp"

        success = True
        success &= self.CreateAnimation(framepaths, previewpath)

        # Create scaled down previews
        for scale in self.scales:
            framepaths = []
            width, height = map(int, scale.split("x"))

            for framenumber in range(self.maxframes):
                scaledframename = "frame-%02d (%d×%d).jpg" % (framenumber + 1,
                                                              width, height)
                scaledframepath = dirname + "/" + scaledframename
                framepaths.append(scaledframepath)

            previewpath = dirname + "/preview (%d×%d).webp" % (width, height)
            success &= self.CreateAnimation(framepaths, previewpath)

        return success

    def CreateAnimation(self, framepaths, animationpath):
        """
        This method creates a WebP animation from frames that are addresses by a sorted list of paths.
        Frame paths that do not exists or cannot be opened will be ignored.
        If there already exists an animation addressed by animation path, nothing will be done.

        The list of frame paths must at least contain 2 entries.

        Args:
            framepaths (list(str)): A list of relative frame paths that will be used to create an animation
            animationpath (str): A relative path where the animation shall be stored at.

        Returns:
            ``True`` when an animation has been created or exists, otherwise ``False``
        """
        if self.framesroot.IsFile(animationpath):
            logging.debug(
                "There is already an animation \"%s\" (Skipping frame generation process)",
                animationpath)
            return True

        # Load all frames
        frames = []
        for framepath in framepaths:
            absframepath = self.framesroot.AbsolutePath(framepath)

            try:
                frame = Image.open(absframepath)
            except FileNotFoundError as e:
                logging.warning(
                    "Unable to load frame \"$s\": %s \033[1;30m(Frame will be ignored)",
                    absframepath, str(e))
                continue

            frames.append(frame)

        # Check if enough frames for a preview have been loaded
        if len(frames) < 2:
            logging.error(
                "Not enough frames were loaded. Cannot create a preview animation. \033[1;30m(%d < 2)",
                len(frames))
            return False

        # Create absolute animation file path
        absanimationpath = self.framesroot.AbsolutePath(animationpath)

        # Calculate time for each frame in ms being visible
        duration = int((self.previewlength * 1000) / self.maxframes)

        # Store as WebP animation
        preview = frames[0]  # Start with frame 0
        preview.save(
            absanimationpath,
            save_all=True,  # Save all frames
            append_images=frames[1:],  # Save these frames
            duration=duration,  # Display time for each frame
            loop=0,  # Show in infinite loop
            method=6)  # Slower but better method [1]

        # [1] https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp

        return True

    def SetVideoFrames(self,
                       videoid,
                       framesdir,
                       thumbnailfile=None,
                       previewfile=None):
        """
        Set Database entry for the video with video ID ``videoid``.
        Using this method defines the frames directory to which all further paths are relative to.
        The thumbnail file addresses a static source frame (like ``frame-01.jpg``),
        the preview file addresses the preview animation (usually ``preview.webp``).

        If ``thumbnailfile`` or ``previewfile`` is ``None``, it will not be changed in the database.

        This method checks if the files exists.
        If not, ``False`` gets returned an *no* changes will be done in the database.

        Example:

            .. code-block:: python

                retval = vf.SetVideoFrames(1000, "Fleshgod Apocalypse/Monnalisa", "frame-02.jpg", "preview.webp")
                if retval == False:
                    print("Updating video entry failed.")

        Args:
            videoid (int): ID of the video that database entry shall be updated
            framesdir (str): Path of the video specific sub directory containing all frames/preview files. Relative to the video frames root directory
            thumbnailfile (str, NoneType): File name of the frame that shall be used as thumbnail, relative to ``framesdir``
            previewfile (str, NoneType): File name of the preview animation, relative to ``framesdir``

        Returns:
            ``True`` on success, otherwise ``False``
        """
        # Check if all files are valid
        if not self.framesroot.IsDirectory(framesdir):
            logging.error(
                "The frames directory \"%s\" does not exist in the video frames root directory.",
                framesdir)
            return False

        if thumbnailfile and not self.framesroot.IsFile(framesdir + "/" +
                                                        thumbnailfile):
            logging.error(
                "The thumbnail file \"%s\" does not exits in the frames directory \"%s\".",
                thumbnailfile, framesdir)
            return False

        if previewfile and not self.framesroot.IsFile(framesdir + "/" +
                                                      previewfile):
            logging.error(
                "The preview file \"%s\" does not exits in the frames directory \"%s\".",
                previewfile, framesdir)
            return False

        # Change paths in the database
        retval = self.db.SetVideoFrames(videoid, framesdir, thumbnailfile,
                                        previewfile)

        return retval

    def UpdateVideoFrames(self, video):
        """
        #. Create frames directory (:meth:`~CreateFramesDirectory`)
        #. Generate frames (:meth:`~GenerateFrames`)
        #. Generate previews (:meth:`~GeneratePreviews`)

        Args:
            video: Database entry for the video for that the frames and preview animation shall be updated

        Returns:
            ``True`` on success, otherwise ``False``
        """
        logging.info("Updating frames and previews for %s", video["path"])

        artist = self.db.GetArtistById(video["artistid"])
        artistname = artist["name"]
        videopath = video["path"]
        videoname = video["name"]
        videoid = video["id"]

        # Prepare everything to start generating frames and previews
        framesdir = self.CreateFramesDirectory(artistname, videoname)

        # Generate Frames
        retval = self.GenerateFrames(framesdir, videopath)
        if retval == False:
            return False

        # Generate Preview
        retval = self.GeneratePreviews(framesdir)
        if retval == False:
            return False

        # Update database
        retval = self.SetVideoFrames(videoid, framesdir, "frame-01.jpg",
                                     "preview.webp")
        return retval

    def ChangeThumbnail(self, video, timestamp):
        """
        This method creates a thumbnail image files, including scaled a version, from a video.
        The image will be generated from a frame addressed by the ``timestamp`` argument.

        To generate the thumbnail, ``ffmpeg`` is used in the following way:

        .. code-block:: bash

            ffmpeg -y -ss $timestamp -i $video["path"] -vf scale=iw*sar:ih -vframes 1 $videoframes/$video["framesdirectory"]/thumbnail.jpg

        ``video`` and ``timestamp`` are the parameters of this method.
        ``videoframes`` is the root directory for the video frames as configured in the MusicDB Configuration file.

        The scale solves the differences between the Display Aspect Ratio (DAR) and the Sample Aspect Ratio (SAR).
        By using a scale of image width multiplied by the SAR, the resulting frame has the same ratio as the video in the video player.

        The total length of the video gets determined by :meth:`~lib.metatags.MetaTags.GetPlaytime`
        If the time stamp is not between 0 and the total length, the method returns ``False`` and does nothing.

        When there is already a thumbnail existing it will be overwritten.

        Args:
            video: A video entry that shall be updated
            timestamp (int): Time stamp of the frame to select in seconds

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

        dirname = video["framesdirectory"]
        videopath = video["path"]
        videoid = video["id"]

        # Determine length of the video in seconds
        try:
            self.metadata.Load(videopath)
            videolength = self.metadata.GetPlaytime()
        except Exception as e:
            logging.exception(
                "Generating a thumbnail for video \"%s\" failed with error: %s",
                videopath, str(e))
            return False

        if timestamp < 0:
            logging.warning(
                "Generating a thumbnail for video \"%s\" requires a time stamp > 0. Given was: %s",
                videopath, str(timestamp))
            return False

        if timestamp > videolength:
            logging.warning(
                "Generating a thumbnail for video \"%s\" requires a time stamp smaller than the video play time (%s). Given was: %s",
                videopath, str(videolength), str(timestamp))
            return False

        # Define destination path
        framename = "thumbnail.jpg"
        framepath = dirname + "/" + framename

        # create absolute paths for FFMPEG
        absframepath = self.framesroot.AbsolutePath(framepath)
        absvideopath = self.musicroot.AbsolutePath(videopath)

        # Run FFMPEG - use absolute paths
        process = [
            "ffmpeg",
            "-y",  # Yes, overwrite existing frame
            "-ss",
            str(timestamp),
            "-i",
            absvideopath,
            "-vf",
            "scale=iw*sar:ih",  # Make sure the aspect ration is correct
            "-vframes",
            "1",
            absframepath
        ]
        logging.debug("Getting thumbnail via %s", str(process))
        try:
            self.fs.Execute(process)
        except Exception as e:
            logging.exception(
                "Generating a thumbnail for video \"%s\" failed with error: %s",
                videopath, str(e))
            return False

        # Scale down the frame
        self.ScaleThumbnail(dirname)

        # Set new Thumbnail
        retval = self.SetVideoFrames(videoid,
                                     dirname,
                                     thumbnailfile="thumbnail.jpg",
                                     previewfile=None)
        if not retval:
            return False

        return True

    def ScaleThumbnail(self, dirname):
        """
        This method creates a scaled version of the existing thumbnail for a video.
        The aspect ration of the frame will be maintained.
        In case the resulting aspect ratio differs from the source file,
        the borders of the source frame will be cropped in the scaled version.

        If a scaled version exist, it will be overwritten.

        The scaled JPEG will be stored with optimized and progressive settings.

        Args:
            dirname (str): Name of the directory where the frames are stored at (relative)

        Returns:
            *Nothing*
        """
        sourcename = "thumbnail.jpg"
        sourcepath = dirname + "/" + sourcename
        abssourcepath = self.framesroot.AbsolutePath(sourcepath)

        for scale in self.scales:
            width, height = map(int, scale.split("x"))
            scaledframename = "thumbnail (%d×%d).jpg" % (width, height)
            scaledframepath = dirname + "/" + scaledframename

            absscaledframepath = self.framesroot.AbsolutePath(scaledframepath)

            size = (width, height)
            frame = Image.open(abssourcepath)
            frame.thumbnail(size, Image.BICUBIC)
            frame.save(absscaledframepath,
                       "JPEG",
                       optimize=True,
                       progressive=True)
        return