--- /dev/null
+#!/usr/local/bin/perl
+
+=head1 NAME
+
+B<fa-encode> - encode WAV files to FLAC
+
+=head1 SYNOPSIS
+
+B<fa-encode> [B<-d> I<artist-directory>] B<input-directory>
+
+I<artist-directory> defaults to the album B<ARTIST> 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;
+
+++ /dev/null
-#! /usr/bin/python2
-
-"""
-=head1 NAME
-
-B<fa-flacd> - archive CDs to single FLAC files
-
-=head1 SYNOPSIS
-
-B<fa-flacd> [B<-j> I<jobs>] [B<-v>]
-
-=head1 DESCRIPTION
-
-B<fa-flacd> and B<fa-rip> together comprise B<flac-archive>, a system
-for archiving audio CDs to single FLAC files. B<fa-flacd> 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<fa-rip> generates the inputs for B<fa-flacd>: 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<fa-flacd> processes the
-temporary directories into per-album files in the artist directories.
-
-Every 5 seconds, B<fa-flacd> scans its current directory for
-directories with a file called "tags" and creates a processing job for
-each one. The number of jobs B<fa-flacd> attempts to run is
-controlled by the B<-j> option and defaults to 4. B<fa-flacd> 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<ra-flacd> 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<fa-flacd> 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<fa-flacd> 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<flac2mp3>, 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<fa-flacd>, 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<jobs>
-
-Run up to I<jobs> jobs instead of the default 4.
-
-=item B<-v> [B<--verbose>]
-
-Print diagnostic information.
-
-=back
-
-=head1 AUTHORS
-
-Written by Eric Gillespie <epg@pretzelnet.org>.
-
-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))