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.
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()
class ArtworkCache(object): """ This class handles the artwork cache. Its main job is to scale an image if a special resolution is requested. Args: artworkdir: Absolute path to the artwork root directory """ def __init__(self, artworkdir): self.artworkroot = Filesystem(artworkdir) def GetArtwork(self, artworkname, resolution): """ This method returns a valid path to an artwork with the specified resolution. The final path will consist of the *artworkdir* given as class parameter, the *resolution* as subdirectory and the *artworkname* as filename. (``{artworkdir}/{resolution}/{artworkname}``) If the artwork does not exist for this resolution it will be generated. If the directory for that scale does not exist, it will be created. Its permission will be ``musicdb:musicdb drwxrwxr-x`` In case an error occurs, an exception gets raised. The resolution is given as string in the format ``{X}x{Y}`` (For example: ``100x100``). *X* and *Y* must have the same value. This method expects an aspect ratio of 1:1. Beside scaling the JPEG, it will be made progressive. Args: artworkname (str): filename of the source artwork (Usually ``$Artist - $Album.jpg``) resolution (str): resolution of the requested artwork Returns: Relative path to the artwork in the specified resolution. ``None`` on error. Raises: ValueError: When the source file does not exist Example: .. code-block:: python cache = ArtworkCache("/data/artwork") path = cache.GetArtwork("example.jpg", "150x150") # returned path: "150x150/example.jpg" # absolute path: "/data/artwork/150x150/example.jpg" """ logging.debug("GetArtwork(%s, %s)", artworkname, resolution) # Check if source exists if not self.artworkroot.Exists(artworkname): logging.error( "Source file %s does not exist in the artwork root directory!", artworkname) raise ValueError( "Source file %s does not exist in the artwork root directory!", artworkname) # Check if already scaled. If yes, our job is done scaledfile = os.path.join(resolution, artworkname) if self.artworkroot.Exists(scaledfile): return scaledfile success = self.RebuildArtwork(artworkname, resolution) if not success: return None return scaledfile def RebuildArtwork(self, artworkname, resolution): """ This method rebuilds an artwork with the specified resolution. If the artwork does not exist for this resolution it will be generated. If the directory for that scale does not exist, it will be created. Its permission will be ``musicdb:musicdb drwxrwxr-x`` In case an error occurs, an exception gets raised. The resolution is given as string in the format ``{X}x{Y}`` (For example: ``100x100``). *X* and *Y* must have the same value. This method expects an aspect ratio of 1:1. Beside scaling the JPEG, it will be made progressive. Args: artworkname (str): filename of the source artwork (Usually ``$Artist - $Album.jpg``) resolution (str): resolution of the requested artwork Returns: ``True`` on success Example: .. code-block:: python cache = ArtworkCache("/data/artwork") if not cache.RebuildArtwork("example.jpg", "150x150"): print("creating a 150x150 thumbnail failed") """ logging.debug("RebuildArtwork(%s, %s)", artworkname, resolution) # Check if the scale-directory already exist. If not, create one if not self.artworkroot.IsDirectory(resolution): logging.debug("Creating subdirectory: %s", resolution) mode = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH try: self.artworkroot.CreateSubdirectory(resolution) self.artworkroot.SetAttributes(resoultion, None, None, mode) except Exception as e: logging.exception( "Creating scaled artwork directory %s failed with error: %s.", resolution, str(e)) return False # Scale image logging.debug("Converting image to %s", resolution) scaledfile = os.path.join(resolution, artworkname) abssrcpath = self.artworkroot.AbsolutePath(artworkname) absdstpath = self.artworkroot.AbsolutePath(scaledfile) # "10x10" -> (10, 10) length = int(resolution.split("x")[0]) size = (length, length) im = Image.open(abssrcpath) im.thumbnail(size, Image.BICUBIC) im.save(absdstpath, "JPEG", optimize=True, progressive=True) return True
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.
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