If I want to carry my music collection around with me, the only two companies that offer devices with a large enough capacity are Apple with its iPod products and Microsoft with its Zune products. Neither set of devices support audio formats such as ogg or flac.

Certain generations of iPods can run the Rockbox operating system which can play these and many other formats. However, Rockbox’s support for 3rd party accessories using the Apple Accessory Protocol is very limited. If I wanted to use an iPod with my car’s in-dash navigation unit and have full functionality to browse and shuffle songs, I’d need to use the stock iPod operating system.

I’ve used software such as Banshee and Floola before to convert my audio collection during the syncing process. Both programs are open source and work fairly well, but the conversion process can take a long time: up to three days to resync an iPod from scratch.

Floola’s conversion process is single threaded, so it will only convert one song at a time even on a muilti-processor system. Banshee is very buggy and will sometimes fail to update the iPod’s music database, leaving all the auto copied to the iPod but inaccessible from the user interface. Banshee also is limited to only MP3 support and cannot convert audio to aac.

So I decided that the solution I’d take is to have two copies of my music collection. I’d keep one copy in iTunes compatible formats, and use that copy to sync with my iPod. I’d keep my originals in the formats I currently have, which is a mix of mp3s, wmas, oggs and flacs.

The following script will copy one music tree to a new folder, leaving formats such as mp3 and wma untouched, while converting all my ogg and flac files into aac files using Nero’s AAC codec for Linux (Note: although Nero’s AAC codec is free, it is not open source). The script is written in python2 and uses flac and vorbis-tools for decoding.

#!/usr/bin/env python
#
#  eyePodify.py - version 0.7
#
# A script for recursively converting a folder of audio files into iPod compatible formats.
# All flac and ogg files are transcoded to aac. All other, including non-audio, files are
# copied unmodified to the destination. Files that already exist in the destination tree
# will be skipped.
#
# For Usage:
#  ./eyePodify.py --help
#
# Dependencies:
#    python       >= 2.7
#    vorbis-tools >= 1.1
#    flac         >= 1.2  
#    neroAac      >= 1.5 (Note: Free but Closed Source)
#                         http://www.nero.com.tw/eng/downloads-nerodigital-nero-aac-codec.php
#
# Copyright 2010 Sumit Khanna. Free for non-commercial use. http://penguindreams.org

import sys
import os
import subprocess
import tempfile
import logging
import shutil
import multiprocessing
import threading
import datetime
from optparse import OptionParser,OptionGroup

def scanSourceTree(src,dest,encRate,numProcs,testRun,customMeta):

   tSem = threading.Semaphore(numProcs)
   logging.debug('Maximum number of threads %d' % numProcs)

   for root, dirs, files in os.walk(src):
     for f in files:
        orig = os.path.join(root,f)
	new  = os.path.join(dest,root[len(src)+1:],f)
	basename,extension = os.path.splitext(new)
	if incompatiableFormat(extension):
	  newtran = basename + '.m4a'
	  if not os.path.exists(newtran):
	    logging.info( 'transcoding %s to %s' % (f,newtran) )
            if not testRun:
	      tCode = Transcoder(orig,newtran,encRate,customMeta,tSem)
	      tSem.acquire()
	      tCode.start()
	  else:
	    logging.debug('transcoded file exists. skipping %s' % newtran )
	else:
	  if not os.path.exists(new):
	    logging.info( 'copying %s to %s' % (f,new) )
            if not testRun:
	      ensure_dir(new)
	      shutil.copy2(orig,new)
	  else:
	    logging.debug('file exists. skipping %s' % (f) )


