diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5da7ef5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +*.pyo + diff --git a/musicman.py b/musicman.py index 5f7cb0f..a19dc51 100755 --- a/musicman.py +++ b/musicman.py @@ -10,6 +10,7 @@ import time #from path import path import musicman +import mutagen #from musicman.utils.constants import ( # VERSION, @@ -101,13 +102,21 @@ def getEmptyDirs(path, excludeDirs=[]): if not directories and not filenames: yield root -def getSong(file): +def getSong(file, orgDir=None, tmpDir=None, dstDir=None): from musicman.utils.metadata import MetaTag from musicman.utils.constants import INTERNAL_FORMATS global config - song = {} + global originDir + global workingDir + global targetDir + global targetFormat + + song = {} + orgDir = originDir if orgDir is None else orgDir + tmpDir = workingDir if tmpDir is None else tmpDir + dstDir = targetDir if dstDir is None else dstDir + - #if file.endswith(INTERNAL_FORMATS): if (os.path.splitext(file)[1][1:] in INTERNAL_FORMATS): metadata = MetaTag(file) @@ -150,6 +159,12 @@ def getSong(file): song['outPath'] = os.path.join(song['artistName'], song['albumName']) outFile = '{0}'.format(song['titleName']) + song['originFile'] = os.path.join(orgDir, song['outPath'], song['outFile']) + song['workingFile'] = os.path.join(tmpDir, song['outPath'], song['outFile']) + song['targetFile'] = os.path.join(dstDir, song['outPath'], song['outFile']) + song['originExt'] = os.path.splitext(file)[1] + song['targetExt'] = '.' + targetFormat + return song else: clearLine() @@ -157,31 +172,20 @@ def getSong(file): print(" Supported:", ", ".join(INTERNAL_FORMATS)) sys.exit(2) -def getSongPaths(song): - global originDir - global workingDir - global targetDir - - print("DEBUG:", originDir) - print("DEBUG:", workingDir) - print("DEBUG:", targetDir) - - sys.exit(0) - -def cleanLibrary(originDir, excludeDirs=[], act=False, verbose=0): +def cleanLibrary(rootDir, excludeDirs=[], act=False, verbose=0): global config if excludeDirs is None: excludeDirs=[] - print("Clean Dirs") + print("Clean Dirs:", rootDir) - #while getEmptyDirs(originDir, excludeDirs) != [] and act == True and tries > 0: + #while getEmptyDirs(rootDir, excludeDirs) != [] and act == True and tries > 0: if act: try: - while getEmptyDirs(originDir, excludeDirs).__next__(): - for path in getEmptyDirs(originDir, excludeDirs): + while getEmptyDirs(rootDir, excludeDirs).__next__(): + for path in getEmptyDirs(rootDir, excludeDirs): print("Removing:", path) if act: @@ -193,64 +197,56 @@ def cleanLibrary(originDir, excludeDirs=[], act=False, verbose=0): except StopIteration: pass else: - for path in getEmptyDirs(originDir, excludeDirs): + for path in getEmptyDirs(rootDir, excludeDirs): print("Empty:", path) - print("Processing Complete!") + #print("Processing Complete!") -def renameLibrary(originDir, excludeDirs=[], act=False, verbose=0): +def renameLibrary(rootDir, excludeDirs=[], act=False, verbose=0): global config if excludeDirs is None: excludeDirs=[] - print("Rename") - for file in getLibrary(originDir, excludeDirs): - #print("File:", file) + print("Renaming:", rootDir) + for file in getLibrary(rootDir, excludeDirs): if (os.path.isdir(os.path.dirname(file)) and os.path.isfile(file)): clearLine() print("Processing:", os.path.dirname(os.path.dirname(file)), next(spinner), end="\r") - song = getSong(file) + song = getSong(file, rootDir) - #print("song", song) if song['metadata'] is None: if verbose > 2: clearLine() print("Skipping: {0} due to lack of metadata".format(file)) else: - filename = os.path.basename(file) - file_ext = os.path.splitext(filename)[1] - - if not os.path.isfile(os.path.join(originDir, song['outPath'], song['outFile'] + file_ext)): + if not os.path.isfile(song['originFile'] + song['originExt']): clearLine() print("Found:", file) if verbose > 0: - print(" New:", os.path.join(originDir, song['outPath'], song['outFile'] + file_ext)) + print(" New:", song['originFile'] + song['originExt']) if (act): if verbose > 1: - print("Renaming: \"{0}\" -> \"{1}\"".format(file, os.path.join(originDir, song['outPath'], song['outFile'] + file_ext))) + print("Renaming: \"{0}\" -> \"{1}\"".format(file, song['originFile'] + song['originExt'])) try: - os.renames(file, - os.path.join(originDir, - song['outPath'], - song['outFile'] + file_ext)) + os.renames(file, song['originFile'] + song['originExt']) except OSError as err: print("ERROR: Failed to move:", err) sys.exit(5) clearLine() - print("Processing Complete!") + #print("Processing Complete!") -def findUntagged(originDir, excludeDirs=[], verbose=0): +def findUntagged(rootDir, excludeDirs=[], verbose=0): global config if excludeDirs is None: excludeDirs=[] - print("Find Untagged") - for file in getLibrary(originDir, excludeDirs): + print("Find Untagged:", rootDir) + for file in getLibrary(rootDir, excludeDirs): if (os.path.isdir(os.path.dirname(file)) and os.path.isfile(file)): clearLine() print("Processing:", os.path.dirname(os.path.dirname(file)), next(spinner), end="\r") @@ -260,49 +256,43 @@ def findUntagged(originDir, excludeDirs=[], verbose=0): if song['metadata'] is None: clearLine() print("Untagged: {0}".format(file)) + clearLine() - print("Processing Complete!") + #print("Processing Complete!") -def findNew(originDir, workingDir, targetDir, targetFormat, excludeDirs=[], verbose=0): +def findNew(rootDir, tmpDir, dstDir, dstFormat, excludeDirs=[], verbose=0): global config if excludeDirs is None: excludeDirs=[] print("Find New Media") - for file in getLibrary(originDir, excludeDirs): + for file in getLibrary(rootDir, excludeDirs): if (os.path.isdir(os.path.dirname(file)) and os.path.isfile(file)): clearLine() print("Processing:", os.path.dirname(os.path.dirname(file)), next(spinner), end="\r") song = getSong(file) - getSongPaths(song) - if song['metadata'] is None: if verbose > 2: clearLine() print("Skipping: {0} due to lack of metadata".format(file)) else: - filename = os.path.basename(file) - file_ext = os.path.splitext(filename)[1] - - if not (os.path.isfile(os.path.join(workingDir, song['outPath'], song['outFile'] + '.' + config['target']['format'])) or - os.path.isfile(os.path.join(targetDir, song['outPath'], song['outFile'] + '.' + config['target']['format']))): + if not (os.path.isfile(song['workingFile'] + song['targetExt']) or + os.path.isfile(song['targetFile'] + song['targetExt'])): + clearLine() print("New:", file) if verbose > 0: - if not os.path.isfile(os.path.join(targetDir, song['outPath'], song['outFile'] + '.' + config['target']['format'])): - print(" No:", os.path.join(targetDir, song['outPath'], song['outFile'] + '.' + config['target']['format'])) - elif not os.path.isfile(os.path.join(workingDir, song['outPath'], song['outFile'] + '.' + config['target']['format'])): - print(" No:", os.path.join(workingDir, song['outPath'], song['outFile'] + '.' + config['target']['format'])) - #if not os.path.isfile(os.path.join(originDir, song['outPath'], song['outFile'] + file_ext)): - # clearLine() - # print("New:", file) + if not os.path.isfile(os.path.isfile(song['workingFile'] + song['targetExt'])): + print(" No:", song['workingFile'] + song['targetExt']) + elif not os.path.isfile(os.path.isfile(song['targetFile'] + song['targetExt'])): + print(" No:", song['targetFile'] + song['targetExt']) - clearLine() - print("Processing Complete!") + #clearLine() + #print("Processing Complete!") -def syncWorking(workingDir, targetDir, excludeDirs=[], act=False, verbose=0): +def syncWorking(tmpDir, excludeDirs=[], act=False, verbose=0): global config if excludeDirs is None: @@ -310,10 +300,9 @@ def syncWorking(workingDir, targetDir, excludeDirs=[], act=False, verbose=0): print("Sync Target Media") - for file in getLibrary(workingDir, excludeDirs): + for file in getLibrary(tmpDir, excludeDirs): if (os.path.isdir(os.path.dirname(file)) and os.path.isfile(file)): clearLine() - #print("Checking:", file) print("Processing:", os.path.dirname(os.path.dirname(file)), next(spinner), end="\r") song = getSong(file) @@ -326,17 +315,84 @@ def syncWorking(workingDir, targetDir, excludeDirs=[], act=False, verbose=0): filename = os.path.basename(file) file_ext = os.path.splitext(filename)[1] - if not os.path.isfile(os.path.join(targetDir, song['outPath'], song['outFile'] + file_ext)): + if not os.path.isfile(song['targetDir'] + song['originExt']): print("Sync:", file) if verbose > 0: - print(" To:", os.path.join(targetDir, song['outPath'], song['outFile'] + file_ext)) + print(" To:", song['targetFile'], song['originExt']) if act: print("would've acted") + + + #if not os.path.isfile(os.path.join(destDir, song['outPath'], song['outFile'] + file_ext)): + # print("Sync:", file) + # if verbose > 0: + # print(" To:", os.path.join(destDir, song['outPath'], song['outFile'] + file_ext)) + # + # if act: + # print("would've acted") - clearLine() - print("Processing Complete!") + #clearLine() + #print("Processing Complete!") +def convertMedia(rootDir, tmpDir, dstDir, dstFormat, excludeDirs=[], act=False, verbose=0): + import subprocess + from musicman.utils.copytags import ( + copy_tags + ) + + if excludeDirs is None: + excludeDirs=[] + + print("Convert Library Media") + + for file in getLibrary(rootDir, excludeDirs): + if (os.path.isdir(os.path.dirname(file)) and os.path.isfile(file)): + clearLine() + print("Processing:", os.path.dirname(os.path.dirname(file)), next(spinner), end="\r") + + song = getSong(file, rootDir, tmpDir, targetDir) + + if song['metadata'] is None: + if verbose > 2: + clearLine() + print("Skipping: {0} due to lack of metadata".format(file)) + else: + if not (os.path.isfile(song['workingFile'] + song['targetExt']) or + os.path.isfile(song['targetFile'] + song['targetExt'])): + clearLine() + print("New:", file) + if verbose > 2: + if not os.path.isfile(os.path.isfile(song['workingFile'] + song['targetExt'])): + print(" No:", song['workingFile'] + song['targetExt']) + elif not os.path.isfile(os.path.isfile(song['targetFile'] + song['targetExt'])): + print(" No:", song['targetFile'] + song['targetExt']) + + if act: + #print("would've acted") + if not os.path.isdir(os.path.dirname(song['workingFile'])): + os.makedirs(os.path.dirname(song['workingFile'])) + #subprocess.call("ffmpeg", "-loglevel", "quiet", "-stats", "-i", file, "-vn", "-c:a", "libfdk_aac", "-vbr", "5", "-nostdin", "-y", song['workingFile'] + song['targetExt']) + try: + subprocess.call('/usr/bin/ffmpeg -loglevel quiet -stats -i "{0}" -vn -c:a libfdk_aac -vbr 5 -nostdin -y "{1}"'.format(file, song['workingFile'] + song['targetExt']), shell=True) + #new = MetaData(song['workingFile'] + song['targetExt']) + #new.update(song['metadata']) + #new.save() + + #old = mutagen.File(file) + #new = mutagen.File(song['workingFile'] + song['targetExt']) + #new.update(old) + #new.save() + copy_tags(file, song['workingFile'] + song['targetExt']) + except KeyboardInterrupt: + if os.path.isfile(song['workingFile'] + song['targetExt']): + os.remove(song['workingFile'] + song['targetExt']) + clearLine() + print(end='\r') + print("Aborted by user") + sys.exit(0) + + clearLine() if __name__ == '__main__': global opt @@ -377,9 +433,13 @@ if __name__ == '__main__': try: if opt.mode == 'clean': cleanLibrary(originDir, opt.excludeDirs, opt.act, opt.verbose) + cleanLibrary(targetDir, opt.excludeDirs, opt.act, opt.verbose) + cleanLibrary(workingDir, opt.excludeDirs, opt.act, opt.verbose) elif opt.mode == 'rename': renameLibrary(originDir, opt.excludeDirs, opt.act, opt.verbose) + renameLibrary(targetDir, opt.excludeDirs, opt.act, opt.verbose) + renameLibrary(workingDir, opt.excludeDirs, opt.act, opt.verbose) elif opt.mode == 'scan': if opt.scanMode is None: @@ -387,15 +447,23 @@ if __name__ == '__main__': sys.exit(1) elif opt.scanMode == 'untagged': findUntagged(originDir, opt.excludeDirs, opt.verbose) + findUntagged(targetDir, opt.excludeDirs, opt.verbose) + findUntagged(workingDir, opt.excludeDirs, opt.verbose) elif opt.scanMode == 'new': findNew(originDir, workingDir, targetDir, targetFormat, opt.excludeDirs, opt.verbose) elif opt.mode == 'sync': syncWorking(workingDir, targetDir, opt.excludeDirs, opt.act, opt.verbose) + + elif opt.mode == 'convert': + convertMedia(originDir, workingDir, targetDir, targetFormat, opt.excludeDirs, opt.act, opt.verbose) except KeyboardInterrupt: clearLine() print(end='\r') print("Aborted by user") + sys.exit(0) + clearLine() + print("Processing Complete!") #for file in getLibrary(config['origin']['path']): diff --git a/musicman/utils/__init__.py b/musicman/utils/__init__.py index 0370c73..9b8846b 100644 --- a/musicman/utils/__init__.py +++ b/musicman/utils/__init__.py @@ -23,6 +23,8 @@ def parse_args(): clean = subparsers.add_parser('clean', help='Clean Mode', description='Library cleanup operations. Allows you to remove empty directories.') clean_lib = clean.add_argument_group('Library Options') clean_lib.add_argument('-o', '--origin', help="Origin Directory for Library (overrides config)", metavar="DIR", type=str, dest="originDir") + clean_lib.add_argument('-t', '--target', help="Target Directory for Library (overrides config)", metavar="DIR", type=str, dest="targetDir") + clean_lib.add_argument('-w', '--work', help="Working Directory for new processed files (overrides config)", metavar="DIR", type=str, dest="workingDir") clean_lib.add_argument('-e', '--exclude', help="Exclude Directory from origin (can be used multiple times)", metavar="DIR", dest='excludeDirs', action='append') clean_act = clean.add_argument_group('Action Options') clean_act.add_argument('-g', '--go', help="Clean up library (default just shows what would be done)", dest="act", action='store_true') @@ -32,8 +34,8 @@ def parse_args(): convert_lib = convert.add_argument_group('Library Options') convert_lib.add_argument('-o', '--origin', help="Origin Directory for Library (overrides config)", metavar="DIR", type=str, dest="originDir") convert_lib.add_argument('-t', '--target', help="Target Directory for Library (overrides config)", metavar="DIR", type=str, dest="targetDir") + convert_lib.add_argument('-w', '--work', help="Working Directory for new processed files (overrides config)", metavar="DIR", type=str, dest="workingDir") convert_lib.add_argument('--format', help="Target directory library format", metavar="FORMAT", type=str, dest="targetFormat") - convert_lib.add_argument('-w', '--work', help="Working Directory for new processed files (overrides config)", metavar="DIR", type=str, dest="workDir") convert_lib.add_argument('-e', '--exclude', help="Exclude Directory from origin (can be used multiple times)", metavar="DIR", dest='excludeDirs', action='append') convert_act = convert.add_argument_group('Action Options') convert_act.add_argument('-g', '--go', help="Convert media (default just shows new items)", dest="act", action='store_true') @@ -54,8 +56,8 @@ def parse_args(): scan_lib = scan.add_argument_group('Library Options') scan_lib.add_argument('-o', '--origin', help="Origin Directory for Library (overrides config)", metavar="DIR", type=str, dest="originDir") scan_lib.add_argument('-t', '--target', help="Target Directory for Library (overrides config)", metavar="DIR", type=str, dest="targetDir") + scan_lib.add_argument('-w', '--work', help="Working Directory for new processed files (overrides config)", metavar="DIR", type=str, dest="workingDir") scan_lib.add_argument('--format', help="Target directory library format", metavar="FORMAT", type=str, dest="targetFormat") - scan_lib.add_argument('-w', '--work', help="Working Directory for new processed files (overrides config)", metavar="DIR", type=str, dest="workDir") scan_lib.add_argument('-e', '--exclude', help="Exclude Directory from origin (can be used multiple times)", metavar="DIR", dest='excludeDirs', action='append') scan_subparsers = scan.add_subparsers(title='Scan Modes', dest='scanMode') scan_untagged = scan_subparsers.add_parser('untagged', help='Find untagged media', description='Scans for untagged or insufficiently tagged media in the library.') @@ -63,7 +65,7 @@ def parse_args(): sync = subparsers.add_parser('sync', help='Sync Mode', description='Moves media from working DIR into target DIR') sync_lib = sync.add_argument_group('Library Options') - sync_lib.add_argument('-o', '--origin', help="Origin Directory for Library (overrides config)", metavar="DIR", type=str, dest="originDir") + #sync_lib.add_argument('-o', '--origin', help="Origin Directory for Library (overrides config)", metavar="DIR", type=str, dest="originDir") sync_lib.add_argument('-t', '--target', help="Target Directory for Library (overrides config)", metavar="DIR", type=str, dest="targetDir") sync_lib.add_argument('-w', '--work', help="Working Directory for new processed files (overrides config)", metavar="DIR", type=str, dest="workingDir") sync_lib.add_argument('-e', '--exclude', help="Exclude Directory from origin (can be used multiple times)", metavar="DIR", dest='excludeDirs', action='append') diff --git a/musicman/utils/__pycache__/__init__.cpython-33.pyc b/musicman/utils/__pycache__/__init__.cpython-33.pyc deleted file mode 100644 index ba5ef8c..0000000 Binary files a/musicman/utils/__pycache__/__init__.cpython-33.pyc and /dev/null differ diff --git a/musicman/utils/copytags.py b/musicman/utils/copytags.py new file mode 100644 index 0000000..bdfe142 --- /dev/null +++ b/musicman/utils/copytags.py @@ -0,0 +1,125 @@ +import os +import os.path +import sys +import re +#import logging + +from mutagen import File as MusicFile +from mutagen.aac import AACError + +try: + from collections import MutableMapping +except ImportError: + from UserDict import DictMixin as MutableMapping +try: + from tqdm import tqdm +except ImportError: + def tqdm(iterable, *args, **kwargs): + return iterable + +# Set up logging +#logFormatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s') +#logger = logging.getLogger(__name__) +#logger.setLevel(logging.INFO) +#logger.handlers = [] +#logger.addHandler(logging.StreamHandler()) +#for handler in logger.handlers: +# handler.setFormatter(logFormatter) + +class AudioFile(MutableMapping): + """A simple class just for tag editing. + + No internal mutagen tags are exposed, or filenames or anything. So + calling clear() won't destroy the filename field or things like + that. Use it like a dict, then .write() it to commit the changes. + When saving, tags that cannot be saved by the file format will be + skipped with a debug message, since this is a common occurrance + with MP3/M4A. + + Optional argument blacklist is a list of regexps matching + non-transferrable tags. They will effectively be hidden, nether + settable nor gettable. + + Or grab the actual underlying mutagen format object from the + .data field and get your hands dirty. + + """ + def __init__(self, filename, blacklist=[], easy=True): + self.filename = filename + self.data = MusicFile(self.filename, easy=easy) + if self.data is None: + raise ValueError("Unable to identify %s as a music file" % (repr(filename))) + # Also exclude mutagen's internal tags + self.blacklist = [ re.compile("^~") ] + blacklist + def __getitem__(self, item): + if self.blacklisted(item): + #logger.debug("Attempted to get blacklisted key: %s." % repr(item)) + return + else: + return self.data.__getitem__(item) + def __setitem__(self, item, value): + if self.blacklisted(item): + #logger.debug("Attempted to set blacklisted key: %s." % repr(item)) + #print("DEBUG: Attempted to set blacklisted key: %s." % repr(item)) + return + else: + try: + return self.data.__setitem__(item, value) + except KeyError: + #print("Skipping unsupported tag {0} for file type {0}".format(item, type(self.data))) + pass + #logger.debug("Skipping unsupported tag %s for file type %s", + # item, type(self.data)) + def __delitem__(self, item): + if self.blacklisted(item): + #logger.debug("Attempted to del blacklisted key: %s." % repr(item)) + return + else: + return self.data.__delitem__(item) + def __len__(self): + return len(self.keys()) + def __iter__(self): + return iter(self.keys()) + + def blacklisted(self, item): + """Return True if tag is blacklisted. + + Blacklist automatically includes internal mutagen tags (those + beginning with a tilde).""" + for regex in self.blacklist: + if re.search(regex, item): + return True + else: + return False + def keys(self): + return [ key for key in self.data.keys() if not self.blacklisted(key) ] + def write(self): + return self.data.save() + +def copy_tags (src, dest): + """Replace tags of dest file with those of src. + + Excludes format-specific tags and replaygain info, which does not + carry across formats.""" + + # A list of regexps matching non-transferrable tags, like file format + # info and replaygain info. This will not be transferred from source, + # nor deleted from destination. + blacklist_regexes = [ re.compile(s) for s in ( + 'encoded', + 'replaygain', + ) ] + + try: + m_src = AudioFile(src, blacklist = blacklist_regexes, easy=True) + m_dest = AudioFile(dest, blacklist = m_src.blacklist, easy=True) + m_dest.clear() + #logging.debug("Adding tags from source file:\n%s", + # "\n".join("%s: %s" % (k, repr(m_src[k])) for k in sorted(m_src.keys()))) + m_dest.update(m_src) + #logger.debug("Added tags to dest file:\n%s", + # "\n".join("%s: %s" % (k, repr(m_dest[k])) for k in sorted(m_dest.keys()))) + m_dest.write() + except AACError: + #logger.warn("No tags copied because output format does not support tags: %s", repr(type(m_dest.data))) + print("WARN: no tags copied because output format does not support tags: {0}".format(repr(type(m_dest.data))))