commit 6371d65baff77943e4702e40049fa1e81e08317f Author: snow flurry Date: Fri Aug 19 12:54:06 2022 -0700 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d9d1b8 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# This drawer contains no socks + +Just a dumping ground for random scripts and trinkets that I use. diff --git a/flacconv.pl b/flacconv.pl new file mode 100755 index 0000000..1d05c5a --- /dev/null +++ b/flacconv.pl @@ -0,0 +1,276 @@ +#!/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} } + } + } + } +}