Пример #1
0
class WKServerConfig(Config):
    """
    This class provides the access to the MusicDB configuration file.
    """
    def __init__(self, filename):
        Config.__init__(self, filename)
        self.fs = Filesystem("/")

        logging.info("Reading and checking WKServer Configuration")

        # [meta]
        self.meta = META()
        self.meta.version           = self.Get(int, "meta",     "version",      1)


        # [websocket]
        self.websocket = WEBSOCKET()
        self.websocket.address      = self.Get(str, "websocket","address",      "127.0.0.1")
        self.websocket.port         = self.Get(int, "websocket","port",         9000)
        self.websocket.url          = self.Get(str, "websocket","url",          "wss://localhost:9000")
        self.websocket.apikey       = self.Get(str, "websocket","apikey",       None)
        if not self.websocket.apikey:
            logging.warning("Value of [websocket]->apikey is not set!")

        self.caldav = CALDAV()
        self.caldav.username     = self.Get(str, "caldav","username",          "user")
        self.caldav.password     = self.Get(str, "caldav","password",          "password")
        self.caldav.url          = self.Get(str, "caldav","url",               "https://localhost:443")

        # [TLS]
        self.tls = TLS()
        self.tls.cert               = self.GetFile( "tls",      "cert",         "/dev/null")
        self.tls.key                = self.GetFile( "tls",      "key",          "/dev/null")
        if self.tls.cert == "/dev/null" or self.tls.key == "/dev/null":
            logging.warning("You have to set a valid TLS certificate and key!")


        # [log]
        self.log        = LOG()
        self.log.logfile            = self.Get(str, "log",      "logfile",      "stderr")
        self.log.loglevel           = self.Get(str, "log",      "loglevel",     "WARNING")
        if not self.log.loglevel in ["DEBUG", "INFO", "WARNING", "ERROR"]:
            logging.error("Invalid loglevel for [log]->loglevel. Loglevel must be one of the following: DEBUG, INFO, WARNING, ERROR")
        self.log.debugfile          = self.Get(str, "log",      "debugfile",    None)
        if self.log.debugfile == "/dev/null":
            self.log.debugfile = None
        self.log.ignore             = self.Get(str, "log",      "ignore",       None, islist=True)


        # [debug]
        self.debug      = DEBUG()     


        logging.info("\033[1;32mdone")



    def GetDirectory(self, section, option, default):
        """
        This method gets a string from the config file and checks if it is an existing directory.
        If not it prints a warning and creates the directory if possible.
        If it fails with an permission-error an additional error gets printed.
        Except printing the error nothing is done.
        The \"invalid\" path will be returned anyway, because it may be OK that the directory does not exist yet.

        The permissions of the new created directory will be ``rwxrwxr-x``
        
        Args:
            section (str): Section of an ini-file
            option (str): Option inside the section of an ini-file
            default (str): Default directory path if option is not set in the file

        Returns:
            The value of the option set in the config-file or the default value.
        """
        path = self.Get(str, section, option, default)
        if self.fs.IsDirectory(path):
            return path

        # Create Directory
        logging.warning("Value of [%s]->%s does not address an existing directory. \033[1;30m(Directory \"%s\" will be created)", section, option, path)
        try:
            self.fs.CreateSubdirectory(path)
        except Exception as e:
            logging.error("Creating directory %s failed with error: %s.", path, str(e))

        # Set mode
        mode = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH
        try:
            self.fs.SetAttributes(path, None, None, mode);
        except Exception as e:
            logging.error("Creating directory %s failed with error: %s.", path, str(e))

        return path # return path anyway, it does not matter if correct or not. Maybe it will be created later on.


    def GetFile(self, section, option, default, logger=logging.error):
        """
        This method gets a string from the config file and checks if it is an existing file.
        If not it prints an error.
        Except printing the error nothing is done.
        The \"invalid\" will be returned anyway, because it may be OK that the file does not exist yet.
        
        Args:
            section (str): Section of an ini-file
            option (str): Option inside the section of an ini-file
            default (str): Default file path if option is not set in the file
            logger: Logging-handler. Default is logging.error. logging.warning can be more appropriate in some situations.

        Returns:
            The value of the option set in the config-file or the default value.
        """
        path = self.Get(str, section, option, default)
        if not self.fs.IsFile(path):
            logger("Value of [%s]->%s does not address an existing file.", section, option)
        return path # return path anyway, it does not matter if correct or not. Maybe it will be created later on.
