#!/usr/bin/env python import sys, os, re, subprocess, string import getopt def main(argv = None): if argv is None: argv = sys.argv try: opts, args = getopt.getopt(argv[1:], "hvmfoa", ["help"]) except getopt.GetoptError, err: # print help information and exit: print str(err) # will print something like "option -a not recognized" usage() sys.exit(2) # list of encoders to use encoders = [] for o, a in opts: if o == '-h': usage(argv[0]) sys.exit() elif o == "-v": versioninfo() sys.exit() elif o == "-a": encoders.append(AACEncoder()) elif o == "-m": encoders.append(MP3Encoder()) elif o == "-o": encoders.append(OggEncoder()) elif o == "-f": encoders.append(FLACEncoder()) if len(encoders) == 0: print "No encoding options specified; defaulting to flac encoding\n" encoders.append(FLACEncoder()) for a in args: encodefile(a, encoders) return 0 def versioninfo(): print "Automatic audio encoder, version 2.1\n" sys.exit() def usage(progname): print """ Syntax: {0} [-hvfmaop] -h : This help message. -v : Prints version info to stdout and exits. -f : Encode to FLAC (Free Lossless Audio Codec) (default). -a : Encode to AAC (Advanced Audio Codec) using faac and mp4tags. -m : Encode to MP3 using lame, id3tag, and id3v2. -o : Encode to Ogg Vorbis using oggenc. Automatically descends into each input file directory and processes files located there. Input file format: __TRACKS__ "" [<semicolon-separated vc list>] Special processing keys: - albumprefix : specifies album prefix string. - usealbumprefix : boolean, format track title like <prefix><num> <title>. - quality_faac : specifies faac quality. - quality_lame : specifies lame shaping quality. - quality_lame_vbr: specifies lame VBR quality. - quality_ogg : specifies oggenc quality. """.format(progname) # encode filename (and path) using given encoders # * returns -1 if file not valid # * returns 0 if encoders all worked # ** TODO # - ? change return values to to constants # - ? check for success of encodings? def encodefile(filename, encoders): # check that this is a valid file if os.path.isfile(filename) == False: print "Error: File '" + filename + "' does not exist.\n" return -1 # split off the path from the base name path, base = os.path.split(filename) # move into the base file's directory origdir = os.getcwd() os.chdir(path) # populate metadata object with file info metadata = AEIMetadata().processaeifile(base) # run each encoder on metadata for e in encoders: e.encode(metadata) # move back to original directory os.chdir(origdir) return 0 # base class for encoder class Encoder: def encode(self, metadata): return 0 def validate(self, entry): valid = True msg = '' return valid, msg # class for encoding to Apple AAC (MP4 format) class AACEncoder(Encoder): # init: set default faac quality def __init__(self): self.default_quality = '100' # encode file metadata def encode(self, metadata): print "\n*** Encoding to AAC:" # encode metadata for each track for entry in metadata.localmds: self.encodetrack(entry) # encode track metadata def encodetrack(self, entry): valid, msg = self.validate(entry) # display any validation messages if msg: print msg # abort if fatal error present if valid == False: return False cmds = entry['cmds'] tags = entry['tags'] # form output filename outputfile = cmds['outputbase'] + '.m4a' # build command lines faacoptions = ['-w'] mp4tags = ['-tool=faac'] comments = '' ignorekeys = ['albumprefix', 'usealbumprefix'] # check for tags corresponding to faac/mp4tags options for key in tags.keys(): if key == 'title': mp4tags.append('-song=' + cmds['displaytitle']) # set faac quality elif key == 'quality_faac': faacoptions.append('-q' + tags['quality_faac'][0]) elif key == 'artist': mp4tags.append('-artist=' + "; ".join(tags['artist'])) elif key == 'album': mp4tags.append('-album=' + "; ".join(tags['album'])) elif key == 'date': mp4tags.append('-year=' + tags['date'][0]) elif key == 'genre': mp4tags.append('-genre=' + "; ".join(tags['genre'])) elif key == 'composer': mp4tags.append('-writer=' + "; ".join(tags['composer'])) elif key == 'tracknumber': mp4tags.append('-track=' + tags['tracknumber'][0]) elif key == 'trackcount': mp4tags.append('-tracks=' + tags['trackcount'][0]) elif key == 'discnumber': mp4tags.append('--disk=' + tags['discnumber'][0]) elif key == 'disccount': mp4tags.append('--disks=' + tags['disccount'][0]) elif key == 'compilation': if tags['compilation'][0]: faacoptions.append('--compilation') elif key == 'faacrawwav': if tags['faacrawwav'][0]: faacoptions.append('-P') # don't include other encoder quality assignments as comments elif key in ignorekeys or re.match(r'^quality', key): pass elif key == 'comment': comments += ''.join([s + '\n' for s in tags['comment']]) else: comments += ''.join([key + '=' + s + '\n' for s in tags[key]]) # remove trailing newline comments = comments.strip() # encode file print "Encoding '" + cmds['inputfile'] + "' to '" + outputfile + "'" encodeargs = ['faac'] encodeargs.extend(faacoptions) encodeargs.append('-o' + outputfile) encodeargs.append(cmds['inputfile']) encodeproc = subprocess.Popen(encodeargs, stdout=subprocess.PIPE) output = encodeproc.communicate()[0] # add additional metadata to encoded file print "Adding metadata to '" + outputfile + "'" mp4tagargs = ['mp4tags'] mp4tagargs.extend(mp4tags) mp4tagargs.append('-comment="' + comments + '"') mp4tagargs.append(outputfile) mp4tagproc = subprocess.Popen(mp4tagargs, stdout=subprocess.PIPE) output = mp4tagproc.communicate()[0] print "\n" def validate(self, entry): valid = True msg = '' tags = entry['tags'] # check for present or numeric faac quality if 'quality_faac' not in tags: msg += "No quality specified; defaulting to -q " msg += self.default_quality + '\n' tags['quality_faac'] = [ self.default_quality[:] ] # present but not numeric: fatal error elif not re.match(r'^\d+$', tags['quality_faac'][0]): msg += "Specified quality '" msg += tags['quality_faac'][0] + "' invalid\n" valid = False return valid, msg return valid, msg # encode for MP3 format (using Lame) class MP3Encoder(Encoder): # set defaults def __init__(self): self.err_blank_genre = -2 self.err_unknown_genre = -1 self.default_quality = '2' self.default_vbr_quality = '4' # build lookup list of id3 tag genres self.build_id3_genres() # build lookup list of id3 tag genres def build_id3_genres(self): self.id3genres = {} # one list of number/name pairs per initial letter for l in string.ascii_lowercase: self.id3genres[l] = [] # get list from lame genrelistproc = subprocess.Popen(['lame', '--genre-list'], stderr=subprocess.PIPE) output = genrelistproc.communicate()[1].strip().split('\n') # separate genre code number from name, store in buckets for entry in output: number = entry[0:3].strip() # lowercase each name, strip out spaces name = entry[4:].lower() name = re.sub(r'\s', '', name) # put number/name pair in appropriate bucket self.id3genres[name[0]].append([number, name]) # match a given genre string to a code def match_id3_genre(self, genretext): # lowercase, strip out spaces text = genretext.lower() text = re.sub(r'\s', '', text) # blank text? if not text: return self.err_blank_genre # check first letter if not text[0].isalpha(): return self.err_unknown_genre # check entries in that letter bucket # checking by equality - make more sophisticated later? for entry in self.id3genres[text[0]]: if text == entry[1]: return entry[0] # no match return self.err_unknown_genre # encode file metadata def encode(self, metadata): print "\n*** Encoding to MP3:" # encode metadata for each track for entry in metadata.localmds: self.encodetrack(entry) # encode track metadata def encodetrack(self, entry): valid, msg = self.validate(entry) # print any validation warnings if msg: print msg # abort track if fatal errors if valid == False: return False cmds = entry['cmds'] tags = entry['tags'] # build output filename outputfile = cmds['outputbase'] + '.mp3' print "Constructing metadata string for '" + outputfile + "'" id3tags = [] lameoptions = [] comments = '' ignorekeys = ['albumprefix', 'usealbumprefix', 'genre'] # check for tags corresoinding to lame/id3tags options for key in tags.keys(): if key == 'title': id3tags.append('--song=' + cmds['displaytitle']) # set LAME shaping quality elif key == 'quality_lame': lameoptions.append('-q ' + tags['quality_lame'][0]) # set LAME shaping quality elif key == 'quality_lame_vbr': lameoptions.append('-V ' + tags['quality_lame_vbr'][0]) elif key == 'artist': id3tags.append('--artist=' + "; ".join(tags['artist'])) elif key == 'album': id3tags.append('--album=' + "; ".join(tags['album'])) elif key == 'date': id3tags.append('--year=' + tags['date'][0]) elif key == 'genrecode': id3tags.append('--genre=' + str(tags['genrecode'])) elif key == 'tracknumber': id3tags.append('--track=' + tags['tracknumber'][0]) elif key == 'trackcount': id3tags.append('--total=' + tags['trackcount'][0]) elif key == 'compilation': if tags['compilation'][0]: comments += 'compilation\n' # don't include other encoder quality assignments as comments elif key in ignorekeys or re.match(r'^quality', key): pass elif key == 'comment': comments += ''.join([s + '\n' for s in tags['comment']]) else: comments += ''.join([key + '=' + s + '\n' for s in tags[key]]) # strip out whitespace comments = comments.strip() # encode track print "Encoding '" + cmds['inputfile'] + "' to '" + outputfile + "'" encodeargs = ['lame', '--nohist'] encodeargs.extend(lameoptions) encodeargs.append(cmds['inputfile']) encodeargs.append(outputfile) encodeproc = subprocess.Popen(encodeargs, stdout=subprocess.PIPE) output = encodeproc.communicate()[0] # add track metadata print "Adding metadata to '" + outputfile + "'" id3tagargs = ['id3tag'] id3tagargs.extend(id3tags) id3tagargs.append('--comment="' + comments + '"') id3tagargs.append(outputfile) id3tagproc = subprocess.Popen(id3tagargs, stdout=subprocess.PIPE) output = id3tagproc.communicate()[0] print "\n" # validate track metadata def validate(self, entry): valid = True msg = '' cmds = entry['cmds'] tags = entry['tags'] # check that encoding qualities are present, numeric # if absent, revert to defaults if 'quality_lame' not in tags: msg += "* Warning: Lame shaping quality not specified, defaulting to " + self.default_quality + "\n" tags['quality_lame'] = [ self.default_quality[:] ] elif not tags['quality_lame'][0].isdigit(): msg += "* Fatal error: Lame quality not numeric.\n" valid = False return valid, msg if 'quality_lame_vbr' not in tags: msg += "* Warning: Lame VBR quality not specified, defaulting to " msg += self.default_vbr_quality + "\n" tags['quality_lame_vbr'] = [ self.default_vbr_quality [:] ] elif not tags['quality_lame_vbr'][0].isdigit(): msg += "* Fatal error: Lame VBR quality not numeric.\n" valid = False return valid, msg # find id3 genre code if 'genre' in tags: genrecode = self.match_id3_genre(tags['genre'][0]) if genrecode == self.err_blank_genre: msg += "* Genre string entry\n" if genrecode == self.err_unknown_genre: msg += "* Genre string '" + tags['genre'][0] + "' unrecognized\n" # positive codes are valid if genrecode >= 0: tags['genrecode'] = genrecode return valid, msg # encode file to Ogg Vorbis format (using oggenc, vorbiscomment) class OggEncoder(Encoder): # set default quality def __init__(self): self.default_quality = '4' # encode file metadata def encode(self, metadata): print "\n*** Encoding to Ogg Vorbis:" # check each track individually for entry in metadata.localmds: self.encodetrack(entry) # encode track metadata def encodetrack(self, entry): valid, msg = self.validate(entry) if msg: print msg if valid == False: return False cmds = entry['cmds'] tags = entry['tags'] # build output file name outputfile = cmds['outputbase'] + '.ogg' # encode track print "Encoding '" + cmds['inputfile'] + "' to '" + outputfile + "'" encodeproc = subprocess.Popen( ['oggenc', '-q' + tags['quality_ogg'][0], '-o' + outputfile, cmds['inputfile']], stdout=subprocess.PIPE) output = encodeproc.communicate()[0] # add metadata to track print "Adding metadata to '" + cmds['outputbase'] + ".flac'" echoproc = subprocess.Popen( ['echo', cmds['vorbiscomments']], stdout=subprocess.PIPE) commentproc = subprocess.Popen( ['vorbiscomment', '-w', '-c-', outputfile], stdin=echoproc.stdout, stdout=subprocess.PIPE) output = commentproc.communicate()[0] print "\n" # validate track metadata # make sure vorbis comments are built def validate(self, entry): valid = True msg = '' tags = entry['tags'] # check encoding quality is present, numeric # if absent, revert to default if 'quality_ogg' not in tags: msg += "* Warning: No quality specified; defaulting to -q " msg += self.default_quality + '\n' tags['quality_ogg'] = [ self.default_quality[:] ] elif not re.match(r'-?\d+(\.\d+)?$', tags['quality_ogg'][0]): msg += "* Fatal error: Specified quality '" msg += tags['quality_ogg'][0] + "' invalid\n" valid = False return valid, msg # build vorbiscomment line if not present yet if 'vorbiscomments' not in entry['cmds']: build_vorbis_comments(entry) return valid, msg # encoder for FLAC format class FLACEncoder(Encoder): # encode file metadata def encode(self, metadata): print "\n*** Encoding to FLAC:" # encode each track for entry in metadata.localmds: self.encodetrack(entry) # encode track metadata def encodetrack(self, entry): valid, msg = self.validate(entry) # print messages if msg: print msg # abort if fatal error if valid == False: return False cmds = entry['cmds'] # build output filename outputfile = cmds['outputbase'] + '.flac' # encode track to flac print "Encoding '" + cmds['inputfile'] + "' to '" + outputfile + "'" encodeproc = subprocess.Popen( ['flac', '-8', '-o' + outputfile, cmds['inputfile']], stdout=subprocess.PIPE) output = encodeproc.communicate()[0] # add metadata to output file print "Adding metadata to '" + outputfile + "'" echoproc = subprocess.Popen( ['echo', cmds['vorbiscomments']], stdout=subprocess.PIPE) commentproc = subprocess.Popen( ['metaflac', '--remove-all-tags', '--import-tags-from=-', outputfile], stdin=echoproc.stdout, stdout=subprocess.PIPE) output = commentproc.communicate()[0] print "\n" # build vorbis comment string if not done yet def validate(self, entry): valid = True msg = '' if 'vorbiscomments' not in entry['cmds']: build_vorbis_comments(entry) return valid, msg # class to store file metadata class AEIMetadata: def __init__(self): self.localmds = [] # extract and process an AEI file def processaeifile(self, filename): # read in keyval pairs, track listings # merge global data into per-track info self.readaeifile(filename) # check validity of data self.validate() # self.buildoutputs() return self # entract data from filename, populate metadata dict def readaeifile(self, filename): print "Processing metadata for '" + filename + "'..." aeifile = open(filename, "r") globalmd = {} for line in aeifile: # trim off whitespace on either side line = line.strip() # done with preamble metadata if line == "__TRACKS__": break # skip blank lines and comments if len(line) == 0 or line[0] == '#': continue # add key-value pair if possible addkeyval(globalmd, line) # build track-matching regular expression # - gp 1: tracknumber # - gp 2: input file name # - gp 3: track title # - gp 4: extra metadata, takes preference over global values trackre = re.compile(r'(\d+)\s+"([^"]+)"\s+"([^"]*)"(.*)') for line in aeifile: # trim off whitespace line = line.strip() # skip blank lines and comments if len(line) == 0 or line[0] == '#': continue # check for match to track RE result = trackre.match(line) # skip line if no match if result is None: print "Skipping line '" + line + "' of '" + filename + "'" continue # build command, tag dictionaries cmds = { 'inputfile': result.group(2).strip() } tags = { 'tracknumber': [ result.group(1) ], 'title': [ result.group(3) ] } # add extra (group 4) tags to local dictionary # first, remove surrounding whitespace localcomments = result.group(4).strip() # process if nonempty if localcomments != '': # split along unescaed semicolons lctokens = re.split(r'(?<!\\);', localcomments) # unescape semicolons lctokens = [re.sub(r'\\;', ';', tok) for tok in lctokens] # add each keyval token for tok in lctokens: addkeyval(tags, tok) # add commands and tags to local metadata list self.localmds.append( {'cmds': cmds, 'tags': tags} ) aeifile.close() # merge global data into local dictionaries for entry in self.localmds: mergemetadatadicts(entry['tags'], globalmd) return self # check for validity common to all encoders def validate(self): invalids = [] for entry in self.localmds: result, msg = validateentry(entry) if result == False: print "Fatal validation errors for '" + entry['cmds']['inputfile'] + "':" print msg.strip() print "Skipping track...\n" invalids.append(entry) # wait to remove invalid entries for entry in invalids: self.localmds.remove(entry) # build displaytitle, output filename base (if valid) def buildoutputs(self): for entry in self.localmds: build_display_title(entry) build_output_base(entry) # merge d2 lists into d1's # both dicts have lists of values for each key def mergemetadatadicts(d1, d2): for key in d2.keys(): # make list for key if not present if key not in d1: d1[key] = [] # copy each element of d2's key list into d1's for elt in d2[key]: d1[key].append(elt[:]) # check general validity of entry def validateentry(entry): valid = True msg = '' cmds = entry['cmds'] tags = entry['tags'] # check whether file specifies an album prefix cmds['usealbumprefix'] = False; if 'usealbumprefix' in tags: # check for 'true', 'yes', or nonzero number # set boolean value in cmds for future reference value = tags['usealbumprefix'][0].lower() if value == "true" or value == "yes": cmds['usealbumprefix'] = True if value.isdigit() and int(value) != 0: cmds['usealbumprefix'] = True if cmds['usealbumprefix'] and 'albumprefix' not in tags: valid = False msg += "* Entry needs an album prefix.\n" if 'title' not in tags: valid = False msg += '* Entry needs title.\n' if 'artist' not in tags: valid = False msg += '* Entry needs artist.\n' if 'tracknumber' not in tags: valid = False msg += '* Entry needs a track number.\n' return valid, msg # build displayed title, depending on whether an album prefix specified, used def build_display_title(entry): cmds = entry['cmds'] tags = entry['tags'] title = '' if cmds['usealbumprefix']: title = tags['albumprefix'][0] + tags['tracknumber'][0] + ' ' title += tags['title'][0] cmds['displaytitle'] = title # build output filename base # convert some symbolic characters to safer representations def build_output_base(entry): cmds = entry['cmds'] tags = entry['tags'] outputbase = tags['artist'][0] + ' - ' + cmds['displaytitle'] # filesafety conversions # ? -> [Q] # \/| -> ' ' # <>* -> - # : -> "" # " -> ' outputbase = re.sub(r'\?', '[Q]', outputbase) outputbase = re.sub(r'[\\\|\/]',' ', outputbase) outputbase = re.sub(r'\:','', outputbase) outputbase = re.sub(r'[\<\>\*]','-', outputbase) # never occurs, given track-line syntax? outputbase = re.sub(r'\"','\'', outputbase) cmds['outputbase'] = outputbase # build comment string for input to vorbis-comment compatible tagger def build_vorbis_comments(entry): cmds = entry['cmds'] tags = entry['tags'] comments = '' keylist = tags.keys() keylist.sort() for key in keylist: # skip album prefix issues if key == 'albumprefix' or key == 'usealbumprefix': continue # skip quality-specific tags if re.match(r'^quality', key): continue # use display title in if key == 'title': comments += 'title=' + cmds['displaytitle'] + '\n' continue # only keep first tracknumber in list if key == 'tracknumber': comments += 'tracknumber=' + tags['tracknumber'][0] + '\n' continue # join all other comments together as key-value pairs comments += ''.join([key + '=' + value + '\n' for value in tags[key]]) # strip off trailing newline, store for later cmds['vorbiscomments'] = comments.strip() # add a key or key-value pair to tag dict def addkeyval(d, line): keyval = line.split('=', 1) key = keyval[0].lower().strip() # abort if blank key if key == '': print "Error: Blank key in line '" + line + "' invalid" return -1 # value defaults to '' if len(keyval) == 2: value = keyval[1].strip() else: value = "" # create value list if needed if key not in d: d[key] = [] # store value d[key].append(value) return 0 # execute program if __name__ == "__main__": sys.exit(main())