#!/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 <<end_of_help  

$Version

SYNTAX: mp3album.pl [options] [directories ...] [files ...]



Mp3album creates a file from the ID3 tag information found in the files. 

See http://www.id3.org for more information about the ID3 format.

Currently mp3album only supports complete albums, that is, albums 

that contain a track with track number 1. All other tracks with

the same album title are then pulled together with this one,

regardless of where they actually reside.



Directories will not be searched recursively. The default directory is 

the current directory.



--verbosity=i		Set verbosity of output (0-2)

--template=s		Set the name of the template file (see __DATA__ section

			of the source code for format)

--filespec=s		Set the filespec (default is *.mp3)

                        This string must be quoted from shell expansion under 

			Unix shells !

--help			Display this screen



This program is complete freeware without any license. If you want to make

the ID3 code into a module, feel free to and maybe mail me the results.

end_of_help

if $optHelp;



@optDirectories = ();

@Files = ();



# Now sort everything left on the command line into either files or directories

while ($tmp = shift) {

  if (-f $tmp) {

    push @Files, $tmp;

  } elsif (-d _) {				# Some (smart?) optimization to save one system call

    push @optDirectories, $tmp;

  } else {

    print "ERROR: \"$tmp\" is neither a directory nor a file";

  };

};



if ($#optDirectories + $#Files == -2) {

  push @optDirectories, File::Spec->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 (<DATA>) {



  # Strip comments and empty lines from template

  next if (/^#/ || /^$/);



  if (/^%(.+)%$/) {

    $SectionName = lc($1);

    $Template{$SectionName} = "";

  } else {

    $Template{$SectionName} .= $_;

  };

};



# Strip <CR> 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

<HTML><HEAD><TITLE>$(artist) - $(album) (MP3 CD $(comment) - $(genre))</TITLE></HEAD><body bgcolor="#000000" link="#000000" vlink="#000000" leftmargin="20" topmargin="0" text="#000000"><div align="right"><A href="../index.html"><img src="../Thumbs/back.gif" alt="Back" border="0" width="50" height="50"></a></div><div align="center"><center><br><br><font SIZE="2" FACE="Arial" COLOR="#FFCC33"><h2>$(artist) - $(album)</h2></font><font SIZE="2" FACE="Arial" COLOR="#000000"><a href="../$(artist) - $(album).m3u"><img src="../Covers/$(artist) - $(album).jpg" width="300" height="300" alt="Play \'$(artist) - $(album)\'"></a><br><br><table BGCOLOR="#000000" CELLSPACING="5" width="80%" cellpadding="5"><TBODY>



%track%

# Created for each file in the album

<tr><td width="30" BGCOLOR="#FFCC33" align="right">$(track)<td BGCOLOR="#FFCC33"><a href="../$(filename)">$(title)</a><td BGCOLOR="#FFCC33" width="41" align="right">$(time)</tr>



%footer%

# Footer, generated from the file with track number 1

<tr><td><td align="right"><font SIZE="3" FACE="Arial" COLOR="#FFCC33">Total Time</font></td><td width="41"><font SIZE="3" FACE="Arial" COLOR="#FFCC33">$(tottime)</font></td></tr></TBODY></table></font><div align="right"><br><br><A HREF="../index.html"><font SIZE="3" FACE="Arial" COLOR="#FFCC33">Back to the index...</font></A></div></center></div></body></html>