Пример #2
0
class MusicDBConfig(Config):
    """
    This class provides the access to the MusicDB configuration file.
    """
    def __init__(self, filename):
        Config.__init__(self, filename)
        self.fs = Filesystem("/")

        logging.info("Reading and checking MusicDB Configuration")

        # [meta]
        self.meta = META()
        self.meta.version           = self.Get(int, "meta",     "version",      1)
        if self.meta.version < 2:
            logging.warning("Version of musicdb.ini is too old. Please update the MusicDB Configuration!")


        # [server]
        self.server = SERVER()
        self.server.pidfile         = self.Get(str, "server",   "pidfile",      "/opt/musicdb/data/musicdb.pid")
        self.server.statedir        = self.Get(str, "server",   "statedir",     "/opt/musicdb/data/mdbstate")
        self.server.fifofile        = self.Get(str, "server",   "fifofile",     "/opt/musicdb/data/musicdb.fifo")


        # [websocket]
        self.websocket = WEBSOCKET()
        self.websocket.address      = self.Get(str, "websocket","address",      "127.0.0.1")
        self.websocket.port         = self.Get(int, "websocket","port",         9000)
        self.websocket.url          = self.Get(str, "websocket","url",          "wss://localhost:9000")
        self.websocket.opentimeout  = self.Get(int, "websocket","opentimeout",  10)
        self.websocket.closetimeout = self.Get(int, "websocket","closetimeout",  5)
        self.websocket.apikey       = self.Get(str, "websocket","apikey",       None)
        if not self.websocket.apikey:
            logging.warning("Value of [websocket]->apikey is not set!")


        # [TLS]
        self.tls = TLS()
        self.tls.cert               = self.GetFile( "tls",      "cert",         "/dev/null")
        self.tls.key                = self.GetFile( "tls",      "key",          "/dev/null")
        if self.tls.cert == "/dev/null" or self.tls.key == "/dev/null":
            logging.warning("You have to set a valid TLS certificate and key!")


        # [database]
        self.database = DATABASE()
        self.database.path          = self.GetFile( "database", "path",         "/opt/musicdb/data/music.db")


        # [music]
        self.music = MUSIC()
        self.music.path             = self.GetDirectory("music",    "path",     "/var/music")
        self.music.owner            = self.Get(str, "music",    "owner",        "user")
        self.music.group            = self.Get(str, "music",    "group",        "musicdb")
        try:
            pwd.getpwnam(self.music.owner)
        except KeyError:
            logging.warning("The group name for [music]->owner is not an existing UNIX user!")
        try:
            grp.getgrnam(self.music.group)
        except KeyError:
            logging.warning("The group name for [music]->group is not an existing UNIX group!")

        ignorelist = self.Get(str, "music",    "ignoreartists","lost+found")
        ignorelist = ignorelist.split("/")
        self.music.ignoreartists = [item.strip() for item in ignorelist]

        ignorelist = self.Get(str, "music",    "ignorealbums", "")
        ignorelist = ignorelist.split("/")
        self.music.ignorealbums = [item.strip() for item in ignorelist]

        ignorelist = self.Get(str, "music",    "ignoresongs",  ".directory / desktop.ini / Desktop.ini / .DS_Store / Thumbs.db")
        ignorelist = ignorelist.split("/")
        self.music.ignoresongs = [item.strip() for item in ignorelist]


        # [artwork]
        self.artwork = ARTWORK()
        self.artwork.path           = self.GetDirectory("artwork",  "path",     "/opt/musicdb/data/artwork")
        self.artwork.scales         = self.Get(int, "artwork",  "scales",       "50, 150, 500", islist=True)
        for s in [50, 150, 500]:
            if not s in self.artwork.scales:
                logging.error("Missing scale in [artwork]->scales: The web UI expects a scale of %d (res: %dx%d)", s, s, s)
        self.artwork.manifesttemplate=self.GetFile( "artwork",  "manifesttemplate", "/opt/musicdb/server/manifest.txt", logging.warning) # a missing manifest does not affect the main functionality
        self.artwork.manifest       = self.Get(str, "artwork",  "manifest",     "/opt/musicdb/server/webui/manifest.appcache")


        # [extern]
        self.extern = EXTERN()
        self.extern.configtemplate  = self.GetFile( "extern",   "configtemplate","/opt/musicdb/server/share/extconfig.ini")
        self.extern.statedir        = self.Get(str, "extern",   "statedir",     ".mdbstate")
        self.extern.configfile      = self.Get(str, "extern",   "configfile",   "config.ini")
        self.extern.songmap         = self.Get(str, "extern",   "songmap",      "songmap.csv")
        

        # [tracker]
        self.tracker = TRACKER()
        self.tracker.dbpath         = self.GetFile( "tracker",  "dbpath",       "/opt/musicdb/data/tracker.db")
        self.tracker.cuttime        = self.Get(int, "tracker",  "cuttime",      "30")


        # [lycra]
        self.lycra = LYCRA()
        self.lycra.dbpath           = self.GetFile( "lycra",    "dbpath",       "/opt/musicdb/data/lycra.db")


        # [Icecast]
        self.icecast    = ICECAST()
        self.icecast.port           = self.Get(int, "Icecast",  "port",     "6666")
        self.icecast.user           = self.Get(str, "Icecast",  "user",     "source")
        self.icecast.password       = self.Get(str, "Icecast",  "password", "hackme")
        self.icecast.mountname      = self.Get(str, "Icecast",  "mountname","/stream")


        # [MusicAI]
        self.musicai    = MUSICAI()
        self.musicai.modelpath      = self.GetDirectory("MusicAI",  "modelpath",        "/opt/musicdb/data/musicai/models")
        self.musicai.tmppath        = self.GetDirectory("MusicAI",  "tmppath",          "/opt/musicdb/data/musicai/tmp")
        self.musicai.logpath        = self.GetDirectory("MusicAI",  "logpath",          "/opt/musicdb/data/musicai/log")
        self.musicai.specpath       = self.GetDirectory("MusicAI",  "spectrogrampath",  "/opt/musicdb/data/musicai/spectrograms")
        self.musicai.slicesize      = self.Get(int, "MusicAI",  "slicesize",    128)
        self.musicai.epoch          = self.Get(int, "MusicAI",  "epoch",        20)
        self.musicai.batchsize      = self.Get(int, "MusicAI",  "batchsize",    128)
        self.musicai.usegpu         = self.Get(bool,"MusicAI",  "usegpu",       True)
        self.musicai.modelname      = self.Get(str, "MusicAI",  "modelname",    "MusicGenre")
        self.musicai.genrelist      = self.Get(str, "MusicAI",  "genrelist",    None, islist=True)
        
        
        # [Randy]
        self.randy      = RANDY()
        self.randy.nodisabled       = self.Get(bool, "Randy",   "nodisabled",   True)
        self.randy.nohated          = self.Get(bool, "Randy",   "nohated",      True)
        self.randy.minsonglen       = self.Get(int,  "Randy",   "minsonglen",   120)
        self.randy.maxsonglen       = self.Get(int,  "Randy",   "maxsonglen",   600)
        self.randy.songbllen        = self.Get(int,  "Randy",   "songbllen",    50)
        self.randy.albumbllen       = self.Get(int,  "Randy",   "albumbllen",   20)
        self.randy.artistbllen      = self.Get(int,  "Randy",   "artistbllen",  10)
        self.randy.maxblage         = self.Get(int,  "Randy",   "maxblage",     24)


        # [log]
        self.log        = LOG()
        self.log.logfile            = self.Get(str, "log",      "logfile",      "stderr")
        self.log.loglevel           = self.Get(str, "log",      "loglevel",     "WARNING")
        if not self.log.loglevel in ["DEBUG", "INFO", "WARNING", "ERROR"]:
            logging.error("Invalid loglevel for [log]->loglevel. Loglevel must be one of the following: DEBUG, INFO, WARNING, ERROR")
        self.log.debugfile          = self.Get(str, "log",      "debugfile",    None)
        if self.log.debugfile == "/dev/null":
            self.log.debugfile = None
        self.log.ignore             = self.Get(str, "log",      "ignore",       None, islist=True)


        # [debug]
        self.debug      = DEBUG()     
        self.debug.disablestats     = self.Get(int, "debug",    "disablestats",     0)
        self.debug.disabletracker   = self.Get(int, "debug",    "disabletracker",   0)
        self.debug.disableai        = self.Get(int, "debug",    "disableai",        1)
        self.debug.disabletagging   = self.Get(int, "debug",    "disabletagging",   0)

        logging.info("\033[1;32mdone")



    def GetDirectory(self, section, option, default, logger=logging.error):
        """
        This method gets a string from the config file and checks if it is an existing directory.
        If not it prints an error.
        Except printing the error nothing is done.
        The \"invalid\" will be returned anyway, because it may be OK that the directory does not exist yet.
        
        Args:
            section (str): Section of an ini-file
            option (str): Option inside the section of an ini-file
            default (str): Default directory if option is not set in the file
            logger: Logging-handler. Default is logging.error. logging.warning can be more appropriate in some situations.

        Returns:
            The value of the option set in the config-file or the default value.
        """
        path = self.Get(str, section, option, default)
        if not self.fs.IsDirectory(path):
            logger("Value of [%s]->%s does not address an existing directory.", section, option)
        return path # return path anyway, it does not matter if correct or not. Maybe it will be created later on.


    def GetFile(self, section, option, default, logger=logging.error):
        """
        This method gets a string from the config file and checks if it is an existing file.
        If not it prints an error.
        Except printing the error nothing is done.
        The \"invalid\" will be returned anyway, because it may be OK that the file does not exist yet.
        
        Args:
            section (str): Section of an ini-file
            option (str): Option inside the section of an ini-file
            default (str): Default file path if option is not set in the file
            logger: Logging-handler. Default is logging.error. logging.warning can be more appropriate in some situations.

        Returns:
            The value of the option set in the config-file or the default value.
        """
        path = self.Get(str, section, option, default)
        if not self.fs.IsFile(path):
            logger("Value of [%s]->%s does not address an existing file.", section, option)
        return path # return path anyway, it does not matter if correct or not. Maybe it will be created later on.
