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. In case an error occcurs, 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. 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 # Check if the scale-directory already exist. If not, create one if not self.artworkroot.IsDirectory(resolution): logging.debug("Creating subdirectory: %s", resolution) self.artworkroot.CreateSubdirectory(resolution) # Scale image logging.debug("Converting image to %s", resolution) 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 scaledfile
class MusicAI(object): """ Args: config: Instance of :class:`lib.cfg.musicdb.MusicDBConfig`. Raises: TypeError: If *config* is of an invalid type """ def __init__(self, config): if type(config) != MusicDBConfig: logging.critical( "FATAL ERROR: Config-class of unknown type \"%s\"!", str(type(config))) raise TypeError() self.config = config self.fs = Filesystem(self.config.music.path) self.genrelist = self.config.musicai.genrelist self.modelname = self.config.musicai.modelname self.modelfile = self.config.musicai.modelpath + "/" + self.modelname + ".DCNN.tfl" def LoadModel(self, modelfilepath): """ This method creates a neural network model and loads the trained data from the model in ``modelfilepath`` if the file ``modelfilepath + ".index"`` exists. The model will be created to run on the GPU if the related configuration is set to ``True``, otherwise the model will run on the CPU. Currently only ``/gpu:0`` and ``/cpu:0`` are supported. This method also resets *TensorFlows* and *TFLearn* internals by calling ``tensorflow.reset_default_graph()`` and ``tflearn.config.init_graph()``. The model can then be saved by :meth:`~mdbapi.musicai.MusicAI.SaveModel` If model creation fails, an exception gets raised. .. warning:: The model data can become invalid if the code for creating the model (:meth:`~mdbapi.musicai.MusicAI.CreateModel`) changes the architecture of the model. Args: modelfilepath (str): Path where the models data are stored Returns: The TFLearn-model Raises: tensorflow.errors.InternalError: When creating the model failes. """ # Reset tensorflow tensorflow.reset_default_graph() tflearn.config.init_graph() model = None # Create Model if self.config.musicai.usegpu: device = "/gpu:0" else: device = "/cpu:0" logging.debug("Create Model for device %s" % (device)) try: with tensorflow.device(device): model = self.CreateModel() except tensorflow.errors.InternalError as e: ename = str(type(e).__name__) logging.exception( "Creating Tensorflow Model for device \"%s\" failed with error \"%s\"" % (str(device), ename)) print( "\033[1;31mCreating Tensorflow Model for device \"%s\" failed with error \"%s\" \033[1;30m(My be related to Out-Of-Memory error for GPU usage)\033[0m" % (str(device), ename)) raise (e) # Load weights/biases if available if self.fs.Exists(modelfilepath + ".index"): model.load(modelfilepath) return model def SaveModel(self, model, modelfilepath): """ This method stores the configuration (weights, biases, …) in ``modelfilepath``. *TFLearn* creates multiple files with ``modelfilepath`` as suffix. The model can then be loaded by :meth:`~mdbapi.musicai.MusicAI.LoadModel` .. warning:: The model data can become invalid if the code for creating the model (:meth:`~mdbapi.musicai.MusicAI.CreateModel`) changes the architecture of the model. Args: model: The *TFLearn* model of the neural network modelfilepath (str): The path and filename-(prefix) where the data of the model will be saved Returns: *Nothing* """ model.save(modelfilepath) def GetStatistics(self): """ This method returns statistics of the sizes of the training and feature sets. Returns: A dictionary with statistics or ``None`` if there is no training set Example: .. code-block:: python stats = musicai.GetStatistics() print("Set Size: ", stats["setsize"]) print("Number of Songs in Set: ", stats["numofsongs"]) """ trainingfilepath = self.config.musicai.modelpath + "/" + self.modelname + ".h5" if not self.fs.Exists(trainingfilepath): return None trainingfile = HDF5Storage(trainingfilepath) # Check if the song was already added to the trainingset try: songset = trainingfile.ReadDataset("songids") outputset = trainingfile.ReadDataset("outputs") except AssertionError: trainingfile.Close() return None stats = {} stats["setsize"] = outputset.shape[0] stats["numofsongs"] = songset.shape[0] return stats # This MUST BE THREADSAFE to process multiple files at once # Accessing own database is allowed # returns file to spetrogram on success or None if something failed def CreateSpectrogram(self, songid, songpath): """ This method creates the spectrograms used for the feature set. If the spectrogram already exists, it will skip the process. The spectrogram is a png-image. .. warning:: This method must be thread safe to process multiple files at once This method represents the following shell commands: .. code-block:: bash # decode song to wave-file ffmpeg -v quiet -y -i $songpath "$songid-stereo.wav" # make mono wave file out of the stereo wave file sox "$songid-stereo.wav" "$songid-mono.wav" remix 1,2 # create spectrogram out of the mono wave file sox "$songid-mono.wav" -n spectrogram -y $slizesize -X 50 -m -r -o "$songid.png" The temporary wave files are stored in the *musicai-tmppath*. They will be removed if the spectrogram was created successfully. Otherwise they will be kept. The spectrogram in the *musicai-specpath*. The slicesize denotes the height of the spectrogram which basically means the granularity of frequencies. This value gets increased by 1 to cover the offset (f=0). One column in the spectrogram represents 50ms of the song. All those variables can be set in the MusicDB Configuration. .. warning:: Before changing the slice-size read the ``sox`` manpages for the ``-y`` parameter carefully. Args: songid: The song ID is used to create temporary files songpath (str): The path to the song that shall be analyzed Returns: path to the spectrogram """ # prepare pathes sourcefile = self.fs.AbsolutePath(songpath) wavefile = self.config.musicai.tmppath + "/" + str( songid) + "-stereo.wav" monofile = self.config.musicai.tmppath + "/" + str( songid) + "-mono.wav" spectrogram = self.config.musicai.specpath + "/" + str(songid) + ".png" # we are already done if self.fs.Exists(spectrogram): return spectrogram # create wave-file # ffmpeg -i audio.aac stereo.wav if not self.fs.Exists(wavefile): process = [ "ffmpeg", "-v", "quiet", # don't be verbose "-y", # overwrite existing file "-i", sourcefile, wavefile ] try: self.fs.Execute(process) except Exception as e: logging.error("Error \"%s\" while executing: %s", str(e), str(process)) return None # create mono-file # sox stereo.wav mono.wav remix 1,2 if not self.fs.Exists(monofile): process = ["sox", wavefile, monofile, "remix", "1,2"] try: self.fs.Execute(process) except Exception as e: logging.error("Error \"%s\" while executing: %s", str(e), str(process)) return None # create spectrogram # sox mono.wav -n spectrogram -Y 200 -X 50 -m -r -o mono.png if not self.fs.Exists(spectrogram): process = [ "sox", monofile, "-n", "spectrogram", "-y", str(self.config.musicai.slicesize + 1), "-X", "50", "-m", "-r", "-o", spectrogram ] try: self.fs.Execute(process) except Exception as e: logging.error("Error \"%s\" while executing: %s", str(e), str(process)) return None # remove tempfiles - Keep spectrogram because it looks so cool. Maybe it finds its place in the UI self.fs.RemoveFile(wavefile) self.fs.RemoveFile(monofile) return spectrogram # This MUST BE THREADSAFE to process multiple files at once # Accessing own database is allowed # returns True on success def CreateFeatureset(self, songid, songpath): """ This function generates the feature set for the MusicAI. The features are generated by the following steps: #. First step is to create the spectrogram calling :meth:`~mdbapi.musicai.MusicAI.CreateSpectrogram`. #. Next it takes slices from the resulting image and converts it into a normalized *numpy* array. #. The begin and end of a song will be chopped of and gets ignored. A slicesize can be defind in the MusicDB Configuration under musicai->slicesize. **Be careful with this configuration and check the influence to other methods in this class!** The first 10 and the last 10 slices will be skipped to avoid unnecessary much intro/outro-data-noise. The resulting data (a feature) is a *numpy* 3D (slicesize, slicesize, 1) matrix of type float in range of 0.0 to 1.0. This matrix of all features of a song gets stored in a HDF5 file under ``$spectrograms/$SongID.h5`` Args: songid: ID of the song the feature set belongs to songpath (str): path to the song that shall be analyzed Returns: ``True`` on success, otherwise ``False`` Example: .. code-block:: python musicai = MusicAI("./musicdb.ini") musicai.CreateFeatureset(mdbsong["id"], mdbsong["path"]): """ # Create Spectrogram spectrogram = self.CreateSpectrogram(songid, songpath) if not spectrogram: return False # Open it and make raw data out of the image image = Image.open(spectrogram) width, height = image.size slicesize = self.config.musicai.slicesize numslices = int(width / slicesize) - 20 # \_ skip slices (intro+outro) startoffset = 10 * slicesize # / if numslices <= 0: logging.warning( "song %s too small! \033[1;30m(do not use for featureset)" % (songpath)) return False # create data dataset = numpy.zeros((numslices, slicesize, slicesize, 1)) for i in range(numslices): # Crop into slices startpixel = i * slicesize + startoffset imageslice = image.crop( (startpixel, 0, startpixel + slicesize, slicesize)) # Make numpy-arrays out of it data = numpy.asarray(imageslice, dtype=numpy.uint8) # image -> ndarray data = data / 255.0 # [0 … 255] -> [0.0 … 1.0] (and makes float) data = data.reshape((slicesize, slicesize, 1)) # X² -> X³ # Store the feature dataset dataset[i] = data # Open storage for the features featurefilepath = self.config.musicai.specpath + "/" + str( songid) + ".h5" featurefile = HDF5Storage(featurefilepath) featurefile.WriteDataset("featureset", dataset) featurefile.Close() return True def HasFeatureset(self, songid): """ This method checks if a song has already a feature set. If there is a HDF5 file in the spectrogram directors the method assumes that this file contains the feature set. The file gets not read. The png encoded spectrogram is not the final feature set and so its existence is not relevant for this method. Args: songid: ID of the song that shall be checked Returns: ``True`` if the songs feature set is available, otherwise ``False`` """ featurefilepath = self.config.musicai.specpath + "/" + str( songid) + ".h5" if self.fs.Exists(featurefilepath): return True return False def AddSongToTrainingset(self, songid, genre): """ This method can be used to add a song to the available training sets. To do so, first the feature set must be created. The feature set file gets read and a genre vector generated. The resultuing two sets (*inputs* = feature set and *outputs* = genre vector) will be stored in the training file. The **inputs** are a HDF5 dataset handler shaping a numpy-array of size *n* of input matrices: ``(n, slicesize, slicesize, 1)`` The **outputs** are a HDF5 dataset handler shaping a numpy-array of size *n* of genre-vectors formatted as shown in :meth:`mdbapi.musicai.MusicAI.GetGenreMatrix`: ``(n, 4, 1)`` The genre name should be the same as the related genre tag is named in the database. It also must be part of the list of genres this AI works with. Args: songid: ID of the song that shall be used for training genre (str): Lower case name of the genre (as stored in the database) the song belongs to Returns: ``True`` if the song has a feature set so that it was added to the database. Otherwise ``False`` Example: .. code-block:: python musicai = MusicAI("./musicdb.ini") musicai.CreateFeatureset(mdbsong["id"], mdbsong["path"]): musicai.AddSongToTrainingset(mdbsong["id"], "metal") """ if not genre in self.genrelist: logging.error("The genre \"%s\" is not in the genrelist: \"%s\"!", genre, str(self.genrelist)) return False if not self.HasFeatureset(songid): logging.waring("Song with id %s does not have a featureset", str(songid)) return False featurefilepath = self.config.musicai.specpath + "/" + str( songid) + ".h5" trainingfilepath = self.config.musicai.modelpath + "/" + self.modelname + ".h5" featurefile = HDF5Storage(featurefilepath) trainingfile = HDF5Storage(trainingfilepath) # Check if the song was already added to the trainingset try: songids = trainingfile.ReadDataset("songids") except AssertionError: songids = [] if songid in songids: logging.waring("Song with id %s does already exist in trainingset", str(songid)) featurefile.Close() trainingfile.Close() return False # Read Featureset featureset = featurefile.ReadDataset("featureset") # read features setsize = featureset.shape[0] # determin size of the workingset genrevector = self.GetGenreMatrix()[genre] # get genre vector genrevector = numpy.array(genrevector) # \_ create outputs-set genreset = numpy.tile(genrevector, (setsize, 1)) # / songidset = numpy.tile(numpy.array(songid), (1, 1)) # Write trainingset trainingfile.WriteDataset("inputs", featureset) trainingfile.WriteDataset("outputs", genreset) trainingfile.WriteDataset("songids", songidset) featurefile.Close() trainingfile.Close() return True def CreateModel(self): """ This method creates and returns the following deep convolutional neural network. The architecture of the network is close to the one provided the following blog article: Julien Despois, *"Finding the genre of a song with Deep Learning -- A.I. Odyyssey part. 1"*, Nov. 2016, internet (non-scientific source), https://chatbotslife.com/finding-the-genre-of-a-song-with-deep-learning-da8f59a61194 Inside the method there are some configurable variables to easily change some basic settings of the network: * ``useflatten = True``: Create a flatten-layer between the convolutional part and the fully connected end * ``keepprob = 0.5``: Probability of an input gets propagated through the dropout-layer * ``activation = "elu"``: Name of the activation function used for the convolutional networks as used by *tensorflow* * ``weightinit = "Xavier"``: Initialisation function for the convolutional layers * ``weightdecay = 0.001`` * ``learningrate = 0.001`` The reason for using ``"elu"`` as activation function can be found in the following paper: Djork-Arné Clevert, Thomas Unterthiner and Sepp Hochreiter, *"Fast and Accurate Deep Network Learning by Exponential Linear Units (ELUs)"*, Nov. 2015, CoRR (scientific source), https://arxiv.org/abs/1511.07289 Returns: The created TFLearn-model """ slicesize = self.config.musicai.slicesize # the slice size of the spectrograms define the size of the input layer genrecount = len( self.config.musicai.genrelist ) # number of genres and so the number of output neurons # Some general architechture configuration useflatten = True # Use a flatten layer afrer the convolutional layers keepprob = 0.5 # configures the dropout layer activation = "elu" # elu? warum nicht das übliche relu? -> Weil besser: https://arxiv.org/abs/1511.07289 weightinit = "Xavier" # Initialisation function for the convolutional layers weightdecay = 0.001 learningrate = 0.001 convnet = input_data(shape=[None, slicesize, slicesize, 1], name='input') convnet = conv_2d(convnet, 64, 2, activation=activation, weights_init=weightinit, weight_decay=weightdecay) convnet = max_pool_2d(convnet, 2) convnet = conv_2d(convnet, 128, 2, activation=activation, weights_init=weightinit, weight_decay=weightdecay) convnet = max_pool_2d(convnet, 2) convnet = conv_2d(convnet, 256, 2, activation=activation, weights_init=weightinit, weight_decay=weightdecay) convnet = max_pool_2d(convnet, 2) convnet = conv_2d(convnet, 512, 2, activation=activation, weights_init=weightinit, weight_decay=weightdecay) convnet = max_pool_2d(convnet, 2) if useflatten: convnet = flatten(convnet) convnet = fully_connected(convnet, 1024, activation=activation, weight_decay=weightdecay) convnet = dropout(convnet, keepprob) convnet = fully_connected(convnet, genrecount, activation='softmax', weight_decay=weightdecay) convnet = regression(convnet, optimizer='rmsprop', loss='categorical_crossentropy', learning_rate=learningrate) model = tflearn.DNN( convnet, tensorboard_verbose=3, # many nice graphs inside tensorboard tensorboard_dir=self.config.musicai.logpath) return model def GetGenreMatrix(self): r""" This method returns a dictionary with the categorization vector for each genre. When training a neural network for categorizing, each element of the result-vector is mapped to one category. This static mapping is returned by this method. The matrix gets dynamic generated out of the configuration of MusicDB. The configured genre list gets just transformed into the matirx. For example, the mapping for the genrelist ``Metal, NDH, Gothic, Elector`` is the following: .. math:: \begin{pmatrix} \text{Metal}\\ \text{NDH}\\ \text{Gothic}\\ \text{Electro} \end{pmatrix} The format of the mapping is a dictionary with each genre as key. Each entry in the dictionary is a vector with the related cell set to 1.0 and the other cells to 0.0. For *Gothic* music, the vector would look like this: .. math:: \text{Gothic} = \begin{pmatrix} 0.0\\ 0.0\\ 1.0\\ 0.0 \end{pmatrix} Returns: A dictionary with expected-prediction-vectors for training """ matrix = {} for index, genre in enumerate(self.genrelist): matrix[genre] = [0.0] * len(self.genrelist) matrix[genre][index] = 1.0 return matrix def HasTrainingset(self, songid): """ This method checks if a song is used for training - if there exists a training set. The check is done in two steps: #. Does the training set file for the model exist. #. Is the song ID listed in the set of training songs in the training set file. Args: songid: ID of the song that shall be checked Returns: ``True`` if there exists a training set, otherwise ``False`` """ trainingfilepath = self.config.musicai.modelpath + "/" + self.modelname + ".h5" if not self.fs.Exists(trainingfilepath): return False trainingfile = HDF5Storage(trainingfilepath) # Check if the song was already added to the trainingset try: songids = trainingfile.ReadDataset("songids") except AssertionError: trainingfile.Close() return False # if the song is listed in this list, it exists in the trainingset if songid in songids: trainingfile.Close() return True return False def PerformTraining(self): """ This method performs the training of the neural network. The training is done in several steps: #. Loading the model using :meth:`~mdbapi.musicai.MusicAI.LoadModel` #. Loading the training set from file #. Performing the training itself. This can be canceled by pressing ctrl-c. #. Saving new model state by calling :meth:`~mdbapi.musicai.MusicAI.SaveModel` The size of the training set is determined by the MusicDB Configuration. Each run gets logged in the log-directory that can be configured in the MusicDB Configuration. The name of each run is the module-name extended with the date (YY-MM-DD) and time (HH:MM) of the training. These logs can be used for visualization using TensorBoard. Returns: ``True`` on success, otherwise ``False`` """ # shorten config runid = self.modelname + " " + datetime.datetime.now().strftime( "%Y-%m-%d %H:%M") batchsize = self.config.musicai.batchsize epoch = self.config.musicai.epoch # load model print("\033[1;34mLoading model … \033[0m") model = self.LoadModel(self.modelfile) # getting features print("\033[1;34mLoading training data … \033[0;36m") trainingfilepath = self.config.musicai.modelpath + "/" + self.modelname + ".h5" trainingfile = HDF5Storage(trainingfilepath) try: inputset = trainingfile.ReadDataset("inputs") outputset = trainingfile.ReadDataset("outputs") except AssertionError as e: logging.exception("Reading the Trainingset failed!") print("\033[1;31m … failed!\033[0m") trainingfile.Close() return False # run training print("\033[1;34mRunning training process … \033[0m") try: model.fit(inputset, outputset, n_epoch=epoch, batch_size=batchsize, shuffle=True, validation_set=0.01, snapshot_step=100, show_metric=True, run_id=runid) except KeyboardInterrupt as e: logging.info( "Training canceled by user. \033[1;30m(Progress will not be saved)\033[0m" ) print( "\033[1;33m … canceled by user. \033[1;30m(Progress will not be saved)\033[0m" ) trainingfile.Close() return False # save model print("\033[1;34mSaving Model … \033[0m") self.SaveModel(model, self.modelfile) trainingfile.Close() return True def PerformPrediction(self, songid): r""" This method starts a prediction of the genre for a song addressed by its song ID. It returns a vector of confidence for the genres as described in :meth:`~mdbapi.musicai.MusicAI.GetGenreMatrix`. The confidence is the average of all features in the set. The featureset gets loaded from the related HDF5 file in the spectrogram directory configured in the MusicDB Configuration. A feature set can be created using the :meth:`~mdbapi.musicai.MusicAI.CreateFeatureset` If the file does not exist ``None`` gets returned. The result is calculated the following way from the set *P* of predictions based on a feature for each genre in the set of genres *G*. .. math:: p_{g} = \frac{1}{\lvert P \rvert}\sum_{i}^{\lvert P \rvert}{p_{i}} \qquad p_{i} \in P; \; g \in G Args: songid: The ID of the song that shall be categorized Returns: A confidence-vector for the genres that were predicted, or ``None`` if an error occurs Example: .. code-block:: python # Create a feature set if there is none yet if not musicai.HasFeatureset(mdbsong["id"]): musicai.CreateFeatureset(mdbsong["id"], mdbsong["path"]) # Perform prediction confidence = musicai.PerformPrediction(mdbsong["id"]) if confidence == None: print("Prediction Failed! No featureset available?") return # Print results # The order of the Genre-List is important! The mapping (index, genre) must be correct! for index, genre in enumerate(config.musicai.genrelist): print("%.2f: %s" % (confidence[index], genre)) """ if not self.HasFeatureset(songid): logging.warning("Song with id %s does not have a featureset", str(songid)) return None # load features featurefilepath = self.config.musicai.specpath + "/" + str( songid) + ".h5" featurefile = HDF5Storage(featurefilepath) try: featureset = featurefile.ReadDataset("featureset") except AssertionError as e: logging.error("Reading Featureset failed with error %s!", str(e)) featureset = None featurefile.Close() if featureset == None: return None # load model model = self.LoadModel(self.modelfile) # run prediction predictionset = model.predict(featureset) featurefile.Close() # accumulate results numofgenres = len(self.genrelist) prediction = [0.0] * numofgenres for entry in predictionset: for i in range(numofgenres): prediction[i] += entry[i] for i in range(numofgenres): prediction[i] /= len(predictionset) return prediction
class UploadManager(object): """ This class manages uploading content to the server MusicDB runs on. All data is stored in the uploads-directory configured in the MusicDB configuration. Args: config: :class:`~lib.cfg.musicdb.MusicDBConfig` object holding the MusicDB Configuration database: (optional) A :class:`~lib.db.musicdb.MusicDatabase` instance Raises: TypeError: When the arguments are not of the correct type. """ def __init__(self, config, database): if type(config) != MusicDBConfig: raise TypeError("config argument not of type MusicDBConfig") if database != None and type(database) != MusicDatabase: raise TypeError( "database argument not of type MusicDatabase or None") self.db = database self.cfg = config self.uploadfs = Filesystem(self.cfg.uploads.path) self.musicfs = Filesystem(self.cfg.music.path) self.artworkfs = Filesystem(self.cfg.artwork.path) # TODO: check write permission of all directories self.fileprocessing = Fileprocessing(self.cfg.uploads.path) self.dbmanager = MusicDBDatabase(config, database) global Tasks if Tasks == None: self.LoadTasks() ##################################################################### # Callback Function Management # ##################################################################### def RegisterCallback(self, function): """ Register a callback function that reacts on Upload related events. For more details see the module description at the top of this document. Args: function: A function that shall be called on an event. Returns: *Nothing* """ global Callbacks Callbacks.append(function) def RemoveCallback(self, function): """ Removes a function from the list of callback functions. Args: function: A function that shall be called removed. Returns: *Nothing* """ global Callbacks # Not registered? Then do nothing. if not function in Callbacks: logging.warning( "A Streaming Thread callback function should be removed, but did not exist in the list of callback functions!" ) return Callbacks.remove(function) def NotifyClient(self, notification, task, message=None): """ This method triggers a client-notification. There are three kind of notifications: * ``"ChunkRequest"``: A new chunk of data is requested * ``"StateUpdate"``: The state or annotations of an upload-task has been changed. See ``"state"`` value. * ``"InternalError"``: There is an internal error occurred during. See ``"message"`` value. The notification comes with the current status of the upload process. This includes the following keys - independent of the state of the upload: * uploadid: ID of the upload the notification is associated with * offset: Offset of the requested data in the source file * chunksize: The maximum chunk size * state: The current state of the upload task * message: ``null``/``None`` or a message from the server * uploadtask: The task dictionary itself * uploadslist: Except for ``ChunkRequest`` events, the WebSocket server append the result of :meth:`lib.ws.mdbwsi.MusicDBWebSocketInterface.GetUploads` to the notification *task* can be ``None`` in case the notification is meant to be an information that a given upload ID is invalid. Args: notification (str): Name of the notification task (dict): Task structure message (str): (optional) text message (like an error message) to the client Returns: *Nothing* Raises: ValueError: When notification has an unknown notification name """ if not notification in [ "ChunkRequest", "StateUpdate", "InternalError" ]: raise ValueError("Unknown notification \"%s\"" % (notification)) status = {} if task != None: status["uploadid"] = task["id"] status["offset"] = task["offset"] # offset of the data to request status[ "chunksize"] = 4096 * 100 # Upload 400KiB (TODO: Make configurable) status["state"] = task["state"] status["uploadtask"] = task else: status["uploadid"] = None status["offset"] = None status["chunksize"] = None status["state"] = "notexisting" status["uploadtask"] = None status["message"] = message global Callbacks for callback in Callbacks: try: callback(notification, status) except Exception as e: logging.exception( "A Upload Management event callback function crashed!") ##################################################################### # State management # ##################################################################### def SaveTask(self, task): """ This method saves a task in the uploads directory under ``tasks/${Task ID}.json`` Args: task (dict): The task to save Returns: *Nothing* """ taskid = task["id"] data = json.dumps(task) path = self.cfg.uploads.path + "/tasks/" + taskid + ".json" if not self.uploadfs.IsDirectory("tasks"): logging.debug("tasks directory missing. Creating \"%s\"", self.cfg.uploads.path + "/tasks") self.uploadfs.CreateSubdirectory("tasks") with open(path, "w+") as fd: fd.write(data) return def LoadTasks(self): """ Loads all task from the JSON files inside the tasks-directory. The list of active tasks will be replaced by the loaded tasks. Returns: *Nothing* """ logging.debug("Loading Upload-Tasks…") taskfilenames = self.uploadfs.ListDirectory("tasks") global Tasks Tasks = {} for taskfilename in taskfilenames: taskpath = self.cfg.uploads.path + "/tasks/" + taskfilename if self.uploadfs.GetFileExtension(taskpath) != "json": continue try: with open(taskpath) as fd: task = json.load(fd) except Exception as e: logging.warning( "Loading task file \"%s\" failed with error \"%s\". \033[1;30m(File will be ignored)", str(taskpath), str(e)) continue if "id" not in task: logging.warning( "File \"%s\" is not a valid task (ID missing). \033[1;30m(File will be ignored)", str(taskpath), str(e)) continue Tasks[task["id"]] = task return ##################################################################### # Management Functions # ##################################################################### def InitiateUpload(self, uploadid, mimetype, contenttype, filesize, checksum, sourcefilename): """ Initiates an upload of a file into a MusicDB managed file space. After calling this method, a notification gets triggered to request the first chunk of data from the clients. In case uploads are deactivated in the MusicDB Configuration, an ``"InternalError"`` Notification gets sent to the clients. Args: uploadid (str): Unique ID to identify the upload task mimetype (str): MIME-Type of the file (example: ``"image/png"``) contenttype (str): Type of the content: (``"video"``, ``"album"``, ``"artwork"``) filesize (int): Size of the complete file in bytes checksum (str): SHA-1 check sum of the source file sourcefilename (str): File name (example: ``"test.png"``) Raises: TypeError: When one of the arguments has not the expected type ValueError: When *contenttype* does not have the expected values """ if type(uploadid) != str: raise TypeError("Upload ID must be of type string") if type(mimetype) != str: raise TypeError("mime type must be of type string") if type(contenttype) != str: raise TypeError("content type must be of type string") if contenttype not in ["video", "album", "artwork"]: raise ValueError( "content type \"%s\" not valid. \"video\", \"album\" or \"artwork\" expected." % (str(contenttype))) if type(filesize) != int: raise TypeError("file size must be of type int") if filesize <= 0: raise ValueError("file size must be greater than 0") if type(checksum) != str: raise TypeError("Checksum must be of type string") if type(sourcefilename) != str: raise TypeError("Source file name must be of type string") if not self.cfg.uploads.allow: self.NotifyClient("InternalError", None, "Uploads deactivated") logging.warning( "Uploads not allowed! \033[1;30m(See MusicDB Configuration: [uploads]->allow)" ) return fileextension = self.uploadfs.GetFileExtension(sourcefilename) destinationname = contenttype + "-" + checksum + "." + fileextension destinationpath = self.cfg.uploads.path + "/" + destinationname # TODO: Check if there is already a task with the given ID. # If this task is in waitforchunk state, the upload can be continued instead of restarting it. # Remove existing upload if destination path exists self.uploadfs.RemoveFile( destinationpath) # Removes file when it exists # Create File with open(destinationpath, "w+b"): pass task = {} task["id"] = uploadid task["filesize"] = filesize task["offset"] = 0 task["contenttype"] = contenttype task["mimetype"] = mimetype task["sourcefilename"] = sourcefilename task["sourcechecksum"] = checksum task["destinationpath"] = destinationpath task[ "videofile"] = None # Path to the video file in the music directory task["state"] = "waitforchunk" task["annotations"] = {} self.SaveTask(task) global Tasks Tasks[uploadid] = task self.NotifyClient("ChunkRequest", task) return def RequestRemoveUpload(self, uploadid): """ This method triggers removing a specific upload. This includes the uploaded file as well as the upload task information and annotations. The upload task can be in any state. When the remove-operation is triggered, its state gets changed to ``"remove"``. Only the ``"remove"`` state gets set. Removing will be done by the Management Thread. Args: uploadid (str): ID of the upload-task Returns: ``True`` on success """ try: task = self.GetTaskByID(uploadid) except Exception as e: logging.error( "Internal error while requesting a new chunk of data: %s", str(e)) return False self.UpdateTaskState(task, "remove") return True def GetTaskByID(self, uploadid): """ This method returns an existing task from the tasklist. The task gets identified by its ID aka Upload ID When the task does not exits, the clients get an ``"InternalError"`` notification. The tasks state is then ``"notexisting"``. Args: uploadid (str): ID of the upload-task Returns: A task dictionary Raises: TypeError: When *uploadid* is not a string ValueError: When *uploadid* is not a valid key in the Tasks-dictionary """ if type(uploadid) != str: raise TypeError("Upload ID must be a string. Type was \"%s\"." % (str(type(uploadid)))) global Tasks if uploadid not in Tasks: self.NotifiyClient("InternalError", None, "Invalid Upload ID") raise ValueError("Upload ID \"%s\" not in Task Queue.", str(uploadid)) return Tasks[uploadid] def UpdateTaskState(self, task, state, errormessage=None): """ This method updates and saves the state of an task. An ``"StateUpdate"`` notification gets send as well. If *errormessage* is not ``None``, the notification gets send as ``"InternalError"`` with the message Args: task (dict): Task object to update state (str): New state message (str): Optional message Returns: *Nothing* """ task["state"] = state self.SaveTask(task) if errormessage: self.NotifyClient("InternalError", task, errormessage) else: self.NotifyClient("StateUpdate", task) return def NewChunk(self, uploadid, rawdata): """ This method processes a new chunk received from the uploading client. Args: uploadid (str): Unique ID to identify the upload task rawdata (bytes): Raw data to append to the uploaded data Returns: ``False`` in case an error occurs. Otherwise ``True``. Raises: TypeError: When *rawdata* is not of type ``bytes`` """ if type(rawdata) != bytes: raise TypeError("raw data must be of type bytes. Type was \"%s\"" % (str(type(rawdata)))) try: task = self.GetTaskByID(uploadid) except Exception as e: logging.error( "Internal error while requesting a new chunk of data: %s", str(e)) return False chunksize = len(rawdata) filepath = task["destinationpath"] try: with open(filepath, "ab") as fd: fd.write(rawdata) except Exception as e: logging.warning( "Writing chunk of uploaded data into \"%s\" failed: %s \033[1;30m(Upload canceled)", filepath, str(e)) self.UpdateTaskState( task, "uploadfailed", "Writing data failed with error: \"%s\"" % (str(e))) return False task["offset"] += chunksize self.SaveTask(task) if task["offset"] >= task["filesize"]: # Upload complete self.UploadCompleted(task) else: # Get next chunk of data self.NotifyClient("ChunkRequest", task) return True def UploadCompleted(self, task): """ This method continues the file management after an upload was completed. The following tasks were performed: * Checking the checksum of the destination file (SHA1) and compares it with the ``"sourcechecksum"`` from the *task*-dict. When the upload was successful, it notifies the clients with a ``"UploadComplete"`` notification. Otherwise with a ``"UploadFailed"`` one. Args: task (dict): The task that upload was completed Returns: ``True`` When the upload was successfully complete, otherwise ``False`` """ # Check checksum destchecksum = self.fileprocessing.Checksum(task["destinationpath"], "sha1") if destchecksum != task["sourcechecksum"]: logging.error( "Upload Failed: \033[0;36m%s \e[1;30m(Checksum mismatch)", task["destinationpath"]) self.UpdateTaskState(task, "uploadfailed", "Checksum mismatch") return False logging.info("Upload Complete: \033[0;36m%s", task["destinationpath"]) self.UpdateTaskState(task, "uploadcomplete") # Now, the Management Thread takes care about post processing or removing no longer needed content return True def GetTasks(self): """ Returns: The dictionary with all upload tasks """ global Tasks return Tasks def PreProcessUploadedFile(self, task): """ This method initiates pre-processing of an uploaded file. Depending on the *contenttype* different post processing methods are called: * ``"video"``: :meth:`~PreProcessVideo` * ``"artwork"``: :meth:`~PreProcessArtwork` The task must be in ``"uploadcomplete"`` state, otherwise nothing happens but printing an error message. If post processing was successful, the task state gets updated to ``"preprocessed"``. When an error occurred, the state will become ``"invalidcontent"``. Args: task (dict): the task object of an upload-task Returns: ``True`` on success, otherwise ``False`` """ if task["state"] != "uploadcomplete": logging.error( "task must be in \"uploadcomplete\" state for post processing. Actual state was \"%s\". \033[1;30m(Such a mistake should not happen. Anyway, the task won\'t be post process and nothing bad will happen.)", task["state"]) return False # Perform post processing logging.debug("Preprocessing upload %s -> %s", str(task["sourcefilename"]), str(task["destinationpath"])) success = False if task["contenttype"] == "video": success = self.PreProcessVideo(task) elif task["contenttype"] == "artwork": success = self.PreProcessArtwork(task) else: logging.warning( "Unsupported content type of upload: \"%s\" \033[1;30m(Upload will be ignored)", str(task["contenttype"])) self.UpdateTaskState(task, "invalidcontent", "Unsupported content type") return False # Update task state if success == True: newstate = "preprocessed" else: newstate = "invalidcontent" self.UpdateTaskState(task, newstate) return success def PreProcessVideo(self, task): """ Args: task (dict): the task object of an upload-task """ meta = MetaTags() try: meta.Load(task["destinationpath"]) except ValueError: logging.error( "The file \"%s\" uploaded as video to %s is not a valid video or the file format is not supported. \033[1;30m(File will be not further processed.)", task["sourcefilename"], task["destinationpath"]) return False # Get all meta infos (for videos, this does not include any interesting information. # Maybe the only useful part is the Load-method to check if the file is supported by MusicDB #tags = meta.GetAllMetadata() #logging.debug(tags) return True def PreProcessArtwork(self, task): """ Args: task (dict): the task object of an upload-task """ origfile = task["destinationpath"] extension = self.uploadfs.GetFileExtension(origfile) jpegfile = origfile[:-len(extension)] + "jpg" if extension != "jpg": logging.debug( "Transcoding artwork file form %s (\"%s\") to JPEG (\"%s\")", extension, origfile, jpegfile) im = Image.open(origfile) im = im.convert("RGB") im.save(jpegfile, "JPEG", optimize=True, progressive=True) task["artworkfile"] = jpegfile return True def AnnotateUpload(self, uploadid, annotations): """ This method can be used to add additional information to an upload. This can be done during or after the upload process. Args: uploadid (str): ID to identify the upload Returns: ``True`` on success, otherwise ``False`` Raises: TypeError: When *uploadid* is not of type ``str`` ValueError: When *uploadid* is not included in the Task Queue """ try: task = self.GetTaskByID(uploadid) except Exception as e: logging.error( "Internal error while requesting a new chunk of data: %s", str(e)) return False for key, item in annotations.items(): task["annotations"][key] = item self.SaveTask(task) self.NotifyClient("StateUpdate", task) return True def IntegrateUploadedFile(self, uploadid, triggerimport): """ This method integrated the uploaded files into the music directory. The whole file tree will be created following the MusicDB naming scheme. The upload task must be in ``preprocesses`` state. If not, nothing happens. When *triggerimport* is ``true``, the upload manager start importing the music. This happens asynchronously inside the Upload Manager Thread. Args: uploadid (str): ID to identify the upload Returns: ``True`` on success, otherwise ``False`` Raises: TypeError: When *uploadid* is not of type ``str`` ValueError: When *uploadid* is not included in the Task Queue """ try: task = self.GetTaskByID(uploadid) except Exception as e: logging.error( "Internal error while requesting a new chunk of data: %s", str(e)) return False if task["state"] != "preprocessed": logging.warning( "Cannot integrate an upload that is not in \"preprocessed\" state. Upload with ID \"%s\" was in \"%s\" state! \033[1;30m(Nothing will be done)", str(task["id"]), str(task["state"])) return # Perform post processing success = False if task["contenttype"] == "video": success = self.IntegrateVideo(task) elif task["contenttype"] == "artwork": success = True # Importing artwork does not require the file at any specific place else: logging.warning( "Unsupported content type of upload: \"%s\" \033[1;30m(Upload will be ignored)", str(task["contenttype"])) self.UpdateTaskState(task, "integrationfailed", "Unsupported content type") return # Update task state if success == True: newstate = "integrated" else: newstate = "integrationfailed" self.UpdateTaskState(task, newstate) # Trigger import if success == False or triggerimport == False: return # … but only if wanted, and previous step was successful self.UpdateTaskState( task, "startimport") # The upload management thread will do the rest return def IntegrateVideo(self, task): """ When an annotation needed for creating the video file path in the music directory is missing, ``False`` gets returned and an error message written into the log """ uploadedfile = task["destinationpath"] # uploaded file try: artistname = task["annotations"]["artistname"] releasedate = task["annotations"]["release"] videoname = task["annotations"]["name"] except KeyError as e: logging.error( "Collection video information for creating its path name failed with key-error for: %s \033[1;30m(Make sure all important annotations are given to that upload: name, artistname, release)", str(e)) return False fileextension = self.uploadfs.GetFileExtension(uploadedfile) videofile = artistname + "/" + releasedate + " - " + videoname + "." + fileextension task["videofile"] = videofile logging.debug("Integrating upload %s -> %s", str(uploadedfile), str(videofile)) # Check if video file already exists if self.musicfs.Exists(videofile): logging.warning( "File \"%s\" already exists in the music directory! \033[1;30m(It will NOT be overwritten)", str(videofile)) self.NotifyClient("InternalError", task, "File already exists in the music directory") return False # Check if artist directory exists if not self.musicfs.IsDirectory(artistname): logging.info( "Artist directory for \"%s\" does not exist and will be created.", str(artistname)) try: self.musicfs.CreateSubdirectory(artistname) except PermissionError: logging.error( "Creating artist sub-directory \"%s\" failed! - Permission denied! \033[1;30m(MusicDB requires write permission to the music file tree)", str(artistname)) self.NotifyClient( "InternalError", task, "Creating artist directory failed - Permission denied") return False # Copy file, create Artist directory if not existing try: success = self.musicfs.CopyFile(uploadedfile, videofile) except PermissionError: logging.error( "Copying video file to \"%s\" failed! - Permission denied! \033[1;30m(MusicDB requires write permission to the music file tree)", str(videofile)) self.NotifyClient("InternalError", task, "Copying failed - Permission denied") return False if (success): logging.info("New video file at %s", str(videofile)) else: logging.warning("Copying video file to \"%s\" failed!", str(videofile)) return success def ImportVideo(self, task): """ Task state must be ``"startimport"`` and content type must be ``"video"`` Returns: ``True`` on success. """ # Check task state and type if task["state"] != "startimport": logging.warning( "Cannot import an upload that is not in \"startimport\" state. Upload with ID \"%s\" was in \"%s\" state! \033[1;30m(Nothing will be done)", str(task["id"]), str(task["state"])) return False success = False if task["contenttype"] != "video": logging.warning( "Video expected. Actual type of upload: \"%s\" \033[1;30m(No video will be imported)", str(task["contenttype"])) return False # Get important information try: artistname = task["annotations"]["artistname"] videopath = task["videofile"] except KeyError as e: logging.error( "Collecting video information for importing failed with key-error for: %s \033[1;30m(Make sure the artist name is annotated to the upload)", str(e)) return False # Check if the artist already exists in the database - if not, add it artist = self.db.GetArtistByPath(artistname) if artist == None: logging.info("Importing new artist: \"%s\"", artistname) try: self.dbmanager.AddArtist(artistname) except Exception as e: logging.error( "Importing artist \"%s\" failed with error: %s \033[1;30m(Video upload canceled)", str(artistname), str(e)) self.NotifyClient("InternalError", task, "Importing artist failed") return False artist = self.db.GetArtistByPath(artistname) # Import video try: success = self.dbmanager.AddVideo(videopath, artist["id"]) except Exception as e: logging.error( "Importing video \"%s\" failed with error: %s \033[1;30m(Video upload canceled)", str(videopath), str(e)) self.NotifyClient("InternalError", task, "Importing video failed") return False if not success: logging.error( "Importing video \"%s\" failed. \033[1;30m(Video upload canceled)", str(videopath), str(e)) self.NotifyClient("InternalError", task, "Importing video failed") return False # Add origin information to database if annotated try: origin = task["annotations"]["origin"] except KeyError as e: pass else: video = self.db.GetVideoByPath(videopath) video["origin"] = origin self.db.WriteVideo(video) logging.info("Importing Video succeeded") return True def ImportVideoArtwork(self, task): """ Returns: ``True`` on success """ # Check task state and type if task["state"] != "importartwork": logging.warning( "Cannot import artwork that is not in \"importartwork\" state. Upload with ID \"%s\" was in \"%s\" state! \033[1;30m(Nothing will be done)", str(task["id"]), str(task["state"])) return False if task["contenttype"] != "video": logging.warning( "Video expected. Actual type of upload: \"%s\" \033[1;30m(No video will be imported)", str(task["contenttype"])) return False # Start generating the artworks videopath = task["videofile"] framemanager = VideoFrames(self.cfg, self.db) video = self.db.GetVideoByPath(videopath) if not video: logging.error( "Getting video \"%s\" from database failed. \033[1;30m(Artwork import canceled)", str(videopath), str(e)) self.NotifyClient("InternalError", task, "Video artwork import failed") return False retval = framemanager.UpdateVideoFrames(video) if retval == False: logging.error( "Generating video frames and preview failed for video \"%s\". \033[1;30m(Artwork import canceled)", str(videopath)) self.NotifyClient("InternalError", task, "Video artwork import failed") return False logging.info("Importing Video thumbnails and previews succeeded") return True def ImportArtwork(self, task): """ Task state must be ``"startimport"`` and content type must be ``"artwork"`` Returns: ``True`` on success. """ # Check task state and type if task["state"] != "startimport": logging.warning( "Cannot import an upload that is not in \"startimport\" state. Upload with ID \"%s\" was in \"%s\" state! \033[1;30m(Nothing will be done)", str(task["id"]), str(task["state"])) return False success = False if task["contenttype"] != "artwork": logging.warning( "Album artwork expected. Actual type of upload: \"%s\" \033[1;30m(No artwork will be imported)", str(task["contenttype"])) return False # Get important information try: artistname = task["annotations"]["artistname"] albumname = task["annotations"]["albumname"] albumid = task["annotations"]["albumid"] sourcepath = task["artworkfile"] except KeyError as e: logging.error( "Collecting artwork information for importing failed with key-error for: %s \033[1;30m(Make sure the artist and album name is annotated as well as the album ID.)", str(e)) return False # Import artwork awmanager = MusicDBArtwork(self.cfg, self.db) artworkname = awmanager.CreateArtworkName(artistname, albumname) success = awmanager.SetArtwork(albumid, sourcepath, artworkname) if not success: logging.error( "Importing artwork \"%s\" failed. \033[1;30m(Artwork upload canceled)", str(sourcepath)) self.NotifyClient("InternalError", task, "Importing artwork failed") return False # Add origin information to database if annotated try: origin = task["annotations"]["origin"] except KeyError as e: pass else: video = self.db.GetVideoByPath(videopath) video["origin"] = origin self.db.WriteVideo(video) logging.info("Importing Artwork succeeded") return True def RemoveTask(self, task): """ ``tasks/${Task ID}.json`` """ logging.info( "Removing uploaded \"%s\" file and task \"%s\" information.", task["sourcefilename"], task["id"]) datapath = task["destinationpath"] taskpath = "tasks/" + task["id"] + ".json" # if artwork, remove artworkfile as well if task["contenttype"] == "artwork": artworkfile = task["artworkfile"] logging.debug("Removing %s", self.uploadfs.AbsolutePath(artworkfile)) self.uploadfs.RemoveFile(artworkfile) logging.debug("Removing %s", self.uploadfs.AbsolutePath(datapath)) self.uploadfs.RemoveFile(datapath) logging.debug("Removing %s", self.uploadfs.AbsolutePath(taskpath)) self.uploadfs.RemoveFile(taskpath) return True
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