#!/usr/bin/perl -w #Last Updated: 2003.11.02 (xris) #Code based on mp3ascd.pl: http://mymp3s.sourceforge.net/mp3ascd.html #Import some routines use utf8; use strict; use Encode; use Getopt::Long; use MP3::Info; use MP3::Tag; use CDDB; use Cwd; #used for editing data in an external editor use POSIX qw(:sys_wait_h); use File::Temp qw(tempfile); use Fcntl qw(F_SETFD); #Load in the options my %Args; GetOptions(\%Args, 'help', 'url', 'nolookup', 'h|host=s', 'port=i', 'editor=s', 'dumb', 'reverse'); $Args{editor} ||= $ENV{EDITOR} ? $ENV{EDITOR} : 'nano'; $Args{host} ||= 'freedb.freedb.org'; $Args{port} ||= 888; #Some minor error checking die "Bad hostname: $Args{host}\n\n" if ($Args{host} !~ /^\w/ or $Args{host} !~ /\w$/ or $Args{host} =~ /[^\w\.\-]/); my $editor_prog = $Args{editor}; $editor_prog =~ s/\s.*$//s; unless (-e $editor_prog or -e "/bin/$editor_prog" or -e "/usr/bin/$editor_prog" or -e "/usr/local/bin/$editor_prog") { die "Unknown editor: $Args{editor}\n\n"; } #Show the help? &ShowHelp if ($Args{help}); #Gather in the directory to parse, or die my $Dir = shift @ARGV; if ($Dir) { $Dir =~ s/\/+$//s; } else { print "No directory specified; using current.\n"; $Dir = '.'; } die "$Dir isn't a directory!\n\n" unless (-d $Dir); #Read in the files in the directory die "Can't open $Dir: $!\n\n" unless (opendir DIR, $Dir); my @Files = map { {name => $_ } } sort byTrackNum grep(!/^\./ && /\.(?:mp3|ogg)$/i, readdir DIR); closedir DIR; die "$Dir contains no .mp3 or .ogg files.\n\n" unless (@Files); #Parse the files and generate the cddb id my $numtracks = 0; my $totalid = 0; my $totalsecs = 2; my $totalframes = 150; my $framestring = '150'; foreach my $file (@Files) { $file->{type} = $file->{name} =~ /\.ogg$/i ? 'ogg' : 'mp3'; my ($info, $secs); #Pull out the time info from mp3 files if ($file->{type} eq 'mp3') { $info = get_mp3info("$Dir/$file->{name}"); $secs = (60 * $info->{MM}) + $info->{SS}; } #Pull out the time info from ogg files elsif ($file->{type} eq 'ogg') { my $safe = ShellSafe("$Dir/$file->{name}"); $info = `ogginfo $safe`; my ($hour, $min, $sec) = $info =~ m/Playback\s+length:\s+(?:(?:(\d+)h:)?(\d+)m:)?(\d+)s/s; die "Unknown output from ogginfo:\n\n$info\n\n" unless ($sec); $hour ||= 0; $min ||= 0; $secs = (3600 * $hour) + (60 * $min) + $sec; } #Use this info to create a cddb lookup id $totalsecs += $secs; $framestring .= " $totalframes" if ($numtracks); #Don't do this for the first track, we already have an entry $totalframes += $secs * 75; #Count some frames while ($secs > 0) { #Count up some cddb-related id info $totalid += $secs % 10; $secs /= 10; } #On to the next $numtracks++; } #Generate the CDDB lookup my $CDDB_ID = sprintf ("%08x", ((($totalid % 0xFF) << 24) | ($totalsecs << 8) | $numtracks)); #Print out the requested cddb url if ($Args{url}) { my $safe = $framestring; $safe =~ tr/ /+/s; print "URL:\n\n", "http://$Args{host}/~cddb/cddb.cgi?cmd=cddb+query+$CDDB_ID+", "$numtracks+$safe+$totalsecs&hello=blah+my.host.com+fads+2&proto=1\n\n"; } #Look up the album my (%Album, @Tracks); $Album{genre} = []; $Album{title} = []; $Album{artist} = []; $Album{year} = []; unless ($Args{nolookup}) { my $found; my $cddb = CDDB->new(Host => $Args{host}, Port => $Args{port}); die "Can't create CDDB lookup at $Args{host}:$Args{port}: $!\n\n" unless ($cddb); #Get a list of matching disks my @disks = $cddb->get_discs($CDDB_ID, "$numtracks $framestring", $totalsecs); foreach my $disk (@disks) { my ($genre, $id, $title) = @{$disk}; next unless ($id); $found++; #Extract some more info about this album my $info = $cddb->get_disc_details($genre, $id); #Print out the full cddb info record # print "$info->{xmcd_record}\n\n"; #Touch up the info and try to extract the artist name $genre =~ s/\bnewage\b/New Age/si; Titlecase(\$title); Titlecase(\$genre); $title =~ s/^\s*(.+?)\s*\/\s*//s; #Extract the artist my $artist = ($1 or 'Various'); $artist = 'Various' if ($artist =~ /^\s*various\s*artists\s*$/i); Titlecase(\$title) if ($title =~ s/^(.+?),\s+the$/The $1/si); #Just in case #Add this stuff to the bunch AddUnique($Album{genre}, $genre); AddUnique($Album{title}, $title); AddUnique($Album{artist}, $artist); #Try to extract a year while ($info->{xmcd_record} =~ /YEAR\s*:\s*(\d{4})\b/sg) { AddUnique($Album{year}, $1); } #Scan the tracks my $i = 0; foreach my $track (@{$info->{ttitles}}) { my ($title, $artist); next if ($track =~ /^\s*unknown\s*(?:\(unknown\)\s*)$/si); #Try to extract the artist from the cddb song title unless ($Args{dumb}) { if ($track =~ /^\s*([^\:\(\)]+)(?:\s+:\s*|\s*:\s+)(.+)\s*$/ or $track =~ /^\s*([^\-\(\)]+?)(?:\s+-\s*|\s*-\s+)([^\-]+?)\s*$/ or $track =~ /^\s*([^\/\(\)]+?)(?:\s+\/\s*|\s*\/\s+)([^\/]+?)\s*$/) { if ($Args{reverse}) { ($title, $artist) = ($1, $2); } else { ($artist, $title) = ($1, $2); } } elsif ($track =~ /^\s*(.+)(?:\s+[\-\/]\s*|\s*[\-\/]\s+)([^\-\/\(\)]+?)\s*$/) { ($title, $artist) = ($1, $2); } $title ||= $track; $title =~ s/\s*\/\s*/; /sg; $title =~ s/\s+[\-\|]\s+/; /sg; $title =~ s/\s*,\s*/; /sg; if ($artist) { $artist =~ s/\s*\/\s*/, /sg; } } #Create the track object my %track = (title => Titlecase($title or $track), artist => Titlecase($artist)); my $exists = 0; foreach my $othertrack (@{$Tracks[$i]}) { if ($othertrack->{title} eq $track{title}) { $exists = 1; $othertrack->{artist} = $track{artist} unless ($othertrack->{artist}); } } push @{$Tracks[$i]}, \%track unless ($exists); $i++; } $Album{Tracks} = $i if (!$Album{Tracks} or $Album{Tracks} < $i); } unless ($found) { print "\nSorry, no match was found for this disk.\n\n"; exit; } #Generate the cddb display information, and perform any requested user edits $Album{Genre} = (shift @{$Album{genre}} or ''); $Album{Artist} = (shift @{$Album{artist}} or ''); $Album{Title} = (shift @{$Album{title}} or ''); $Album{Year} = (shift @{$Album{year}} or ''); while (1) { my $multiples = 0; my $display = ''; #Genre print "Genre: $Album{Genre}\n"; foreach my $genre (@{$Album{genre}}) { print " (also: $genre)\n"; $multiples++; } #Artist print "Artist: $Album{Artist}\n"; foreach my $artist (@{$Album{artist}}) { print " (aka: $artist)\n"; $multiples++; } #Title print "Title: $Album{Title}\n"; foreach my $title (@{$Album{title}}) { print " (aka: $title)\n"; $multiples++; } #Title print "Year: $Album{Year}\n"; foreach my $year (@{$Album{year}}) { print " (also: $year)\n"; $multiples++; } #Track Info print "\n"; my $tracknum; foreach my $trackgroup (@Tracks) { my $i; $tracknum++; $tracknum =~ s/^0*(?=\d$)/0/; foreach my $track (@{$trackgroup}) { print $i ? " (aka: " : " $tracknum. "; print "$track->{artist}: " if ($track->{artist} and $track->{artist} ne $Album{Artist}); print $track->{title}; print ')' if ($i); print "\n"; foreach my $field (sort keys %{$track}) { next if ($field eq 'artist' or $field eq 'title'); print " \u$field: "; print ' ' x (5 - length $field) if (length $field < 5); print "$track->{$field}\n"; } $i++; } $multiples += $i if ($i > 1); } print "\n"; #Inform the user that there were multiple matches. if ($multiples) { print "There were multiple possible matches for this album.\n", "You will now be forwarded to $Args{editor} for editing.\n", "Press ENTER to continue."; <>; } #Force an edit if we have to, but otherwise, just ask the user else { my $answer; until ($answer and $answer =~ /^[yn]/i) { print "Would you like to edit the above CDDB information (Y/n)? "; $answer = <>; } last if ($answer =~ /^n/i); } &EditInfo; } #Add the information to the mp3 tracks foreach my $i (0 .. @Tracks - 1) { my $track = $Tracks[$i][0]; my $file = $Files[$i]; $track->{number} = sprintf('%02d', $i + 1); #Save the tags to the file if ($file->{type} eq 'mp3') { SaveID3Tags($file, $track); } elsif ($file->{type} eq 'ogg') { SaveOggTags($file, $track); } print "Wrote tag for $file->{name}\n"; #Rename the file RenameFile($file, $track); } #Rename the directory to match the artist/album names my $dirname = $Album{Title}; if ($Album{Artist} !~ /^various$/i and $Album{Genre} !~ /\bsoundtrack\b/i) { substr($dirname, 0, 0) = "$Album{Artist} - "; } $dirname =~ s/^\s*the\s+//si; fix_utf8(\$dirname); if ($Dir eq '.') { my $thisdir = getcwd; } else { if (fix_utf8($Dir) ne $dirname and -e $dirname) { print "A directory named $dirname already exists; skipping rename.\n"; } else { rename $Dir, $dirname or die "Can't rename $Dir: $!\n\n"; print "Renamed $Dir\n to: $dirname\n" unless ($Dir eq $dirname); } } } #All done! print "Done.\n\n"; ##### ## Beware: Subroutines lurk below! ##### sub EditInfo { # Collect out the album info my $file = < \tGenre: $Album{Genre} EOF foreach my $genre (@{$Album{genre}}) { $file .= fix_utf8("###\tGenre: $genre\n"); } $file .= "\tArtist: $Album{Artist}\n"; foreach my $artist (@{$Album{artist}}) { $file .= "###\tArtist: $artist\n"; } $file .= "\tTitle: $Album{Title}\n"; foreach my $title (@{$Album{title}}) { $file .= "###\tTitle: $title\n"; } $file .= "\tYear: $Album{Year}\n"; foreach my $year (@{$Album{year}}) { $file .= "###\tYear: $year\n"; } $file .= < ## ## Track Info: (Written in the order it appears below) ## ## ## Artist: Performer ## Title: Song Title ## ## EOF my $count = 0; foreach my $trackgroup (@Tracks) { my (@titles, @artists); foreach my $track (@{$trackgroup}) { push @titles, "\tTitle: $track->{title}\n"; push @artists, "\tArtist: $track->{artist}\n" if ($track->{artist} and $track->{artist} ne $Album{Artist}); } $count++; $file .= join('', "\n", @artists ? shift @artists : '', @artists ? (map {"#$_"} @artists) : '', shift @titles, @titles ? (map {"#$_"} @titles) : '', "\n\n"); } #Create a new temp file, convert to UTF-8 and print my $fh = tempfile(DIR => '/tmp'); fix_utf8(\$file); print $fh $file; #fnctl makes sure the filehandle $fh does not closed when we execute the editor fcntl $fh, F_SETFD, 0; system ("$Args{editor} /dev/fd/".fileno($fh)); seek $fh, 0, 0; #Read in the data my $data = ''; while (my $line = <$fh>) { $line =~ s/^\s*#[^\n]*(?:\n|$)//; $data .= $line if ($line =~ /\S/); } close $fh; #Extract the album info unless ($data =~ s/\s*(.+?)\s*<\/album>//si) { print "\n!!! Editor returned malformed information.\n!!! Please try again.\n\n"; return; } my $albuminfo = $1; my %album; while ($albuminfo =~ /(?:^|\n)\s*(\w+):\s*([^\n]*?)\s*(?=\n|$)/sg) { my $field = lc $1; my $val = $2; $val = '' unless ($val =~ /\S/); $album{"\u$field"} = $val; } #Verify the album fields unless ($album{Title}) { print "\n!!! No album title was specified.\n!!! Please try again.\n\n"; return; } unless ($album{Artist}) { print "\n!!! No album artist was specified.\n!!! Please try again.\n\n"; return; } unless ($album{Genre}) { print "\n!!! No album genre was specified.\n!!! Please try again.\n\n"; return; } if ($album{Year} and $album{Year} !~ /^\d{4}$/) { print "\n!!! Album year must be a 4 digit number.\n!!! Please try again.\n\n"; return; } #Store the album information my $numtracks = $Album{Tracks}; #back up the number of tracks %Album = %album; $Album{Tracks} = $numtracks; #restore the number of tracks #Extract the track information my @tracks; my $tracknum; #### should probably do something here to extract the tracks in order, in case someone decides to reorder them..... while ($data =~ s/\s*(.+?)\s*<\/track>//si) { my $trackinfo = $1; my %track; $tracknum++; while ($trackinfo =~ /(?:^|\n)\s*(\w+):\s*([^\n]+?)\s*(?=\n|$)/sg) { $track{lc $1} = $2; } #Verify the track fields unless ($track{title}) { print "\n!!! No title specified for track $tracknum.\n!!! Please try again.\n\n"; return; } if ($track{year} and $track{year} !~ /^\d{4}$/) { print "\n!!! Year for track $tracknum must be a 4 digit number.\n!!! Please try again.\n\n"; return; } #Add it to the list push @tracks, [\%track]; } if ($tracknum != $Album{Tracks}) { print "\n!!! Editor returned a different number of tracks than were passed in.\n!!! Please try again.\n\n"; return; } #Store the track information @Tracks = @tracks; } sub SaveID3Tags { my $file = shift; my $track = shift; #Completely wipe out the old tags - v2.0 tags mess up MP3::Tag my $safe = ShellSafe("$Dir/$file->{name}"); `id3v2 -D $safe`; #open the mp3 and grab the tags my $mp3 = MP3::Tag->new("$Dir/$file->{name}"); $mp3->get_tags; #wipe out the existing ID3 tags $mp3->{ID3v1}->remove_tag if (exists $mp3->{ID3v1}); $mp3->{ID3v2}->remove_tag if (exists $mp3->{ID3v2}); #Build a new ID3v1 tag $mp3->new_tag('ID3v1'); $mp3->{ID3v1}->track($track->{number}); $mp3->{ID3v1}->song( fix_utf8($track->{title}, 1)); $mp3->{ID3v1}->artist(fix_utf8($track->{artist} or $Album{Artist}, 1)); $mp3->{ID3v1}->album( fix_utf8($track->{album} or $Album{Title}, 1)); $mp3->{ID3v1}->genre( fix_utf8($track->{genre} or $Album{Genre}, 1)); $mp3->{ID3v1}->year( fix_utf8($track->{year} or $Album{Year}, 1)) if ($track->{year} or $Album{Year}); #Build a new ID3v2 tag $mp3->new_tag('ID3v2'); $mp3->{ID3v2}->add_frame('TRCK', "$track->{number}/$Album{Tracks}"); $mp3->{ID3v2}->add_frame('TIT2', fix_utf8($track->{title}, 1)); $mp3->{ID3v2}->add_frame('TPE1', fix_utf8($track->{artist} or $Album{Artist}, 1)); $mp3->{ID3v2}->add_frame('TALB', fix_utf8($track->{album} or $Album{Title}, 1)); $mp3->{ID3v2}->add_frame('TCON', fix_utf8($track->{genre} or $Album{Genre}, 1)); $mp3->{ID3v2}->add_frame('MCDI', fix_utf8($CDDB_ID, 1)); $mp3->{ID3v2}->add_frame('TYER', fix_utf8($track->{year} or $Album{Year}, 1)) if ($track->{year} or $Album{Year}); #Save the tags $mp3->{ID3v1}->write_tag; $mp3->{ID3v2}->write_tag; $mp3->close(); } sub SaveOggTags { #cddb returns ISO-8859-1 encodings my $file = shift; my $track = shift; my $safe = ShellSafe("$Dir/$file->{name}"); open(OGG, "| vorbiscomment -w $safe") or die "Can't open pipe to vorbiscomment: $!\n\n"; print OGG "TRACKNUMBER=$track->{number}/$Album{Tracks}\n", "TITLE=", fix_utf8($track->{title}), "\n", 'ARTIST=', fix_utf8($track->{artist} or $Album{Artist}), "\n", 'ALBUM=', fix_utf8($track->{album} or $Album{Title}), "\n", 'GENRE=', fix_utf8($track->{genre} or $Album{Genre}), "\n"; print OGG 'DATE=', fix_utf8($track->{year} or $Album{Year}), "\n" if ($track->{year} or $Album{Year}); close OGG; #VERSION ORGANIZATION DESCRIPTION LOCATION COPYRIGHT ISRC } sub RenameFile { my $file = shift; my $track = shift; my $title = fix_utf8($track->{title}); $title =~ s/(?<=\S)\s*\(.*?\)//sg; $title =~ s/\//_/sg; $title =~ s/^\s+//s; $title =~ s/\s+$//s; # Get the current direectory and chdir to $Dir - renaming in UTF-8 doesn't work properly if we don't my $cwd = getcwd(); chdir $Dir; #File with this name already exists if (fix_utf8($file->{name}) ne "$track->{number}. $title.$file->{type}" and -e "$track->{number}. $title.$file->{type}") { my $answer; until ($answer and $answer =~ /^[yn]/i) { print "A File named \"$track->{number}. $title.$file->{type}\" already exists.\nOverwrite (Y/n}? "; $answer = <>; } if ($answer =~ /^y/i) { rename $file->{name}, "$track->{number}. $title.$file->{type}" or die "Can't rename $Dir/$file->{name}: $!\n\n"; print "Renamed $Dir/$file->{name}\n to: $Dir/$track->{number}. $title.$file->{type}\n"; } else { print "Skipping rename of \"$file->{name}\"\n"; } } #Just rename the file, it's unique else { rename $file->{name}, "$track->{number}. $title.$file->{type}" or die "Can't rename $Dir/$file->{name}: $!\n\n"; unless ($file->{name} eq "$track->{number}. $title.$file->{type}") { print "Renamed $Dir/$file->{name}\n to: $Dir/$track->{number}. $title.$file->{type}\n"; } } # chdir back to where we were chdir $cwd; } sub AddUnique { my $list = shift; my $value = shift; return unless ($value =~ /\S/); my $exists = 0; if ($list and @{$list}) { foreach my $item (@{$list}) { next unless ($item =~ /^$value$/i); $exists = 1; } } push @{$list}, $value unless ($exists); } sub Titlecase { #In English the first letter of the first and last words should always be capitalized and #the first letter of all intervening words should all be capitalized except for: # (i) prepositions having less than five letters, except "From", which should be capitalized # (e.g. "for", "in", "with" or "to", but "From" or "Under"), # (ii) conjunctions ("and", "or" and "but") and (ii) articles ("the", "a" and "an"). my $val = shift; my $str = ref $val eq 'SCALAR' ? $val : \$val; return $$str unless ($$str); $$str =~ tr/A-Z/a-z/; $$str =~ s/(? $bnum; } else { $a cmp $b; } }