Пример #3
0
class MusicDBConfig(Config):
    """
    This class provides the access to the MusicDB configuration file.
    """
    def __init__(self, filename):
        Config.__init__(self, filename)
        self.fs = Filesystem("/")

        logging.info("Reading and checking MusicDB Configuration")

        # [meta]
        self.meta = META()
        self.meta.version           = self.Get(int, "meta",     "version",      1)
        if self.meta.version < 2:
            logging.warning("Version of musicdb.ini is too old. Please update the MusicDB Configuration!")


        # [server]
        self.server = SERVER()
        self.server.pidfile         = self.Get(str, "server",   "pidfile",      "/opt/musicdb/data/musicdb.pid")
        self.server.statedir        = self.GetDirectory("server","statedir",    "/opt/musicdb/data/mdbstate")
        self.server.fifofile        = self.Get(str, "server",   "fifofile",     "/opt/musicdb/data/musicdb.fifo")
        self.server.webuiconfig     = self.Get(str, "server",   "webuiconfig",  "/opt/musicdb/data/webui.ini")


        # [websocket]
        self.websocket = WEBSOCKET()
        self.websocket.address      = self.Get(str, "websocket","address",      "127.0.0.1")
        self.websocket.port         = self.Get(int, "websocket","port",         9000)
        self.websocket.url          = self.Get(str, "websocket","url",          "wss://localhost:9000")
        self.websocket.opentimeout  = self.Get(int, "websocket","opentimeout",  10)
        self.websocket.closetimeout = self.Get(int, "websocket","closetimeout",  5)
        self.websocket.apikey       = self.Get(str, "websocket","apikey",       None)
        if not self.websocket.apikey:
            logging.warning("Value of [websocket]->apikey is not set!")


        # [TLS]
        self.tls = TLS()
        self.tls.cert               = self.GetFile( "tls",      "cert",         "/dev/null")
        self.tls.key                = self.GetFile( "tls",      "key",          "/dev/null")
        if self.tls.cert == "/dev/null" or self.tls.key == "/dev/null":
            logging.warning("You have to set a valid TLS certificate and key!")


        # [database]
        self.database = DATABASE()
        self.database.path          = self.GetFile( "database", "path",         "/opt/musicdb/data/music.db")


        # [music]
        self.music = MUSIC()
        self.music.path             = self.GetDirectory("music",    "path",     "/var/music")
        self.music.owner            = self.Get(str, "music",    "owner",        "user")
        self.music.group            = self.Get(str, "music",    "group",        "musicdb")
        try:
            pwd.getpwnam(self.music.owner)
        except KeyError:
            logging.warning("The group name for [music]->owner is not an existing UNIX user!")
        try:
            grp.getgrnam(self.music.group)
        except KeyError:
            logging.warning("The group name for [music]->group is not an existing UNIX group!")

        ignorelist = self.Get(str, "music",    "ignoreartists","lost+found")
        ignorelist = ignorelist.split("/")
        self.music.ignoreartists = [item.strip() for item in ignorelist]

        ignorelist = self.Get(str, "music",    "ignorealbums", "")
        ignorelist = ignorelist.split("/")
        self.music.ignorealbums = [item.strip() for item in ignorelist]

        ignorelist = self.Get(str, "music",    "ignoresongs",  ".directory / desktop.ini / Desktop.ini / .DS_Store / Thumbs.db")
        ignorelist = ignorelist.split("/")
        self.music.ignoresongs = [item.strip() for item in ignorelist]


        # [artwork]
        self.artwork = ARTWORK()
        self.artwork.path           = self.GetDirectory("artwork",  "path",     "/opt/musicdb/data/artwork")
        self.artwork.scales         = self.Get(int, "artwork",  "scales",       "50, 150, 500", islist=True)
        for s in [50, 150, 500]:
            if not s in self.artwork.scales:
                logging.error("Missing scale in [artwork]->scales: The web UI expects a scale of %d (res: %dx%d)", s, s, s)
        self.artwork.manifesttemplate=self.GetFile( "artwork",  "manifesttemplate", "/opt/musicdb/server/manifest.txt", logging.warning) # a missing manifest does not affect the main functionality
        self.artwork.manifest       = self.Get(str, "artwork",  "manifest",     "/opt/musicdb/server/webui/manifest.appcache")


        # [videoframes]
        self.videoframes = VIDEOFRAMES()
        self.videoframes.path           = self.GetDirectory("videoframes",  "path",     "/opt/musicdb/data/videoframes")
        self.videoframes.frames         = self.Get(int, "videoframes",  "frames",       "5")
        self.videoframes.previewlength  = self.Get(int, "videoframes",  "previewlength","3")
        self.videoframes.scales         = self.Get(str, "videoframes",  "scales",       "50x27, 150x83", islist=True)
        for s in ["150x83"]:
            if not s in self.videoframes.scales:
                logging.error("Missing scale in [videoframes]->scales: The web UI expects a scale of %s", s)
        for scale in self.videoframes.scales:
            try:
                width, height   = map(int, scale.split("x"))
            except Exception as e:
                logging.error("Invalid video scale format in [videoframes]->scales: Expected format WxH, with W and H as integers. Actual format: %s.", scale)

        # [uploads]
        self.uploads = UPLOAD()
        self.uploads.allow           = self.Get(bool,    "uploads", "allow",      False)
        self.uploads.path            = self.GetDirectory("uploads", "path",       "/tmp")


        # [extern]
        self.extern = EXTERN()
        self.extern.configtemplate  = self.GetFile( "extern",   "configtemplate","/opt/musicdb/server/share/extconfig.ini")
        self.extern.statedir        = self.Get(str, "extern",   "statedir",     ".mdbstate")
        self.extern.configfile      = self.Get(str, "extern",   "configfile",   "config.ini")
        self.extern.songmap         = self.Get(str, "extern",   "songmap",      "songmap.csv")
        

        # [tracker]
        self.tracker = TRACKER()
        self.tracker.dbpath         = self.GetFile( "tracker",  "dbpath",       "/opt/musicdb/data/tracker.db")
        self.tracker.cuttime        = self.Get(int, "tracker",  "cuttime",      "30")


        # [lycra]
        self.lycra = LYCRA()
        self.lycra.dbpath           = self.GetFile( "lycra",    "dbpath",       "/opt/musicdb/data/lycra.db")


        # [Icecast]
        self.icecast    = ICECAST()
        self.icecast.port           = self.Get(int, "Icecast",  "port",     "6666")
        self.icecast.user           = self.Get(str, "Icecast",  "user",     "source")
        self.icecast.password       = self.Get(str, "Icecast",  "password", "hackme")
        self.icecast.mountname      = self.Get(str, "Icecast",  "mountname","/stream")


        # [Randy]
        self.randy      = RANDY()
        self.randy.nodisabled       = self.Get(bool, "Randy",   "nodisabled",   True)
        self.randy.nohated          = self.Get(bool, "Randy",   "nohated",      True)
        self.randy.minsonglen       = self.Get(int,  "Randy",   "minsonglen",   120)
        self.randy.maxsonglen       = self.Get(int,  "Randy",   "maxsonglen",   600)
        self.randy.songbllen        = self.Get(int,  "Randy",   "songbllen",    50)
        self.randy.albumbllen       = self.Get(int,  "Randy",   "albumbllen",   20)
        self.randy.artistbllen      = self.Get(int,  "Randy",   "artistbllen",  10)
        self.randy.videobllen       = self.Get(int,  "Randy",   "videobllen",   10)
        self.randy.maxblage         = self.Get(int,  "Randy",   "maxblage",     24)
        self.randy.maxtries         = self.Get(int,  "Randy",   "maxtries",     10)


        # [log]
        self.log        = LOG()
        self.log.logfile            = self.Get(str, "log",      "logfile",      "stderr")
        self.log.loglevel           = self.Get(str, "log",      "loglevel",     "WARNING")
        if not self.log.loglevel in ["DEBUG", "INFO", "WARNING", "ERROR"]:
            logging.error("Invalid loglevel for [log]->loglevel. Loglevel must be one of the following: DEBUG, INFO, WARNING, ERROR")
        self.log.debugfile          = self.Get(str, "log",      "debugfile",    None)
        if self.log.debugfile == "/dev/null":
            self.log.debugfile = None
        self.log.ignore             = self.Get(str, "log",      "ignore",       None, islist=True)


        # [debug]
        self.debug      = DEBUG()     
        self.debug.disablestats     = self.Get(int, "debug",    "disablestats",     0)
        self.debug.disabletracker   = self.Get(int, "debug",    "disabletracker",   0)
        self.debug.disableai        = self.Get(int, "debug",    "disableai",        1)
        self.debug.disabletagging   = self.Get(int, "debug",    "disabletagging",   0)
        self.debug.disableicecast   = self.Get(int, "debug",    "disableicecast",   0)
        self.debug.disablevideos    = self.Get(int, "debug",    "disablevideos",    0)

        logging.info("\033[1;32mdone")



    def GetDirectory(self, section, option, default):
        """
        This method gets a string from the config file and checks if it is an existing directory.
        If not it prints a warning and creates the directory if possible.
        If it fails with an permission-error an additional error gets printed.
        Except printing the error nothing is done.
        The \"invalid\" path will be returned anyway, because it may be OK that the directory does not exist yet.

        The permissions of the new created directory will be ``rwxrwxr-x``
        
        Args:
            section (str): Section of an ini-file
            option (str): Option inside the section of an ini-file
            default (str): Default directory path if option is not set in the file

        Returns:
            The value of the option set in the config-file or the default value.
        """
        path = self.Get(str, section, option, default)
        if self.fs.IsDirectory(path):
            return path

        # Create Directory
        logging.warning("Value of [%s]->%s does not address an existing directory. \033[1;30m(Directory \"%s\" will be created)", section, option, path)
        try:
            self.fs.CreateSubdirectory(path)
        except Exception as e:
            logging.error("Creating directory %s failed with error: %s.", path, str(e))

        # Set mode
        mode = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH
        try:
            self.fs.SetAttributes(path, None, None, mode);
        except Exception as e:
            logging.error("Creating directory %s failed with error: %s.", path, str(e))

        return path # return path anyway, it does not matter if correct or not. Maybe it will be created later on.


    def GetFile(self, section, option, default, logger=logging.error):
        """
        This method gets a string from the config file and checks if it is an existing file.
        If not it prints an error.
        Except printing the error nothing is done.
        The \"invalid\" will be returned anyway, because it may be OK that the file does not exist yet.
        
        Args:
            section (str): Section of an ini-file
            option (str): Option inside the section of an ini-file
            default (str): Default file path if option is not set in the file
            logger: Logging-handler. Default is logging.error. logging.warning can be more appropriate in some situations.

        Returns:
            The value of the option set in the config-file or the default value.
        """
        path = self.Get(str, section, option, default)
        if not self.fs.IsFile(path):
            logger("Value of [%s]->%s does not address an existing file.", section, option)
        return path # return path anyway, it does not matter if correct or not. Maybe it will be created later on.
