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 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 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