#! /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 import org.diplodocus.jobs from org.diplodocus.structures import ListDictDict from org.diplodocus.util import die, flatten, nothing from org.diplodocus.util import catch_EnvironmentError as c from flac_archive.tags import Tags 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 = ' ' artist = (tags.gets('ARTIST', separator=', ') 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 def notify_start(pid, jobs): # make this print '2 jobs; start 378 for Artist/01 Title.flac' spew('%d jobs; start %d for %s\n' % (len(jobs), pid, dir[0])) def notify_finish(pid, status, jobs): 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') # Never returns (see getjob comment). org.diplodocus.jobs.run(maxjobs=maxjobs, debug=debug, get_job=getjob, notify_start=notify_start, notify_finish=notify_finish) 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))