#!/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 -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})); } } } # ... 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} } } } } }