]> diplodocus.org Git - flac-archive/commitdiff
Replace fa-flacd with fa-encode.
authorEric Gillespie <epg@pretzelnet.org>
Fri, 18 Feb 2022 05:47:54 +0000 (23:47 -0600)
committerEric Gillespie <epg@pretzelnet.org>
Fri, 18 Feb 2022 05:47:54 +0000 (23:47 -0600)
fa-flacd was python TWO!

fa-encode [new file with mode: 0755]
fa-flacd [deleted file]

diff --git a/fa-encode b/fa-encode
new file mode 100755 (executable)
index 0000000..5a8a72d
--- /dev/null
+++ b/fa-encode
@@ -0,0 +1,193 @@
+#!/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;
+
diff --git a/fa-flacd b/fa-flacd
deleted file mode 100755 (executable)
index 5716223..0000000
--- a/fa-flacd
+++ /dev/null
@@ -1,348 +0,0 @@
-#! /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))