All pastes #1555793 Raw Edit

flac2mp3

public text v1 · immutable
#1555793 ·published 2009-09-06 05:24 UTC
rendered paste body
#!/usr/bin/env perl
#
# $Id$
#
# Converts a directory full of flac files into a corresponding
# directory of mp3 files
#
# Robin Bowes <robin@robinbowes.com>
#
# 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] <flacdir> <mp3dir>
    --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__