The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/perl -w

use File::Copy;
use MP3::Tag;

# some settings for getting command-line options
use Getopt::Long;
Getopt::Long::Configure(qw/no_ignore_case_always ignore_case bundling/);
my %options = ( '--song=s'    => "Name of the song",
		'--album=s'   => "Album",
		'--artist=s'  => "Artist",
		'--comment=s' => "Comment",
		'--track=i'   => "Track",
		'--genre=s'   => "Genre",
		'--year=i'    => "Year",
		'--removetag' => "Removes an existing tag",
		'-q'          => "Be quiet",
		'-v'          => "Be verbose",
                '-f'          => "Force",
		'--show'      => "Show the existing tag",
		'--showgenres'=> "Show the existing genres (!!not yet!!)",
		'--setfilename' => "Set filename from tag (according to format string)",
		'--getfilename' => "Set tag according to filename (and format string)",
		'--format=s'  => "Set the format string for get/setfilenam (default: %a - %s.mp3)",
		'--nospaces'  => "Replace Spaces through _ in filenames",
		'--test'      => "Do NOT change the files. Only print which changes would be made",
		'--skipwithoutv1' => "Don't do anything if no ID3v1 tag exists",
		'--skipwithv1' => "Don't use --getfilename option if ID3v1 tags already exists",
	       );

# get the command line options
my %opt;
getoptions(\%opt, %options);

if (exists $opt{showgenres}) {
  my $genres = MP3::Tag::genres();
  print join (", ", @$genres) ."\n";
}

unless ($#ARGV >=0) {
  print "error: Filename(s) missing\n" unless exists $opt{showgenres};
  exit 0 if exists $opt{showgenres};
  exit 1;
}

# is there only one or more files to work with?
$opt{single}=1 if $#ARGV==0;

# prepare v and q flag (v has higher priority)
delete $opt{q} if (exists $opt{v} && exists $opt{q});

# prepare for setfilename / getfilename (not allowed together)
if (exists $opt{setfilename} && exists $opt{getfilename}) {
  print "error: Cannot use --setfilename and --getfilename together\n";
  delete $opt{setfilename};
  delete $opt{getfilename};
}
$opt{format} = "%a - %s.mp3" unless exists $opt{format};
my ($stencil, $details);
($stencil, $details) = formatstr_setfilename($opt{format}) if exists $opt{setfilename};
($stencil, $details) = formatstr_getfilename($opt{format}) if exists $opt{getfilename};

