#!/usr/bin/env perl # # $Id$ # # Converts a directory full of flac files into a corresponding # directory of mp3 files # # Robin Bowes # # Release History: # - See changelog.txt use warnings; use strict; use FindBin; use lib "$FindBin::Bin/lib"; use Audio::FLAC::Header; use Carp; use Data::Dumper; use File::Basename; use File::Copy; use File::Find::Rule; use File::Path; use File::Spec; use File::Temp qw/ cleanup /; use File::Which; use Getopt::Long; use MP3::Tag; use Scalar::Util qw/ looks_like_number /; use Proc::ParallelLoop; # ------- User-config options start here -------- # Assume flac and lame programs are in the path. # If not, put full path to programs here. # # On Windows: # * If you specify a path, you must include the ".exe" extension # * Long filenames are OK, e.g. c:/Program Files/flac/flac.exe # * You can use "/" or "\\" or even "\" as path separator, e.g.: # c:\windows\system32\flac.exe # or # c:/windows/system32/flac.exe # or # c:\\windows\\system32\\flac.exe our $flaccmd = 'flac'; our $lamecmd = 'lame'; our $numcores = 2; # Modify lame options if required our @lameargs = qw ( -V2 -h --quiet --nohist --vbr-new --resample 44100 ); # -------- User-config options end here --------- our @flacargs = qw ( --decode --stdout --silent ); # hash mapping FLAC tag names to MP3 frames our %MP3frames = ( 'ALBUM' => 'TALB', #'BAND' => 'TPE2', 'ALBUMARTIST' => 'TPE2', 'ARTIST' => 'TPE1', 'BPM' => 'TBPM', 'COMMENT' => 'COMM', 'COMPILATION' => 'TCMP', 'COMPOSER' => 'TCOM', 'CONDUCTOR' => 'TPE3', 'DATE' => 'TYER', 'DISCNUMBER' => 'TPOS', 'GENRE' => 'TCON', 'ISRC' => 'TSRC', 'LYRICIST' => 'TEXT', 'PUBLISHER' => 'TPUB', 'TITLE' => 'TIT2', 'TRACKNUMBER' => 'TRCK', 'MUSICBRAINZ_ALBUMID' => 'TXXX', 'MUSICBRAINZ_ALBUMSTATUS' => 'TXXX', 'MUSICBRAINZ_ALBUMTYPE' => 'TXXX', 'MUSICBRAINZ_ARTISTID' => 'TXXX', 'MUSICBRAINZ_SORTNAME' => 'TXXX', 'MUSICBRAINZ_TRACKID' => 'UFID', 'MUSICBRAINZ_TRMID' => 'TXXX', 'MD5' => 'TXXX', 'GAPLESS' => 'COMM', 'REPLAYGAIN_TRACK_PEAK' => 'TXXX', 'REPLAYGAIN_TRACK_GAIN' => 'TXXX', 'REPLAYGAIN_ALBUM_PEAK' => 'TXXX', 'REPLAYGAIN_ALBUM_GAIN' => 'TXXX', ); our %MP3frametexts = ( 'COMMENT' => 'Short Text', 'MD5' => 'MD5', 'GAPLESS' => 'iTunPGAP', 'MUSICBRAINZ_ALBUMARTISTID' => 'MusicBrainz Album Artist Id', 'MUSICBRAINZ_ALBUMID' => 'MusicBrainz Album Id', 'MUSICBRAINZ_ALBUMSTATUS' => 'MusicBrainz Album Status', 'MUSICBRAINZ_ALBUMTYPE' => 'MusicBrainz Album Type', 'MUSICBRAINZ_ARTISTID' => 'MusicBrainz Artist Id', 'MUSICBRAINZ_SORTNAME' => 'MusicBrainz Sortname', 'MUSICBRAINZ_TRACKID' => 'MusicBrainz Trackid', 'MUSICBRAINZ_TRMID' => 'MusicBrainz TRM Id', 'REPLAYGAIN_TRACK_PEAK' => 'REPLAYGAIN_TRACK_PEAK', 'REPLAYGAIN_TRACK_GAIN' => 'REPLAYGAIN_TRACK_GAIN', 'REPLAYGAIN_ALBUM_PEAK' => 'REPLAYGAIN_ALBUM_PEAK', 'REPLAYGAIN_ALBUM_GAIN' => 'REPLAYGAIN_ALBUM_GAIN', ); # Hash telling us which key to use if a complex frame hash is encountered # For example, the COMM frame is complex and returns a hash with the # following keys (with example values): # 'Language' => 'ENG' # 'Description' => 'Short Text' # 'Text' => 'This is the actual comment field' # # In this case, we want to use the "Description" to check if this is the # correct frame. # We always grab the "Text" for the frame data. our %Complex_Frame_Keys = ( 'COMM' => 'Description', 'TXXX' => 'Description', 'UFID' => '_Data' ); our %Options; # Catch interupts (SIGINT) $SIG{INT} = \&INT_Handler; GetOptions( \%Options, "quiet!", "tagdiff", "debug!", "tagsonly!", "force!", "usage", "help", "version", "pretend", ); # info flag is the inverse of --quiet $::Options{info} = !$::Options{quiet}; package main; # Turn off output buffering (makes debugging easier) $| = 1; # Do I need to set the default value of any options? # Or does GetOptions handle it? # If I do, what's the "best" way to do it? my ( $srcdirroot, $destdirroot ) = @ARGV; showversion() if ( $::Options{version} ); showhelp() if ( $::Options{help} ); showusage() if ( !defined $srcdirroot || !defined $destdirroot || $::Options{usage} ); my $pretendString = ''; $pretendString = '** Pretending ** ' if $::Options{pretend}; # Check flac and lame are found # First see if the specified command is executable. # If not, look in path foreach my $cmd ( $flaccmd, $lamecmd ) { my $cmdpath; if ( -x $cmd ) { $cmdpath = $cmd; } else { $cmdpath = which($cmd); } croak "$cmd not found" unless $cmdpath; $::Options{info} && msg("Using $cmd from: $cmdpath"); } # Convert directories to absolute paths $srcdirroot = File::Spec->rel2abs($srcdirroot); $destdirroot = File::Spec->rel2abs($destdirroot); die "Source directory not found: $srcdirroot\n" unless -d $srcdirroot; # count all flac files in srcdir # Display a progress report after each file, e.g. Processed 367/4394 files # Possibly do some timing and add a Estimated Time Remaining # Will need to only count files that are going to be processed. # Hmmm could get complicated. # Change directory into srcdirroot chdir $srcdirroot; $::Options{info} && msg( $pretendString . "Processing directory: $srcdirroot" ); # Now look for files in the current directory # (following symlinks) my @flac_files = File::Find::Rule->file()->extras( { follow => 1 } )->name( qr/\.flac/i ) ->in('.'); my @jpg_files = File::Find::Rule->file()->extras( { follow => 0 } )->name( qr/\.jpg/i )->not_symlink ->in('.'); $::Options{debug} && msg( Dumper(@flac_files) ); if ( $::Options{info} ) { my $file_count = @flac_files; # array in scalar context returns no. items my $jpg_count = @jpg_files; my $files_word = 'file'; my $jpg_word = 'jpeg'; if ( $file_count > 1 ) { $files_word .= 's'; } if ( $jpg_count > 1 ) { $jpg_word .= 's'; } msg("$file_count flac $files_word found, $jpg_count $jpg_word found. Sorting..."); } @flac_files = sort @flac_files; @jpg_files = sort @jpg_files; $::Options{info} && msg("Sort complete."); # Get directories from destdirroot and put in an array my ( $dstroot_volume, $dstroot_dirpath, $dstroot_file ) = File::Spec->splitpath( $destdirroot, 1 ); my @dstroot_dirs = File::Spec->splitdir($dstroot_dirpath); #foreach my $src_file (@flac_files) { pareach [ @flac_files ], sub { my $src_file = shift; # Get directories in src file and put in an array my ( $src_volume, $src_dirpath, $src_filename ) = File::Spec->splitpath($src_file); my @src_dirs = File::Spec->splitdir($src_dirpath); # Join together dst_root and src_dirs my @dst_dirs; push @dst_dirs, @dstroot_dirs; push @dst_dirs, @src_dirs; # Join all the dst_dirs back together again my $dst_dirpath = File::Spec->catdir(@dst_dirs); # Get the basename of the src file my ( $src_base, $src_dir, $src_ext ) = fileparse( $src_filename, qr{\.flac} ); # Now join it all together to get the complete path of the dest_file my $dst_file = File::Spec->catpath( $dstroot_volume, $dst_dirpath, $src_base . '.mp3' ); my $dst_dir = File::Spec->catpath( $dstroot_volume, $dst_dirpath, '' ); convert_file( $src_file, $dst_dir, $dst_file ); }, { Max_Workers => $numcores }; #} foreach my $src_jpg (@jpg_files) { my ( $src_volume, $src_dirpath, $src_filename ) = File::Spec->splitpath($src_jpg); my @src_dirs = File::Spec->splitdir($src_dirpath); my @dst_dirs; push @dst_dirs, @dstroot_dirs; push @dst_dirs, @src_dirs; my $dst_dirpath = File::Spec->catdir(@dst_dirs); my $dst_file = File::Spec->catpath( $dstroot_volume, $dst_dirpath, $src_filename ); my $dst_dir = File::Spec->catpath( $dstroot_volume, $dst_dirpath, '' ); if ( ! -e $dst_file ) { $::Options{debug} && msg("Copying $src_jpg"); copy($src_jpg,$dst_file); } else { $::Options{debug} && msg("$src_jpg exists, skipping"); } } 1; sub showusage { print <<"EOT"; Usage: $0 [--quiet] [--debug] [--tagsonly] [--force] --quiet Disable informational output to stdout --debug Enable debugging output. For developers only! --tagsonly Don't do any transcoding - just update tags --pretend Don't actually do anything --force Force transcoding and tag update even if not required --tagdiff Print source/dest tag values if different EOT exit 0; } sub msg { my $msg = shift; print "$msg\n"; } sub convert_file { my ( $srcfilename, $dst_dir, $destfilename ) = @_; $::Options{debug} && msg("srcfile: '$srcfilename'"); $::Options{debug} && msg("destfile: '$destfilename'"); # create object to access flac tags my $srcfile = Audio::FLAC::Header->new($srcfilename); # get tags from flac file my $srcframes = $srcfile->tags(); $::Options{debug} && msg "Tags from source file:\n" . Dumper $srcframes; # hash to hold tags that will be updated my %frames_to_update; # weed out tags not valid in destfile foreach my $frame ( keys %$srcframes ) { if ( $MP3frames{$frame} ) { $frames_to_update{$frame} = $srcframes->{$frame}; } } # get MD5 checksdum from flac file and add to frames_to_update hash $frames_to_update{'MD5'} = $srcfile->info('MD5CHECKSUM'); $frames_to_update{'GAPLESS'} = 1; # Fix up TRACKNUMBER if ( $frames_to_update{'TRACKNUMBER'} ) { my $fixeduptracknumber = fixUpTrackNumber( $frames_to_update{'TRACKNUMBER'}, $srcfilename ); if ( $fixeduptracknumber ne $frames_to_update{'TRACKNUMBER'} ) { $frames_to_update{'TRACKNUMBER'} = $fixeduptracknumber; } } if ( $::Options{debug} ) { msg("Tags we know how to deal with from source file:"); msg( Dumper \%frames_to_update ); } # Initialise file processing flags my %pflags = ( exists => 0, # assume file doesn't exist md5 => 1, # and the md5 checksum doesn't match tags => 0, # and the tags match (this will be set if tags # don't match ); # if destfile already exists if ( -e $destfilename ) { $pflags{exists} = 1; $::Options{debug} && msg("destfile exists: '$destfilename'"); # General approach: # Transcode the file if destfile md5 tag is different than the srcfile md5 # Update the tags if tags are different # Get tags from dst file and compare $::Options{debug} && msg("Comparing tags"); # Compare tags; build hash of changed tags; my $mp3 = MP3::Tag->new($destfilename); my @tags = $mp3->get_tags; $::Options{debug} && msg( Dumper @tags ); my $ID3v2 = $mp3->{"ID3v2"}; # If an ID3v2 tag is found if ( defined $ID3v2 ) { $::Options{debug} && msg("ID3v2 tag found"); # loop over all valid destfile frames foreach my $frame ( keys %MP3frames ) { $::Options{debug} && msg("frame is '$frame'"); # To do: Check the frame is valid # Specifically, make sure the GENRE is one of the standard ID3 tags my $method = $MP3frames{$frame}; $::Options{debug} && msg("method is '$method'"); # Check for tag in destfile my ( $tagname, @info ) = $ID3v2->get_frames($method); $::Options{debug} && msg( "values from id3v2 tags:\n" . Dumper \$tagname, \@info ); my $dest_text = ''; # check for complex frame (e.g. Comments) TAGLOOP: foreach my $tag_info (@info) { if ( ref $tag_info ) { my $cfname = $MP3frametexts{$frame}; my $cfkey = $Complex_Frame_Keys{$method}; if ( $$tag_info{$cfkey} eq $cfname ) { $dest_text = $$tag_info{'Text'}; if ( $frame eq 'MD5' ) { $pflags{md5} = ( $frames_to_update{'MD5'} ne $dest_text ); if ( $::Options{debug} ) { msg("\$pflags{md5} is " . ( $pflags{md5} ? 'set' : 'not set' ) ); } } last TAGLOOP; } } else { $dest_text = $tag_info; } } $::Options{debug} && msg( "\$dest_text: " . Dumper $dest_text ); # Fix up TRACKNUMBER if ( $frame eq 'TRACKNUMBER' ) { my $fixeduptracknumber = fixUpTrackNumber( $dest_text, $destfilename ); if ( $fixeduptracknumber ne $dest_text ) { $dest_text = $fixeduptracknumber; } } # get tag from srcfile my $srcframe = utf8toLatin1( $frames_to_update{$frame} ); $srcframe = '' if ( !defined $srcframe ); # Strip trailing spaces from src frame value $srcframe =~ s/ *$//; # If set the flag if any frame is different if ( $dest_text ne $srcframe ) { $pflags{tags} = 1; if ( $::Options{tagdiff} ) { msg("frame: '$frame'"); msg("srcframe value: '$srcframe'"); msg("destframe value: '$dest_text'"); } } } } else { # no ID2V2 object found so set the flag so the tags get written $pflags{tags} = 1; } } if ( $::Options{debug} ) { msg("pf_exists: $pflags{exists}"); msg("pf_tags: $pflags{tags}"); msg("pf_md5: $pflags{md5}\n"); } if ( $::Options{debug} ) { msg("Tags to be written if tags need updating\n"); msg( Dumper \%frames_to_update ); } # Building command used to convert file (tagging done afterwards) # Needs some work on quoting filenames containing special characters my $quotedsrc = $srcfilename; my $quoteddest = $destfilename; if ( ( !$pflags{exists} || $pflags{md5} || $::Options{force} ) && !$::Options{tagsonly} ) { $::Options{info} && msg( $pretendString . "Transcoding \"$quotedsrc\"" ); # Transcode to a temp file in the destdir. # Rename the file if the conversion completes sucessfully # This avoids leaving incomplete files in the destdir # If we're "pretending", don't create a File::Temp object my $tmpfilename; my $tmpfh; if ( $::Options{pretend} ) { $tmpfilename = $quoteddest; } else { # Create the destination directory if it # doesn't already exist mkpath($dst_dir) or die "Can't create directory $dst_dir\n" unless -d $dst_dir; $tmpfh = new File::Temp( UNLINK => 1, DIR => $dst_dir, SUFFIX => '.tmp' ); $tmpfilename = $tmpfh->filename; } my $convert_command = "\"$flaccmd\" @flacargs \"$quotedsrc\"" . "| \"$lamecmd\" @lameargs - \"$tmpfilename\""; $::Options{debug} && msg("$convert_command"); # Convert the file (unless we're pretending} my $exit_value; if ( !$::Options{pretend} ) { $exit_value = system($convert_command); } else { $exit_value = 0; } $::Options{debug} && msg("Exit value from convert command: $exit_value"); if ($exit_value) { msg("$convert_command failed with exit code $exit_value"); # delete the destfile if it exists unlink $destfilename; # should check exit status of this command exit($exit_value); } if ( !$::Options{pretend} ) { # If we get here, assume the conversion has succeeded $tmpfh->unlink_on_destroy(0); $tmpfh->close; croak "Failed to rename '$tmpfilename' to '$destfilename' $!" unless rename( $tmpfilename, $destfilename ); # the destfile now exists! $pflags{exists} = 1; # and the tags need writing $pflags{tags} = 1; } } if ( $::Options{debug} ) { msg("pf_exists: $pflags{exists}"); msg("pf_tags: $pflags{tags}"); msg( "\$::Options{pretend}: " . ( $::Options{pretend} ? 'set' : 'not set' ) ); } # Write the tags if ($pflags{exists} && ( $pflags{tags} || $::Options{force} ) ) { $::Options{info} && msg( $pretendString . "Writing tags to \"$quoteddest\"" ); if ( !$::Options{pretend} ) { my $mp3 = MP3::Tag->new($destfilename); # Remove any existing tags $mp3->{ID3v2}->remove_tag if exists $mp3->{ID3v2}; # Create a new tag $mp3->new_tag("ID3v2"); foreach my $frame ( keys %frames_to_update ) { $::Options{debug} && msg("changedframe is '$frame'"); # To do: Check the frame is valid # Specifically, make sure the GENRE is one of the standard ID3 tags my $method = $MP3frames{$frame}; $::Options{debug} && msg("method is $method"); # Convert utf8 string to Latin1 charset my $framestring = utf8toLatin1( $frames_to_update{$frame} ); # Only add the frame if framestring is not empty if ($framestring) { $::Options{debug} && msg("Setting $frame = '$framestring'"); # COMM, TXX, and UFID are Complex frames that must be # treated differently. if ( $method eq "COMM" ) { my $frametext = $MP3frametexts{$frame}; $frametext = "Short Text" if ( !( defined($frametext) ) ); $mp3->{"ID3v2"} ->add_frame( $method, 'eng', $frametext, $framestring ); } elsif ( $method eq "TXXX" ) { my $frametext = $MP3frametexts{$frame}; $frametext = $frame if ( !( defined($frametext) ) ); $mp3->{"ID3v2"}->add_frame( $method, 0, $frametext, $framestring ); } elsif ( $method eq 'UFID' ) { my $frametext = $MP3frametexts{$frame}; $mp3->{'ID3v2'} ->add_frame( $method, $framestring, $frametext ); } else { $mp3->{"ID3v2"}->add_frame( $method, $framestring ); } } } $mp3->{ID3v2}->write_tag; $mp3->close(); # should we optionally reset the destfile timestamp to the same as the srcfile # utime $srcstat->mtime, $srcstat->mtime, $destfilename; } } } sub INT_Handler { my $signame = shift; die "Exited with SIG$signame\n"; } sub utf8toLatin1 { my $data = shift; # Don't run the substitution on an empty string if ($data) { $data =~ s/([\xC0-\xDF])([\x80-\xBF])/chr(ord($1)<<6&0xC0|ord($2)&0x3F)/eg; $data =~ s/[\xE2][\x80][\x99]/'/g; } return $data; } sub fixUpTrackNumber { my ( $trackNum, $filename ) = @_; # Check TRACKNUMBER tag is not empty if ($trackNum) { # Check TRACKNUMBER tag is numeric if ( looks_like_number($trackNum) ) { $trackNum = sprintf( "%02u", $trackNum ); } else { $::Options{info} && msg("TRACKNUMBER not numeric in $filename"); } } return $trackNum; } # vim:set softtabstop=4: # vim:set shiftwidth=4: __END__