from ID3 import * import ogg.vorbis import os, os.path, sys, re from getopt import getopt from string import Template # TODO: if rearranging paths, remove obsolete ones; e.g. if moving from artist/album to # artist directories, remove all the album directories ##### manager for music file collection class music_manager: def __init__(self, options): # substitutions: pairs mapping from a regular expression # to a replacement string [[regexp1, replacement1], ..., [regexpn, replacementn]] # substitutions for file paths self.filename_subs = [["[`\'\"\)\(\,#!]+", ""], ["&+", "and"], ["\.{2,}", "."], ["-{2,}", "-"], ["\_{2,}", "_"], ["_-_", "-"], ["^[\_ \-]+", ""], ["[\_\- ]+$", ""], ["/{2,}", "/"]] # substitutions for tags self.tag_subs = [["`", "'"], ["&+", "and"], ["\-{2,}", "-"], [" \(Single\)", ""], ["[ ]{2,}", " "], ["\_{2,}", "_"]] # options to use for manager self.options = options # suffixes for files we can process self.suffixes = [".mp3", ".ogg"] # process a directory full of mp3/ogg files def process(self, base_path): files = os.listdir(base_path) for a_file in files: path = os.path.join(base_path, a_file) if os.path.isfile(path): start_path, suffix = os.path.splitext(path) suffix = suffix.lower() if suffix in self.suffixes: self.do_file(path, suffix) elif self.get_option('recursive'): self.process(path) # process a single file # filepath = fully qualified path to the file (including suffix) # suffix = filename suffix (including .) passed as a kind of "caching", # so we don't have to derive it all the time def do_file(self, filepath, suffix): print "*" * 30 print "Processing " + filepath tags = {} # get file and its tags if suffix == ".mp3": tags = ID3(filepath).as_dict() elif suffix == ".ogg": f = ogg.vorbis.VorbisFile(filepath) raw_tags = f.comment().as_dict() for key in raw_tags.keys(): tags[key] = raw_tags[key][0] # if we're cleaning (cleantags option), clean up tags if self.get_option('cleantags'): for key in tags.keys(): tags[key] = self.clean(tags[key], self.tag_subs) # work out the base destination directory (from the output option) base_dir = self.get_option('output') if base_dir == ".": base_dir = os.getcwd() else: base_dir = self.path_from_tags(tags, base_dir) # if we're renaming from ID3 tags (rename option), work out the new filename newfilename = "" if self.get_option('rename') != "": newfilename = self.path_from_tags(tags, self.get_option('rename')) if newfilename != "": newfilename = newfilename + suffix newfilename = self.clean(newfilename, [["[\/\\\]", ""]]); # keep the filename if newfilename has not been set if newfilename == "": _, newfilename = os.path.split(filepath) if self.get_option('cleanname'): newfilename = self.clean(newfilename, self.filename_subs) # force filename to lowercase if self.get_option('lowercase'): newfilename = string.lower(newfilename) # write tags to file self.set_tags(tags, filepath, suffix) # move the original file to its new destination if base_dir[-1:] != "/": new_filepath = os.path.join(base_dir, newfilename) else: new_filepath = base_dir + newfilename self.move_file(filepath, new_filepath) # get a parser option; if not present, return 0 (false) def get_option(self, option): value = 0 if self.options.has_key(option): value = self.options[option] return value # do a set of regular expression substitutions on a string # substitutions = list of lists; each sublist is a pair [str, replace], # where str is the regular expression to be replaced, and replace the replacement string def clean(self, clean_me, substitutions): try: for sub in substitutions: clean_me = re.sub(sub[0], sub[1], clean_me) return clean_me except: print "Could not substitute %s for %s" % (sub[0], sub[1]) # generate string by inserting values from a tag dictionary into it # pattern = pattern for building the new filename (see usage) def string_from_tags(self, tags, pattern): strout = pattern # only use templates if some substitutions are specified in pattern if re.search("\$", pattern): templ = Template(pattern) strout = templ.substitute(a=self.get_tag(tags, 'ARTIST'), t=self.get_tag(tags, 'TITLE'), b=self.get_tag(tags, 'ALBUM'), n=self.get_tag(tags, 'TRACK'), y=self.get_tag(tags, 'YEAR')) if self.get_option('stripspace'): strout = self.clean(strout, [["[ ]+", "_"]]) if self.get_option('lowercase'): strout = string.lower(strout) return strout # generate a filename from tag dictionary def path_from_tags(self, tags, pattern): newfilename = self.string_from_tags(tags, pattern) newfilename = self.clean(newfilename, self.filename_subs) return newfilename # get a tag from a tags dictionary, or return the empty string def get_tag(self, tags, tag_name): tag_value = "" if tags.has_key(tag_name): tag_value = tags[tag_name] return tag_value # set tags on a file def set_tags(self, tags, filepath, suffix): if suffix == ".mp3": afile = ID3(filepath) for key in tags.keys(): afile[key] = tags[key] afile.write() elif suffix == ".ogg": comments = ogg.vorbis.VorbisComment(tags) comments.write_to(filepath) # move a file; if elements of new_filepath do not exist, they are created first; # if the file ends up moving out of a directory and the directory ends up empty, delete # the directory def move_file(self, filepath, new_filepath): dir_path, _ = os.path.split(new_filepath) if not os.path.isdir(dir_path): os.makedirs(dir_path) if filepath != new_filepath: print "Moving %s to %s" % (filepath, new_filepath) if not os.path.isfile(new_filepath): os.rename(filepath, new_filepath) old_dir_path, _ = os.path.split(filepath) # clear any directories we've emptied as a result of moving the file self.clear_dirs(old_dir_path) else: print "File '%s' already exists, so leaving '%s' where it is" % (new_filepath, filepath) # recursively remove empty directories; # only works from dirpath up to the root_dir option (i.e. the path where processing # started from) def clear_dirs(self, dirpath): # only process up to the root directory passed in as an option go_up_to = self.get_option('root_dir') if dirpath != go_up_to: dir_content = os.listdir(dirpath) # remove the directory if it's empty if len(dir_content) == 0: os.rmdir(dirpath) # clear directories above the current one if dirpath[-1:] == "/": dirpath = dirpath[:-1] headpath, _ = os.path.split(dirpath) self.clear_dirs(headpath) ####################### MAIN usage = "\nUSAGE:\npython music_manager.py [--cleantags] [--cleanname] [--recursive] [--output=] [--rename=] [--lowercase] [--stripspace] \n\n--cleantags = clean ID3 and Vorbis tags (default false)\n--cleanname = clean up odd characters in filenames (default false)\n--recursive = recursively work from downwards (default false)\n--output = output template (see below) (default . [current directory])\n--rename = rename individual files (see below for patterns allowed) (default \"%a-%t\")\n--lowercase = shift all filenames to lowercase\n--stripspace = replace spaces in output paths (including filenames) with underscores\n = directory to process (only .ogg and .mp3 files will be affected) (mandatory)\n\nOutput template should be a path to the directory you want to output to, and may contain formatting placeholders\n\nFor output_template and rename_template, the following formatting placeholders are available:\n%a = artist, %t = title, %b = album, %n = track number, %y = year\n(Note that if a placeholder references a tag with no value, you may get an error)\n" # options for parsing: # cleantags = clean wierd characters out of ID3 tags # recursive = recurse into subdirectories # output = directory to output files to # sort = should files be sorted into directories? # rename = should files be renamed according to their ID3 tags? specify a template for the # new filename; based on the string.Template formatting rules, except use "%" instead of "$"; # defaults to '%a-%t' options = {"cleantags":0, "cleanname":0, "recursive":0, "output":"", "rename":"", "lowercase":0, "stripspace":0, "root_dir":""} # parse command line arguments if len(sys.argv[2:]) == 0: print usage else: optlist, args = getopt(sys.argv[1:], 'cxro:n:slh', ["cleantags", "cleanname", "recursive", "output=", "rename=", "stripspace", "lowercase", "help"]) for opt, value in optlist: if opt == "-c" or opt == "--cleantags": options['cleantags'] = 1 if opt == "-x" or opt == "--cleanname": options['cleanname'] = 1 elif opt == "-r" or opt == "--recursive": options['recursive'] = 1 elif opt == "-o" or opt == "--output": if value != "": value = re.sub("%", "$", value) else: value = "." if value[:1] == ".": value = os.getcwd() + value[1:] options['output'] = value elif opt == "-n" or opt == "--rename": if value != "": value = re.sub("%", "$", value) else: value = '$a-$t' options['rename'] = value elif opt == "-s" or opt == "--stripspace": options['stripspace'] = 1 elif opt == "-l" or opt == "--lowercase": options['lowercase'] = 1 elif opt == "-h" or opt == "--help": print usage # get path of directory to parse; default to current directory if len(args) > 0: dir_to_parse = args[0] # remove trailing slash if there is one if dir_to_parse[-1:] == "/": dir_to_parse = dir_to_parse[:-1] else: print "Aborting, as no directory to parse was specified" sys.exit() # set this as the root (used when clearing directories) options['root_dir'] = dir_to_parse # process files in selected directory m = music_manager(options) m.process(dir_to_parse)