X-Git-Url: https://diplodocus.org/git/flac-archive/blobdiff_plain/1f9496c729e649cedae547ce6e3de04b052bb5e7..1ae4fb3e6c8b5fe43ae5b5f50b2a8e1368a37f42:/fa-flacd?ds=sidebyside diff --git a/fa-flacd b/fa-flacd index 37e79a0..5e93e1f 100755 --- a/fa-flacd +++ b/fa-flacd @@ -1,247 +1,33 @@ -#! /usr/bin/env perl - -# $Id$ +#! /usr/bin/python +""" =head1 NAME -B, B, B - archive CDs to single FLAC files +B - archive CDs to single FLAC files =head1 SYNOPSIS -B [B<-jv>] - -B +B [B<-j> I] [B<-v>] -B I - -=cut +=head1 DESCRIPTION -use strict; -use warnings; - -use File::Basename; -use Getopt::Std; $Getopt::Std::STANDARD_HELP_VERSION = 1; -use POSIX ':sys_wait_h'; - -our $VERSION = 1; - -my $verbose; -my @jobs; -my @finished; - -sub verbose { - $verbose and print(STDERR $_) for @_; -} - -# Return the ARTIST, ALBUM, and DATE followed by a list of all the -# lines in the file FN. -sub get_tags { - my $fn = shift; - my $tag; - my $value; - my $artist; - my $album; - my @tags; - - verbose("Opening tags file $fn\n"); - open(TAGS, $fn) or die("open($fn): $!"); - while () { - chomp; - push(@tags, $_); - - ($tag, $value) = split(/=/, $_, 2); - - if (/^ARTIST=/) { - $artist = $value; - verbose("ARTIST $artist from $fn\n"); - } elsif (/^ALBUM=/) { - $album = $value; - verbose("ALBUM $album from $fn\n"); - } - } - close(TAGS) or die("close($fn): $!"); - - return ($artist, $album, @tags); -} - -# Process the fa-rip output in the directory DIR. -sub flac { - my $dir = shift; - my $artist; - my $album; - my @tags; - my $outfile; - my $status; - - verbose("Renaming $dir/tags\n"); - rename("$dir/tags", "$dir/using-tags") - or die("rename($dir/tags, $dir/using-tags): $!"); - - ($artist, $album, @tags) = get_tags("$dir/using-tags"); - - verbose("mkdir($artist)\n"); - -d $artist or mkdir($artist) or die("mkdir($artist): $!"); - - verbose("chdir($dir)\n"); - chdir($dir) or die("chdir($dir): $!"); - - $outfile = "$album"; - for ($outfile) { - s/\//_/g; - s/:/_/g; - s/'/_/g; - s/"/_/g; - } - - verbose("Running flac\n"); - $status = system('flac', '-o', "../$artist/$outfile.flac-tmp", - '--delete-input-file', '-V', '--cuesheet', - 'cue', '--no-padding', '--best', - map({ ('-T', $_) } @tags), - 'wav'); - if (WIFEXITED($status) and ($status = WEXITSTATUS($status)) != 0) { - die("flac: $status"); - } elsif (WIFSIGNALED($status)) { - die("flac killed with signal ", WTERMSIG($status)); - } elsif (WIFSTOPPED($status)) { - die("flac stopped with signal ", WSTOPSIG($status)); - } - - verbose("Cleaning up $dir\n"); - unlink('using-tags') or die("unlink(using-tags): $!"); - unlink('cue') or die("unlink(cue): $!"); - rename('toc', "../$artist/$outfile.toc") - or die("rename(toc, ../$artist/$outfile.toc): $!"); - rename('log', "../$artist/$outfile.log") - or die("rename(log, ../$artist/$outfile.log): $!"); - chdir('..') or die("chdir(..): $!"); - rmdir($dir) or die("rmdir($dir): $!"); - - rename("$artist/$outfile.flac-tmp", "$artist/$outfile.flac") - or die("rename($artist/$outfile.flac-tmp, $artist/$outfile.flac): $!"); - - return 0; -} - -sub reaper { - my $pid; - - while (($pid = waitpid(-1, WNOHANG)) > 0) { - push(@finished, [$pid, $?]); - } - - $SIG{CHLD} = \&reaper; -} - -sub newjob { - my $dir = shift; - my $pid; - - $pid = fork(); - if (not defined($pid)) { - die("fork: $!"); - } elsif ($pid == 0) { - $SIG{CHLD} = 'IGNORE'; - open(STDERR, ">$dir/log") or die("open(STDERR, >$dir/log): $!"); - exit(flac($dir)); - } - - verbose("new job $pid for $dir\n"); - return $pid; -} - -sub deljob { - my $i = shift; - my $j; - my $pid; - my $status; - - $pid = $finished[$i][0]; - $status = $finished[$i][1]; - - verbose("$pid finished ("); - if (WIFEXITED($status)) { - verbose('exited ', WEXITSTATUS($status)); - } elsif (WIFSIGNALED($status)) { - verbose('signalled ', WTERMSIG($status)); - } elsif (WIFSTOPPED($status)) { - verbose('stopped ', WSTOPSIG($status)); - } - verbose(")\n"); - - for ($j = 0; $j <= $#jobs; $j++) { - $pid == $jobs[$j] and splice(@jobs, $j, 1) and last; - } - - splice(@finished, $i, 1); -} - -sub flacloop { - my $MAXJOBS = shift; - my $i; - my $j; - - - $SIG{CHLD} = \&reaper; - while (1) { - if (scalar(@jobs) <= $MAXJOBS) { - foreach $i (glob('*/tags')) { - push(@jobs, newjob(dirname($i))) <= $MAXJOBS or last; - } - } - - for ($i = 0; $i <= $#finished; $i++) { - deljob($i); - } - - verbose(scalar(@jobs), " jobs\n"); - sleep(5); - } -} - -MAIN: { - my %opts; - - $opts{'j'} = 4; - $opts{'v'} = 0; - if (not getopts('j:v', \%opts)) { - print(STDERR "usage: flacd [-jN -v]\n"); - exit(2); - } - - $verbose = $opts{'v'}; - - flacloop($opts{'j'}); -} - - -__END__ +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. -=head1 DESCRIPTION +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. -B, 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 CDs to -encode and rename; it never exits. B generates the inputs for -B: the ripped WAV file, Vorbis tags, and a cuesheet. -B is not meant to be run directly; B uses it to -generate the candidate Vorbis tags. - -All three programs expect to be run from the same directory. They use -that directory to manage directories named by artist and by disc ID. -Intermediate files are written to the disc ID directory. B -processes the disc ID directories into per-album files in the artist -directories. - -=head2 FA-FLACD - -B does not exit; it runs until the user kills it. Every 5 -seconds it 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. +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 @@ -250,41 +36,32 @@ 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, the "cue" and "log" files are moved to the -artist directory (and named by album) and the ID directory is removed. - -=head2 FA-RIP +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 -B uses C to create a directory for storage of its -intermediate files. It uses C to create the "cue" file and -then passes the number of tracks (from the "cue" file) as command-line -arguments to B. Finally, it execs C to rip -the CD to the "wav" file. - -In order for this CD to be processed by B, the user must -create a "tags" file. This is usually done by renaming one of the -candidate-tags files and deleting the others. - -=head2 FA-TAGS - -B uses C to populate candidate-tags -files. These are numbered in the order of entries read from -MusicBrainz, e.g. candidate-tags-1, candidate-tags-2, etc. B -also creates candidate-tags-0, which has the correct fields for this -CD (including correct number of TITLE= lines), but with all fields -blank. - -B requires the number of tracks as its sole argument. +=over 4 -=head1 ENVIRONMENT +=item B<-j> [B<--jobs>] I -=over 4 +Run up to I jobs instead of the default 4. -=item CDDEV +=item B<-v> [B<--verbose>] -B uses this to rip audio and save the cuesheet for a CD. It -makes some effort to check some common device names for FreeBSD, -Linux, and NetBSD by default. +Print diagnostic information. =back @@ -297,10 +74,270 @@ it under the same terms as Perl itself. =cut -# Local variables: -# cperl-indent-level: 4 -# perl-indent-level: 4 -# indent-tabs-mode: nil -# End: - -# vi: set tabstop=4 expandtab: +""" + +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 = ' ' + 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 + + 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))