# loop for each file
chomp(@ARGV = <STDIN>) unless @ARGV;
for my $filename (@ARGV) {
  # get the tags
  $mp3 = MP3::Tag->new($filename);
  unless (defined $mp3) {
    print "Skipping $filename ...\n";
    next;
  }
  $mp3->get_tags;

  unless (exists $mp3->{ID3v1}) {
    print "No ID3v1-Tag found\n" if exists $opt{show} || exists $opt{v};
    next if exists $opt{skipwithoutv1};
    $mp3->new_tag("ID3v1");
  } else {
    next if exists $opt{skipwithv1};
  }

  # deletet tag if wanted (option: --removetag)
  $mp3->{ID3v1}->remove_tag if $opt{removetag};

  # set tag if this is wanted
  # option: --song, --artist, --album, --comment, --year, --genre, --track
  $mp3->{ID3v1}->song($opt{song}) if exists $opt{song};
  $mp3->{ID3v1}->artist($opt{artist}) if exists $opt{artist};
  $mp3->{ID3v1}->album($opt{album}) if exists $opt{album};
  $mp3->{ID3v1}->comment($opt{comment}) if exists $opt{comment};
  $mp3->{ID3v1}->year($opt{year}) if exists $opt{year};
  $mp3->{ID3v1}->genre($opt{genre}) if exists $opt{genre};
  $mp3->{ID3v1}->track($opt{track}) if exists $opt{track};

  if (exists $opt{song} || exists $opt{artist} || exists $opt{album} || 
      exists $opt{comment} || exists $opt{year} || exists $opt{genre} || exists $opt{track}) {
    if (exists $opt{test}) {
      # do nothing, but show new tag
      $opt{show} = 1;
    } else {
      if ($mp3->{ID3v1}->write_tag()) {
	print "Tag written\n" unless exists $opt{q};
      } else {
	print "Couldn't write tag\n" unless exists $opt{q};
      }
    }
  }

  # show tag (option --show)
  if ($opt{show}) {
    print "\nID3v1-Tag: $filename\n" unless exists $opt{single};
    print "New tag would be:\n" if exists $opt{test};
    print "   Song: " .$mp3->{ID3v1}->song . "\n";
    print " Artist: " .$mp3->{ID3v1}->artist . "\n";
    print "  Album: " .$mp3->{ID3v1}->album . "\n";
    print "Comment: " .$mp3->{ID3v1}->comment . "\n";
    print "   Year: " .$mp3->{ID3v1}->year . "\n";
    print "  Genre: " .$mp3->{ID3v1}->genre . "\n";
    print "  Track: " .$mp3->{ID3v1}->track . "\n";
  }

  # set filename from tag (option: --setfilename)
  # with --format the format of the new filename can be set (see formatstr below)
  if (exists $opt{setfilename}) {
    unless (exists $opt{test}) { # check if there really exists a tag
      $mp3->get_tags;
      unless (exists $mp3->{ID3v1}) {
	print "No ID3v1 Tag exists. Can't change $filename\n";
	exit -1;
      }
    }
    my $new = $stencil;
    my $i=0;
    foreach (@$details) {
      my $txt = $mp3->{ID3v1}->{$_->{tag}};
      $txt =~ s/ *$//;
      $txt = substr $txt, 0, $_->{length} if exists $_->{length} && ((! exists $_->{fill}) || exists  $_->{precise} );
      $txt = $_->{fill} x ($_->{length}-length($txt)) . $txt if exists $_->{fill};
      $new =~ s/%$i/$txt/;
      $i++;
    }
    $new =~ s/ /_/g if exists $opt{nospaces};

    print "Trying to rename $filename to $new\n" if exists $opt{v} && !exists $opt{test};
    print "$filename => $new\n" if exists $opt{test};

    if ( !exists $opt{test} && $new && checkpath($new)) {
      $mp3->close;
      move($filename, $new);
    } else {
      print "Cannot set filename from tag: $new is invalid\n" unless exists $opt{q} || exists $opt{test} ; 
    }
  }

  # set tag from filename (option: --getfilename)
  # with --format the format of the filename can be set
  if (exists $opt{getfilename}) {
	  my @matches;
	  if (@matches = ($filename =~ $stencil)) {
		  while(($key,$val)=each %$details) {
			  $mp3->{ID3v1}->$val($matches[$key]);
		  }
		  if (exists $opt{test} or exists $opt{v} or exists $opt{show}) {
			  print "\nID3v1-Tag: $filename\n" unless exists $opt{single};
			  print "After scanning filename, new tag would be:\n";
			  print "   Song: " .$mp3->{ID3v1}->song . "\n";
			  print " Artist: " .$mp3->{ID3v1}->artist . "\n";
			  print "  Album: " .$mp3->{ID3v1}->album . "\n";
			  print "Comment: " .$mp3->{ID3v1}->comment . "\n";
			  print "   Year: " .$mp3->{ID3v1}->year . "\n";
			  print "  Genre: " .$mp3->{ID3v1}->genre . "\n";
			  print "  Track: " .$mp3->{ID3v1}->track . "\n";
		  }
		  unless (exists $opt{test}) {
			  if ($mp3->{ID3v1}->write_tag()) {
				  print "Tag written\n" unless exists $opt{q};
			  } else {
				  print "Couldn't write tag\n" unless exists $opt{q};
			  }
		  }
	} else {
		print"Couldn't analyze '$filename' with /$stencil/\n" unless exists $opt{q};
	}
  }


}

######################################## SUBS

# check if the path to this file exists
# if not, ask if missing dirs should be created
# return true/false if the path is ok
sub checkpath {
  my $file = shift;
  my $tree=""; my $treeok=1;
  while ($file =~ /([^\/]*)\//g && $treeok) {
    unless ($1 eq "" || -d $tree.$1) {
      print "$tree$1/ doesn't exists!\n" if exists $opt{v};
      $treeok=0;
      last unless confirm("Should I create the directory $tree$1/");
      if (mkdir $tree.$1, 0755) {
	$treeok=1;
      } else {
	print "Cannot create directory $tree$1/\n";
      }
    }
    $tree .=$1."/";
  }
  return $treeok;
}

