#! /usr/bin/python """ =head1 NAME B - archive CDs to single FLAC files =head1 SYNOPSIS B [B<-j> I] [B<-v>] =head1 DESCRIPTION B and B together comprise B, a system for archiving audio CDs to single FLAC files. B is the guts of the system. It runs in the directory where the audio archives are stored, scanning for new ripped CDs to encode and rename; it never exits. B generates the inputs for B: the ripped WAV file, Vorbis tags, and a cuesheet. Both programs expect to be run from the same directory. They use that directory to manage directories named by artist. Intermediate files are written to temporary directories here. B processes the temporary directories into per-album files in the artist directories. Every 5 seconds, B scans its current directory for directories with a file called "tags" and creates a processing job for each one. The number of jobs B attempts to run is controlled by the B<-j> option and defaults to 4. B will print diagnostic output when the B<-v> option is given. A processing job first renames the directory's "tags" file to "using-tags" so that B will not try to start another job for this directory. This file is left as is when an error is encountered, so a new job will not be started until the user corrects the error condition and renames "using-tags" back to "tags". Next, it encodes the "wav" file to a FLAC file, using the "cue" file for the cuesheet and "using-tags" for Vorbis tags. Any diagnostic output is saved in the "log" file. Finally, B moves the "cue" and "log" files to the artist directory (named by album) and removes the temporary directory. If the temporary directory contains an executable file named "post-processor", B executes that file with the relative path to the output FLAC file as an argument. The output files are in their final location when "post-processor" starts. Possible uses are running B, moving the output files to a different location, removing the lock file, or adding to a database. The standard input, output, and error streams are inherited from B, so they may be connected to anything from a tty to /dev/null. This means that you may want to redirect these streams, if you want to save them or do any logging. =head1 OPTIONS =over 4 =item B<-j> [B<--jobs>] I Run up to I jobs instead of the default 4. =item B<-v> [B<--verbose>] Print diagnostic information. =back =head1 AUTHORS Written by Eric Gillespie . flac-archive is free software; you may redistribute it and/or modify it under the same terms as Perl itself. =cut """ import os import re import sys import time import traceback from errno import EEXIST, ENOENT from glob import glob from optparse import OptionParser from flac_archive.tags import Tags def c(f, *args): try: return f(*args) except EnvironmentError, error: error.msg = '%s.%s(%s): ' % (f.__module__, f.__name__, ', '.join(map(str, args))) raise def die(e, *args): sys.stderr.write(''.join(args)) sys.exit(e) def spew(*args): if verbose: for i in args: sys.stderr.write(i) ################################################################################ # The child processes def run_flac(infile, cue, outfile, tags): argv = ['flac', '-o', outfile + '.flac-tmp', '--delete-input-file', '-V', '--no-padding', '--best'] if cue != None: argv.extend(['--cuesheet', cue]) for i in tags: argv.extend(['-T', i]) argv.append(infile) # flac 1.1.3 PICTURE support if os.path.exists('cover.front'): argv.extend(['--picture', '3|image/jpeg|||cover.front']) spew('Running flac\n') status = os.spawnvp(os.P_WAIT, argv[0], argv) if status > 0: die(2, 'flac exited with status ', str(status)) elif status < 0: die(2, 'flac killed with signal ', str(abs(status))) c(os.rename, outfile + '.flac-tmp', outfile + '.flac') def flac(dir, tracknum, tags): """Encode a single wav file to a single flac file, whether the wav and flac files represent individual tracks or whole discs.""" separator = ' ' if len(tags.get('ALBUMARTIST')) > 0: artist_tag = tags.gets('ALBUMARTIST', separator=', ') else: artist_tag = tags.gets('ARTIST', separator=', ') artist = (artist_tag or '').replace('/', '_') album = (tags.gets('ALBUM', separator=separator) or '').replace('/', '_') discnum = tags.gets('DISCNUMBER') spew('mkdir(%s)\n' % (artist,)) try: c(os.mkdir, artist) except EnvironmentError, error: error.errno == EEXIST or die(2, error.msg, traceback.format_exc()) if tracknum != None: outdir = '/'.join([artist, album]) spew('mkdir(%s)\n' % (outdir,)) try: c(os.mkdir, outdir) except EnvironmentError, error: error.errno == EEXIST or die(2, error.msg, traceback.format_exc()) spew('chdir(%s)\n' % (dir,)) c(os.chdir, dir) if tracknum == None: outfile = album if discnum != None: outfile = ''.join([discnum, ' ', outfile]) run_flac('wav', 'cue', '/'.join(['..', artist, outfile]), tags.all()) files = ['%s/%s.flac' % (artist, outfile)] c(os.unlink, 'cue') outlog = '/'.join(['..', artist, outfile + '.log']) c(os.rename, 'log', outlog) else: title = tags.gets('TITLE', tracknum, separator).replace('/', '_') tmp = [] if discnum != None: tmp.append('%02d' % (int(discnum),)) tmp.extend(['%02d' % (tracknum,), title]) part = tags.gets('PART', tracknum) if part != None: tmp.extend(['-', part]) outfile = '/'.join([outdir, ' '.join(tmp)]) run_flac('track%02d.cdda.wav' % (tracknum,), None, '../' + outfile, tags.track(tracknum)) outlog = ''.join(['../', outfile, '.log']) files = [outfile + '.flac'] c(os.rename, str(tracknum) + '.log', outlog) c(os.chdir, '..') post = dir + '/post-processor' if os.path.exists(post): spew('Running ', post); spew(files); spew('\n') files.insert(0, post) os.spawnv(os.P_WAIT, post, files) c(os.unlink, post) # Clean up if we're the last job for dir; for multi-file dirs, # it's possible for more than one job to run cleanup at once, so # don't fail if things are already clean. ld = os.listdir(dir) if ld == ['using-tags'] or sorted(ld) == ['cover.front', 'using-tags']: try: try: os.unlink(dir + '/cover.front') except OSError: pass os.unlink(dir + '/using-tags') os.rmdir(dir) except EnvironmentError: pass return 0 ################################################################################ # The master process def get_tags(fn): """Return the ARTIST, ALBUM, and DATE followed by a list of all the lines in the file FN.""" tags = Tags() spew('Opening tags file %s\n' % (fn,)) tags.load(open(fn)) spew('ARTIST %s from %s\n' % (tags.gets('ARTIST'), fn)) spew('ALBUM %s from %s\n' % (tags.gets('ALBUM'), fn)) spew('DISCNUMBER %s from %s\n' % (tags.gets('DISCNUMBER'), fn)) return tags def flacloop(maxjobs): dir = [None] # [str] instead of str for lame python closures jobs = [] # Get a job for jobs.run. On each call, look for new fa-rip # directories and append an item to the queue @jobs for each wav # file therein. Then, if we have anything in the queue, return a # function to call flac for it, otherwise sleep for a bit. This # looks forever, never returning None, so jobs.run never returns. def getjob(reap): # Look for new fa-rip directories. while True: for i in glob('*/tags'): try: dir[0] = os.path.dirname(i) spew("Renaming %s/tags\n" % (dir[0],)) c(os.rename, dir[0] + '/tags', dir[0] + '/using-tags') tags = get_tags(dir[0] + '/using-tags') if os.path.exists(dir[0] + '/wav'): # single-file jobs.append((dir[0], None, tags)) else: # multi-file # Don't need cue file. try: c(os.unlink, dir[0] + '/cue') except EnvironmentError, error: if error.errno != ENOENT: raise error jobs.extend([(dir[0], x, tags) for x in xrange(1, len(tags) + 1)]) except Exception, error: sys.stderr.write(getattr(error, 'msg', '')) traceback.print_exc() sys.stderr.write('Continuing...\n') # Return a job if we found any work. try: job = jobs.pop(0) except IndexError: # Didn't find anything; wait a while and check again. time.sleep(5) reap() continue def lamb(): log = '/'.join([job[0], job[1] == None and 'log' or str(job[1]) + '.log']) try: c(os.dup2, c(os.open, log, os.O_CREAT | os.O_WRONLY), 2) return flac(*job) except EnvironmentError, error: sys.stderr.write(getattr(error, 'msg', '')) traceback.print_exc() return 1 return lamb while True: status = getjob(lambda *a,**k: None)() spew('%d jobs; %d finished (' % (len(jobs), pid)) if os.WIFEXITED(status): spew('exited with status ', str(os.WEXITSTATUS(status))) elif os.WIFSIGNALED(status): spew('killed with signal ', str(os.WTERMSIG(status))) elif os.WIFSTOPPED(status): spew('stopped with signal ', str(os.WSTOPSIG(status))) spew(')\n') def main(argv): # Control the exit code for any uncaught exceptions. try: parser = OptionParser() parser.disable_interspersed_args() parser.add_option('-X', '--debug', action='store_true', default=False) parser.add_option('-j', '--jobs', type='int', default=1) parser.add_option('-v', '--verbose', action='store_true', default=False) except: traceback.print_exc() return 2 try: # Raises SystemExit on invalid options in argv. (options, args) = parser.parse_args(argv[1:]) except Exception, error: if isinstance(error, SystemExit): return 1 traceback.print_exc() return 2 try: global debug, verbose debug = options.debug verbose = options.verbose # Never returns... flacloop(options.jobs) except Exception, error: # but might blow up. if isinstance(error, SystemExit): raise sys.stderr.write(getattr(error, 'msg', '')) traceback.print_exc() return 2 if __name__ == '__main__': sys.exit(main(sys.argv))