Пример #4
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()
Пример #5
0
class MetaTags(object):
    """
    This class can be used to access the metadata of the music files.

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

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

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

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

        Returns:
            *Nothing*

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

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

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

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

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

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

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

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

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

        If the file already exists, it gets overwritten.

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

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

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

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

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

        return False

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

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

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

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

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

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

        return None

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

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

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

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

            return name

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

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

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

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

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

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

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

        return None

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

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

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

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

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

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

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

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

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

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

        return int(number)

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

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

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

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

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

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

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

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

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

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

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

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

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

            return "CD"

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

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

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

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

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

        return None

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

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

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

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

        return int(time)

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

        The corresponding command line is the following:

            .. code-block:: bash

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

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

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

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

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

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

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

        return int(bitrate)
Пример #6
0
class tracker(MDBModule):
    def __init__(self, config, database):
        self.config = config
        self.musicdb = database
        self.fs = Filesystem(self.config.music.path)
        self.trackerdb = TrackerDatabase(self.config.tracker.dbpath)

    def GenerateDotFile(self, target, targetid, relations, dotfile):
        """
        This method generates a dot file visualizing the relations between the target and the related songs or artists.
        Also, the weights get visualized by the thickness of the edges of the generated graph.

        .. warning::

            If the file exists, its content gets replaced!

        Args:
            target (str): The target all IDs apply to. Can be ``"song"`` or ``"artist"``.
            targetid (int): ID of the song or artists, the relations belong to
            relations: A list of relations as returned by :meth:`lib.db.tracker.TrackerDatabase.GetRelations`
            dotfile (str): A path to write the dotfile to.

        Returns:
            ``True`` on success. If there is any error, ``False`` gets returned.
        """
        if target not in ["song", "artist"]:
            return False

        # give the IDs a name
        if target == "song":
            targetname = self.musicdb.GetSongById(targetid)["name"]
        elif target == "artist":
            targetname = self.musicdb.GetArtistById(targetid)["name"]
        else:
            return False

        for relation in relations:
            if target == "song":
                relation["name"] = self.musicdb.GetSongById(
                    relation["id"])["name"]
            elif target == "artist":
                relation["name"] = self.musicdb.GetArtistById(
                    relation["id"])["name"]
            else:
                return False

        dot = open(dotfile, "w")
        dot.write("digraph songenv {\n")

        # Related Song
        dot.write("\tsubgraph {\n")
        dot.write("\t\trank   = same; ")
        for relation in relations:
            dot.write("\"" + relation["name"] + "\"; ")
        dot.write("\n\t}\n")

        # center
        dot.write("\tsubgraph {\n")
        dot.write("\t\trank = same; " + targetname + ";\n")
        dot.write("\t}\n")

        dot.write("\tedge [ arrowhead=\"none\" ; len=7 ];\n\n")

        # edges
        for relation in relations:
            penwidth = max(1, int(relation["weight"] / 10))

            dot.write("\t\"" + relation["name"] + "\" -> \"" + targetname +
                      "\" [penwidth=" + str(penwidth) + "];\n")

        dot.write("}\n\n")
        dot.close()
        return True

    def ShowRelations(self, target, targetid, relations):
        """
        This method lists all entries in the relations list returned by the database for the given target ID

        Args:
            target (str): The target all IDs apply to. Can be ``"song"`` or ``"artist"``.
            targetid (int): ID of the song or artists, the relations belong to
            relations: A list of relations as returned by :meth:`lib.db.tracker.TrackerDatabase.GetRelations`

        Returns:
            ``True`` on success. If there is any error, ``False`` gets returned.
        """
        if target not in ["song", "artist"]:
            return False

        for relation in relations:
            # Get Weight
            weight = relation["weight"]

            # Get Name
            if target == "song":
                name = self.musicdb.GetSongById(relation["id"])["name"]
            elif target == "artist":
                name = self.musicdb.GetArtistById(relation["id"])["name"]
            else:
                return False

            # Get Color
            if target == "song":
                colorweight = weight
            elif target == "artist":
                colorweight = int(weight / 5)
            else:
                return False

            if colorweight <= 1:
                color = "\033[1;30m"
            elif colorweight == 2:
                color = "\033[1;34m"
            elif colorweight == 3:
                color = "\033[1;36m"
            else:
                color = "\033[1;37m"

            # Print
            print(" \033[1;35m[%2d] %s%s" % (weight, color, name))

        return True

    @staticmethod
    def MDBM_CreateArgumentParser(parserset, modulename):
        parser = parserset.add_parser(
            modulename, help="access information from the song tracker")
        parser.set_defaults(module=modulename)
        parser.add_argument("-s",
                            "--show",
                            action="store_true",
                            help="Show the related songs or artists")
        parser.add_argument(
            "-d",
            "--dot",
            action="store",
            metavar="dotfile",
            type=str,
            help=
            "if this option is given, a dot-file will be generated with the results"
        )
        parser.add_argument("--test",
                            action="store_true",
                            help="for testing - it's magic! read the code")
        parser.add_argument(
            "path",
            help=
            "Path to the song or artist on that the previos options will be applied"
        )

    # return exit-code
    def MDBM_Main(self, args):

        if args.test:
            from tqdm import tqdm
            print("\033[1;35mTranslating old table to new table …\033[0m")

            # # Translate old table to new table
            # sql = "SELECT song, successor, weight FROM graph"
            # results = self.trackerdb.GetFromDatabase(sql)
            # for result in results:
            #     for _ in range(result[2]):
            #         self.trackerdb.AddRelation("song", result[0], result[1])

            # # Generate artistrelations out of songrelations
            # sql = "SELECT songida, songidb, weight FROM songrelations"
            # results = self.trackerdb.GetFromDatabase(sql)
            # for result in tqdm(results):

            #     artista = self.musicdb.GetSongById(result[0])["artistid"]
            #     artistb = self.musicdb.GetSongById(result[1])["artistid"]

            #     for _ in range(result[2]):
            #         self.trackerdb.AddRelation("artist", artista, artistb)

            print("\033[1;32mdone!\033[0m")
            return 0

        # Genrate path relative to the music root directory - if possible
        try:
            path = self.fs.AbsolutePath(
                args.path)  # Be sure the path is absolute (resolve "./")
            path = self.fs.RemoveRoot(
                path)  # Now make a relative artist or song path
        except Exception as e:
            print(
                "\033[1;31mInvalid path. Determin relative path to the music root directory failed with error: %s",
                str(e))
            return 1

        # Identify target by path and get target ID
        if self.fs.IsFile(path):
            mdbsong = self.musicdb.GetSongByPath(path)
            if not mdbsong:
                print(
                    "\033[1;31mPath %s is a file, but it is not a song file!\033[0m"
                    % (path))
            target = "song"
            targetid = mdbsong["id"]

        elif self.fs.IsDirectory(path):
            mdbartist = self.musicdb.GetArtistByPath(path)
            if not mdbartist:
                print(
                    "\033[1;31mPath %s is a directory, but it is not an artist directory!\033[0m"
                    % (path))
            target = "artist"
            targetid = mdbartist["id"]

        else:
            print("\033[1;31mPath %s does not exist!\033[0m" % (path))
            return 1

        # Get target relation
        print(
            "\033[1;34mGetting \033[1;36m%s\033[1;34m relations from database … \033[0m"
            % (target))
        relations = self.trackerdb.GetRelations(target, targetid)
        print("\033[1;36m%d\033[1;34m entries found.\033[0m" %
              (len(relations)))

        # Apply parameters
        if args.show:
            self.ShowRelations(target, targetid, relations)

        if args.dot:
            rootfs = Filesystem()
            dotfile = rootfs.AbsolutePath(args.dot)
            self.GenerateDotFile(target, targetid, relations, dotfile)

        return 0
Пример #7
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
Пример #8
0
class MetaTags(object):
    """
    This class can be used to access the metadata of the music files.

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

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

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

        Supported file extensions for video files:

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

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

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

        Returns:
            *Nothing*

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

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

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

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

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

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

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

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

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

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

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

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

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

        return metadata

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

        If the file already exists, it gets overwritten.

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

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

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

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

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

        return False

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

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

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

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

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

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

        return None

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

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

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

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

            return name

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

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

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

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

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

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

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

        return None

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

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

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

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

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

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

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

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

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

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

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

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

        return int(number)

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

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

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

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

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

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

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

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

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

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

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

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

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

            return "CD"

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

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

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

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

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

        return None

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

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

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

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

        return int(time)

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

        The corresponding command line is the following:

            .. code-block:: bash

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

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

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

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

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

        The corresponding command line is the following:

            .. code-block:: bash

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

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

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

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

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

        .. math::

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

        The height will not be changed.

        The corresponding command line is the following:

        .. code-block:: bash

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

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

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

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

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

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

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

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

        return int(bitrate)
Пример #9
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