--- /dev/null
+#!/usr/local/bin/perl
+
+=head1 NAME
+
+B<fa-mp3cd> - Make Universal Disk Format image of MP3 files from FLAC files
+
+=head1 SYNOPSIS
+
+B<fa-mp3cd> [B<-d> I<work-directory>] [B<-o> I<image-file>] [B<-V> I<VOLI>] I<file> [...]
+
+=head1 DESCRIPTION
+
+Each I<file> argument is a single-track[1] FLAC file to be transcoded to MP3
+and stored in the UDF image. By default, place MP3 files in directories named
+after the album title. Change with B<-dirname> options tracks after the
+B<-dirname> go into that dirname. Restore default with B<-diralbum>.[2]
+
+B<fa-mp3cd> starts by validating all input files and planning the layout, then
+looking into I<work-directory> for any files not in the plan, exiting with an
+error if any are found. If files exist that are in the plan, assume this is a
+rerun after crash and resume starting at the last file written. E.g. if
+writing 100 files and crash after finishing file 98 but before starting file
+99, go ahead and rewrite file 98.
+
+=head2 Notes
+
+=over
+
+=item 1.
+Still considering restoring whole-disc FLAC file support.
+
+=item 2.
+Not yet implemented.
+
+=back
+
+=head1 OPTIONS
+
+=over
+
+=item B<-d> I<work-directory>
+
+Path of the directory to write the UDF layout into. Default is the
+current directory.
+
+=item B<-o> I<image-file>
+
+Path of file to write UDF image to. No default: if not specified, B<fa-mp3cd>
+prepares the I<work-directory> but makes no image.
+
+=item B<-V> I<VOLI>
+
+Passed through to B<mkisofs(1)>, which says:
+
+=item B<--no-resume>
+
+Assume files in I<work-directory> are up to date rather than assuming resume
+after crash.
+
+=item B<--print-size>
+
+Run B<mkisofs(1)> to print out the estimated size (in kilobytes) of the image
+as well as how much remains of the 650 MB allowed on CD-ROM.
+
+=over
+
+Specifies the volume ID (volume name or label) to be written into the master
+block. There is space on the disc for 32 characters of information.
+
+=back
+
+Some systems may use this volume ID as the name of a mount point.
+
+=back
+
+=head1 AUTHORS
+
+Written by Eric Gillespie <epg@pretzelnet.org>.
+
+=cut
+
+package epg::flac::archive::mp3::cd;
+
+use v5.12;
+use warnings;
+use FindBin;
+
+# TODO eval hack
+eval {
+ require "$FindBin::Bin/flac2mp3";
+ #require "$FindBin::Bin/tags.pl";
+};
+epg::flac::archive::tags->import(
+ qw[
+ track_tags
+ read_tags_metaflac
+ mangle_for_file_name
+ quote
+ two_digits
+ ]);
+
+sub plan_flac {
+ my $workdir = shift;
+ my $path = shift;
+ my $tags = shift;
+
+ my %track = track_tags($tags);
+ $track{flac} = $path;
+ $track{filename} = mangle_for_file_name(
+ join(' ',
+ (map { two_digits($_) } @{$track{discnumber}}),
+ two_digits($track{tracknumber}),
+ $track{title},
+ )) . '.mp3';
+ $track{dir} = join('/', $workdir, mangle_for_file_name($track{album}));
+ \%track
+}
+
+sub plan {
+ my $workdir = shift;
+ map { plan_flac($workdir, @$_) } @_
+}
+
+# if @layout is partially on disk, return the portion that remains to be written
+sub check_directory {
+ my $workdir = shift;
+ my $workdir_files = shift;
+ my @layout = @_;
+
+ my @remaining;
+ my $last;
+ my %workdir_files = map { ($_, 1) } @$workdir_files;
+ for my $mp3 (@layout) {
+ my $path = join('/', $mp3->{dir}, $mp3->{filename});
+ if (exists($workdir_files{$path})) {
+ delete($workdir_files{$path});
+ $last = $mp3;
+ } else {
+ if (defined($last)) {
+ push(@remaining, $last);
+ undef($last);
+ }
+ push(@remaining, $mp3);
+ }
+ }
+ defined($last) && push(@remaining, $last);
+ if (%workdir_files > 0) {
+ die("unexpected files in $workdir: ", join(' ', sort(keys(%workdir_files))))
+ }
+
+ @remaining
+}
+
+sub read_args {
+ my $workdir = '.'; # TODO
+ my $imagefile; # TODO
+ # TODO other flags
+ $workdir, $imagefile, @_
+}
+
+sub find_files {
+ my $path = shift;
+ # If we come across stupid file names embedded newlines, check_directory will complain about them.
+ open(my $fh, '-|', 'find', $path, '!', '-type', 'd') || die("find $path ! -type d: $!");
+ my @files;
+ while (<$fh>) {
+ chomp;
+ push(@files, $_);
+ }
+ if (!close($fh)) {
+ if ($! == 0) {
+ die("find $path ! -type d exited $?")
+ }
+ die("close(find $path ! -type d): $!")
+ }
+ \@files
+}
+
+sub main {
+ my ($workdir, $imagefile, @flac_files) = read_args(@_);
+
+ # -no-resume would mean skipping preparing the work directory at all.
+ # So that's probably not the right name for the flag.
+ my @layout = check_directory(
+ $workdir,
+ find_files($workdir),
+ plan(
+ $workdir,
+ map { ["$_", read_tags_metaflac($_)] } @flac_files,
+ ));
+ for my $mp3 (@layout) {
+ say('mkdir -p ' . $mp3->{dir});
+ epg::flac::archive::mp3::flac2mp3(join('/', $mp3->{dir}, $mp3->{filename}), $mp3->{flac}, $mp3);
+ }
+
+ my @output;
+ if (defined($imagefile)) {
+ # Have to redirect it; won't accept -o with -reproducible-date!
+ #'-o', quote($imagefile)
+ @output = ('>', quote($imagefile));
+ } else {
+ # "Print estimated filesystem size in multiples of the sector size (2048 bytes)"
+ # Overhead seems to be under 1MB so shoot for total of 649 MB of files.
+ @output = ('-print-size');
+ }
+ # If I cared for fully reproducible image, set all file and directory timestamps:
+ # touch -d `git log -1 '--format=%cd' '--date=format:%FT%T'`
+ say(join(' ',
+ 'mkisofs',
+ @output,
+ #'-reproducible-date', `date '+%Y-%m-%d %H:%M:%S %z'`,
+ #'-reproducible-date', `git log -1 '--format=%ai'`,
+ #'-reproducible-date', quote('2022-03-11 23:37:11 -0600'),
+ '-J',
+ '-full-iso9660-filenames',
+ '-r',
+ '-udf',
+ quote($workdir),
+ ));
+
+ 0
+}
+
+if (!caller) {
+ exit(main(@ARGV))
+}
+
+1;
require "$FindBin::Bin/tags.pl";
epg::flac::archive::tags->import(
qw[
- disc_tags
- read_tags
+ track_tags
+ read_tags_metaflac
mangle_for_file_name
quote
two_digits
]);
sub flac2mp3 {
- my $quoted_flac = quote(shift);
+ my $mp3 = shift;
+ my $flac = shift;
my $tags = shift;
- my ($artist, $album, $date, $discnumber) = disc_tags(%$tags);
-
- # TODO resurrect whole-disc FLAC?
- # Stupid hack: only a single-track file should have the
- # TRACKNUMBER tag, so use it if set for the first pass through
- # the loop. At the end of the loop, we'll set $track for the
- # next run, so this continues to work for multi-track files.
- # if track == None:
- # track = 1
- # else:
- # track = int(track)
-
- my $tracknumber = epg::flac::archive::tags::one(TRACKNUMBER => $tags);
- my $title = epg::flac::archive::tags::one(TITLE => $tags);
- # TODO restore PARTNUMBER and VERSION next time i need them
-
- say('metaflac --export-picture-to=flac2mp3.cover.$$', " $quoted_flac && pic_options=", '"--ti flac2mp3.cover.$$"');
-
# This is an old TODO; what's wrong with --ty ?
# TODO: Look at TDOR, TDRL, TDRC for date.
say(join(' ',
'flac',
'-cd',
- $quoted_flac,
+ quote($flac),
'|',
'lame',
'--id3v2-only',
'--pad-id3v2-size', 0,
'--preset standard',
'--ta',
- quote($artist),
+ quote($tags->{artist}),
'--tl',
- quote($album),
+ quote($tags->{album}),
'--tn',
- quote($tracknumber),
+ quote($tags->{tracknumber}),
'--tt',
- quote($title),
+ quote($tags->{title}),
'--ty',
- quote($date),
+ quote($tags->{date}),
'$pic_options',
#(map { ('--tv', quote("TPE2=$_")) } @{$albumartist}),
- (map { ('--tv', quote("TPOS=$_")) } @{$discnumber}),
+ (map { ('--tv', quote("TPOS=$_")) } @{$tags->{discnumber}}),
'-',
- quote(
- mangle_for_file_name(
- join(' ',
- $artist,
- $album,
- (map { two_digits($_) } @{$discnumber}),
- two_digits($tracknumber),
- $title,
- ))
- . '.mp3'
- )
- ));
- say('unset pic_options');
-}
-
-sub read_tags_metaflac {
- my $fn = shift;
- open(my $fh, '-|', 'metaflac', '--no-utf8-convert', '--export-tags-to=-', $fn) || die("metalfac: $!");
- my @result = read_tags($fh);
- if (!close($fh)) {
- if ($! == 0) {
- die("metaflac exited $?")
- }
- die("close(metaflac): $!")
- }
- @result
+ quote($mp3),
+ ))
}
sub main {
- for my $fn (@_) {
- my ($tags) = read_tags_metaflac($fn);
- flac2mp3($fn, $tags);
+ for my $flac (@_) {
+ say('metaflac --export-picture-to=flac2mp3.cover.$$ ', quote($flac), ' && pic_options="--ti flac2mp3.cover.$$"');
+ my %tags = track_tags(read_tags_metaflac($flac));
+ flac2mp3(
+ mangle_for_file_name(
+ join(' ',
+ $tags{artist},
+ $tags{album},
+ (map { two_digits($_) } @{$tags{discnumber}}),
+ two_digits($tags{tracknumber}),
+ $tags{title},
+ ))
+ . '.mp3',
+ $flac,
+ \%tags,
+ );
+ say('unset pic_options');
}
say('rm -f flac2mp3.cover.$$');
--- /dev/null
+use v5.12;
+use warnings;
+
+use Test::More tests => 7;
+
+use Test::Differences;
+use Test::Exception;
+
+require './tags.pl';
+require './fa-mp3cd';
+
+my @Rising = (
+ { title => ['Tarot Woman'] },
+ { title => ['Run With the Wolf'] },
+ { title => ['Starstruck'] },
+ );
+for (my $i = 0; $i < @Rising; $i++) {
+ my $n = $i + 1;
+ $Rising[$i] = {
+ dir => './Rising',
+ flac => "$n.flac",
+ filename => "$n.mp3",
+ tracknumber => $n,
+ artist => 'Rainbow',
+ album => 'Rising',
+ date => '1976-05-17',
+ %{$Rising[$i]},
+ };
+}
+
+eq_or_diff
+ \@Rising,
+ [epg::flac::archive::mp3::cd::check_directory('.', [], @Rising)],
+ 'empty work directory';
+
+eq_or_diff
+ \@Rising,
+ [epg::flac::archive::mp3::cd::check_directory(
+ '.',
+ ['./Rising/1.mp3'],
+ @Rising)],
+ 'track 1 exists - plan to write 1-3';
+
+eq_or_diff
+ [@Rising[1,2]],
+ [epg::flac::archive::mp3::cd::check_directory(
+ '.',
+ [
+ './Rising/1.mp3',
+ './Rising/2.mp3',
+ ],
+ @Rising)],
+ 'track 1,2 exist - plan to write 2,3';
+
+eq_or_diff
+ [$Rising[2]],
+ [epg::flac::archive::mp3::cd::check_directory(
+ '.',
+ [
+ './Rising/1.mp3',
+ './Rising/2.mp3',
+ './Rising/3.mp3',
+ ],
+ @Rising)],
+ 'track 1-3 exist - plan to write 3';
+
+throws_ok {
+ epg::flac::archive::mp3::cd::check_directory(
+ '.',
+ ['./unexpected1'],
+ @Rising)
+} qr(unexpected files in .: ./unexpected1),
+ '1 unexpected file';
+
+throws_ok {
+ epg::flac::archive::mp3::cd::check_directory(
+ '.',
+ ['./unexpected1', './unexpected2'],
+ @Rising)
+} qr(unexpected files in .: ./unexpected1 ./unexpected2),
+ '2 unexpected files';
+
+throws_ok {
+ epg::flac::archive::mp3::cd::check_directory(
+ '.',
+ [
+ './unexpected',
+ './Rising/1.mp3',
+ './Rising/unexpected',
+ './Rising/2.mp3',
+ './l/o/l',
+ ],
+ @Rising)
+} qr(unexpected files in .: ./Rising/unexpected ./l/o/l ./unexpected),
+ '3 unexpected files, 2 expected';
--- /dev/null
+use v5.12;
+use warnings;
+
+use Test::More tests => 3;
+
+use Test::Differences;
+
+require './tags.pl';
+require './fa-mp3cd';
+
+my @Rising = (
+ { TITLE => ['Tarot Woman'] },
+ { TITLE => ['Run With the Wolf'] },
+ );
+for (my $i = 0; $i < @Rising; $i++) {
+ my $n = $i + 1;
+ $Rising[$i] = [
+ "$n.flac",
+ {
+ TRACKNUMBER => [$n],
+ ARTIST => ['Rainbow'],
+ ALBUM => ['Rising'],
+ DATE => ['1976-05-17'],
+ %{$Rising[$i]},
+ }];
+}
+
+eq_or_diff
+ [
+ {
+ dir => './Rising',
+ flac => '1.flac',
+ filename => '01_Tarot_Woman.mp3',
+ artist => 'Rainbow',
+ album => 'Rising',
+ date => '1976-05-17',
+ discnumber => [],
+ tracknumber => 1,
+ title => 'Tarot Woman',
+ },
+ {
+ dir => './Rising',
+ flac => '2.flac',
+ filename => '02_Run_With_the_Wolf.mp3',
+ artist => 'Rainbow',
+ album => 'Rising',
+ date => '1976-05-17',
+ discnumber => [],
+ tracknumber => 2,
+ title => 'Run With the Wolf',
+ },
+ ],
+ [epg::flac::archive::mp3::cd::plan('.', @Rising)];
+
+eq_or_diff
+ [
+ {
+ dir => 'a/b/fakeal',
+ flac => 'file.flac',
+ filename => '01_faket.mp3',
+ artist => 'fakear',
+ album => 'fakeal',
+ date => 0,
+ discnumber => [],
+ tracknumber => 1,
+ title => 'faket',
+ }
+ ],
+ [
+ epg::flac::archive::mp3::cd::plan(
+ 'a/b',
+ ['file.flac', {
+ ARTIST => ['fakear'],
+ ALBUM => ['fakeal'],
+ DATE => [0],
+ TRACKNUMBER => [1],
+ TITLE => ['faket'],
+ }],
+ )],
+ 'relative work directory';
+
+eq_or_diff
+ [
+ {
+ dir => '/a/b/fakeal',
+ flac => 'file.flac',
+ filename => '01_faket.mp3',
+ artist => 'fakear',
+ album => 'fakeal',
+ date => 0,
+ discnumber => [],
+ tracknumber => 1,
+ title => 'faket',
+ }
+ ],
+ [
+ epg::flac::archive::mp3::cd::plan(
+ '/a/b',
+ ['file.flac', {
+ ARTIST => ['fakear'],
+ ALBUM => ['fakeal'],
+ DATE => [0],
+ TRACKNUMBER => [1],
+ TITLE => ['faket'],
+ }],
+ )],
+ 'absolute work directory';
use Exporter 'import';
our @EXPORT_OK = qw[
- disc_tags
read_tags
+ read_tags_metaflac
+ disc_tags
+ track_tags
+
mangle_for_file_name
quote
two_digits
\%album, \@tracks
}
+sub read_tags_metaflac {
+ my $fn = shift;
+ open(my $fh, '-|', 'metaflac', '--no-utf8-convert', '--export-tags-to=-', $fn) || die("metaflac: $!");
+ my @result = read_tags($fh);
+ if (!close($fh)) {
+ if ($! == 0) {
+ die("metaflac exited $?")
+ }
+ die("close(metaflac): $!")
+ }
+ @result
+}
+
sub one_or_undef {
my $name = shift;
my $tags = shift;
$artist, $album_tag, $date, \@discnumber
}
+# TODO dedup with flac2mp3
+sub track_tags {
+ my $tags = shift;
+ # TODO probably ought to return hash too. then track_tags would augment
+ my ($artist, $album, $date, $discnumber) = disc_tags(%$tags);
+
+ # TODO resurrect whole-disc FLAC?
+ # Stupid hack: only a single-track file should have the
+ # TRACKNUMBER tag, so use it if set for the first pass through
+ # the loop. At the end of the loop, we'll set $track for the
+ # next run, so this continues to work for multi-track files.
+ # if track == None:
+ # track = 1
+ # else:
+ # track = int(track)
+
+ my $tracknumber = epg::flac::archive::tags::one(TRACKNUMBER => $tags);
+ my $title = epg::flac::archive::tags::one(TITLE => $tags);
+ # TODO restore PARTNUMBER and VERSION next time i need them
+
+ (
+ artist => $artist,
+ album => $album,
+ date => $date,
+ discnumber => $discnumber,
+ tracknumber => $tracknumber,
+ title => $title,
+ )
+}
+
sub mangle_for_file_name {
my $fn = shift;
$fn =~ s/[!,.?]//g; # discard these punctuation marks