# prints a question and waits for a [y]es or [n]o
# returns true (1) for [y] or false (o) for [n]
# with option -f (force) returns always true (1)
sub confirm {
  return 1 if $opt{f};

  my $question = shift;
  print $question ." [y/n] ?";

  my $key = <STDIN>;  
  chomp $key;
  return ($key =~ /^[YyJj]/) ? 1 : 0;
}

#######################################

# converts formatstr into the internal dataformat
#
# the formatstr may include any text, valid for a filename
# also it contains symbols,which must start with a % and end with one of [salgyt]
# %s song, %a - artist %l - album, %y -year, %g - genre, %t -track
#
# each symbol can contain additional information:
# a length l, directly after the %
# a fill char, given as :x or !:x where x is the fillchar, and :x or !:x follows after the length
#
# * if there is only a length l given, the string will be max. l chars long, 
#          that means cut off after l chars if it is longer
# * if there is a length and a :x, then a string which is shorter then l chars, 
#          will be filled from left with the char x to meet the length l
#          a longer sting will not be affected
# * if there is a length and a !:x, then a string shorter than l, will also filled
#          from left with x, but a longer string than l, will be cut off at l
#
# eg track=3  artist=Abba  song=Waterloo
# '%2:0t.$a - $s.mp3' will be translated to '03.Abba - Waterloo.mp3'
# '%t.$6!:_a - $6!:_s.mp3' will be translated to '3.__Abba - Waterl.mp3'
#
# intern format contains $stencil and @details
# the stencil contains text and %i makros. i is an integer, counting up from 0
# $details[i] contains {tag} which should be used to replace %i in the stencil
# {length}, {fill} and {precise} give some additional information for replacing
sub formatstr_setfilename {
  my $format = shift;
  my %tags = (s=>"song", a=>"artist", l=>"album", y=>"year", g=>"genre", t=>"track", c=>"comment");
  my @fmt;

  while ($format =~ /%([0-9]*)(?:(!)?:(.))?([salygct])/g) {
    my $t;
    $t->{length}=$1 if defined $1 && $1 ne "";
    $t->{precise}=1 if defined $2 && $2 ne "";
    $t->{fill}=$3 if defined $3 && $3 ne "";
    $t->{tag} = $tags{$4} if defined $4 && $4 ne "";
    push @fmt, $t if defined $4 && $4 ne "";
  }
  my $i=0;
  $format =~ s/%([0-9]*)(?:(!)?:(.))?([salygt])/"%".$i++/eg;
  return ($format, \@fmt);
}

sub formatstr_getfilename {
	my $format = shift;
	my $pos=0;
	my %tags = (s=>"song", a=>"artist", l=>"album", y=>"year", g=>"genre", t=>"track", c=>"comment");
	my %info;
	while ($format =~ /%([salgcyt])/g) {
		$info{$pos++}=$tags{$1};
	}
	$format =~ s/([\[\]*.?()])/\\$1/g;
	$format =~ s/%[yt]/(\\d+)/g;
	$format =~ s/%[salgc]/(.+?)/g;
	return (qr!$format!, \%info);
}

########################################

sub getoptions {
    my ($optref, %options) = @_;
    unless ( GetOptions ($optref, keys %options) ) {
	# found unknown option
	# show usage of options
	print "\nUSAGE: $0 " . join(" ", sort keys %options) ." file(s)\n\n";
	my ($eq, $co, $neg) = (0,0,0); 
	foreach (sort keys %options) {
	    printf "%13s : %s\n", $_, $options{$_};
	    $eq = 1 if /=/;
	    $co = 1 if /:/;
	    $neg = 1 if /!/;
	}
	print "\n" if $eq || $co || $neg;
	print " --switch!  - switch may be negated as --noswitch\n" if $neg;
	print " --switch=x - switch must be followed by a value x\n" if $eq;
	print " --switch:x - switch can be followed by a value x\n" if $co;
	print "             / f - value must be a float\n".
	    "          x =  s - value must be a string\n".
		"             \\ i - value must be an integer\n" if $eq || $co;
	# and exit because of unknown options
	exit(0);
    }
}