sock-drawer/flacconv.pl
2022-08-19 12:54:06 -07:00

277 lines
8.4 KiB
Perl
Executable file

#!/usr/bin/perl
use strict;
use warnings;
use utf8;
# use open qw( :std :encoding(UTF-8) );
use Audio::FLAC::Header;
use Carp;
use Data::Dumper;
use Encode;
use File::Copy;
use File::Find::Rule;
use File::Path qw(make_path);
use File::Spec qw(catfile catdir);
use File::Which qw(which);
use List::MoreUtils qw(uniq);
use Path::Tiny qw(path);
use Readonly;
use Scalar::Util qw(looks_like_number reftype);
my $prgmname = $0;
my ($path, $destdir, $filetype) = @ARGV;
my $debug = $ENV{DEBUG} or 0;
my $ffmpeg;
# For metadata arguments to ffmpeg
Readonly my $METATAG_FMT => '%s=%s';
# Database to map "normalized" FLAC tags to lossy tags.
# Arguably there might be better "algorithmic" ways to do this, but
# I like having a flat quasi-database to look up how tags are mapped.
my $tagmap = {
mp3 => {
"ALBUMARTIST" => "album_artist",
"DISCNUMBER" => "disc",
"TRACKNUMBER" => "track",
"TRACKTOTAL" => "TRACKTOTAL",
"ARTIST" => "artist",
"ALBUM" => "album",
"DATE" => "date",
"GENRE" => "genre",
"TITLE" => "title",
},
m4a => {
"ALBUMARTIST" => "album_artist",
"DISCNUMBER" => "disc",
"TRACKNUMBER" => "track",
"TRACKTOTAL" => "TRACKTOTAL",
"ARTIST" => "artist",
"ALBUM" => "album",
"DATE" => "date",
"GENRE" => "genre",
"TITLE" => "title",
},
opus => {
"ALBUMARTIST" => ["ALBUMARTIST", "album_artist"],
"DISCNUMBER" => "DISCNUMBER",
"TRACKNUMBER" => "TRACKNUMBER",
"TRACKTOTAL" => "TRACKTOTAL",
"ARTIST" => "ARTIST",
"ALBUM" => "ALBUM",
"DATE" => "DATE",
"GENRE" => "GENRE",
"TITLE" => "TITLE",
}
};
my $codecmap = {
mp3 => ['-c:a', 'libmp3lame', '-q:a', '0'],
m4a => ['-c:a', 'libfdk_aac', '-vbr', '5', '-c:v', 'copy', '-disposition:v:1', 'attached_pic'],
# opus uses -vn because some clients (android) get confused and
# think it's video if there's a "video frame" there
opus => ['-vn', '-c:a', 'libopus', '-b:a', '160K', '-f', 'ogg']
};
sub usage {
my ($exterr) = shift;
if (defined $exterr) {
print STDERR "err: $exterr\n";
}
print STDERR "usage: $0 path destdir [filetype]\n";
print STDERR " filetype can be 'm4a', 'mp3', or 'opus' (default: opus)\n";
exit 1
}
# get "normalized" FLAC tags
sub get_tags {
my $flacname = shift;
my %flactags = do {
my $flacobj = Audio::FLAC::Header->new($flacname);
my $fobjtag = $flacobj->tags();
map {
uc($_) => Encode::decode('UTF-8', $fobjtag->{$_}, Encode::FB_CROAK)
} %$fobjtag;
};
# vorbis comments don't really have a "standard" tag set, so the
# album artist might be stored a couple different ways. this is
# necessary for my weird tastes in folder structure.
if (not exists $flactags{"ALBUMARTIST"}) {
if (exists $flactags{"ALBUM_ARTIST"}) {
$flactags{"ALBUMARTIST"} = $flactags{"ALBUM_ARTIST"};
} else {
carp("Couldn't get album artist for " . $flactags{"ALBUM"});
return;
}
}
if (exists $flactags{"TRACKNUMBER"}) {
# split tracknumber into TRACKNUMBER and TRACKTOTAL
if ($flactags{"TRACKNUMBER"} =~ m{^(\d+)/(\d+)$}x) {
$flactags{"TRACKNUMBER"} = $1;
$flactags{"TRACKTOTAL"} = $2;
}
}
if (exists $flactags{"DISCNUMBER"}) {
# DISCNUMBER can be X/Y, so we'll leave it be and use DISCIND
# for later filename formatting
if ($flactags{"DISCNUMBER"} =~ m{^(\d+)/(\d+)$}x) {
$flactags{"DISCIND"} = $1;
$flactags{"DISCTOTAL"} = $2;
} else {
$flactags{"DISCIND"} = $flactags{"DISCNUMBER"};
}
}
return \%flactags;
}
# Actual ffmpeg conversion
sub do_convert {
my $flacname = shift;
my $flactags = &get_tags($flacname);
if (not defined $flactags) {
carp("Couldn't get FLAC metadata for $flacname!");
return;
}
my @pathdirs = ($destdir,
$flactags->{"ALBUMARTIST"},
$flactags->{"ALBUM"});
my $outpath = File::Spec->catdir(@pathdirs);
make_path($outpath) if (! -d $outpath);
$flactags->{"TITLE"} =~ s,/,-,g;
# Create the destination path
my $fname = sprintf("%02d %s.%s", $flactags->{"TRACKNUMBER"},
$flactags->{"TITLE"},
$filetype);
# Prepend the disc number to the track number
# e.g., 2-05 My Song.mp3
if (exists $flactags->{"DISCIND"}) {
# If DISCTOTAL exists, we can confirm if there are more than
# one disc in the collection.
if (!exists $flactags->{"DISCTOTAL"}
or $flactags->{"DISCTOTAL"} > 1) {
$fname = $flactags->{"DISCIND"} . "-$fname";
}
}
my $outfile = File::Spec->catfile(@pathdirs, $fname);
if (-e $outfile) {
print "$outfile already exists, ignoring...\n";
return $outfile;
}
# ffmpeg -i <flacname> <codec_args> -map_metadata -1 ...
my @ffargs = ($ffmpeg, "-i", $flacname,
@{$codecmap->{$filetype}},
'-map_metadata', '-1'); # don't copy metadata
# Generate the args to map metadata
my $tm = $tagmap->{$filetype};
for my $tag (keys %$flactags) {
if (exists $tm->{$tag}) {
my $rt = (reftype($tm->{$tag}) or 'none');
if ($rt eq 'ARRAY') {
# one FLAC tag applied to multiple $filetype tags
push (@ffargs, '-metadata',
sprintf($METATAG_FMT, $_, $flactags->{$tag}))
for @{ $tm->{$tag} };
} else {
# 1:1 tagging
push(@ffargs, '-metadata',
sprintf($METATAG_FMT, $tm->{$tag}, $flactags->{$tag}));
}
}
}
# ... <outfile>
push(@ffargs, $outfile);
print "CMD: @ffargs\n" if (exists $ENV{"FLACCONV_DEBUG"}
and $ENV{"FLACCONV_DEBUG"} == 1);
if (system(@ffargs) == 0) {
return $outfile;
} else {
carp("Failed to process $flacname!");
return;
}
}
if (!defined $path || !defined $destdir) {
&usage;
}
$filetype = "opus" if not defined $filetype;
&usage("Filetype $filetype not supported")
if not exists $tagmap->{$filetype};
$ffmpeg = which("ffmpeg") or croak("Could not find ffmpeg in PATH!");
if (-e $destdir) {
croak("$destdir exists but isn't a directory!") if not -d $destdir;
} else {
make_path($destdir);
}
# map of output dirs to their source dirs
my %outdirs;
if (-f $path) {
# single file? no problem..........
my $outfile = &do_convert($path);
croak("Failed to process $path in single-file mode, bailing out!")
if not defined $outfile;
my $outdir = path($outfile)->parent->stringify;
my $indir = path($path)->parent->stringify;
$outdirs{$outdir} = [$indir];
} elsif (-d $path) {
my @flacfiles = File::Find::Rule->file->name('*.flac')->in($path);
my @outfiles;
for (@flacfiles) {
my $decfile = Encode::decode('UTF-8', $_, Encode::FB_CROAK);
print "Reading $decfile\n";
my $outfile = &do_convert($decfile);
last if not defined $outfile;
# add outfile dir to outdir
my $outdir = path($outfile)->parent->stringify;
my $indir = path($decfile)->parent->stringify;
$outdirs{$outdir} = [] unless exists $outdirs{$outdir};
print "dbg: pushing $outdir to $indir\n" if $debug;
push(@{ $outdirs{$outdir} }, $indir);
}
} else {
croak("$path is neither a directory nor a file... does it exist?");
}
# copy cover.{png,jpg} to the destination dir
for my $dir (keys %outdirs) {
print "dbg: $dir = $outdirs{$dir}\n" if $debug;
for my $id (@{ $outdirs{$dir} }) {
print "dbg: opening $id\n" if $debug;
opendir IND, $id or last;
my ($cfile) = grep(/(cover|folder)\.(png|jpe?g)/xi, readdir IND);
closedir IND;
if (defined $cfile) {
# get file extension
if ($cfile =~ /.+\.(.+)?$/) {
my $copath = path($dir)->child("cover.$1");
print "$cfile -> $copath\n";
copy(File::Spec->catfile($id, $cfile), $copath)
or carp("Unable to copy $cfile: $!");
last; # @{ $outdirs{$dir} }
}
}
}
}