#!/usr/bin/perl # -w # # ABSOLUTELY NO WARRANTY WITH THIS PACKAGE. USE IT AT YOUR OWN RISK. # # # Purpose: Unix shell friendly MP3 renaming and ID3 retagging program # # # Usage: mp3butler.pl [options] file(s) # -1 first letter naming convention in -R reorg mode # -c commit mode (default: $Commit) # -d text delete text from filename # -i automatically fix artist and title ID3 tags # -I like -i plus lookup ID3 tags on MusicBrainz # -l list ID3 v1 and v2 tags for specified files # -L list ID3 genre types # -n delete numbers from beginning of filename # -p allow punctuation in ID3 tags ie. Don't vs. Dont # -r recursively decend into specified directories # -R reorganize into directory structure # -s synchronize ID3 v1/v2 tags with one another # -a text explicitly set ID3 tag Artist # -t text explicitly set ID3 tag Title # -A text explicitly set ID3 tag Album # -C text explicitly set ID3 tag Comment # -T num explicitly set ID3 tag Track # -Y num explicitly set ID3 tag Year # -G num explicitly set ID3 tag Genre # -x patt search and replace pattern in ID3 tags (/disc1/CD1/) # -X rename file with values of ID3 artist and title tags # -z num zap ID3 v1/v2 tags from file - specify 1=v1 2=v2 # -h help # -H list rant about this programs design decisions # -v verbose # # # Copyright (c) 2005 2.40 Iain Lea iain@bricbrac.de # Copyright (c) 2001 1.20 Rando Christensen eyez@babblica.net # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License as # published by the Free Software Foundation; either version 2 of # the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # See the GNU General Public License for more details. # # You should have received a copy of the GNU GPL along with this # program; if not, write to the Free Software Foundation, Inc., # 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # # # Requirements: # The following perl modules available from CPAN are required: # perl -MCPAN -e shell; # install MP3::Tag # # Remember to make the script executable 'chmod u+x mp3butler.pl' # # # Contributors: # Rando Christensen eyez@babblica.net (original author v1.20) # # # Changelog: # 2.40 2005.10.10 # - added more punctuation rules # - fixed -i option to be autoset if setting a single id3 tag # 2.30 # - added -s synchronize ID3 v1/v2 tags with one another option # - added -z remove all tags for ID3 v1 or v2 option # - added -X rename file based on ID3 artist and title values # - added set file permission of renamed file (default 644) # - added &ParseEnvVars function for parsing of option variables # which is 1. Config file 2. Env variables 3. command line # - added &GetId3Tag to get single tags for use with -x option # - added &SetId3Tags to be more modular with v1 and v2 tags # - changed config file name from .butlerrc to .mp3butlerrc # - changed &CreateCfgFile to document ENV variables # - changed all ENV variables to begin with $MP3BUTLER_ # - changed &ListId3Tags output # - fixed -t option that was not in getopts() # - fixed track data that was in mixed "1" and "01" format # - fixed wierd \264 chars in filenames in &FixFilename # 2.20 # - added -I like -i option plus lookup tags via MusicBrainz # which requires the id3butler.pl helper script in $PATH # - added -x search & replace pattern in ID3 tags option # - added &CreateCfgFile to create default ~/.butlerrc file # - added more punctuation rules # 2.10 # - added $BUTLER_CONFIG shell variable to define config file # - added another punctuation rule # - changed output of &Reorg function to match rename and retag # - changed punctuation rules to be in function &FixPunctuation # - fixed -l list ID3 tags option if ID3v1/v2 tag does not exist # 2.00 # - added -l list id3 tags option # - added -L list id3 genre types # - added -a id3 tag artist option # - added -t id3 tag title option # - added -A id3 tag album option # - added -T id3 tag track option # - added -C id3 tag comment option # - added -Y id3 tag year option # - added -G id3 tag genre option # - added -d delete text from filename option ie. various- # - added -n delete numbers from beginning of filename # - added -H option to list contents of butler-1.2 RANT file :) # - added -c commit option which swaps around v1.2 behaviour # - added -p option to enable punctuation in tags ie. Don't vs Dont # - code reorg # - changed so MP3::Tag is now a requirement # - changed to use tmp files in renaming of files (fixes cygwin problem) # 1.20 # - last official release # # # Todo: # - FIX -Y year! # 2.40 # - -G "Folk/Rock" gets written as "Folk-Rock" # - Unmatched ( in regex; marked by <-- HERE in m/^A Last Request ( <-- HERE # I Want Your Se/ at /usr/local/bin/mp3butler.pl line 976 (Faith GM) # - convert ListGenres from MP3::Tag list genre code/function # - -s 'comment=""' search tag option ? # - add year and genre to id3butler and also extra check in FreeDB # - if in -I mode then only print V1/V2 info if a lookup is to be done! # - MP3::Info to get time info ??? time ??? #use strict; require 'getopts.pl'; use File::Find (); use File::Copy; use MP3::Tag; # constants my $Version = 'v2.40'; my $ScriptName = 'mp3butler.pl'; my $ScriptUrl = "http://iainlea.dyndns.org/software/mp3butler"; my $Copyright = "Copyright (c) 2001-2005 Iain Lea & Rando Christensen"; # # variables Note: 'local' is used in pref to 'my' due to &ParseCfgFile magic! # my $CfgFile = ($ENV{"MP3BUTLER_CONFIG"} ? $ENV{"MP3BUTLER_CONFIG"} : "$ENV{'HOME'}/.mp3butlerrc"); local $FirstLetter = 0; local $Commit = 0; local $FixId3Tags = 0; local $DoRecurseDir = 0; local $DoReorg = 0; local $DoId3TagLookup = 0; local $DeleteText = ""; local $DeleteNum = 0; local $ListId3Tags = 0; local $ListId3Genre = 0; local $SyncId3Tags = 0; local $Id3TagArtist = -1; local $Id3TagTitle = -1; local $Id3TagAlbum = -1; local $Id3TagComment = -1; local $Id3TagTrack = -1; local $Id3TagYear = -1; local $Id3TagGenre = -1; local $DoPunctuation = 0; local $Id3RenameFile = 0; local $ZapId3VersionTag = 0; local $Verbose = 0; my %Id3SearchReplaceList; my $DoId3SearchReplace = 0; my $ListRant = 0; # my $Id3Lookup = ""; my $NumRenamed = 0; my $NumRetagged = 0; my $NumReorg = 0; my $FixedArtist; my $FixedTitle; my $FixedAlbum; my @FileList; my @SongList; my $File; $| = 1; &ParseCfgFile ($CfgFile); &ParseEnvVars; &ParseCmdLine ($0, 0); foreach $File (@FileList) { if (-d $File) { &RecurseDir ($File) if $DoRecurseDir; } if (-f $File) { unshift (@SongList, $File); } } @SongList = &FixFilename (@SongList); if ($ListId3Genre) { &ListId3Genre; } elsif ($ListId3Tags) { foreach $File (@SongList) { &ListId3Tags ($File); } } else { if ($ZapId3VersionTag) { &ZapId3VersionTag (@SongList); } elsif ($FixId3Tags) { &FixId3Tags (@SongList); } elsif ($SyncId3Tags) { &SyncId3Tags (@SongList); } &Reorg (@SongList) if $DoReorg; if ($NumRenamed || $NumRetagged || $NumReorg) { print ("\nDone renamed $NumRenamed retagged $NumRetagged reorganized $NumReorg file(s)!\n"); } if (! $Commit) { print ("\nNote: -c commit option needs to be specified to make changes!\n"); } } exit 0; ####################################################################### # SUBROUTINES sub ParseCmdLine { my ($ProgPath, $ShowUsage) = @_; my $ProgName = $ProgPath; $ProgName = $1 if ($ProgName =~ /.*\/([^\/]*)/); &Getopts ('1a:A:cC:d:G:hHiIlLnprRst:T:vx:XY:z:'); if ($opt_h || $ShowUsage) { print < 2) { print "Error: -z option - incorrect value.\n\n"; &ParseCmdLine ($0, 1); } if ($#FileList == -1 && ! $ListId3Genre) { &ParseCmdLine ($0, 1); } } sub ParseCfgFile { my ($CfgFile) = @_; if (-f "$CfgFile") { if (open (CFG, "$CfgFile")) { while () { chomp; # killing these things: s[/\*.*\*/][]; # /* comment */ s[//.*][]; # // comment s/#.*//; # # comment s/^\s+//; # whitespace before stuff s/\s+$//; # whitespace after stuff next unless length; # If our line is empty, We should ignore some stuff if (/^\/([^\/]*)\/(.*)\/$/) { $Id3SearchReplaceList{$1} = $2; $DoId3SearchReplace = 1; # print "DBG: [$_] param=$1 value=$2\n"; } else { ($CfgParam, $CfgValue) = split (/=/, $_); $$CfgParam = $CfgValue; # print "DBG: [$_] param=$CfgParam value=$CfgValue $$CfgParam\n"; } } close CFG; # print "DBG: CFG FirstLetter=[$FirstLetter]\n"; # print "DBG: CFG Commit=[$Commit]\n"; # print "DBG: CFG FixId3Tags=[$FixId3Tags]\n"; # print "DBG: CFG DoRecurseDir=[$DoRecurseDir]\n"; # print "DBG: CFG DoReorg=[$DoReorg]\n"; # print "DBG: CFG DoId3TagLookup=[$DoId3TagLookup]\n"; # print "DBG: CFG DeleteText=[$DeleteText]\n"; # print "DBG: CFG DeleteNum=[$DeleteNum]\n"; # print "DBG: CFG ListId3Tags=[$ListId3Tags]\n"; # print "DBG: CFG ListId3Genre=[$ListId3Genre]\n"; # print "DBG: CFG SyncId3Tags=[$SyncId3Tags]\n"; # print "DBG: CFG Id3TagArtist=[$Id3TagArtist]\n"; # print "DBG: CFG Id3TagTitle=[$Id3TagTitle]\n"; # print "DBG: CFG Id3TagAlbum=[$Id3TagAlbum]\n"; # print "DBG: CFG Id3TagComment=[$Id3TagComment]\n"; # print "DBG: CFG Id3TagTrack=[$Id3TagTrack]\n"; # print "DBG: CFG Id3TagYear=[$Id3TagYear]\n"; # print "DBG: CFG Id3TagGenre=[$Id3TagGenre]\n"; # print "DBG: CFG DoPunctuation=[$DoPunctuation]\n"; # print "DBG: CFG Id3RenameFile=[$Id3RenameFile]\n"; # print "DBG: CFG ZapId3VersionTag=[$ZapId3VersionTag]\n"; # print "DBG: CFG Verbose=[$Verbose]\n"; } } else { &CreateCfgFile ($CfgFile); } } sub CreateCfgFile { my ($CfgFile) = @_; print "Info: creating default config file $CfgFile\n\n"; if (open (CFG, ">$CfgFile")) { print CFG < \&FileSpec}, $Dir); } sub FileSpec { $_ = $File::Find::name; # we only want .mp3 files (case insensitive) unshift (@SongList, $_) if (m/mp3$/i); } sub FixFilename { my (@FileList) = @_; my $orig, $dirname, $type, @newarray, $newfile, $tmpfile; my $Id3Artist, $Id3Title; # print "DBG1: $_\n" foreach (@FileList); foreach (@FileList) { $orig = $_; $dirname = ""; if (m/(.*\/)(.*\.mp3)/) { $dirname = $1; $_ = $2; } # it should have either '.mp3' or .ogg or .oGg or some such at the end.. next unless m/(mp3|ogg)$/i; if (m/mp3$/i) { $type = 'mp3'; if ($Id3RenameFile) { $Id3Artist = &GetId3Tag ("$_", "artist"); $Id3Title = &GetId3Tag ("$_", "title"); $_ = "$Id3Artist-$Id3Title" if ($Id3Artist && $Id3Title); # print "DBG: artist=[$Id3Artist] title=[$Id3Title] file=[$_]\n"; } } else { $type = 'ogg'; } s/$DeleteText// if $DeleteText; s/^\d+-// if $DeleteNum; s/!//g; s/(.*[A-z])&([A-z].*)/$1_and_$2/; s/&/and/; y/ /_/; y/{}[]/()()/; s/_?\(/-/g; s/\)//g; $_ = lc; s/\.(mp3|ogg)$//; s/([A-z].*)\.([A-z].*)/$1_$2/; s/[\?\*"'\.,`|~]//g; s/_*$//g; s/_+/_/g; s/_-/-/g; s/-_/-/g; if ($DeleteNum) { s/^[^A-Za-z]*//g; } else { s/^[^A-Za-z0-9]*//g; } s/-+/-/g; # weird one as it was needed to clear up \264 problem! s/[^A-z0-9_\-\.]*//g; $_ .= ".$type"; $_ = "$dirname$_"; # $orig =~ s/'/'\\''/g; # print("moving '$orig' to '$_'\n") if ($orig ne $_); $newfile = $_; $tmpfile = "$newfile.$$"; if ($orig ne $newfile) { print ("Fix: \"$orig\" -> $newfile\n") if $Verbose; if ($Commit) { # rename in 2 stages (due to cygwin wierdness!) if (rename ($orig, $tmpfile)) { if (! rename ($tmpfile, $newfile)) { print "Error: $tmpfile -> $newfile - $!\n"; exit 0; } chmod (0644, $newfile); } else { print "Error: $orig -> $tmpfile - $!\n"; exit 0; } $NumRenamed++; } unshift (@newarray, $newfile); } else { unshift (@newarray, $orig); } } # print "DBG2: $_\n" foreach (@newarray); return (@newarray); } sub FixId3Tags { my (@FileList) = @_; my $File, $Song, $Artist, $Title, @Song, @Artist, @Title; foreach $File (@FileList) { chomp $File; $Song = $File; $Song =~ s+.*/++; $Song =~ s/\.(mp3|ogg)$//; $Song =~ s/-live/ (live)/; @Song = split ('-', $Song, 2); $Song[1] =~ s/-([a-zA-Z0-9_]*)/ ($1)/g; $Artist = $Song[0]; $Title = $Song[1]; @Artist = split (/_/, "$Artist"); $FixedArtist = join (" ", @Artist); $FixedArtist =~ s/(\w+)/\u\L$1/g; @Title = split (/_/, "$Title"); $FixedTitle = join (" ", @Title); $FixedTitle =~ s/(\w+)/\u\L$1/g; $FixedAlbum = &GetId3Tag ($File, "album"); $FixedAlbum =~ s/(\w+)/\u\L$1/g; # search and replace user specified patterns in ID3 tags if ($DoId3SearchReplace) { $FixedArtist = &Id3SearchReplace ($FixedArtist); $FixedTitle = &Id3SearchReplace ($FixedTitle); $FixedAlbum = &Id3SearchReplace ($FixedAlbum); } # fix the punctuation in the id3 tag if ($DoPunctuation) { $FixedArtist = &FixPunctuation ($FixedArtist); $FixedTitle = &FixPunctuation ($FixedTitle); $FixedAlbum = &FixPunctuation ($FixedAlbum); } if ($DoId3TagLookup) { print ("Tag: lookup -a \"$FixedArtist\" -t \"$FixedTitle\"\n"); ($FixedArtist, $FixedTitle, $FixedAlbum) = &Id3TagLookup ("$FixedArtist", "$FixedTitle"); } if ($FixedAlbum) { print ("Tag: set -a \"$FixedArtist\" -t \"$FixedTitle\" -A \"$FixedAlbum\" $File\n") if $Verbose; } else { print ("Tag: set -a \"$FixedArtist\" -t \"$FixedTitle\" $File\n") if $Verbose; } if ($Commit) { &SetId3Tags ($File, "ID3v1"); &SetId3Tags ($File, "ID3v2"); chmod (0644, $File); $NumRetagged++; } &ListId3Tags ($File) if ($Verbose || $NewAlbum); print "\n" if ($DoId3TagLookup && $NewAlbum); } } sub SyncId3Tags { my (@FileList) = @_; my $File; foreach $File (@FileList) { chomp $File; $Id3TagArtist = &GetId3Tag ($File, "artist"); $Id3TagTitle = &GetId3Tag ($File, "title"); $Id3TagAlbum = &GetId3Tag ($File, "album"); $Id3TagGenre = &GetId3Tag ($File, "genre"); $Id3TagTrack = &GetId3Tag ($File, "track"); $Id3TagYear = &GetId3Tag ($File, "year"); $Id3TagComment = &GetId3Tag ($File, "comment"); if ($Commit) { &SetId3Tags ($File, "ID3v1"); &SetId3Tags ($File, "ID3v2"); chmod (0644, $File); $NumRetagged++; } &ListId3Tags ($File) if ($Verbose); } } sub SetId3Tags { my ($File, $Version) = @_; my $Mp3, $Track; $Mp3 = MP3::Tag->new ($File); $Mp3->getTags; unless (exists $Mp3->{$Version}) { $Mp3->newTag ($Version); } if ($Id3TagArtist != -1) { $Mp3->{$Version}->artist ($Id3TagArtist); } else { $Mp3->{$Version}->artist ($FixedArtist); } if ($Id3TagTitle != -1) { $Mp3->{$Version}->title ($Id3TagTitle); } else { $Mp3->{$Version}->title ($FixedTitle); } if ($Id3TagAlbum != -1) { $Mp3->{$Version}->album ($Id3TagAlbum); } else { $Mp3->{$Version}->album ($FixedAlbum) if $FixedAlbum; } if ($Id3TagTrack != -1) { $Mp3->{$Version}->track ($Id3TagTrack); } else { $Track = sprintf "%d", $Mp3->{$Version}->track; $Mp3->{$Version}->track ($Track); } $Mp3->{$Version}->comment ($Id3TagComment) if ($Id3TagComment != -1); $Mp3->{$Version}->year ($Id3TagYear) if ($Id3TagYear != -1); $Mp3->{$Version}->genre ($Id3TagGenre) if ($Id3TagGenre != -1); $Mp3->{$Version}->write_tag (); $Mp3->close (); } sub Id3TagLookup { my ($Artist, $Title) = @_; my %TagLookupList, $NewArtist, $NewTitle, $NewAlbum, $Num = 0; $NewArtist = $Artist; $NewTitle = $Title; $NewAlbum = ""; if (open (PIPE, "$Id3Lookup -a '$Artist' -t '$Title' |")) { while () { # print "DBG: $_\n"; if (/^#/) { print "\n"; next; } @FieldList = split (',', $_); $FieldList[1] =~ s/___COMMA___/,/g; $FieldList[2] =~ s/___COMMA___/,/g; $FieldList[3] =~ s/___COMMA___/,/g; printf "[%d] Artist: %s Title: %s Album: %s\n", ++$Num, $FieldList[1], $FieldList[2], $FieldList[3]; $TagLookupList{$Num}->{artist} = $FieldList[1]; $TagLookupList{$Num}->{title} = $FieldList[2]; $TagLookupList{$Num}->{album} = $FieldList[3]; } close PIPE; if ($Num) { print "\nEnter [1-$Num] to select or any other key to ignore: "; $Num = ; chop $Num; print "\n"; if ($TagLookupList{$Num}) { $NewArtist = $TagLookupList{$Num}->{artist}; $NewTitle = $TagLookupList{$Num}->{title}; $NewAlbum = $TagLookupList{$Num}->{album}; } } } # print ("\nTag: got -a \"$FixedArtist\" -t \"$FixedTitle\" -A \"$FixedAlbum\"\n\n") if $Verbose; return ($NewArtist, $NewTitle, $NewAlbum); } sub ListId3Tags { my ($File) = @_; my $Mp3, $Version; if (-f $File) { $Mp3 = MP3::Tag->new ($File); $Mp3->getTags; print "Song Filename: $File\n" if ! $FixId3Tags; foreach $Version ("ID3v1", "ID3v2") { if (exists $Mp3->{$Version}) { printf "$Version Title..: %s\n", $Mp3->{$Version}->title; printf "$Version Artist.: %s\n", $Mp3->{$Version}->artist; printf "$Version Album..: %s\n", $Mp3->{$Version}->album; printf "$Version Genre..: %s\n", $Mp3->{$Version}->genre; printf "$Version Track..: %s\n", $Mp3->{$Version}->track; printf "$Version Year...: %s\n", $Mp3->{$Version}->year; printf "$Version Comment: %s\n", $Mp3->{$Version}->comment; } } $Mp3->close (); } } sub GetId3Tag { my ($File, $Tag) = @_; my $Mp3, $TagV1, $TagV2, $Value; $TagV1 = $TagV2 = $Value = ""; $Mp3 = MP3::Tag->new ($File); $Mp3->getTags; if (exists $Mp3->{'ID3v1'}) { $TagV1 = $Mp3->{'ID3v1'}->$Tag; } if (exists $Mp3->{'ID3v2'}) { $TagV2 = $Mp3->{'ID3v2'}->$Tag; } $Mp3->close (); # print "DBG: v1=[$TagV1]\n"; # print "DBG: v2=[$TagV2]\n"; if ("$TagV1" eq "$TagV2") { # print "DBG: TagV1 eq TagV2\n"; $Value = $TagV1; } elsif ($TagV1 && ! $TagV2) { # print "DBG: TagV1 && ! TagV2\n"; $Value = $TagV1; } elsif ($TagV2 && ! $TagV1) { # print "DBG: TagV2 && ! TagV1\n"; $Value = $TagV2; } elsif ($TagV2 =~ /^$TagV1/ && length $TagV2 > length $TagV1) { # print "DBG: TagV2 =~ /TagV1/\n"; $Value = $TagV2; } else { # print "DBG: default TagV1\n"; $Value = $TagV1; } return $Value; } sub ZapId3VersionTag { my (@FileList) = @_; my $File, $Mp3; foreach $File (@FileList) { chomp $File; $Mp3 = MP3::Tag->new ($File); $Mp3->getTags; if (exists $Mp3->{"ID3v$ZapId3VersionTag"}) { # print "DBG: zap ID3v$ZapId3VersionTag tag\n"; if ($Commit) { $Mp3->{"ID3v$ZapId3VersionTag"}->remove_tag; $NumRetagged++; } } $Mp3->close (); &ListId3Tags ($File) if ($Verbose); } } sub Reorg { my (@FileList) = @_; my @artist, @title, $song, $artist, $title, $finart, $fintit; my $fn, $ek, $newdir, $newfn, $firstlet, $tmpdir, @dirlist; foreach $copy (@FileList) { chomp $copy; $song = $copy; $song =~ s+.*/++; $song =~ s/\.mp3$//; $song =~ s/-live/ (live)/; @song = split ('-', $song, 2); $song[1] =~ s/-([a-zA-Z0-9_]*)/ ($1)/g; $fn = $copy; $fn =~ s+.*/++; $ek = $copy; $ek =~ s+\./++; $artist = $song[0]; $title = $song[1]; @artist = split (/_/, "$artist"); $finart = join (" ", @artist); $finart =~ s/(\w+)/\u\L$1/g; @title = split (/_/, "$title"); $fintit = join (" ", @title); $fintit =~ s/(\w+)/\u\L$1/g; # special case - remove "the_" and "a_" from directory name! if (substr ($artist, 0, 4) eq "the_") { $artist = substr ($artist, 4); } if (substr ($artist, 0, 2) eq "a_") { $artist = substr ($artist, 2); } if ($FirstLetter) { $firstlet = substr ($artist, 0, 1); $fn =~ s/_*-_*/-/g; $newfn = "$firstlet/$artist/$fn"; $newdir = "$firstlet/$artist"; } else { $fn =~ s/_*-_*/-/g; $newfn = "$artist/$fn"; $newdir = "$artist"; } if ($ek ne $newfn) { print ("Org: $ek -> $newfn\n") if $Verbose; if ($Commit) { if (-d $newdir) { move ($copy, $newfn); } else { if ($newdir =~ m+/+) { @dirlist = split ("/", $newdir); for ($i = 0; $i <= $#dirlist; $i++) { $tmpdir = join ("/", @dirlist[0 .. $i]); unless (-d $tmpdir) { mkdir ($tmpdir) || print "Error: mkdir $tmpdir - $!\n"; } } } else { mkdir ($newdir); } move($copy, $newfn); } $NumReorg++; } } } } sub GetFullPath { my ($Prog) = @_; my ($Path); foreach $Path (split (/:/, $ENV{PATH})) { if (-x "$Path/$Prog") { return "$Path/$Prog"; } } return ""; } sub ListId3Genre { print<