def __init__(self, config, albumpath, title, x, y, w, h): ListView.__init__(self, title, x, y, w, h) ButtonView.__init__(self, align="center") self.AddButton("↑", "Go up") self.AddButton("↓", "Go down") self.AddButton("c", "Clean name") self.AddButton("e", "Edit name") #self.AddButton("␣", "Toggle") #self.AddButton("↵", "Commit") #self.AddButton("␛", "Cancel") # elements are a tuple (original path, new path) self.cfg = config self.fs = Filesystem(self.cfg.music.path) self.albumpath = albumpath self.nameinput = FileNameInput() self.numberinput = TextInput() self.cdnuminput = TextInput() dialogh = 2 + 3 self.dialog = Dialog("Rename Song", self.x, self.y + 1, self.w, dialogh) self.dialog.AddInput("Song name:", self.nameinput, "Correct name only") self.dialog.AddInput("Song number:", self.numberinput, "Song number only") self.dialog.AddInput("CD number:", self.cdnuminput, "CD number or nothing") self.dialogmode = False
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 __init__(self, config): if type(config) != MusicDBConfig: logging.error("Config-class of unknown type!") raise TypeError("config argument not of type MusicDBConfig") logging.debug("Crawler path is %s", CRAWLERPATH) self.config = config self.lycradb = LycraDatabase(self.config.lycra.dbpath) self.fs = Filesystem(CRAWLERPATH) self.crawlers = None
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 __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 __init__(self, config, database): if type(config) != MusicDBConfig: print( "\033[1;31mFATAL ERROR: Config-class of unknown type!\033[0m") raise TypeError("config argument not of type MusicDBConfig") if type(database) != MusicDatabase: print( "\033[1;31mFATAL ERROR: Database-class of unknown type!\033[0m" ) raise TypeError("database argument not of type MusicDatabase") self.db = database self.cfg = config self.fs = Filesystem(self.cfg.music.cache) self.fileprocessor = Fileprocessing(self.cfg.music.cache) self.artworkcache = ArtworkCache(self.cfg.artwork.path)
def Backup(self): """ Creates a backup of the database file. The path of the backup will be the same as the source file. It gets the following extension: ``.YYYY-MM-DDTHH:MM.bak`` Returns: *Nothing* """ backuppath = self.databasepath backuppath += "." backuppath += datetime.now().isoformat(timespec='minutes') backuppath += ".bak" fs = Filesystem() fs.CopyFile(self.databasepath, backuppath) return
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()
def UpgradeMusicDB(self): self.PrintCheckFile("music.db") newversion = 3 # Check version of MusicDB version = self.GetDatabaseVersion(self.db) # Check if good if version == newversion: self.PrintGood() return True # Upgrade if too old self.PrintUpgrade(version, newversion) if version == 1: retval = self.AddMetaTableToDatabase(self.db) if not retval: return False version = 2 # Upgrade to version 3 if version == 2: print("Updating albums-table:\033[0;36m") # Add new column to Albums-Table sql = "ALTER TABLE albums ADD COLUMN added INTEGER DEFAULT 0;" self.db.Execute(sql) # Initialize new column fs = Filesystem(self.cfg.music.path) albums = self.db.GetAlbums() for album in tqdm(albums, unit="albums"): moddate = fs.GetModificationDate(album["path"]) album["added"] = moddate self.db.WriteAlbum(album) # Update version in database self.SetDatabaseVersion(self.db, 3) version = 3 self.PrintGood() return True
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 Backup(self): """ Creates a backup of the configuration file. The path of the backup will be the same as the source file. It gets the following extension: ``.YYYY-MM-DDTHH:MM.bak`` Returns: *Nothing* """ backuppath = self.configpath backuppath += "." backuppath += datetime.now().isoformat(timespec='minutes') backuppath += ".bak" fs = Filesystem() try: fs.CopyFile(self.configpath, backuppath) except Exception as e: logging.warning( "creating backup of configuration failed with error %s. Writing backup to /tmp/configbackup.ini", str(e)) fs.CopyFile(self.configpath, "/tmp/configbackup.ini") return
def __init__(self, config, database): if type(config) != MusicDBConfig: print( "\033[1;31mFATAL ERROR: Config-class of unknown type!\033[0m") raise TypeError("config argument not of type MusicDBConfig") if type(database) != MusicDatabase: print( "\033[1;31mFATAL ERROR: Database-class of unknown type!\033[0m" ) raise TypeError("database argument not of type MusicDatabase") self.db = database self.cfg = config self.mp = None self.fs = Filesystem("/") self.fileprocessor = Fileprocessing("/") self.artworkcache = ArtworkCache(self.cfg.artwork.path) self.SetMountpoint( "/mnt") # initialize self.mp with the default mount point /mnt
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 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.
def StreamingThread(): """ This thread manages the streaming of the songs from the Song Queue to the Icecast server. The thread tracks the played song using the :doc:`/mdbapi/tracker` module. It also tracks randomly added songs assuming the user skips or removes songs that don't fit. Only completely played songs will be considered. Skipped songs will be ignored. The thread triggers the following events: * ``StatusChanged``: When the play-state * ``TimeChanged``: To update the current streaming progress of a song The ``TimeChanged`` event gets triggered approximately every second. """ from lib.stream.icecast import IcecastInterface from mdbapi.tracker import Tracker global Config global RunThread global CommandQueue global State # Create all interfaces that are needed by this Thread musicdb = MusicDatabase(Config.database.path) tracker = Tracker(Config, musicdb) filesystem = Filesystem(Config.music.path) queue = SongQueue(Config, musicdb) randy = Randy(Config, musicdb) icecast = IcecastInterface(port=Config.icecast.port, user=Config.icecast.user, password=Config.icecast.password, mountname=Config.icecast.mountname) icecast.Mute() while RunThread: # Sleep a bit to reduce the load on the CPU. If disconnected, sleep a bit longer if State["isconnected"]: time.sleep(0.1) else: time.sleep(2) # Check connection to Icecast, and connect if disconnected. isconnected = icecast.IsConnected() if State["isconnected"] != isconnected: State["isconnected"] = isconnected Event_StatusChanged() if not isconnected: # Try to connect, and check if connection succeeded in the next turn of the main loop logging.info("Trying to reconnect to Icecast…") icecast.Connect() continue # Get current song that shall be streamed. queueentry = queue.CurrentSong() if queueentry == None or queueentry["entryid"] == None: logging.info("Waiting for 5s to try to get a new song to play.") time.sleep(5) continue mdbsong = musicdb.GetSongById(queueentry["songid"]) songpath = filesystem.AbsolutePath(mdbsong["path"]) # Stream song icecast.UpdateTitle(mdbsong["path"]) logging.debug("Start streaming %s", songpath) timeplayed = 0 lasttimestamp = time.time() for frameinfo in icecast.StreamFile(songpath): # Send every second the estimated time position of the song. if not frameinfo["muted"]: timeplayed += frameinfo["header"]["frametime"] timestamp = time.time() timediff = timestamp - lasttimestamp if timediff >= 1.0: Event_TimeChanged(timeplayed / 1000) lasttimestamp = timestamp # Check if the thread shall be exit if not RunThread: break # read and handle queue commands if there is one if len(CommandQueue) == 0: continue command, argument = CommandQueue.pop(0) if command == "PlayNextSong": logging.debug("Playing next song") break # Stop streaming current song, and start the next one elif command == "Play": logging.debug("Setting Play-State to %s", str(argument)) State["isplaying"] = argument icecast.Mute( not State["isplaying"]) # Mute stream, when not playing Event_StatusChanged() else: # when the for loop streaming the current song gets not left via break, # then the whole song was streamed. So add that song to the trackers list # Also update the last time played information. # In case the loop ended because Icecast failed, update the Status if icecast.IsConnected(): if not queueentry["israndom"]: # do not track random songs tracker.AddSong(queueentry["songid"]) if not Config.debug.disablestats: musicdb.UpdateSongStatistic(queueentry["songid"], "lastplayed", int(time.time())) else: icecast.Mute() State["isplaying"] = False State["isconnected"] = False Event_StatusChanged() # Current song completely streamed. Get next one. # When the song was stopped to shutdown the server, do not skip to the next one # In case the loop stopped because of an Icecast error, stay at the last song. if RunThread and icecast.IsConnected(): queue.NextSong()
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
def MDBM_Main(self, args): if args.test: from tqdm import tqdm print("\033[1;35mTranslating old table to new table …\033[0m") # # Translate old table to new table # sql = "SELECT song, successor, weight FROM graph" # results = self.trackerdb.GetFromDatabase(sql) # for result in results: # for _ in range(result[2]): # self.trackerdb.AddRelation("song", result[0], result[1]) # # Generate artistrelations out of songrelations # sql = "SELECT songida, songidb, weight FROM songrelations" # results = self.trackerdb.GetFromDatabase(sql) # for result in tqdm(results): # artista = self.musicdb.GetSongById(result[0])["artistid"] # artistb = self.musicdb.GetSongById(result[1])["artistid"] # for _ in range(result[2]): # self.trackerdb.AddRelation("artist", artista, artistb) print("\033[1;32mdone!\033[0m") return 0 # Genrate path relative to the music root directory - if possible try: path = self.fs.AbsolutePath( args.path) # Be sure the path is absolute (resolve "./") path = self.fs.RemoveRoot( path) # Now make a relative artist or song path except Exception as e: print( "\033[1;31mInvalid path. Determin relative path to the music root directory failed with error: %s", str(e)) return 1 # Identify target by path and get target ID if self.fs.IsFile(path): mdbsong = self.musicdb.GetSongByPath(path) if not mdbsong: print( "\033[1;31mPath %s is a file, but it is not a song file!\033[0m" % (path)) target = "song" targetid = mdbsong["id"] elif self.fs.IsDirectory(path): mdbartist = self.musicdb.GetArtistByPath(path) if not mdbartist: print( "\033[1;31mPath %s is a directory, but it is not an artist directory!\033[0m" % (path)) target = "artist" targetid = mdbartist["id"] else: print("\033[1;31mPath %s does not exist!\033[0m" % (path)) return 1 # Get target relation print( "\033[1;34mGetting \033[1;36m%s\033[1;34m relations from database … \033[0m" % (target)) relations = self.trackerdb.GetRelations(target, targetid) print("\033[1;36m%d\033[1;34m entries found.\033[0m" % (len(relations))) # Apply parameters if args.show: self.ShowRelations(target, targetid, relations) if args.dot: rootfs = Filesystem() dotfile = rootfs.AbsolutePath(args.dot) self.GenerateDotFile(target, targetid, relations, dotfile) return 0
def __init__(self, filename): Config.__init__(self, filename) self.fs = Filesystem("/") logging.info("Reading and checking MusicDB Configuration") # [meta] self.meta = META() self.meta.version = self.Get(int, "meta", "version", 1) if self.meta.version < 2: logging.warning("Version of musicdb.ini is too old. Please update the MusicDB Configuration!") # [server] self.server = SERVER() self.server.pidfile = self.Get(str, "server", "pidfile", "/opt/musicdb/data/musicdb.pid") self.server.statedir = self.Get(str, "server", "statedir", "/opt/musicdb/data/mdbstate") self.server.fifofile = self.Get(str, "server", "fifofile", "/opt/musicdb/data/musicdb.fifo") # [websocket] self.websocket = WEBSOCKET() self.websocket.address = self.Get(str, "websocket","address", "127.0.0.1") self.websocket.port = self.Get(int, "websocket","port", 9000) self.websocket.url = self.Get(str, "websocket","url", "wss://localhost:9000") self.websocket.opentimeout = self.Get(int, "websocket","opentimeout", 10) self.websocket.closetimeout = self.Get(int, "websocket","closetimeout", 5) self.websocket.apikey = self.Get(str, "websocket","apikey", None) if not self.websocket.apikey: logging.warning("Value of [websocket]->apikey is not set!") # [TLS] self.tls = TLS() self.tls.cert = self.GetFile( "tls", "cert", "/dev/null") self.tls.key = self.GetFile( "tls", "key", "/dev/null") if self.tls.cert == "/dev/null" or self.tls.key == "/dev/null": logging.warning("You have to set a valid TLS certificate and key!") # [database] self.database = DATABASE() self.database.path = self.GetFile( "database", "path", "/opt/musicdb/data/music.db") # [music] self.music = MUSIC() self.music.path = self.GetDirectory("music", "path", "/var/music") self.music.owner = self.Get(str, "music", "owner", "user") self.music.group = self.Get(str, "music", "group", "musicdb") try: pwd.getpwnam(self.music.owner) except KeyError: logging.warning("The group name for [music]->owner is not an existing UNIX user!") try: grp.getgrnam(self.music.group) except KeyError: logging.warning("The group name for [music]->group is not an existing UNIX group!") ignorelist = self.Get(str, "music", "ignoreartists","lost+found") ignorelist = ignorelist.split("/") self.music.ignoreartists = [item.strip() for item in ignorelist] ignorelist = self.Get(str, "music", "ignorealbums", "") ignorelist = ignorelist.split("/") self.music.ignorealbums = [item.strip() for item in ignorelist] ignorelist = self.Get(str, "music", "ignoresongs", ".directory / desktop.ini / Desktop.ini / .DS_Store / Thumbs.db") ignorelist = ignorelist.split("/") self.music.ignoresongs = [item.strip() for item in ignorelist] # [artwork] self.artwork = ARTWORK() self.artwork.path = self.GetDirectory("artwork", "path", "/opt/musicdb/data/artwork") self.artwork.scales = self.Get(int, "artwork", "scales", "50, 150, 500", islist=True) for s in [50, 150, 500]: if not s in self.artwork.scales: logging.error("Missing scale in [artwork]->scales: The web UI expects a scale of %d (res: %dx%d)", s, s, s) self.artwork.manifesttemplate=self.GetFile( "artwork", "manifesttemplate", "/opt/musicdb/server/manifest.txt", logging.warning) # a missing manifest does not affect the main functionality self.artwork.manifest = self.Get(str, "artwork", "manifest", "/opt/musicdb/server/webui/manifest.appcache") # [extern] self.extern = EXTERN() self.extern.configtemplate = self.GetFile( "extern", "configtemplate","/opt/musicdb/server/share/extconfig.ini") self.extern.statedir = self.Get(str, "extern", "statedir", ".mdbstate") self.extern.configfile = self.Get(str, "extern", "configfile", "config.ini") self.extern.songmap = self.Get(str, "extern", "songmap", "songmap.csv") # [tracker] self.tracker = TRACKER() self.tracker.dbpath = self.GetFile( "tracker", "dbpath", "/opt/musicdb/data/tracker.db") self.tracker.cuttime = self.Get(int, "tracker", "cuttime", "30") # [lycra] self.lycra = LYCRA() self.lycra.dbpath = self.GetFile( "lycra", "dbpath", "/opt/musicdb/data/lycra.db") # [Icecast] self.icecast = ICECAST() self.icecast.port = self.Get(int, "Icecast", "port", "6666") self.icecast.user = self.Get(str, "Icecast", "user", "source") self.icecast.password = self.Get(str, "Icecast", "password", "hackme") self.icecast.mountname = self.Get(str, "Icecast", "mountname","/stream") # [MusicAI] self.musicai = MUSICAI() self.musicai.modelpath = self.GetDirectory("MusicAI", "modelpath", "/opt/musicdb/data/musicai/models") self.musicai.tmppath = self.GetDirectory("MusicAI", "tmppath", "/opt/musicdb/data/musicai/tmp") self.musicai.logpath = self.GetDirectory("MusicAI", "logpath", "/opt/musicdb/data/musicai/log") self.musicai.specpath = self.GetDirectory("MusicAI", "spectrogrampath", "/opt/musicdb/data/musicai/spectrograms") self.musicai.slicesize = self.Get(int, "MusicAI", "slicesize", 128) self.musicai.epoch = self.Get(int, "MusicAI", "epoch", 20) self.musicai.batchsize = self.Get(int, "MusicAI", "batchsize", 128) self.musicai.usegpu = self.Get(bool,"MusicAI", "usegpu", True) self.musicai.modelname = self.Get(str, "MusicAI", "modelname", "MusicGenre") self.musicai.genrelist = self.Get(str, "MusicAI", "genrelist", None, islist=True) # [Randy] self.randy = RANDY() self.randy.nodisabled = self.Get(bool, "Randy", "nodisabled", True) self.randy.nohated = self.Get(bool, "Randy", "nohated", True) self.randy.minsonglen = self.Get(int, "Randy", "minsonglen", 120) self.randy.maxsonglen = self.Get(int, "Randy", "maxsonglen", 600) self.randy.songbllen = self.Get(int, "Randy", "songbllen", 50) self.randy.albumbllen = self.Get(int, "Randy", "albumbllen", 20) self.randy.artistbllen = self.Get(int, "Randy", "artistbllen", 10) self.randy.maxblage = self.Get(int, "Randy", "maxblage", 24) # [log] self.log = LOG() self.log.logfile = self.Get(str, "log", "logfile", "stderr") self.log.loglevel = self.Get(str, "log", "loglevel", "WARNING") if not self.log.loglevel in ["DEBUG", "INFO", "WARNING", "ERROR"]: logging.error("Invalid loglevel for [log]->loglevel. Loglevel must be one of the following: DEBUG, INFO, WARNING, ERROR") self.log.debugfile = self.Get(str, "log", "debugfile", None) if self.log.debugfile == "/dev/null": self.log.debugfile = None self.log.ignore = self.Get(str, "log", "ignore", None, islist=True) # [debug] self.debug = DEBUG() self.debug.disablestats = self.Get(int, "debug", "disablestats", 0) self.debug.disabletracker = self.Get(int, "debug", "disabletracker", 0) self.debug.disableai = self.Get(int, "debug", "disableai", 1) self.debug.disabletagging = self.Get(int, "debug", "disabletagging", 0) logging.info("\033[1;32mdone")
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.
def __init__(self, root="/"): self.fs = Filesystem(root) self.file = None # the mutagenx file handler/object self.ftype = None # contains the identifier of the filetype (m4a, mp3, flac) self.extension = None # contains the actual file extension self.path = None # contains the full path of the file for debugging
class MetaTags(object): """ This class can be used to access the metadata of the music files. Args: root (str): All paths used in this class are relative to this path. Default is ``"/"``. """ def __init__(self, root="/"): self.fs = Filesystem(root) self.file = None # the mutagenx file handler/object self.ftype = None # contains the identifier of the filetype (m4a, mp3, flac) self.extension = None # contains the actual file extension self.path = None # contains the full path of the file for debugging def Load(self, path): """ Supported file extensions: * For MPEG4: ``mp4``, ``aac``, ``m4a`` * For MPEG3: ``mp3``, ``MP3`` * For FLAC: ``flac`` Args: path (str): path to the song file that shall be loaded Returns: *Nothing* Raises: TypeError: If path is not a string ValueError: If file not exist or file cannot be read. ValueError: If the file extension or file format is not supported """ logging.debug("Analysing file from %s", path) if type(path) != str: raise TypeError("Path must be a string!") # do some filename-management self.path = self.fs.AbsolutePath(path) if not self.fs.IsFile(self.path): raise ValueError("File \"%s\" does not exist" % (self.path)) # remenber the path for debugging self.extension = self.fs.GetFileExtension(self.path) # normalize the extension if self.extension in ["mp4", "aac", "m4a"]: self.ftype = "m4a" elif self.extension == "flac": self.ftype = "flac" elif self.extension in ["mp3", "MP3"]: self.ftype = "mp3" else: self.path = None raise ValueError("Unsupported file-extension \"%s\" of \"%s\"" % (self.extension, path)) logging.debug("Loading file of type %s from \"%s\"", self.ftype, self.path) # open the file if self.ftype == "flac": self.file = FLAC(self.path) elif self.ftype == "mp3": self.file = MP3(self.path) elif self.ftype == "m4a": self.file = MP4(self.path) else: self.path = None raise ValueError("Unsupported file-type %s" % (self.ftype)) def GetAllMetadata(self): """ This method collects as much information as possible from a file. The information gets stored in a dictionary. * ``song``: :meth:`~lib.metatags.MetaTags.GetSongname` * ``album``: :meth:`~lib.metatags.MetaTags.GetAlbumname` * ``artist``: :meth:`~lib.metatags.MetaTags.GetArtistname` * ``releaseyear``: :meth:`~lib.metatags.MetaTags.GetReleaseyear` * ``cdnumber``: :meth:`~lib.metatags.MetaTags.GetCDNumber` * ``songnumber``: :meth:`~lib.metatags.MetaTags.GetTracknumber` * ``origin``: :meth:`~lib.metatags.MetaTags.GetOrigin` * ``playtime``: :meth:`~lib.metatags.MetaTags.GetPlaytime` * ``bitrate``: :meth:`~lib.metatags.MetaTags.GetBitrate` * ``lyrics``: :meth:`~lib.metatags.MetaTags.GetLyrics` """ metadata = {} metadata["song"] = self.GetSongname() metadata["album"] = self.GetAlbumname() metadata["artist"] = self.GetArtistname() metadata["releaseyear"] = self.GetReleaseyear() metadata["cdnumber"] = self.GetCDNumber() metadata["songnumber"] = self.GetTracknumber() metadata["origin"] = self.GetOrigin() metadata["playtime"] = self.GetPlaytime() metadata["bitrate"] = self.GetBitrate() metadata["lyrics"] = self.GetLyrics() return metadata def StoreArtwork(self, imgfilename): """ This method stores an artwork from the metadata into a file. If there is no artwork in the metadata, ``False`` gets returned. If the file already exists, it gets overwritten. Args: imgfilename (str): Absolute path to an image file to store the image at Returns: ``True`` on success, otherwise ``False`` """ try: if self.ftype == "mp3": # Source: http://stackoverflow.com/questions/6171565/how-do-i-read-album-artwork-using-python artwork = self.file[ "APIC:"] # access APIC frame and grab the image with open(imgfilename, "wb") as img: img.write(artwork.data) return True elif self.ftype == "m4a": artwork = self.file[b"covr"][0] with open(imgfilename, "wb") as img: img.write(artwork) return True elif self.ftype == "flac": artwork = self.file.pictures[0].data with open(imgfilename, "wb") as img: img.write(artwork) return True except KeyError: logging.debug("File \"%s\" does not have a Cover-Image", self.path) except Exception as e: logging.warning( "Storing artwork to \"\033[0;33m%s\033[1;33m\" failed with error \"%s\"!", imgfilename, e) return False def GetSongname(self): """ This method returns the name of a song Returns: The song name as string, or ``None`` if entry does not exist """ try: if self.ftype == "m4a": return self.file[b"\xa9nam"][0] elif self.ftype == "mp3": name = self.file["TIT2"][0] # check if the unicodes were read wrong try: name = name.encode("latin-1").decode("utf-8") except: pass return name elif self.ftype == "flac": return self.file["Title"][0] except KeyError: logging.debug("File \"%s\" does not have a songname", self.path) return None return None def GetAlbumname(self): """ This method returns the name of the album Returns: The album name as string, or ``None`` if entry does not exist """ if self.ftype == "m4a": if b"\xa9alb" in self.file: return self.file[b"\xa9alb"][0] else: logging.debug("File \"%s\" does not have an albumname", self.path) return None elif self.ftype == "mp3": # some songs dont have this tag if "TALB" in self.file: name = self.file["TALB"][0] else: logging.debug("File \"%s\" does not have an albumname", self.path) return None # check if the unicodes were read wrong try: name = name.encode("latin-1").decode("utf-8") except: pass return name elif self.ftype == "flac": return self.file["Album"][0] logging.debug("File \"%s\" does not have an albumname", self.path) return None def GetArtistname(self): """ This method returns the name of the artist Returns: The artist name as string, or ``None`` if entry does not exist """ try: if self.ftype == "m4a": return self.file[b"\xa9ART"][0] elif self.ftype == "mp3": name = self.file["TPE1"][0] # check if the unicodes were read wrong try: name = name.encode("latin-1").decode("utf-8") except: pass return name elif self.ftype == "flac": return self.file["Artist"][0] except KeyError: logging.debug("File \"%s\" does not have an artistname", self.path) return None return None def GetReleaseyear(self): """ This method returns the release year Returns: The release year as integer, or ``0`` if entry does not exist """ date = 0 try: if self.ftype == "m4a": date = self.file[b"\xa9day"][0] date = date.split("-")[0] # get just the year elif self.ftype == "mp3": if not "TDRC" in self.file: return int(date) date = self.file["TDRC"][0] date = date.text try: date = date.split("-")[0] except: pass elif self.ftype == "flac": date = self.file["Date"][0] date = date.split("-")[0] # get just the year except KeyError: logging.debug("File \"%s\" does not have a release year", self.path) return None try: date = int(date) except ValueError: logging.debug("File \"%s\" has a malformed date value!", self.path) data = 0 return date def GetCDNumber(self): """ This method returns the CD Number. The CD number is only stored in MP4 metadata. For all other formats, this method always returns ``0`` Returns: The CD number as integer, or ``0`` if entry does not exist """ number = 0 if self.ftype == "m4a": try: number = self.file[b"disk"][0][0] except KeyError as e: pass elif self.ftype == "mp3": return 0 elif self.ftype == "flac": return 0 return int(number) def GetTracknumber(self): """ This method returns the track number. Returns: The track number as integer, or ``0`` if entry does not exist """ number = 0 try: if self.ftype == "m4a": number = self.file[b"trkn"][0][0] elif self.ftype == "mp3": number = self.file["TRCK"][0] elif self.ftype == "flac": number = self.file["Tracknumber"][0] except KeyError: logging.debug("File \"%s\" does not have a tracknumber!", self.path) return 0 try: number = number.split("/")[0] except: pass # mutagenx has problems with unicode. In some cases, the tracknumber was not read correctly # that's sooooo embarrassing for a python3 lib try: number = int(number) except: logging.debug("File \"%s\" has a malformated tracknumber!", self.path) number = 0 return number def GetOrigin(self): """ This method tries to determine where the file come from. The following origins can be detected: * ``"iTunes"`` * ``"bandcamp"`` * ``"music163"`` aka 网易云音乐 * ``"CD"`` as fallback for unknown *flac* files * ``"internet"`` as fallback for any other unknown files Returns: Name of the origin as string """ # check m4a if self.ftype == "m4a": if b"----:com.apple.iTunes:iTunNORM" in self.file: return "iTunes" if b"----:com.apple.iTunes:iTunSMPB" in self.file: return "iTunes" if b"apID" in self.file: return "iTunes" if b"\xa9cmt" in self.file: comment = self.file[b"\xa9cmt"][0] comment = comment.lower() index = comment.find("bandcamp") return "bandcamp" # Check mp3 elif self.ftype == "mp3": # usually, music.163 uses the TPUB frame … try: if self.file["TPUB"][0] == "网易云音乐": return "music163" except KeyError: pass # … but not always :( - There is a secound way: COMM contains a key try: if "COMM::\'eng\'" in self.file: if "163 key" in self.file["COMM::\'eng\'"][0]: return "music163" elif "bandcamp" in self.file["COMM::\'eng\'"][0]: return "bandcamp" except KeyError: pass # Check flac elif self.ftype == "flac": try: comments = self.file["Comment"] for comment in comments: if "bandcamp" in comment: return "bandcamp" except: pass return "CD" # "No" origin? So it's from the internet return "internet" def GetLyrics(self): """ This method tries to get lyrics from the metadata. If on lyrics were found, ``None`` gets returned. Returns: The lyrics as string, or ``None`` if entry does not exist """ if self.ftype == "m4a": return None elif self.ftype == "flac": try: lyrics = self.file[b"\xa9lyr"][0] except: return None elif self.ftype == "mp3": try: lyrics = self.file.tags.getall('USLT')[0].text if type(lyrics) == str and len(lyrics) > 0: return lyrics except: return None return None def GetPlaytime(self): """ This method tries to determine the playtime of a song. It first calls :meth:`~lib.metatags.MetaTags.AnalysePlaytime` which gets the playtime direct form file using ``ffprobe``. Only if this method fails, the playtime gets read from the meta data. Returns: playtime in second, or ``0`` if there is no way to get the time """ time = 0 if self.ftype in ["m4a", "mp3", "flac"]: try: analtime = round(self.AnalysePlaytime()) except: analtime = None # never trust metadata, if we got duration, reading the metadata is not needed anymore if analtime: time = analtime else: try: time = round(self.file.info.length) except: time = 0 return int(time) def AnalysePlaytime(self): """ Analyses the playtime of a file using ``ffprobe``. The corresponding command line is the following: .. code-block:: bash ffprobe -v error -show_entries format=duration -print_format default=noprint_wrappers=1:nokey=1 $PATH Returns: The duration in seconds (as float) or ``None`` if the analysis fails """ process = [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-print_format", "default=noprint_wrappers=1:nokey=1", self.path ] logging.debug("Running duration analysis: %s", str(process)) try: retval = subprocess.check_output(process) logging.debug("Analysis returned %s", str(retval)) retval = float(retval) except Exception as e: logging.error("Error \"%s\" while executing: %s", str(e), str(process)) return None logging.debug("Analysis returned duration of %fs", retval) return retval def GetBitrate(self): """ This method returns the bitrate of the file. Returns: bitrate as integer or ``0`` """ bitrate = 0 if self.ftype in ["m4a", "mp3", "flac"]: try: bitrate = self.file.info.bitrate except: bitrate = 0 return int(bitrate)
def __init__(self, config, database): self.config = config self.musicdb = database self.fs = Filesystem(self.config.music.path) self.trackerdb = TrackerDatabase(self.config.tracker.dbpath)
class tracker(MDBModule): def __init__(self, config, database): self.config = config self.musicdb = database self.fs = Filesystem(self.config.music.path) self.trackerdb = TrackerDatabase(self.config.tracker.dbpath) def GenerateDotFile(self, target, targetid, relations, dotfile): """ This method generates a dot file visualizing the relations between the target and the related songs or artists. Also, the weights get visualized by the thickness of the edges of the generated graph. .. warning:: If the file exists, its content gets replaced! Args: target (str): The target all IDs apply to. Can be ``"song"`` or ``"artist"``. targetid (int): ID of the song or artists, the relations belong to relations: A list of relations as returned by :meth:`lib.db.tracker.TrackerDatabase.GetRelations` dotfile (str): A path to write the dotfile to. Returns: ``True`` on success. If there is any error, ``False`` gets returned. """ if target not in ["song", "artist"]: return False # give the IDs a name if target == "song": targetname = self.musicdb.GetSongById(targetid)["name"] elif target == "artist": targetname = self.musicdb.GetArtistById(targetid)["name"] else: return False for relation in relations: if target == "song": relation["name"] = self.musicdb.GetSongById( relation["id"])["name"] elif target == "artist": relation["name"] = self.musicdb.GetArtistById( relation["id"])["name"] else: return False dot = open(dotfile, "w") dot.write("digraph songenv {\n") # Related Song dot.write("\tsubgraph {\n") dot.write("\t\trank = same; ") for relation in relations: dot.write("\"" + relation["name"] + "\"; ") dot.write("\n\t}\n") # center dot.write("\tsubgraph {\n") dot.write("\t\trank = same; " + targetname + ";\n") dot.write("\t}\n") dot.write("\tedge [ arrowhead=\"none\" ; len=7 ];\n\n") # edges for relation in relations: penwidth = max(1, int(relation["weight"] / 10)) dot.write("\t\"" + relation["name"] + "\" -> \"" + targetname + "\" [penwidth=" + str(penwidth) + "];\n") dot.write("}\n\n") dot.close() return True def ShowRelations(self, target, targetid, relations): """ This method lists all entries in the relations list returned by the database for the given target ID Args: target (str): The target all IDs apply to. Can be ``"song"`` or ``"artist"``. targetid (int): ID of the song or artists, the relations belong to relations: A list of relations as returned by :meth:`lib.db.tracker.TrackerDatabase.GetRelations` Returns: ``True`` on success. If there is any error, ``False`` gets returned. """ if target not in ["song", "artist"]: return False for relation in relations: # Get Weight weight = relation["weight"] # Get Name if target == "song": name = self.musicdb.GetSongById(relation["id"])["name"] elif target == "artist": name = self.musicdb.GetArtistById(relation["id"])["name"] else: return False # Get Color if target == "song": colorweight = weight elif target == "artist": colorweight = int(weight / 5) else: return False if colorweight <= 1: color = "\033[1;30m" elif colorweight == 2: color = "\033[1;34m" elif colorweight == 3: color = "\033[1;36m" else: color = "\033[1;37m" # Print print(" \033[1;35m[%2d] %s%s" % (weight, color, name)) return True @staticmethod def MDBM_CreateArgumentParser(parserset, modulename): parser = parserset.add_parser( modulename, help="access information from the song tracker") parser.set_defaults(module=modulename) parser.add_argument("-s", "--show", action="store_true", help="Show the related songs or artists") parser.add_argument( "-d", "--dot", action="store", metavar="dotfile", type=str, help= "if this option is given, a dot-file will be generated with the results" ) parser.add_argument("--test", action="store_true", help="for testing - it's magic! read the code") parser.add_argument( "path", help= "Path to the song or artist on that the previos options will be applied" ) # return exit-code def MDBM_Main(self, args): if args.test: from tqdm import tqdm print("\033[1;35mTranslating old table to new table …\033[0m") # # Translate old table to new table # sql = "SELECT song, successor, weight FROM graph" # results = self.trackerdb.GetFromDatabase(sql) # for result in results: # for _ in range(result[2]): # self.trackerdb.AddRelation("song", result[0], result[1]) # # Generate artistrelations out of songrelations # sql = "SELECT songida, songidb, weight FROM songrelations" # results = self.trackerdb.GetFromDatabase(sql) # for result in tqdm(results): # artista = self.musicdb.GetSongById(result[0])["artistid"] # artistb = self.musicdb.GetSongById(result[1])["artistid"] # for _ in range(result[2]): # self.trackerdb.AddRelation("artist", artista, artistb) print("\033[1;32mdone!\033[0m") return 0 # Genrate path relative to the music root directory - if possible try: path = self.fs.AbsolutePath( args.path) # Be sure the path is absolute (resolve "./") path = self.fs.RemoveRoot( path) # Now make a relative artist or song path except Exception as e: print( "\033[1;31mInvalid path. Determin relative path to the music root directory failed with error: %s", str(e)) return 1 # Identify target by path and get target ID if self.fs.IsFile(path): mdbsong = self.musicdb.GetSongByPath(path) if not mdbsong: print( "\033[1;31mPath %s is a file, but it is not a song file!\033[0m" % (path)) target = "song" targetid = mdbsong["id"] elif self.fs.IsDirectory(path): mdbartist = self.musicdb.GetArtistByPath(path) if not mdbartist: print( "\033[1;31mPath %s is a directory, but it is not an artist directory!\033[0m" % (path)) target = "artist" targetid = mdbartist["id"] else: print("\033[1;31mPath %s does not exist!\033[0m" % (path)) return 1 # Get target relation print( "\033[1;34mGetting \033[1;36m%s\033[1;34m relations from database … \033[0m" % (target)) relations = self.trackerdb.GetRelations(target, targetid) print("\033[1;36m%d\033[1;34m entries found.\033[0m" % (len(relations))) # Apply parameters if args.show: self.ShowRelations(target, targetid, relations) if args.dot: rootfs = Filesystem() dotfile = rootfs.AbsolutePath(args.dot) self.GenerateDotFile(target, targetid, relations, dotfile) return 0
class SongView(ListView, ButtonView): def __init__(self, config, albumpath, title, x, y, w, h): ListView.__init__(self, title, x, y, w, h) ButtonView.__init__(self, align="center") self.AddButton("↑", "Go up") self.AddButton("↓", "Go down") self.AddButton("c", "Clean name") self.AddButton("e", "Edit name") #self.AddButton("␣", "Toggle") #self.AddButton("↵", "Commit") #self.AddButton("␛", "Cancel") # elements are a tuple (original path, new path) self.cfg = config self.fs = Filesystem(self.cfg.music.path) self.albumpath = albumpath self.nameinput = FileNameInput() self.numberinput = TextInput() self.cdnuminput = TextInput() dialogh = 2 + 3 self.dialog = Dialog("Rename Song", self.x, self.y + 1, self.w, dialogh) self.dialog.AddInput("Song name:", self.nameinput, "Correct name only") self.dialog.AddInput("Song number:", self.numberinput, "Song number only") self.dialog.AddInput("CD number:", self.cdnuminput, "CD number or nothing") self.dialogmode = False def FindSongs(self): files = self.fs.GetFiles(self.albumpath, self.cfg.music.ignoresongs) songs = [] # Only take audio files into account - ignore images and booklets for f in files: extension = self.fs.GetFileExtension(f) if extension in ["mp3", "flac", "m4a", "aac"]: songs.append((f, f)) return songs def CleanFileNames(self): for index, element in enumerate(self.elements): origpath = element[0] path = element[1] directory = self.fs.GetDirectory(path) filename = self.fs.GetFileName(path) extension = self.fs.GetFileExtension(path) seg = self.FileNameSegments(filename) newfilename = filename[seg["number"]:seg["gap"]] newfilename += filename[seg["name"]:] newfilename = unicodedata.normalize("NFC", newfilename) newpath = os.path.join(directory, newfilename + "." + extension) self.elements[index] = (origpath, newpath) # no path, no file extension! # returns indices of name segments def FileNameSegments(self, filename): seg = {} # Start of song number m = re.search("\d", filename) if m: seg["number"] = m.start() else: seg["number"] = 0 # End of song number (1 space is necessary) m = re.search("\s", filename[seg["number"]:]) if m: seg["gap"] = seg["number"] + 1 + m.start() else: seg["gap"] = seg["number"] + 1 # Find start of song name m = re.search("\w", filename[seg["gap"]:]) if m: seg["name"] = seg["gap"] + m.start() else: seg["name"] = seg["gap"] return seg def UpdateUI(self): newsongs = self.FindSongs() self.SetData(newsongs) def onDrawElement(self, element, number, maxwidth): oldpath = element[0] path = element[1] width = maxwidth filename = self.fs.GetFileName(path) extension = self.fs.GetFileExtension(path) analresult = self.fs.AnalyseSongFileName(filename + "." + extension) # Render validation if not analresult: validation = "\033[1;31m ✘ " else: validation = "\033[1;32m ✔ " width -= 3 # Render file name renderedname = "" width -= len(filename) seg = self.FileNameSegments(filename) renderedname += "\033[1;31m\033[4m" + filename[ 0:seg["number"]] + "\033[24m" renderedname += "\033[1;34m" + filename[seg["number"]:seg["gap"]] renderedname += "\033[1;31m\033[4m" + filename[ seg["gap"]:seg["name"]] + "\033[24m" renderedname += "\033[1;34m" + filename[seg["name"]:] # Render file extension fileextension = "." + extension fileextension = fileextension[:width] fileextension = fileextension.ljust(width) return validation + "\033[1;34m" + renderedname + "\033[1;30m" + fileextension def Draw(self): if self.dialogmode == True: pass else: ListView.Draw(self) x = self.x + 1 y = self.y + self.h - 1 w = self.w - 2 ButtonView.Draw(self, x, y, w) def HandleKey(self, key): if self.dialogmode == True: if key == "enter": # Commit dialog inputs songname = self.nameinput.GetData() songnumber = self.numberinput.GetData() cdnumber = self.cdnuminput.GetData() element = self.dialog.oldelement path = element[ 1] # the editable path is 1, 0 is the original path directory = self.fs.GetDirectory(path) extension = self.fs.GetFileExtension(path) if len(songnumber) == 1: songnumber = "0" + songnumber if cdnumber: songnumber = cdnumber + "-" + songnumber newpath = os.path.join( directory, songnumber + " " + songname + "." + extension) self.SetSelectedData((element[0], newpath)) self.dialogmode = False self.Draw() # show list view instead of dialog elif key == "escape": self.dialogmode = False self.dialog.oldname = None # prevent errors by leaving a clean state self.Draw() # show list view instead of dialog # reject changes else: self.dialog.HandleKey(key) else: if key == "up" or key == "down": ListView.HandleKey(self, key) elif key == "c": self.CleanFileNames() elif key == "e": # edit name element = self.GetSelectedData() editpath = element[1] filename = self.fs.GetFileName(editpath) seg = self.FileNameSegments(filename) songnumber = filename[seg["number"]:seg["gap"]].strip() songname = filename[seg["name"]:].strip() if "-" in songnumber: cdnumber = songnumber.split("-")[0].strip() songnumber = songnumber.split("-")[1].strip() else: cdnumber = "" self.nameinput.SetData(songname) self.numberinput.SetData(songnumber) self.cdnuminput.SetData(cdnumber) self.dialog.oldelement = element self.dialog.Draw() self.dialogmode = 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.Get(str, "server", "statedir", "/opt/musicdb/data/mdbstate") self.server.fifofile = self.Get(str, "server", "fifofile", "/opt/musicdb/data/musicdb.fifo") # [websocket] self.websocket = WEBSOCKET() self.websocket.address = self.Get(str, "websocket", "address", "127.0.0.1") self.websocket.port = self.Get(int, "websocket", "port", 9000) self.websocket.url = self.Get(str, "websocket", "url", "wss://localhost:9000") self.websocket.opentimeout = self.Get(int, "websocket", "opentimeout", 10) self.websocket.closetimeout = self.Get(int, "websocket", "closetimeout", 5) self.websocket.apikey = self.Get(str, "websocket", "apikey", None) if not self.websocket.apikey: logging.warning("Value of [websocket]->apikey is not set!") # [TLS] self.tls = TLS() self.tls.cert = self.GetFile("tls", "cert", "/dev/null") self.tls.key = self.GetFile("tls", "key", "/dev/null") if self.tls.cert == "/dev/null" or self.tls.key == "/dev/null": logging.warning("You have to set a valid TLS certificate and key!") # [database] self.database = DATABASE() self.database.host = self.GetFile("database", "dbhost", "localhost") self.database.port = self.GetFile("database", "dbport", 3307) self.database.name = self.GetFile("database", "dbname", "musicdb") self.database.user = self.GetFile("database", "dbuser", "root") self.database.password = self.GetFile("database", "dbpass", "") self.database.charset = self.GetFile("database", "charset", "utf8") # [music] self.music = MUSIC() self.music.path = self.GetDirectory("music", "path", "/var/music") self.music.owner = self.Get(str, "music", "owner", "user") self.music.group = self.Get(str, "music", "group", "musicdb") try: pwd.getpwnam(self.music.owner) except KeyError: logging.warning( "The group name for [music]->owner is not an existing UNIX user!" ) try: grp.getgrnam(self.music.group) except KeyError: logging.warning( "The group name for [music]->group is not an existing UNIX group!" ) ignorelist = self.Get(str, "music", "ignoreartists", "lost+found") ignorelist = ignorelist.split("/") self.music.ignoreartists = [item.strip() for item in ignorelist] ignorelist = self.Get(str, "music", "ignorealbums", "") ignorelist = ignorelist.split("/") self.music.ignorealbums = [item.strip() for item in ignorelist] ignorelist = self.Get( str, "music", "ignoresongs", ".directory / desktop.ini / Desktop.ini / .DS_Store / Thumbs.db") ignorelist = ignorelist.split("/") self.music.ignoresongs = [item.strip() for item in ignorelist] # [artwork] self.artwork = ARTWORK() self.artwork.path = self.GetDirectory("artwork", "path", "/opt/musicdb/data/artwork") self.artwork.scales = self.Get(int, "artwork", "scales", "50, 150, 500", islist=True) for s in [50, 150, 500]: if not s in self.artwork.scales: logging.error( "Missing scale in [artwork]->scales: The web UI expects a scale of %d (res: %dx%d)", s, s, s) self.artwork.manifesttemplate = self.GetFile( "artwork", "manifesttemplate", "/opt/musicdb/server/manifest.txt", logging.warning ) # a missing manifest does not affect the main functionality self.artwork.manifest = self.Get( str, "artwork", "manifest", "/opt/musicdb/server/webui/manifest.appcache") # [extern] self.extern = EXTERN() self.extern.configtemplate = self.GetFile( "extern", "configtemplate", "/opt/musicdb/server/share/extconfig.ini") self.extern.statedir = self.Get(str, "extern", "statedir", ".mdbstate") self.extern.configfile = self.Get(str, "extern", "configfile", "config.ini") self.extern.songmap = self.Get(str, "extern", "songmap", "songmap.csv") # [tracker] self.tracker = TRACKER() self.tracker.host = self.GetFile("tracker", "dbhost", "localhost") self.tracker.port = self.GetFile("tracker", "dbport", 3307) self.tracker.name = self.GetFile("tracker", "dbname", "tracker") self.tracker.user = self.GetFile("tracker", "dbuser", "root") self.tracker.password = self.GetFile("tracker", "dbpass", "") self.tracker.charset = self.GetFile("tracker", "charset", "utf8") # [lycra] self.lycra = LYCRA() self.lycra.host = self.GetFile("lycra", "dbhost", "localhost") self.lycra.port = self.GetFile("lycra", "dbport", 3307) self.lycra.name = self.GetFile("lycra", "dbname", "lycra") self.lycra.user = self.GetFile("lycra", "dbuser", "root") self.lycra.password = self.GetFile("lycra", "dbpass", "") self.lycra.charset = self.GetFile("lycra", "charset", "utf8") # [Icecast] self.icecast = ICECAST() self.icecast.port = self.Get(int, "Icecast", "port", "6666") self.icecast.user = self.Get(str, "Icecast", "user", "source") self.icecast.password = self.Get(str, "Icecast", "password", "hackme") self.icecast.mountname = self.Get(str, "Icecast", "mountname", "/stream") # [MusicAI] self.musicai = MUSICAI() self.musicai.modelpath = self.GetDirectory( "MusicAI", "modelpath", "/opt/musicdb/data/musicai/models") self.musicai.tmppath = self.GetDirectory( "MusicAI", "tmppath", "/opt/musicdb/data/musicai/tmp") self.musicai.logpath = self.GetDirectory( "MusicAI", "logpath", "/opt/musicdb/data/musicai/log") self.musicai.specpath = self.GetDirectory( "MusicAI", "spectrogrampath", "/opt/musicdb/data/musicai/spectrograms") self.musicai.slicesize = self.Get(int, "MusicAI", "slicesize", 128) self.musicai.epoch = self.Get(int, "MusicAI", "epoch", 20) self.musicai.batchsize = self.Get(int, "MusicAI", "batchsize", 128) self.musicai.usegpu = self.Get(bool, "MusicAI", "usegpu", True) self.musicai.modelname = self.Get(str, "MusicAI", "modelname", "MusicGenre") self.musicai.genrelist = self.Get(str, "MusicAI", "genrelist", None, islist=True) # [Randy] self.randy = RANDY() self.randy.nodisabled = self.Get(bool, "Randy", "nodisabled", True) self.randy.nohated = self.Get(bool, "Randy", "nohated", True) self.randy.minsonglen = self.Get(int, "Randy", "minsonglen", 120) self.randy.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) # [log] self.log = LOG() self.log.logfile = self.Get(str, "log", "logfile", "stderr") self.log.loglevel = self.Get(str, "log", "loglevel", "WARNING") if not self.log.loglevel in ["DEBUG", "INFO", "WARNING", "ERROR"]: logging.error( "Invalid loglevel for [log]->loglevel. Loglevel must be one of the following: DEBUG, INFO, WARNING, ERROR" ) self.log.debugfile = self.Get(str, "log", "debugfile", None) if self.log.debugfile == "/dev/null": self.log.debugfile = None self.log.ignore = self.Get(str, "log", "ignore", None, islist=True) # [debug] self.debug = DEBUG() self.debug.disablestats = self.Get(int, "debug", "disablestats", 0) self.debug.disabletracker = self.Get(int, "debug", "disabletracker", 0) self.debug.disableai = self.Get(int, "debug", "disableai", 1) self.debug.disabletagging = self.Get(int, "debug", "disabletagging", 0) logging.info("\033[1;32mdone") def GetDirectory(self, section, option, default, logger=logging.error): """ This method gets a string from the config file and checks if it is an existing directory. If not it prints an error. Except printing the error nothing is done. The \"invalid\" will be returned anyway, because it may be OK that the directory does not exist yet. Args: section (str): Section of an ini-file option (str): Option inside the section of an ini-file default (str): Default directory if option is not set in the file logger: Logging-handler. Default is logging.error. logging.warning can be more appropriate in some situations. Returns: The value of the option set in the config-file or the default value. """ path = self.Get(str, section, option, default) if not self.fs.IsDirectory(path): logger("Value of [%s]->%s does not address an existing directory.", section, option) return path # return path anyway, it does not matter if correct or not. Maybe it will be created later on. def GetFile(self, section, option, default, logger=logging.error): """ This method gets a string from the config file and checks if it is an existing file. If not it prints an error. Except printing the error nothing is done. The \"invalid\" will be returned anyway, because it may be OK that the file does not exist yet. Args: section (str): Section of an ini-file option (str): Option inside the section of an ini-file default (str): Default file path if option is not set in the file logger: Logging-handler. Default is logging.error. logging.warning can be more appropriate in some situations. Returns: The value of the option set in the config-file or the default value. """ path = self.Get(str, section, option, default) if not self.fs.IsFile(path): logger("Value of [%s]->%s does not address an existing file.", section, option) return path # return path anyway, it does not matter if correct or not. Maybe it will be created later on.
def __init__(self, artworkdir): self.artworkroot = Filesystem(artworkdir)
class Lycra(object): """ This class does the main lyrics management. Args: config: MusicDB Configuration object. Raises: TypeError: when *config* is not of type :class:`~lib.cfg.musicdb.MusicDBConfig` """ def __init__(self, config): if type(config) != MusicDBConfig: logging.error("Config-class of unknown type!") raise TypeError("config argument not of type MusicDBConfig") logging.debug("Crawler path is %s", CRAWLERPATH) self.config = config self.lycradb = LycraDatabase(self.config.lycra.dbpath) self.fs = Filesystem(CRAWLERPATH) self.crawlers = None def LoadCrawlers(self): """ This method loads all crawlers inside the crawler directory. .. warning:: Changes at crawler may not be recognized until the whole application gets restarted. Only new added crawler gets loaded. Already loaded crawler are stuck at Pythons module cache. Returns: ``None`` """ # Get a list of all modules crawlerfiles = self.fs.GetFiles(".") modulenames = [ self.fs.GetFileName(x) for x in crawlerfiles if self.fs.GetFileExtension(x) == "py" ] if len(modulenames) == 0: logging.warning( "No modules found in \"%s\"! \033[1;30m(… but crawler cache is still usable.)", self.fs.AbsolutePath(CRAWLERPATH)) self.crawlers = None return None # load all modules self.crawlers = [] for modulename in modulenames: modfp, modpath, moddesc = imp.find_module(modulename, [CRAWLERPATH]) try: logging.debug("Loading %s …", str(modpath)) module = imp.load_module(modulename, modfp, modpath, moddesc) except Exception as e: logging.error( "Loading Crawler %s failed with error: %s! \033[1;30m(Ignoring this specific Crawler)", str(e), str(modpath)) finally: # Since we may exit via an exception, close fp explicitly. if modfp: modfp.close() crawler = {} crawler["module"] = module crawler["modulename"] = modulename self.crawlers.append(crawler) if len(self.crawlers) == 0: logging.warning( "No crawler loaded from \"%s\"! \033[1;30m(… but crawler cache is still usable.)", self.fs.AbsolutePath(CRAWLERPATH)) self.crawlers = None return None def RunCrawler(self, crawler, artistname, albumname, songname, songid): """ This method runs a specific crawler. This crawler gets all information available to search for a specific songs lyric. This method is for class internal use. When using this class, call :meth:`~mdbapi.lycra.Lycra.CrawlForLyrics` instead of calling this method directly. Before calling this method, :meth:`~mdbapi.lycra.Lycra.LoadCrawlers` must be called. The crawler base class :class:`lib.crawlerapi.LycraCrawler` catches all exceptions so that they do not net to be executed in an try-except environment. Args: crawler (str): Name of the crawler. If it addresses the file ``lib/crawler/example.py`` the name is ``example`` artistname (str): The name of the artist as stored in the MusicDatabase albumname (str): The name of the album as stored in the MusicDatabase songname (str): The name of the song as stored in the MusicDatabase songid (int): The ID of the song to associate the lyrics with the song Returns: ``None`` """ crawlerclass = getattr(crawler["module"], crawler["modulename"]) crawlerentity = crawlerclass(self.lycradb) crawlerentity.Crawl(artistname, albumname, songname, songid) return None def CrawlForLyrics(self, artistname, albumname, songname, songid): """ Loads all crawler from the crawler directory via :meth:`~mdbapi.lycra.Lycra.LoadCrawlers` and runs them via :meth:`~mdbapi.lycra.Lycra.RunCrawler`. Args: artistname (str): The name of the artist as stored in the music database albumname (str): The name of the album as stored in the music database songname (str): The name of the song as stored in the music database songid (int): The ID of the song to associate the lyrics with the song Returns: ``False`` if something went wrong. Otherwise ``True``. (This is *no* indication that there were lyrics found!) """ # Load / Reload crawlers try: self.LoadCrawlers() except Exception as e: logging.error( "Loading Crawlers failed with error \"%s\"! \033[1;30m(… but crawler cache is still usable.)", str(e)) return False if not self.crawlers: return False for crawler in self.crawlers: self.RunCrawler(crawler, artistname, albumname, songname, songid) return True def GetLyrics(self, songid): """ This method returns the lyrics of a song. See :meth:`lib.db.lycradb.LycraDatabase.GetLyricsFromCache` """ return self.lycradb.GetLyricsFromCache(songid)
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()
def __init__(self, root="/"): Filesystem.__init__(self, root)
class MusicCache(object): """ Args: config: MusicDB configuration object database: MusicDB database Raises: TypeError: when *config* or *database* not of type :class:`~lib.cfg.musicdb.MusicDBConfig` or :class:`~lib.db.musicdb.MusicDatabase` """ def __init__(self, config, database): if type(config) != MusicDBConfig: print( "\033[1;31mFATAL ERROR: Config-class of unknown type!\033[0m") raise TypeError("config argument not of type MusicDBConfig") if type(database) != MusicDatabase: print( "\033[1;31mFATAL ERROR: Database-class of unknown type!\033[0m" ) raise TypeError("database argument not of type MusicDatabase") self.db = database self.cfg = config self.fs = Filesystem(self.cfg.music.cache) self.fileprocessor = Fileprocessing(self.cfg.music.cache) self.artworkcache = ArtworkCache(self.cfg.artwork.path) def GetAllCachedFiles(self): """ This method returns three lists of paths of all files inside the cache. The tree lists are the following: #. All artist directories #. All album paths #. All song paths Returns: A tuple of three lists: (Artist-Paths, Album-Paths, Song-Paths) Example: .. code-block:: python (artists, albums, songs) = cache.GetAllCachedFiles() print("Albums in cache:") for path in albums: name = musicdb.GetAlbumByPath(path)["name"] print(" * " + name) print("Files in cache:") for path in songs: print(" * " + path) """ # Get all files from the cache artistpaths = self.fs.ListDirectory() albumpaths = self.fs.GetSubdirectories(artistpaths) songpaths = self.fs.GetFiles(albumpaths) return artistpaths, albumpaths, songpaths def RemoveOldArtists(self, cartistpaths, mdbartists): """ This method removes all cached artists when they are not included in the artist list from the database. ``cartistpaths`` must be a list of artist directories with the artist ID as directory name. From these paths, a list of available artist ids is made and compared to the artist ids from the list of artists returned by the database (stored in ``mdbartists``) Is there a path/ID in ``cartistpaths`` that is not in the ``mdbartists`` list, the directory gets removed. The pseudo code can look like this: .. code-block:: python for path in cartistpaths: if int(path) not in [mdbartists["id"]]: self.fs.RemoveDirectory(path) Args: cartistpaths (list): a list of artist directories in the cache mdbartists (list): A list of artist rows from the Music Database Returns: *Nothing* """ artistids = [artist["id"] for artist in mdbartists] cachedids = [int(path) for path in cartistpaths] for cachedid in cachedids: if cachedid not in artistids: self.fs.RemoveDirectory(str(cachedid)) def RemoveOldAlbums(self, calbumpaths, mdbalbums): """ This method compares the available album paths from the cache with the entries from the Music Database. If there are albums that do not match the database entries, then the cached album will be removed. Args: calbumpaths (list): a list of album directories in the cache (scheme: "ArtistID/AlbumID") mdbalbums (list): A list of album rows from the Music Database Returns: *Nothing* """ # create "artistid/albumid" paths validpaths = [ os.path.join(str(album["artistid"]), str(album["id"])) for album in mdbalbums ] for cachedpath in calbumpaths: if cachedpath not in validpaths: self.fs.RemoveDirectory(cachedpath) def RemoveOldSongs(self, csongpaths, mdbsongs): """ This method compares the available song paths from the cache with the entries from the Music Database. If there are songs that do not match the database entries, then the cached song will be removed. Args: csongpaths (list): a list of song files in the cache (scheme: "ArtistID/AlbumID/SongID:Checksum.mp3") mdbsongs (list): A list of song rows from the Music Database Returns: *Nothing* """ # create song paths validpaths = [] for song in mdbsongs: path = self.GetSongPath(song) if path: validpaths.append(path) for cachedpath in csongpaths: if cachedpath not in validpaths: self.fs.RemoveFile(cachedpath) def GetSongPath(self, mdbsong, absolute=False): """ This method returns a path following the naming scheme for cached songs (``ArtistID/AlbumID/SongID:Checksum.mp3``). It is not guaranteed that the file actually exists. Args: mdbsong: Dictionary representing a song entry form the Music Database absolute: Optional argument that can be set to ``True`` to get an absolute path, not relative to the cache directory. Returns: A (possible) path to the cached song (relative to the cache directory, ``absolute`` got not set to ``True``). ``None`` when there is no checksum available. The checksum is part of the file name. """ # It can happen, that there is no checksum for a song. # For example when an old installation of MusicDB got not updated properly. # Better check if the checksum is valid to avoid any further problems. if len(mdbsong["checksum"]) != 64: logging.error( "Invalid checksum of song \"%s\": %s \033[1;30m(64 hexadecimal digit SHA265 checksum expected. Try \033[1;34mmusicdb repair --checksums \033[1;30mto fix the problem.)", mdbsong["path"], mdbsong["checksum"]) return None path = os.path.join(str(mdbsong["artistid"]), str(mdbsong["albumid"])) # ArtistID/AlbumID path = os.path.join(path, str(mdbsong["id"])) # ArtistID/AlbumID/SongID path += ":" + mdbsong[ "checksum"] + ".mp3" # ArtistID/AlbumID/SongID:Checksum.mp3 if absolute: path = self.fs.AbsolutePath(path) return path def Add(self, mdbsong): """ This method checks if the song exists in the cache. When it doesn't, the file will be created (this may take some time!!). This process is done in the following steps: #. Check if song already cached. If it does, the method returns with ``True`` #. Create directory tree if it does not exist. (``ArtistID/AlbumID/``) #. Convert song to mp3 (320kbp/s) and write it into the cache. #. Update ID3 tags. (ID3v2.3.0, 500x500 pixel artworks) Args: mdbsong: Dictionary representing a song entry form the Music Database Returns: ``True`` on success, otherwise ``False`` """ path = self.GetSongPath(mdbsong) if not path: return False # check if file exists, and create it when not. if self.fs.IsFile(path): return True # Create directory if not exists directory = os.path.join(str(mdbsong["artistid"]), str(mdbsong["albumid"])) # ArtistID/AlbumID if not self.fs.IsDirectory(directory): self.fs.CreateSubdirectory(directory) # Create new mp3 srcpath = os.path.join(self.cfg.music.path, mdbsong["path"]) dstpath = self.fs.AbsolutePath(path) retval = self.fileprocessor.ConvertToMP3(srcpath, dstpath) if retval == False: logging.error("Converting %s to %s failed!", srcpath, dstpath) return False os.sync() # Optimize new mp3 mdbalbum = self.db.GetAlbumById(mdbsong["albumid"]) mdbartist = self.db.GetArtistById(mdbsong["artistid"]) try: relartworkpath = self.artworkcache.GetArtwork( mdbalbum["artworkpath"], "500x500") except Exception as e: logging.error( "Getting artwork from cache failed with exception: %s!", str(e)) logging.error(" Artwork: %s, Scale: %s", mdbalbum["artworkpath"], "500x500") return False absartworkpath = os.path.join(self.cfg.artwork.path, relartworkpath) retval = self.fileprocessor.OptimizeMP3Tags( mdbsong, mdbalbum, mdbartist, srcpath=path, dstpath=path, absartworkpath=absartworkpath, forceID3v230=True) if retval == False: logging.error("Optimizing %s failed!", path) return False return True