diff --git a/flac2mp3.pl b/flac2mp3.pl index 0d944b8..c753c5c 100755 --- a/flac2mp3.pl +++ b/flac2mp3.pl @@ -47,13 +47,22 @@ # c:/windows/system32/flac.exe # or # c:\\windows\\system32\\flac.exe -my $flaccmd = 'flac'; -my $lamecmd = 'lame'; + +my ($flaccmd, $lamecmd); +if ($^O eq 'MSWin32' or $^O eq 'MSWin64') { + # This is an example of typical Windows filepaths. + # Change them if necessary: + $flaccmd = q|C:\Program Files (x86)\FLAC\flac.exe|; + $lamecmd = q|C:\Program Files\LAME\lame.exe|; +} +else { + $flaccmd = 'flac'; + $lamecmd = 'lame'; +} # Modify lame options if required my @lameargs = qw ( --noreplaygain - --vbr-new -V 2 -h --nohist @@ -153,7 +162,7 @@ GetOptions( \%Options, "quiet!", "tagdiff", "debug!", "tagsonly!", "force!", "usage", "help", "version", "pretend", "skipfile!", "skipfilename=s", - "processes=i", "tagseparator=s", "lameargs=s", "copyfiles" + "processes=i", "tagseparator=s", "lameargs=s", "copyfiles", "delete" ); # info flag is the inverse of --quiet @@ -191,9 +200,13 @@ $cmdpath = which($cmd); } croak "$cmd not found" unless $cmdpath; - $Options{info} && msg("Using $cmd from: $cmdpath"); + msg_info("Using $cmd from: $cmdpath"); } +# Turn off unsyncing, due to broken implementation in +# some software (such as Logitech Media Server) +MP3::Tag->config(id3v23_unsync => 0); + # Convert directories to absolute paths $source_root = File::Spec->rel2abs($source_root); $target_root = File::Spec->rel2abs($target_root); @@ -207,20 +220,23 @@ # Will need to only count files that are going to be processed. # Hmmm could get complicated. -$Options{info} - && msg( $pretendString . "Processing directory: $source_root" ); +msg_info( $pretendString . "Processing directory: $source_root" ); # Now look for files in the source dir # (following symlinks) +my %flac_mp3_files = get_all_paths('name', '.flac', $source_root, $target_root, '.mp3'); +my @flac_files = sort keys { %flac_mp3_files }; + +my $file_count = scalar @flac_files; +msg_info( "Found $file_count flac file" . ( $file_count > 1 ? 's' : '' . "\n" ) ); -my @flac_files = @{ find_files( $source_root, qr/\.flac$/i ) }; +# If allowed, delete surplus files and folders from target directory, keeping +# it in perfect sync with (i.e. a mirror of) the source directory. +delete_excess_files_from_dest($source_root, $target_root) if ( $Options{delete} ) ; -# Get directories from target_dir and put in an array -my ( $target_root_volume, $target_root_path, $target_root_file ) = File::Spec->splitpath( $target_root, 1 ); -my @target_root_elements = File::Spec->splitdir($target_root_path); # use parallel processing to launch multiple transcoding processes -msg("Using $Options{processes} transcoding processes.\n"); +msg_info("Using $Options{processes} transcoding processes.\n"); my $pm = new Parallel::ForkManager($Options{processes}); foreach my $src_file (@flac_files) { $pm->start and next; # Forks and returns the pid for the child @@ -231,19 +247,17 @@ if ( $Options{copyfiles} ) { - my @non_flac_files - = sort File::Find::Rule->file()->extras( { follow => 1 } )->not_name(qr/\.flac$/i) - ->in($source_root); + my %non_flac_files = get_all_paths('not_name', '.flac', $source_root, $target_root, ''); + my @non_flac_files = keys %non_flac_files; my $non_flac_file_count = scalar @non_flac_files; - $Options{info} && - msg( "Found $non_flac_file_count non-flac file" .( $non_flac_file_count != 1 ? 's' : '' . "\n" ) ); + msg_info( "Found $non_flac_file_count non-flac file" .( $non_flac_file_count != 1 ? 's' : '' . "\n" ) ); # Copy non-flac files from source to dest directories my $t0 = time; my $cntr_all = 0; my $cntr_copied = 0; foreach my $src_file (@non_flac_files) { - my ($dst_dir, $dst_file) = get_dest_file_path_non_flac($src_file); + my $dst_file = $non_flac_files{$src_file}; # Flag which determines if file should be copied: my $do_copy = 1; # Don't copy file if it already exists in dest directory and @@ -256,11 +270,13 @@ }; } 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; + # Create the destination directory if it + # doesn't already exist + (undef, my $dst_dir) = + File::Basename::fileparse($dst_file); # retrieve directory name + unless ( $Options{pretend} || -d $dst_dir ) { + mkpath($dst_dir) or die "Can't create directory $dst_dir\n"; + } }; if ( $do_copy ) { unless ( $Options{pretend} ) { @@ -270,35 +286,14 @@ }; $cntr_all ++; # Show the progress every second - if ( ((time - $t0) >= 1) || ($cntr_all==$non_flac_file_count) ) { + if ( $Options{info} && + ( ((time - $t0) >= 1) || ($cntr_all==$non_flac_file_count) ) ) { $t0 = time; - print("\r" . $pretendString . $cntr_copied . " non-flac files of " . $cntr_all ." were copied to dest directories."); + print("\r" . $pretendString . $cntr_copied . + " non-flac files of " . $cntr_all ." were copied to dest directories."); }; }; - msg("\n"); # double line feed -}; - - -sub get_dest_file_path_non_flac { - my $source = shift; - - # remove $source_dir from front of $src_file - my $target = $source; - $target =~ s{\Q$source_root/\E}{}xms; - - # Get directories in target and put in an array - # Note: the filename is the source file name - my ( $target_volume, $target_path, $source_file ) = File::Spec->splitpath($target); - my @target_path_elements = File::Spec->splitdir($target_path); - - # Add the dst_dirs to the dst root and join back together - $target_path = File::Spec->catdir( @target_root_elements, @target_path_elements ); - - # Now join it all together to get the complete path of the dest_file - $target = File::Spec->catpath( $target_root_volume, $target_path, $source_file ); - my $target_dir = File::Spec->catpath( $target_root_volume, $target_path, '' ); - - return $target_dir,$target; + msg_info("\n"); # double line feed }; sub get_md5_of_non_flac_file { @@ -310,43 +305,114 @@ sub get_md5_of_non_flac_file { return $md5_code; }; -# use parallel processing to launch multiple transcoding processes -sub path_and_conversion{ - my $source = shift; - - # remove $source_dir from front of $src_file - my $target = $source; - $target =~ s{\Q$source_root/\E}{}xms; - - # Get directories in target and put in an array - # Note: the filename is the source file name - my ( $target_volume, $target_path, $source_file ) = File::Spec->splitpath($target); - my @target_path_elements = File::Spec->splitdir($target_path); - - # Add the dst_dirs to the dst root and join back together - $target_path = File::Spec->catdir( @target_root_elements, @target_path_elements ); - # Add volume for OSes that require it (MSWin etc.) - $target_path = File::Spec->catpath( $target_root_volume, $target_path, '' ); - - # Get the basename of the dst file - my ( $target_base, $target_dir, $source_ext ) = fileparse( $source_file, qr{\Q.flac\E$}xmsi ); - - # Now join it all together to get the complete path of the dest_file - $target = File::Spec->catpath( $target_volume, $target_path, $target_base . '.mp3' ); +sub delete_excess_files_from_dest { + my ($source_root, $target_root) = @_; + + # Generate (source => target) hashes for the files found using + # each of the following combinations of root dirs and file suffixes + my %existing_target_mp3_files = get_all_paths('name', '.mp3', $target_root, $target_root, '.mp3'); + my %existing_source_mp3_files = get_all_paths('name', '.mp3', $source_root, $target_root, '.mp3'); + my %non_flac_files = get_all_paths('not_name', '.flac', $source_root, $target_root, ''); + my %existing_target_non_mp3_files = get_all_paths('not_name', '.mp3', $target_root, $target_root, ''); + + # 1. calculate what files to expect in directory after finished transcoding and copying + my @expected_transcoded_mp3s = keys { reverse %flac_mp3_files }; # expected mp3 files in target from transcoded flac files + my @expected_copied_files = keys { reverse %non_flac_files }; # expected files in target copied from non-flac files in source + my @expected_files = uniq(@expected_transcoded_mp3s, @expected_copied_files); # Join the arrays and remove any duplicates + + # 2. check what files are actually present + my @actual_mp3s = keys { reverse %existing_target_mp3_files }; # actual existing mp3 files in target + my @actual_non_mp3s = keys { reverse %existing_target_non_mp3_files }; # existing non-mp3 files in target + my @actual_files = (@actual_mp3s, @actual_non_mp3s); # Join the arrays (being mutually exclusive, there is no overlap) + + # 3. determine which files to remove from target directory tree + my @files_to_remove = single_difference(\@expected_files, \@actual_files); + + # 4. determine which subdirectories to remove from target directory tree + my @expected_subdirs_in_target = get_all_dirs($source_root,$target_root); + my @actual_subdirs_in_target = get_all_dirs($target_root,$target_root); + my @dirs_to_remove = single_difference(\@expected_subdirs_in_target, \@actual_subdirs_in_target); + + # 5. carry out the deletions + foreach my $file (@files_to_remove) { + $Options{pretend} || unlink $file or die "Unable to delete $file: $!"; + msg_info($pretendString . "Deleted \"$file\""); + } + foreach my $dir (reverse sort @dirs_to_remove) { + $Options{pretend} || File::Path->remove_tree($dir) or die "Unable to delete directory $dir: $!"; + msg_info($pretendString . "Deleted directory \"$dir\""); + } +} - convert_file( $source, $target ); +# Return all unique elements of input array @_ +sub uniq { + return sort keys %{{ map { $_ => 1 } @_ }} }; -1; +# Acccept two arrays @A and @B as argument, return elements in @B that aren't in @A. +sub single_difference { + my ($A, $B) = @_; + + # build lookup table + my %seen = (); + my @bonly = (); + @seen{@$A} = (1) x @$A; + foreach my $item (@$B) { + push(@bonly, $item) unless $seen{$item}; + } + return sort @bonly; +} -sub find_files { - my $path = shift; - my $regex = shift; +sub get_all_dirs { + my ($root, $new_root) = @_; + # we supply no suffix, so we search for directories (not files): + my @orig_dirs = @{ find_files_or_dirs($root) }; + + my @dirs = (); + foreach my $dir (@orig_dirs) { + # strip source root dir from path... + my $rel_path = File::Spec->abs2rel( $dir, $root ); + # then replace it with target root dir + push @dirs, File::Spec->rel2abs( $rel_path, $new_root ); + } + return sort @dirs; +} - my @found_files; +sub get_all_paths { + my ($rule, $suffix, $root, $new_root, $new_suffix) = @_; + my @orig_files = @{ find_files_or_dirs($root, $rule, $suffix) }; + + # Even if $root = $new_root, we need to do the following operations + # to get a consistent path format (otherwise problematic in e.g. MS Win) + # that is suitable for later string comparison: + my %paths = (); + foreach my $src (@orig_files) { + # Strip source root dir from file path + my $rel_path = File::Spec->abs2rel( $src, $root ); + # ... then replace it with target root dir and change file suffix. + ($paths{$src} = File::Spec->rel2abs( $rel_path, $new_root ) ) =~ s{$suffix$}{$new_suffix}xmsi; + } + return %paths +} - my $found_list = File::Find::Rule->extras( { follow => 1 } )->name($regex); - if ( $Options{skipfile} ) { +sub find_files_or_dirs { + my $path = shift; + my $rule = shift; + my $suffix = shift; + + # If a matching rule and file suffix is defined we are looking for files, + # otherwise we are looking for directories. + my $found_list; + if (defined $rule && defined $suffix) { + $found_list = File::Find::Rule->file()->extras( { follow => 1 } )->$rule(qr{$suffix$}xmsi) + } + else { + $found_list = File::Find::Rule->directory->extras( { follow => 1 } ) + }; + + # skip any directories where a "skipfile" is found + my @found; + if ( $Options{skipfile} && ($path eq $source_root) ) { my $skip_list = File::Find::Rule->directory->exec( sub { my ( $fname, $fpath, $frpath ) = @_; @@ -358,33 +424,33 @@ sub find_files { } } )->prune->discard; - @found_files = sort File::Find::Rule->or( $skip_list, $found_list )->in($path); + @found = sort File::Find::Rule->or( $skip_list, $found_list )->in($path); } else { - - @found_files = sort $found_list ->in($path); + @found = sort $found_list ->in($path); } +return \@found; +} - $Options{debug} && msg( Dumper(@found_files) ); - - if ( $Options{info} ) { - my $file_count = scalar @found_files; - msg( "Found $file_count flac file" . ( $file_count > 1 ? 's' : '' . "\n" ) ); - } +sub path_and_conversion{ + my $source = shift; + my $target = $flac_mp3_files{$source}; - return \@found_files; -} + convert_file( $source, $target ); +}; sub showusage { print <<"EOT"; -Usage: $0 [--pretend] [--quiet] [--debug] [--tagsonly] [--force] [--tagdiff] [--noskipfile] [--skipfilename=] [--lameargs='parameter-list'] --pretend Don't actually do anything +Usage: $0 [--pretend] [--quiet] [--debug] [--tagsonly] [--force] [--tagdiff] [--noskipfile] [--skipfilename=] [--lameargs='parameter-list'] + + --pretend Don't actually do anything --quiet Disable informational output to stdout --debug Enable debugging output. For developers only! --tagsonly Don't do any transcoding - just update tags --force Force transcoding and tag update even if not required --tagdiff Print source/dest tag values if different --lameargs='s' specify parameter(string) to be passed to the LAME Encoder - Default: "--noreplaygain --vbr-new -V 2 -h --nohist --quiet" + Default: "--noreplaygain -V 2 -h --nohist --quiet" --noskipfile Ignore any skip files --skipfilename Specify the name of the skip file. Default: flac2mp3.ignore @@ -395,13 +461,18 @@ sub showusage { same tag. Default: "/" --copyfiles Copy non-flac files to dest directories + --delete Delete surplus files and directories in destination, keeping in sync with source dir EOT exit 0; } -sub msg { - my $msg = shift; - print "$msg\n"; +sub msg { + print "@_\n" +} + +sub msg_info { + # display only if "--quiet" option is not in use + $Options{info} && msg(@_) } sub convert_file { @@ -625,7 +696,6 @@ sub examine_destfile_tags { msg("srcframe value: '$srcframe'"); msg("destframe value: '$dest_text'"); } - } } } @@ -656,9 +726,6 @@ sub transcode_file { my $pflags_ref = shift; my %pflags = %$pflags_ref; # this is only to minimize changes - my ( $target_volume, $target_dir, $target_filename ) = File::Spec->splitpath($target); - my $dst_dir = File::Spec->catpath( $target_volume, $target_dir, '' ); - if ( ( !$pflags{exists} || $pflags{md5} || $Options{force} ) && !$Options{tagsonly} ) { @@ -673,6 +740,8 @@ sub transcode_file { $tmpfilename = $target; } else { + # retrieve destination directory name + (undef, my $dst_dir) = File::Basename::fileparse($target); # Create the destination directory if it # doesn't already exist @@ -689,8 +758,7 @@ sub transcode_file { ); $tmpfilename = $tmpfh->filename; } - $Options{info} - && msg( $pretendString . "Transcoding \"$source\"" ); + msg_info( $pretendString . "Transcoding \"$source\"" ); my $convert_command = "\"$flaccmd\" @flacargs \"$source\"" . "| \"$lamecmd\" @lameargs - \"$tmpfilename\""; @@ -752,14 +820,9 @@ sub write_tags { my %pflags = %$pflags_ref; # this is only to minimize changes # Write the tags - if ($pflags{exists} - && ( $pflags{tags} - || $Options{force} ) - ) - { + if ( $pflags{exists} && ( $pflags{tags} || $Options{force} ) ) { - $Options{info} - && msg( $pretendString . "Writing tags to \"$destfilename\"" ); + msg_info( $pretendString . "Writing tags to \"$destfilename\"" ); if ( !$Options{pretend} ) { my $mp3 = MP3::Tag->new($destfilename); @@ -847,8 +910,7 @@ sub fixUpTrackNumber { $trackNum = sprintf( "%02u", $trackNum ); } else { - $Options{info} - && msg('TRACKNUMBER not numeric'); + msg_info('TRACKNUMBER not numeric'); } } return $trackNum; @@ -898,4 +960,4 @@ sub picsToAPICframes { # vim:set softtabstop=4: # vim:set shiftwidth=4: -__END__ +__END__ \ No newline at end of file