#!/usr/bin/perl -w # # Cheezy method to create HTML lists for every mp3 album in a directory # # FEATURES: # . Reads ID3 v1 and v2 tags # . Configurable output format # . Simple to use # # TODO: # . Implement unsynchronisation for the ID3v2 tags # . Maybe make "smart album creation" optional to also include "lonely" tracks # # These modules should be in every (standard) distribution of Perl use File::Spec; # So that it even works on Macs ... use File::Basename; use IO::File; use Getopt::Long; $Version = "MP3Album v0.82 - corion\@informatik.uni-frankfurt.de"; $optVerbosity = 2; # Talk about many things $optTemplateName = "main::DATA"; # Use the built-in template $optFilespec = "*.mp3"; %Options = ( "verbosity" => \$optVerbosity, "template" => \$optTemplateName, "filespec" => \$optFilespec, "help" => \$optHelp, ); GetOptions( \%Options, "template=s", "verbosity=i", "filespec=s", "help|h|?") or die "Use --help for help\n"; die <curdir; }; # The hash will contain all template data %Template = (); die "\"$optTemplateName\" not found." if (!(-e $optTemplateName|| ($optTemplateName eq "main::DATA"))); if ($optTemplateName ne "main::DATA") { open DATA, "<" . $optTemplateName or die "Can't open \"$optTemplateName\" : $!\n" }; $SectionName = ""; while () { # Strip comments and empty lines from template next if (/^#/ || /^$/); if (/^%(.+)%$/) { $SectionName = lc($1); $Template{$SectionName} = ""; } else { $Template{$SectionName} .= $_; }; }; # Strip from output filename template chomp $Template{"outfile"}; %Data = (); # Filename -> file data map @Albums = (); # Holds the data of the first file of all found albums (albums are found by looking for tracks with track number 1) # The ID3v2 tags also get mapped to friendly names for compatibility with the # mp3 shell extension %TagNames = ( # ID3 v2.2 tags "TRK" => "track", "TT2" => "title", "TP1" => "artist", "TAL" => "album", "COM" => "comment", "TCO" => "genre", "TYE" => "year", # ID3 v2.3 tags "TRCK" => "track", "TIT2" => "title", "TPE1" => "artist", "TALB" => "album", "COMM" => "comment", "TCON" => "genre", ); # Type information for the different tags %ID3v22TagTypes = ( "TRK" => \&UnpackNumericString, "TT2" => \&UnpackString, "TP1" => \&UnpackString, "TAL" => \&UnpackString, "TSI" => \&UnpackNumericString, "TCO" => \&UnpackString, "COM" => \&UnpackComment, ); %ID3v23TagTypes = ( "TRCK" => \&UnpackNumericString, "TIT2" => \&UnpackString, "TPE1" => \&UnpackString, "TALB" => \&UnpackString, "TSIZ" => \&UnpackNumericString, "TCON" => \&UnpackString, "COMM" => \&UnpackComment, ); # Bitrate information for mp3 decoding @BitRates = (0, 32000, 40000, 48000, 56000, 64000, 80000, 96000, 112000, 128000, 160000, 192000, 224000, 256000, 320000, 0); #$tmp = MP3Info( "diefan~1.mp3" ); #if (ref $tmp) { # foreach $key (sort keys %$tmp) { # print "$key:" . $tmp->{$key} . "\n"; # }; #} else { # print $tmp; #}; #exit; # Convert the glob filespec into something suitable for a RE match $optFilespec =~ s/([.+\/\[\]\(\)\'$^~])/\\$1/g; $optFilespec =~ s/\*/\.\*/g; print "$Version\n"; print "Reading "; foreach $Directory (@optDirectories) { print $Directory . ", "; opendir DIR, $Directory or die "Can't read '$Directory' : $!\n"; my @Contents = readdir( DIR ); foreach $Entry (@Contents) { my $Name = File::Spec->catfile( $Directory, $Entry ); if (-f $Name) { push( @Files, $Name ) if ($Name =~ /$optFilespec/i); }; }; closedir DIR; }; print( ($#Files + 1). " file(s), reading ID3 info" ); foreach $File (@Files) { my( $tmp ) = MP3Info( $File ); if ( ref $tmp ) { $Data{ $File } = $tmp; } else { # $tmp contains a (non fatal) error message instead of data on the file print "\n" . $tmp if ($optVerbosity > 1); }; }; undef @Files; # Extract all (first tracks of) Albums @Albums = grep { (($_->{"TRCK"}||0) == 1) } (values %Data); if ($#Albums >= 0) { print ", " . ($#Albums + 1) . " album(s) found.\n"; } else { print ", no albums found, aborting."; exit; }; # Now process each album foreach $Album (@Albums) { print "Processing " . $Album->{"album"} . " (". $Album->{"artist"} . ")"; # Find all tracks with a defined album title and a title equal to the current title @AlbumFiles = grep { (($_->{"album"}||"") eq $Album->{"album"}) } values %Data; print ", " . ($#AlbumFiles + 1) . " tracks"; # Calculate total time $TotalTime = 0; foreach $Track (@AlbumFiles) { $TotalTime += $Track->{"sectime"}; }; # And update necessary variables foreach $Track (@AlbumFiles) { $Track->{"sectottime"} = $TotalTime; $Track->{"tottime"} = PlayTime( $TotalTime ); $Track->{"count"} = $#AlbumFiles + 1; }; # Sort all the tracks by track number @AlbumFiles = sort { $a->{"track"} <=> $b->{"track"} } @AlbumFiles; # Output an error if there are duplicate or missing track numbers foreach $TrackNum (1..$#AlbumFiles+1) { if ($AlbumFiles[ $TrackNum-1 ]->{"track"} != $TrackNum ) { print "\nWARNING: Missing/duplicate track(s) found"; last; }; }; # Open the output file $Indexname = ReplaceVars( $AlbumFiles[0], $Template{"outfile"} ); open( INDEX, "> $Indexname") or die "\nERROR creating \"$Indexname\" : $!\n"; # And print out the stuff print INDEX ReplaceVars( $AlbumFiles[0], $Template{"header"} ); foreach $Info (@AlbumFiles) { print INDEX ReplaceVars( $Info, $Template{"track"} ); }; print INDEX ReplaceVars( $AlbumFiles[0], $Template{"footer"} ); close INDEX; # Done with this album print ", done.\n"; }; # Only boring stuff below here exit; # Replaces all "$()" with entries from the hash ref sub ReplaceVars( $$ ) { my ($Result, $Varname); my $Ref = shift; my $Right = shift; $Result = ""; while ($Right =~ /\$\(([a-zA-Z0-9]+)\)/ ) { $Result .= $`; $Varname = lc($1); $Right = $'; if (exists $Ref->{$Varname} ) { $Varname = $Ref->{$Varname}; } else { print "\nWARNING: \"" . $Ref->{"filename"}. "\" : Undefined variable \"\$($Varname)\"" if ($optVerbosity); $Varname = ""; }; $Result .= "$Varname"; }; $Result .= $Right; return $Result; }; sub TwoDigits( $ ) { my $Result = shift; if (length( $Result ) == 1) { $Result = "0$Result"; }; return "$Result"; }; sub PlayTime( $ ) { my $Time = shift; my $Result; my ($hour, $min, $sec) = (0,0,0); if ($Time) { $sec = $Time % 60; $min = int($Time / 60) % 60; $hour = int( $Time / 3600 ); $Result = &TwoDigits( $min ) . ":" . &TwoDigits( $sec ); if ($hour) { $Result = $hour . ":" . $Result; }; }; # Strip leading zero $Result =~ s/^0//; return $Result; }; # Decodes a 28-bit number sub DecodeNum( $ ) { my( $In ) = shift; @Bits = split //, $In; my $Result = 0; foreach $Bit (@Bits) { $Result = $Result * 128 + ord( $Bit ); }; return $Result; }; # Checks if a string is ASCII and unpacks it (no unicode support here) sub UnpackString( $ ) { my $String = shift; if ($String =~ /^\00(.*)$/) { $String = $1; } else { die "\nUnicode string encountered ($String). Unicode is not supported yet.\n"; }; return $String; }; sub UnpackNumericString( $ ) { my $String = shift; $String = UnpackString( $String ); my @Digits = split( //, $String ); $String = 0; foreach $Digit (@Digits) { $String = $String * 10 + ord( $Digit ) - ord( "0" ); }; return $String; }; # Unpacks a comment record sub UnpackComment( $ ) { my $String = shift; $String = UnpackString( $String ); $String =~ s/^...[^\x00]*\x00//; return $String; }; # Reads information about the mp3 stream from the file # If the result is undef, everything is OK # If the result is defined, something went wrong and the result is the error message sub ReadStreamInfo( $ ) { my $Hash = shift; my $Result = undef; my $Frame; # Get some statistics about the file my ( @Filedata ) = stat( MP3FILE ) or die "Can't stat() \"" . $Hash->{"filename"} . "\" : $!\n"; # Get some data from the mp3 stream $Hash->{"filesize"} = $Filedata[7]; $Hash->{"filesizek"} = int($Filedata[7] / 1042); $Hash->{"filesizem"} = int($Filedata[7] / (1042*1024)); # Now calculate the bitrate and thus the playlength of the track seek( MP3FILE, $Hash->{"streamstart"}, 0 ); # Synchronize to the next 0xFF after the header read( MP3FILE, $Frame, 1) or die "reading from $Filename : $!\n"; while (ord( $Frame ) != 255) { read( MP3FILE, $Frame, 1) or die "reading from $Filename : $!\n"; }; read( MP3FILE, $Frame, 1) or die "reading from $Filename : $!\n"; read( MP3FILE, $Frame, 1) or die "reading from $Filename : $!\n"; my ($Bitrate) = $BitRates[ord( $Frame ) >> 4]; if ($Bitrate) { $Hash->{"bitrate"} = $Bitrate; $Hash->{"sectime"} = 0; $Hash->{"sectime"} = int((($Filedata[7]-($Hash->{"streamstart"}+10)) * 8) / $Bitrate) if ($Bitrate); $Hash->{"time"} = PlayTime( $Hash->{"sectime"} ); } else { $Result = "ERROR: Unknown bitrate in " . $Hash->{"filename"}; }; return $Result; }; # Extracts information from a ID3v1 file # Returns nothing sub ReadID3v1Tags( $$ ) { my $Hash = shift; my $ID3Tag = shift; # Enter some known data into the hash $Hash->{"tagversion"} = "ID3v1.0"; $Hash->{"taglength"} = 128; $Hash->{"tagoffset"} = tell( MP3FILE ) - $Hash->{"taglength"}; $Hash->{"streamstart"} = 0; my ($tagTAG, $tagTitle, $tagArtist, $tagAlbum, $tagYear, $tagComment, $tagGenre) = unpack( "a3A30A30A30A4a30A", $ID3Tag ); # Fix for the ID3v1 extension, where the track number is stored in the last two bytes # of the comment field : if ($tagComment=~ s/\00([\x01-\x63])$//sm) { $Hash->{"TRCK"} = ord( $1 ); }; # Strip trailing stuff foreach $Tag (\$tagTitle,\$tagArtist,\$tagAlbum,\$tagComment,\$tagGenre) { $$Tag =~ s/[\00 ]+$//sm; }; $Hash->{"TIT2"} = "$tagTitle"; $Hash->{"TPE1"} = "$tagArtist"; $Hash->{"TALB"} = "$tagAlbum"; $Hash->{"COMM"} = "$tagComment"; $Hash->{"TCON"} = "(" . ord( $tagGenre ) . ")"; }; # Extracts information from an ID3 v2.2 file (obsolete format - do not write this format) sub ReadID3v22Tags( $$ ) { my $Hash = shift; my $ID3Header = shift; # Enter some known data into the hash $Hash->{"tagversion"} = "ID3v2.2"; $Hash->{"tagoffset"} = 0; $ID3Header =~ /^ID3\02\00.(....)$/sm; my ($HeaderSize) = DecodeNum( $1 ); my ($StreamStart) = $HeaderSize + 10; my ($Frame); # Set up the remaining stuff vor a ID3v2 tag $Hash->{"taglength"} = $HeaderSize; $Hash->{"streamstart"} = $StreamStart; while ($HeaderSize > 0) { read( MP3FILE, $Frame, 6 ) or die "\nError reading from \"" . $Hash->{"name"} . "\" : $!\n"; my( $Tag, $Size ) = unpack( "a3a3", $Frame ); # Sanity check for the tag if ($Tag =~ /^[a-zA-Z0-9][a-zA-Z0-9][a-zA-Z0-9]/) { my( $Buffer ); $Size = DecodeNum( $Size ); read( MP3FILE, $Buffer, $Size ) or die "Error reading data from \"" . $Hash->{"name"} . "\" ($Size bytes): $!\n"; if (exists $ID3v22TagTypes{$Tag}) { my $Decoder = $ID3v22TagTypes{$Tag}; $Buffer = &$Decoder( $Buffer ); }; # Only store a tag if it wasn't there before (because of corrupt tags !) $Hash->{$Tag} = $Buffer unless $Hash->{$Tag}; $HeaderSize += -(6+$Size); } else { last; }; }; }; # Extracts information from a ID3v2 file # Returns nothing sub ReadID3v23Tags( $$ ) { my $Hash = shift; my $ID3Header = shift; # Enter some known data into the hash $Hash->{"tagversion"} = "ID3v2.3"; $Hash->{"tagoffset"} = 0; $ID3Header =~ /^ID3...(....)$/sm; my ($HeaderSize) = DecodeNum( $1 ); my ($StreamStart) = $HeaderSize + 10; my ($Frame); # Set up the remaining stuff vor a ID3v2 tag $Hash->{"taglength"} = $HeaderSize; $Hash->{"streamstart"} = $StreamStart; while ($HeaderSize > 0) { read( MP3FILE, $Frame, 10 ) or die "\nError reading from \"" . $Hash->{"name"} . "\" : $!\n"; my( $Tag, $Size, $Flags ) = unpack( "a4a4a2", $Frame ); if ($Tag =~ /^[a-zA-Z0-9][a-zA-Z0-9][a-zA-Z0-9][a-zA-Z0-9]/) { my( $Buffer ); $Size = DecodeNum( $Size ); read( MP3FILE, $Buffer, $Size ) or die "Error reading data from \"" . $Hash->{"name"} . "\" ($Size bytes): $!\n"; if (exists $ID3v23TagTypes{$Tag}) { my $Decoder = $ID3v23TagTypes{$Tag}; $Buffer = &$Decoder( $Buffer ); }; # Only store a tag if it wasn't there before (because of corrupt tags !) $Hash->{$Tag} = $Buffer unless $Hash->{$Tag}; $HeaderSize += -(10+$Size); } else { last; }; }; }; # Returns a hash reference with the information about the file sub MP3Info( $ ) { my $Filename = shift; my %Hash = ( "filename" => basename( "$Filename" ), "path" => dirname("$Filename"), "name" => "$Filename", ); my $Result = \%Hash; my( $ID3Header ); open( MP3FILE, "<" . $Filename ) or die "\nError opening $Filename : $!\n"; binmode( MP3FILE ); read( MP3FILE, $ID3Header, 10 ) or die "\nError reading from $Filename : $!\n"; if ($ID3Header =~ /^ID3\03......$/sm) { ReadID3v23Tags( \%Hash, $ID3Header ); } elsif ($ID3Header =~ /^ID3\02......$/sm) { ReadID3v22Tags( \%Hash, $ID3Header ); #$Result = "Unknown ID3v2 version in \"$Filename\""; } else { seek( MP3FILE, -128, 2 ) or die "\nError seeking in $Filename : $!\n"; read( MP3FILE, $ID3Header, 128 ) or die "\nError reading from $Filename : $!\n"; if ($ID3Header =~ /^TAG/s) { ReadID3v1Tags( \%Hash, $ID3Header ); } else { $Result = "ERROR: \"$Filename\" has no ID3 tag"; }; }; # If no error occurred until now, read information about the mp3 stream # and fix up some of the tag names if (ref $Result) { my $tmp = ReadStreamInfo( \%Hash ); if ($tmp) { $Result = $tmp; } else { # Now duplicate some of the names foreach $Tag (keys %TagNames) { if (exists $Hash{$Tag}) { $Hash{$TagNames{$Tag}} = "$Hash{$Tag}"; #print "$Tag : " . $TagNames{$Tag} . " : ". $Hash{$TagNames{$Tag}} . "\n"; }; }; if (exists $Hash{"TRCK"}) { $Hash{"track2"} = TwoDigits( $Hash{"TRCK"} ); }; }; }; close MP3FILE or die "closing $Filename : $!\n"; return $Result; }; __DATA__ # The built-in HTML template for the album # The name (and location) of the generated file # # To use your own template, either modify this one or copy everything from below __DATA__ # into another file and start mp3album.pl with the "--template filename" switch # %outfile% #html/$(artist) - $(album).html $(artist) - $(album).html %header% # Created from the data of the file with track number 1 $(artist) - $(album) (MP3 CD $(comment) - $(genre))
Back


$(artist) - $(album)

Play \'$(artist) - $(album)\'

%track% # Created for each file in the album %footer% # Footer, generated from the file with track number 1
$(track)$(title)$(time)
Total Time$(tottime)