Esempio n. 1
0
class MusicCache(object):
    """
    Args:
        config: MusicDB configuration object
        database: MusicDB database

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

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

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

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

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

        Example:

            .. code-block:: python

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

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

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

        return artistpaths, albumpaths, songpaths

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

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

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

            .. code-block:: python

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return path

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

        This process is done in the following steps:

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

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

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

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

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

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

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

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

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

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

        return True
Esempio n. 2
0
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()
Esempio n. 3
0
class MusicDBExtern(object):
    """
    Args:
        config: MusicDB configuration object
        database: MusicDB database

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

        self.db = database
        self.cfg = config
        self.mp = None
        self.fs = Filesystem("/")
        self.fileprocessor = Fileprocessing("/")
        self.artworkcache = ArtworkCache(self.cfg.artwork.path)
        self.SetMountpoint(
            "/mnt")  # initialize self.mp with the default mount point /mnt

    def CheckForDependencies(self) -> bool:
        """
        Checks for dependencies required by this module.
        Those dependencies are ``ffmpeg`` and `id3edit <https://github.com/rstemmer/id3edit>_`.

        If a module is missing, the error message will be printed into the log file
        and also onto the screen (stderr)

        Returns:
            ``True`` if all dependencies exist, otherwise ``False``.
        """
        nonemissing = True  # no dependency is missing, as long as no check returns false

        if not self.fileprocessor.ExistsProgram("ffmpeg"):
            logging.error("Required dependency \"ffmpeg\" missing!")
            print("\033[1;31mRequired dependency \"ffmpeg\" missing!",
                  file=sys.stderr)
            nonemissing = False

        if not self.fileprocessor.ExistsProgram("id3edit"):
            logging.error("Required dependency \"id3edit\" missing!")
            print("\033[1;31mRequired dependency \"id3edit\" missing!",
                  file=sys.stderr)
            nonemissing = False

        return nonemissing

    # INITIALIZATION METHODS
    ########################

    def SetMountpoint(self, mountpoint="/mnt"):
        """
        Sets the mountpoint MusicDBExtern shall work on.
        If the mountpoint does not exists, ``False`` gets returned.
        The existence of a mountpoint does not guarantee that the device is mounted.
        Furthermore the method does not check if the mounted device is initialized - this can be done by calling :meth:`~mdbapi.extern.MusicDBExtern.IsStorageInitialized`.

        Args:
            mountpoint (str): Path where the storage that shall be worked on is mounted

        Returns:
            ``True`` if *mountpoint* exists, ``False`` otherwise.
        """
        if not os.path.exists(mountpoint):
            logging.error("\033[1;31mERROR: " + mountpoint +
                          " is not a valid mountpoint/directory!\033[0m")
            return False
        self.mp = mountpoint
        logging.debug("set mountpoint to %s", self.mp)
        return True

    def IsStorageInitialized(self):
        """
        This method checks for the state-directory and the storage configuration file inside the state directory.
        If both exists, the storage is considered as initialized and ``True`` gets returned.

        Returns:
            ``True`` if storage is initialized, otherwise ``False``
        """
        if not self.mp:
            logging.error(
                "mountpoint-variable not set. Missing SetMountpoint call!")
            return False

        extstatedir = os.path.join(
            self.mp,
            self.cfg.extern.statedir)  # directory for the state config files
        extcfgfile = os.path.join(
            extstatedir,
            self.cfg.extern.configfile)  # config file for the external storage

        if not os.path.exists(extstatedir):
            logging.debug("State directory missing!")
            return False
        if not os.path.exists(extcfgfile):
            logging.debug("Config file missing!")
            return False

        return True

    def InitializeStorage(self):
        """
        This method creates the state-directory inside the mountpoint.
        Then a template of the storage configuration gets copied inside the new creates state-directory

        Returns:
            ``True`` on success, else ``False``
        """
        if not self.mp:
            logging.error(
                "mountpoint-variable not set. Missing SetMountpoint!")
            return False

        extstatedir = os.path.join(
            self.mp,
            self.cfg.extern.statedir)  # directory for the state config files
        extcfgfile = os.path.join(
            extstatedir,
            self.cfg.extern.configfile)  # config file for the external storage

        try:
            logging.info("Creating %s" % extstatedir)
            os.makedirs(extstatedir)

            logging.info("Creating %s" % extcfgfile)
            shutil.copy(self.cfg.extern.configtemplate, extcfgfile)

        except Exception as e:
            logging.error("Initializing external storage failed!")
            logging.error(e)
            return False

        logging.info("\033[1;32mCreated new MDB-state at \033[0;36m %s" %
                     extstatedir)
        return True

    # OPTIMIZATION METHODS
    ######################

    def ReducePathLength(self, path):
        """
        This method reduces a path length to hopefully fit into a path-length-limit.
        The reduction is done by removing the song name from the path.
        Everything left is the directory the song is stored in, the song number and the file extension.

        Example:

            .. code-block:: python

                self.ReducePathLength("artist/album/01 very long name.mp3")
                # returns "artist/album/01.mp3"
                self.ReducePathLength("artist/album/1-01 very long name.mp3")
                # returns "artist/album/1-01.mp3"

        Args:
            path (str): path of the song

        Returns:
            shortend path as string if successfull, ``None`` otherwise
        """
        # "directory/num name.ext" -> "directory", "num name.ext"
        directory, songfile = os.path.split(path)
        # "num name.ext" -> "num name", "ext"
        name, extension = os.path.splitext(songfile)
        # "num name" -> "num"
        number = name.split(" ")[0]

        newpath = os.path.join(directory, number)
        newpath += extension

        return newpath

    def FixPath(self, string, charset):
        """
        This method places characters that are invalid for *charset* by a valid one.

            #. Replaces ``?<>\:*|"`` by ``_``
            #. Replaces ``äöüÄÖÜß`` by ``aouAOUB``

        .. warning::

            Obviously, this method is incomplete and full of shit. It must and will be replaced in future.

        Example:

            .. code-block:: python

                self.FixPath("FAT/is f*cking/scheiße.mp3")
                # returns "FAT/is f_cking/scheiBe.mp3"
        
        Args:
            string (str): string that shall be fixed
            charset (str): until now, only ``"FAT"`` is considered. Other sets will be ignored

        Returns:
            A string that is valid for *charset*
        """
        # 1. replace all bullshit-chars
        good = "_"

        if charset == "FAT":
            bad = "?<>\\:*|\""
        else:
            return string

        fixed = re.sub("[" + bad + "]", good, string)

        # 2. fix unicode problems
        # TODO: Rebuild this method. Consider the whole unicode space!
        if charset in ["FAT", "ASCII"]:
            fixed = fixed.replace("ä", "a")
            fixed = fixed.replace("ö", "o")
            fixed = fixed.replace("ü", "u")
            fixed = fixed.replace("Ä", "A")
            fixed = fixed.replace("Ö", "O")
            fixed = fixed.replace("Ü", "U")
            fixed = fixed.replace("ß", "B")  # This is ScheiBe

        return fixed

    # UPDATE METHODS
    ################

    def ReadSongmap(self, mappath):
        """
        This method reads the song map that maps relative song paths from the collection to relative paths on the external storage.

        Args:
            mappath (str): absolute path to the songmap

        Returns:
            ``None`` if there is no songmap yet. Otherwise a list of tuples (srcpath, dstpath) is returned.
        """

        # if there is no song-list, assume we are in a new environment
        if not os.path.exists(mappath):
            logging.warning(
                "No songlist found under %s. \033[0;33m(assuming this is the first run and there are no songs yet)",
                mappath)
            return None

        with open(mappath) as csvfile:
            rows = csv.reader(csvfile,
                              delimiter=",",
                              escapechar="\\",
                              quotechar="\"",
                              quoting=csv.QUOTE_NONNUMERIC)

            # Format of the lines: rel. source-path, rel. destination-path
            rows = list(
                rows
            )  # Transform csv-readers internal iteratable object to a python list.
            songmap = []  # for a list of tuple (src, dst)
            for row in tqdm(rows):
                songmap.append((row[0], row[1]))

        return songmap

    def UpdateSongmap(self, songmap, mdbpathlist):
        """
        This method updates the songmap read with :meth:`~mdbapi.extern.MusicDBExtern.ReadSongmap`.
        Therefore, new source-paths will be added, and old one will be removed.
        The new ones will be tuple of ``(srcpath, None)`` added to the list.
        Removing songs will be done by replacing the sourcepath with ``None``.
        This leads to a list of tuple, with each tuple representing one of the following states:

            #. ``(srcp, dstp)`` Nothing to do: Source and Destination files exists
            #. ``(srcp, None)`` New file in the collection that must be copied to the external storage
            #. ``(None, dstp)`` Old file on the storage that must be removed

        Args:
            songmap: A list of tuples representing the external storage state. If ``None``, an empty map will be created.
            mdbpathlist: list of relative paths representing the music colletion.

        Returns:
            The updated songmap gets returned
        """
        # if there is no songmap, create one
        if not songmap:
            songmap = []

        songupdatemap = []

        # Check for outdated entries in the songmap
        for entry in songmap:
            # if srcpath still in collection, otherwise remove ist
            if entry[0] in mdbpathlist:
                mdbpathlist.remove(
                    entry[0])  # no need to check this entry again
                songupdatemap.append(entry)
            else:
                songupdatemap.append((None, entry[1]))

        # add the rest of the pathlist to the map
        for path in mdbpathlist:
            songupdatemap.append((path, None))

        return songupdatemap

    def RemoveOldSongs(self, songmap, extconfig):
        """
        Remove all songs that have a destination-entry but no source-entry in the songmap.
        This constellation means that there is a file on the storage that does not exist in the music collection.

        Args:
            songmap: A list of tuple representing the external storage state.
            extconfig: Instance of the external storage configuration.

        Returns:
            The updated songmap without the entries of the files that were removed in this method
        """
        # read config
        musicdir = extconfig.paths.musicdir

        # remove all old files
        for entry in tqdm(songmap):
            # skip if the destination path has a related source path
            if entry[0] != None:
                continue

            # Generate all absolute paths that will be considered to remove
            abspath = os.path.join(self.mp, musicdir)
            abspath = os.path.join(abspath, entry[1])
            # Separate between song, album and artist
            songpath = abspath
            albumpath = os.path.split(songpath)[0]
            artistpath = os.path.split(albumpath)[0]

            # remove abandond destination file
            logging.debug("Trying to remove %s", songpath)
            try:
                os.remove(songpath)  # delete file
                os.rmdir(albumpath)  # remove album if there are no more songs
                os.rmdir(
                    artistpath)  # remove artist if there are no more albums
            except:
                pass

        # remove all entries from the songmap that hold outdated/abandoned files
        songmap = [entry for entry in songmap if entry[0] != None]

        return songmap

    def CopyNewSongs(self, songmap, extconfig):
        """
        This method handles the songs that are new to the collection and not yet copied to the external storage.
        The process is split into two tasks: 

            #. Generate path names for the new files on the external storage
            #. Copy the songs to the external storage

        The copy-process itself is done in another method :meth:`~mdbapi.extern.MusicDBExtern.CopySong`.
        In future, the ``CopySong`` method shall be called simultaneously for multiple songs.

        Args:
            songmap: A list of tuples representing the external storage state.

        Returns:
            *songmap* with the new state of the storage. The dstpath-column is set for the copied songs.
        """
        # read configuration
        musicdir = extconfig.paths.musicdir
        charset = extconfig.constraints.charset
        pathlimit = extconfig.constraints.pathlen

        # split songmap into "already existing" and "new songs"
        oldsongs = [entry for entry in songmap if entry[1] != None]
        newsongs = [entry for entry in songmap if entry[1] == None]

        # 1.: generate destination paths for the new songs
        # all pathes are relative!
        for index, entry in enumerate(newsongs):
            srcpath = entry[0]

            # make constraint-compatible destination path
            dstpath = self.FixPath(srcpath, charset)

            # check for length-limit (the +1 is a "/")
            if pathlimit > 0 and len(musicdir) + 1 + len(dstpath) > pathlimit:
                dstpath = self.ReducePathLength(dstpath)

                # check result
                if len(musicdir) + 1 + len(dstpath) > pathlimit:
                    logging.warning(
                        "Path \"%s\" is too long and cannot be shorted to %d characters!"
                        " \033[1;30m(processing song anyway)", str(dstpath),
                        pathlimit)

            # Add new potential dstpath to the entry
            # (the extension may change later due to transcoding)
            newsongs[index] = (entry[0], dstpath)

        # 2.: Start the copy-process TODO: Make it in parallel
        with tqdm(total=len(newsongs)) as progressbar:
            for index, element in enumerate(newsongs):
                srcpath, dstpath = element
                dstpath = self.CopySong(srcpath, dstpath, extconfig)
                # same or corrected path, None on error.
                newsongs[index] = (srcpath, dstpath)
                progressbar.update()

        # merge entrie again and return a complete list of the current state of the storage
        songmap = []
        songmap.extend(oldsongs)
        songmap.extend(newsongs)
        return songmap

    def CopySong(self, relsrcpath, reldstpath, extconfig):
        """
        In this method, the copy process is done. This method is the core of this class.
        The copy process is done in several steps:

            #. Preparation of all paths and configurations
            #. Create missing directories
            #. Transcode, optimize, copy file

        It also creates the Artist and Album directory if they do not exist.
        If a song file already exists, the copy-process gets skipped.

        Arguments:
            relsrcpath (str): relative source path to the song that shall be copied
            reldstpath (str): relative destination path. Its extension may be changed due to transcoding.
            extconfig: Instance of the external storage configuration

        Returns:
            On success the updated ``reldstpath`` is returned. It may differ from the parameter due to transcoding the file. Otherwise ``None`` is returned.
        """
        # read config
        musicdir = extconfig.paths.musicdir
        forcemp3 = extconfig.constraints.forcemp3
        optimizemp3 = extconfig.mp3tags.optimize
        noartwork = extconfig.mp3tags.noartwork
        prescale = extconfig.mp3tags.prescale
        forceid3v230 = extconfig.mp3tags.forceid3v230
        optimizem4a = extconfig.m4atags.optimize

        # handle paths
        srcextension = os.path.splitext(relsrcpath)[1]
        if forcemp3 and srcextension != ".mp3":
            reldstpath = os.path.splitext(reldstpath)[0] + ".mp3"

        abssrcpath = os.path.join(self.cfg.music.path, relsrcpath)

        absdstpath = os.path.join(self.mp, musicdir)
        absdstpath = os.path.join(absdstpath, reldstpath)
        absdstdirectory = os.path.split(absdstpath)[0]

        logging.debug("copying song from %s to %s", abssrcpath, absdstpath)

        # TODO: Add option to force overwriting
        if os.path.exists(absdstpath):
            logging.debug("%s skipped - does already exist", absdstpath)
            return reldstpath

        # Create directories if not exits
        if not os.path.exists(absdstdirectory):
            try:
                os.makedirs(absdstdirectory)
            except Exception as e:
                logging.error(
                    "Creating directory \"" + absdstdirectory +
                    "\" failed with error %s!"
                    "\033[1;30m (skipping song)", str(e))
                return None

        # Open Music Database
        musicdb = self.db
        # FIXME: Invalid in multithreading env
        # Sadly the Optimization methods for the tags also access self.db.
        # No chance for multithreading in near future

        mdbsong = musicdb.GetSongByPath(relsrcpath)
        mdbalbum = musicdb.GetAlbumById(mdbsong["albumid"])
        mdbartist = musicdb.GetArtistById(mdbsong["artistid"])

        # handle artwork if wanted
        if noartwork:
            absartworkpath = None
        else:
            # Remember: paths of artworks are handled relative to the artwork cache
            if prescale:
                try:
                    relartworkpath = self.artworkcache.GetArtwork(
                        mdbalbum["artworkpath"], prescale)
                except Exception as e:
                    logging.error(
                        "Getting artwork from cache failed with exception: %s!",
                        str(e))
                    logging.error("   Artwork: %s", mdbalbum["artworkpath"])
                    return False

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

            else:
                absartworkpath = os.path.join(self.cfg.artwork.path,
                                              mdbalbum["artworkpath"])

        # copy the file
        if forcemp3 and srcextension != ".mp3":
            retval = self.fileprocessor.ConvertToMP3(abssrcpath, absdstpath)
            if retval == False:
                logging.error(
                    "\033[1;30m(skipping song due to previous error)")
                return None

            os.sync(
            )  # This may help to avoid corrupt files. Conversion and optimization look right

            retval = self.fileprocessor.OptimizeMP3Tags(
                mdbsong, mdbalbum, mdbartist, absdstpath, absdstpath,
                absartworkpath, forceid3v230)
            if retval == False:
                logging.error(
                    "\033[1;30m(skipping song due to previous error)")
                return None

        elif optimizemp3 and srcextension == ".mp3":
            retval = self.fileprocessor.OptimizeMP3Tags(
                mdbsong, mdbalbum, mdbartist, abssrcpath, absdstpath,
                absartworkpath, forceid3v230)
            if retval == False:
                logging.error(
                    "\033[1;30m(skipping song due to previous error)")
                return None

        elif optimizem4a and srcextension == ".m4a":
            retval = self.fileprocessor.OptimizeM4ATags(
                mdbsong, mdbalbum, mdbartist, abssrcpath, absdstpath)
            if retval == False:
                logging.error(
                    "\033[1;30m(skipping song due to previous error)")
                return None

        else:
            self.fileprocessor.CopyFile(abssrcpath, absdstpath)

        # return updated relative destination path for the songmap
        return reldstpath

    def WriteSongmap(self, songmap, mappath):
        """
        Writes all valid entries of *songmap* into the state-file.
        This method generates the new state of the external storage.

        A valid entry has a source and a destination path.

        Arguments:
            songmap: A list of tuples representing the external storage state.
            mappath (str): Path to the state-file

        Returns:
            ``None``
        """
        # open songlist (recreate the whole file)
        with open(mappath, "w") as csvfile:
            csvwriter = csv.writer(csvfile,
                                   delimiter=",",
                                   escapechar="\\",
                                   quotechar="\"",
                                   quoting=csv.QUOTE_NONNUMERIC)

            for entry in songmap:
                if entry[0] != None and entry[1] != None:
                    csvwriter.writerow(list(entry))

        return None

    def UpdateStorage(self):
        """
        This method does the whole update process. It consists of the following steps:

        #. Prepare envrionment like determin configfiles and opening them.
        #. Get all song paths from the Music Database
        #. :meth:`~mdbapi.extern.MusicDBExtern.ReadSongmap` - Read the current state of the external storage
        #. :meth:`~mdbapi.extern.MusicDBExtern.UpdateSongmap` - Update the list with the current state of the music collection
        #. :meth:`~mdbapi.extern.MusicDBExtern.RemoveOldSongs` - Remove old songs from the external storage that are no longer in the collection
        #. :meth:`~mdbapi.extern.MusicDBExtern.CopyNewSongs` - Copy new songs from the collection to the storage. Here, transcoding will be applied if configured. See `Handling Toxic Environments`_
        #. :meth:`~mdbapi.extern.MusicDBExtern.WriteSongmap` - Writes the new state of the external storage device

        Returns:
            ``None``
        """
        if not self.mp:
            logging.error("Mountpoint is not initialized!")
            return None

        extstatedir = os.path.join(self.mp, self.cfg.extern.statedir)

        # Get songmap-file
        mapfile = os.path.join(extstatedir, self.cfg.extern.songmap)

        # Open external storage configuration
        extcfgfile = os.path.join(extstatedir, self.cfg.extern.configfile)
        extconfig = ExternConfig(extcfgfile)
        if extconfig.meta.version != 3:
            logging.warning(
                "Unexpected config-version of external storage configuration: %d != 3."
                "\033[0;33mDoing nothing to prevent Damage!",
                extconfig.meta.version)
            return None

        # Get all song Paths
        print(" \033[1;35m * \033[1;34mReading database …\033[0;36m")
        songs = self.db.GetAllSongs()
        mdbpathlist = [song["path"] for song in songs]

        # Start update
        print(" \033[1;35m * \033[1;34mReading songmap …\033[0;36m")
        songmap = self.ReadSongmap(mapfile)
        print(" \033[1;35m * \033[1;34mUpdating songmap …\033[0;36m")
        songmap = self.UpdateSongmap(songmap, mdbpathlist)
        print(" \033[1;35m * \033[1;34mRemoving outdated files …\033[0;36m")
        songmap = self.RemoveOldSongs(songmap, extconfig)
        print(" \033[1;35m * \033[1;34mCopying new files …\033[0;36m")
        songmap = self.CopyNewSongs(songmap, extconfig)
        print(" \033[1;35m * \033[1;34mWriting songmap …\033[0;36m")
        self.WriteSongmap(songmap, mapfile)

        return None