class Transcoder(threading.Thread):

  def __init__(self,src,dest,encRate,customMeta,tSem):
    super(Transcoder,self).__init__()
    self.src = src
    self.dest = dest
    self.encRate = encRate
    self.semaphore = tSem
    self.customMeta = customMeta

  def runprocess(self,args):
   logging.debug('running command: ' + ' '.join(args) )
   proc = subprocess.Popen(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
   output,error = proc.communicate()
   status = proc.returncode
   oot = 'Output:\n%s\nError:\n%s\n' % (output,error)
   return oot,status

  def pullMetaData(self,src,codec):
    output = None
    status = None
    data = []

    logging.debug('parsing %s metadata for %s' % (codec,src))

    if codec == 'ogg':
      output,status = self.runprocess(['ogginfo',src])
      for dataLine in output.splitlines():
	line = dataLine.split('=')
	if len(line) == 2:
	  data.append(line)
    elif codec == 'flac':
      output,status = self.runprocess(['metaflac','--list','--block-type=VORBIS_COMMENT',src])
      for dataLine in output.splitlines():
	line = dataLine.strip().split(':')
	if line[0].startswith('comment[') :
	  data.append(line[1].strip().split('='))

    if(status != 0):
       logging.warn('%s metadata extractor returned %d. possible corrupt %s' % (codec,status,codec))

    return data


  def writeMetaData(self,dest,data):
     metaCmd = ['neroAacTag',dest]
     for meta in data:
       aacTag = vorbisMetaToAAC(meta[0].lower().strip())
       if aacTag is not None:
	  metaCmd.append('-meta:%s=%s' % (aacTag,meta[1].strip()))
	  logging.debug('pulled standard-meta %s = %s' % (aacTag,meta[1]))
       else:
          if self.customMeta == True:
	    metaCmd.append('-meta-user:%s=%s' % (meta[0].lower().strip(),meta[1].strip()))
	    logging.debug('pulled custom-meta %s = %s' % (meta[0].lower(),meta[1]))
          else:
            logging.debug('ignoring custom-meta %s = %s' % (meta[0].lower(),meta[1]))

     output,status = self.runprocess(metaCmd)
     if status != 0:
	logging.warn('meta tagger exited with error %d: %s' % (status,output))


  def run(self):  
   src = self.src
   dest = self.dest
   encRate = self.encRate

   logging.debug('source: %s\ndest: %s' % (src,dest))

   srcHead,srcFile = os.path.split(src)
   dstName,dstExt = os.path.splitext(srcFile)
   dstNew = dstName + '.m4a'
   codec = dstExt.lower().strip('.')

   logging.info('transcoding (wav) %s' % (dstName))

   #create temp file
   interWav = tempfile.NamedTemporaryFile(delete=False)
   tmpName = interWav.name
   interWav.close()
   logging.debug('created tmp wav file %s' % tmpName)

   #convert MP3 to Wave
   #output,status = self.runprocess(['mplayer','-vc','null','-vo','null','-ao','pcm:fast','-ao','pcm:file='+tmpName,src])
   if codec == 'ogg':
      output,status = self.runprocess(['oggdec','-o',tmpName,src])
   elif codec == 'flac':
      output,status = self.runprocess(['flac','-d','-F','-f','-o',tmpName,src])

   if status == 0:
     logging.info('transcoding (aac) %s' % dstNew)

     ensure_dir(dest)

     #transcode Wave to AAC with 2 pass Nero Encoder
     rate = '%s' % (encRate * 1000)
     output,status = self.runprocess(['neroAacEnc','-br',rate,'-2pass','-if',tmpName,'-of',dest])

     if(status == 0):
        logging.info('transcoding %s complete. copying metadata.' % dstNew)

        #copy metadata
        data = self.pullMetaData(src,codec)
        self.writeMetaData(dest,data)

     else:
        logging.error('transcoding (aac) unsuccessful. error code: %d.\n\noutput\n%s' % (status,output))
   else:
     logging.error('transcoding (WAV) unsuccessful. error code: %d.\n\noutput\n%s' % (status,output))

   #delete tmp file
   if os.path.isfile(tmpName):
     logging.debug('removing tmp file %s' % tmpName)
     os.unlink(tmpName)

   self.semaphore.release()

def vorbisMetaToAAC(tag):
   if tag == 'date':
      return 'year'
   elif tag == 'tracknumber':
      return 'track'
   elif tag == 'tracktotal':
      return 'totaltracks'
   elif tag == 'discnumber':
      return 'disc'
   elif tag == 'title' or tag == 'artist' or tag == 'genre' or tag == 'album':
      return tag
   else:
      return None

def incompatiableFormat(ext):
   if ext == '.ogg' or ext == '.flac':
     return True
   else:
     return False

def ensure_dir(f):
   logging.debug('checking directory %s' % f)
   d = os.path.dirname(f)
   if not os.path.exists(d):
     logging.debug('creating directory %s' % d)
     os.makedirs(d)

def logfile_arg():
   def func(option,opt_str,value,parser):
      if parser.rargs and not parser.rargs[0].startswith('-'):
         val=parser.rargs[0]
         parser.rargs.pop(0)
      else:
         #defaults to program_name_YYYY-MM-DD_HHMMSS.log
         val = sys.argv[0] + '_' + datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S') + '.log'
      setattr(parser.values,option.dest,val)
   return func

if __name__ == "__main__":

   parser = OptionParser(usage="%prog [-dt] [-b (bitrate)] [-l logfile] <source-tree> <destination-tree>",
                         description="A script for recursively converting a folder of audio files into iPod compatible formats. All flac and ogg files are transcoded to aac. All other, including non-audio, files are copied unmodified to the destination. Files that already exist in the destination tree will be skipped.\n", version="%prog 0.7", epilog='Copyright 2010 Sumit Khanna. Free for non-commercial use. PenguinDreams.org')
   parser.add_option('-d','--debug',action='store_true',help='show additional debugging output')
   parser.add_option('-l','--logfile',action='callback',callback=logfile_arg(),help='store output to logfile [default: %s_yyyy-mm-dd-hhmmss.log]' % sys.argv[0],metavar='FILE',dest='logfile')
   parser.set_defaults(verbose=True)
   parser.add_option('-t','--test',action='store_true',help='test run (no files copied/encoded)')
   parser.add_option('-v', action='store_true', dest='verbose', help='verbose output (default, combine with -d for additional information)')
   parser.add_option('-q', action='store_false', dest='verbose', help='run silent')

   encoderOpts = OptionGroup(parser,'Encoding Options')
   encoderOpts.add_option('-b',type='int',help='target bitrate for aac in kbps [default: %default]',metavar='BITRATE',default=192)
   encoderOpts.add_option('-j',type='int',help='number of encoder processes to launch. [defaults to one plus the total number of CPUs (currently: %default)]',metavar="PROCS",default=multiprocessing.cpu_count() + 1)
   encoderOpts.add_option('-c','--custom-meta',action='store_true',dest='customMeta',help='Copy non-standard AAC meta data')
   parser.add_option_group(encoderOpts)

   (options, args) = parser.parse_args()

   if len(args) != 2:
      parser.error('You must specify a source and destination tree')
   elif (not os.path.isdir(args[0])) or (not os.path.isdir(args[1])):
      parser.error('Source and destinations must be directories')
   else:

      #-d option
      if options.debug == True:
        logging.getLogger('').setLevel(logging.DEBUG)
      else:
        logging.getLogger('').setLevel(logging.INFO)

      #logger setup
      if options.verbose is True:
        console = logging.StreamHandler()
	console.setFormatter(logging.Formatter('%(asctime)s: %(message)s'))
        logging.getLogger('').addHandler(console)

      if options.logfile is not None:
        logfile = logging.FileHandler(options.logfile)
        logfile.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s %(message)s'))
        logging.getLogger('').addHandler(logfile)             

      if options.test:
        logging.info('Test Run! -- No files will actually be copied or reencoded')

      logging.debug('Options: %s' % options)
      logging.debug('Arguments: %s' % args)

      #begin recursive scan
      scanSourceTree(args[0],args[1],options.b,options.j,options.test,options.customMeta)