From cfd5982bbc4e0321c4bf6e13ac54337f38372066 Mon Sep 17 00:00:00 2001 From: Eric Gillespie Date: Thu, 17 Feb 2022 23:47:54 -0600 Subject: [PATCH] Replace fa-flacd with fa-encode. fa-flacd was python TWO! --- fa-encode | 193 ++++++++++++++++++++++++++++++ fa-flacd | 348 ------------------------------------------------------ 2 files changed, 193 insertions(+), 348 deletions(-) create mode 100755 fa-encode delete mode 100755 fa-flacd diff --git a/fa-encode b/fa-encode new file mode 100755 index 0000000..5a8a72d --- /dev/null +++ b/fa-encode @@ -0,0 +1,193 @@ +#!/usr/local/bin/perl + +=head1 NAME + +B - encode WAV files to FLAC + +=head1 SYNOPSIS + +B [B<-d> I] B + +I defaults to the album B in the tags file; one of +these is required. + +TODO: implement B<-d> + +=cut + +# POSIX.1-2017, Base Definitions, 3.282 Portable Filename Character Set says +# [A-Za-z0-9._-] +# https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_282 + +# Flac tags (set with `flac -T fieldname=...`) are Vorbis comments. +# https://www.xiph.org/vorbis/doc/v-comment.html#fieldnames field names we care about: +# - TITLE - Track/Work name +# - VERSION - may be used to differentiate multiple versions of the same track title in a single collection. (e.g. remix info) +# - ALBUM - The collection name to which this track belongs +# - TRACKNUMBER - The track number of this piece +# - ARTIST - The artist generally considered responsible for the track +# - DATE - Date the track was recorded (XXX I use US release date) +# I use one more, though I'm unsure where I got it. Did I make it up? +# - DISCNUMBER - number in multi-disc collection + +# Input is a directory containing: +# - trackNN.cdda.wav - WAV format files ripped from CD-DA audio tracks where NN is track number 01 - 99 +# - tags - described below +# - cover.front.jpeg - optional JPEG format file containing the album front cover + +# The tags file is composed of two blocks: +# 1. album tags +# 2. track tags +# Album tags may be: +# - ALBUM +# - ARTIST +# - DATE +# - DISCNUMBER (optional) + +# Track tags may be any of the rest of the tags listed above, suffixed with +# [N] where N is the track number. ARTIST, and only ARTIST, may also appear +# in the track tags. In this case, it overrides the album artist. In order +# to add artists, the album artist must be listed again. For example, Reba +# McEntire's "The Heart Won't Lie" on the album "It's Your Call" features +# Vince Gill, and is specified as: +# ARTIST=Reba McEntire +# ALBUM=It's Your Call +# TITLE[5]=The Heart Won't Lie +# ARTIST[5]=Reba McEntire +# ARTIST[5]=Vince Gill + +package epg::flac::archive::encode; + +use v5.12; +use warnings; + +sub mangle_for_file_name { + my $fn = shift; + $fn =~ s/\s+/_/g; + $fn =~ s/[^A-Za-z0-9._]/-/g; + $fn +} + +sub read_tags { + my $fn = shift; + open(my $fh, '<', $fn) || die("open($fn): $!"); + my %album; + my @tracks; + my $tracknum = 0; + while (<$fh>) { + chomp; + s/^([^=[\]]+)(\[(\d+)])?=// || die("no field name in $_"); + my $name = $1; + # TODO validate $name + # TODO album tags should be illegal after track tags + if (defined($3)) { + if ($3 == $tracknum + 2) { + $tracknum++; + } elsif ($3 != $tracknum + 1) { + $tracknum++; # increment from 0 to 1 for error message + die("illegal track number jump from $tracknum to $3") + } + push(@{$tracks[$tracknum]->{$name}}, $_); + } else { + push(@{$album{$name}}, $_); + } + } + + \%album, \@tracks +} + +sub quote { + my $s = shift; + $s =~ s/'/'\\''/g; + "'$s'" +} + +sub main { + my $input_directory = shift; + my ($album, $tracks) = read_tags("$input_directory/tags"); + if (!defined($album->{ALBUM}) || scalar(@{$album->{ALBUM}}) != 1) { + die('exactly one ALBUM tag required') + } + my $album_tag = $album->{ALBUM}->[0]; + # TODO -d support + if (!defined($album->{ARTIST}) || scalar(@{$album->{ARTIST}}) != 1) { + die('exactly one ARTIST tag required') + } + my $artist = $album->{ARTIST}->[0]; + + my $date; + if (defined($album->{DATE})) { + if (scalar(@{$album->{DATE}}) != 1) { + die('one or zero DATE tags required') + } + $date = $album->{DATE}->[0]; + } + my @discnumber = (); + if (defined($album->{DISCNUMBER})) { + if (scalar(@{$album->{DISCNUMBER}}) != 1) { + die('one or zero DISCNUMBER tags required') + } + @discnumber = (sprintf('%02d', $album->{DISCNUMBER}->[0])); + } + + my $dir = join('/', '..', mangle_for_file_name($artist), mangle_for_file_name($album_tag)); + + say('set -ex'); + say('mkdir -p ', quote($dir)); + my $tracknum = 0; + for my $track (@$tracks) { + $tracknum++; + if (!defined($track->{TITLE}) || scalar(@{$track->{TITLE}}) < 1) { + die("at least one TITLE required for track $tracknum") + } + $track->{ALBUM} = $album->{ALBUM}; + $track->{TRACKNUMBER} = [$tracknum]; + if (!defined($track->{ARTIST}) && defined($album->{ARTIST})) { + $track->{ARTIST} = $album->{ARTIST}; + } + if (defined($date)) { + $track->{DATE} = [$date]; + } + if (@discnumber) { + $track->{DISCNUMBER} = \@discnumber; + } + my $title = join(' ', @{$track->{TITLE}}); + my $tracknum_s = sprintf('%02d', $tracknum); + my $fn = join('/', + $dir, + join('_', + @discnumber, + $tracknum_s, + mangle_for_file_name($title) . '.flac', + ), + ); + -e $fn && die("cowardly refusing to clobber $fn"); + my @pictures = ('--picture', quote('3|image/jpeg|||cover.front')); # TODO optional + say(join(' ', + 'flac -o', + quote($fn), + '--delete-input-file', + '-V', + '--no-padding', + '--best', + @pictures, + (map { + my $name = $_; + map { + ('-T', quote($name . '=' . $_)) + } @{$track->{$_}} + } sort(keys(%$track))), + "track$tracknum_s.cdda.wav" + )); + } + say('rm tags cover.front'); + + return 0; +} + +if (!caller) { + exit(main(@ARGV)) +} + +1; + diff --git a/fa-flacd b/fa-flacd deleted file mode 100755 index 5716223..0000000 --- a/fa-flacd +++ /dev/null @@ -1,348 +0,0 @@ -#! /usr/bin/python2 - -""" -=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 = ' ' - 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)] - - try: - c(os.unlink, 'cue') - except EnvironmentError, error: - if error.errno != ENOENT: - raise error - - 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)) -- 2.56.0