#!/usr/bin/perl -w
#
#    YaRET -- Yet another Ripper-Encoder-Tagger
#
#    Copyright (c) 1999 by Marco Nenciarini <mnencia@prato.linux.it>
#                   and by Adam Luter       <luterac@auburn.edu>
#
# 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 General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.

use strict;

$|++;

my $ver = "2.1.0 (Jul 10, 2003)";

########################################
# Required Modules

use AppConfig qw(:expand :argcount); # Debian packages: libappconfig-perl
use Audio::CD; # Use CPAN module (perl -MCPAN -eshell)
               # also install Debian packages: libcdaudio0 libcdaudio0-dev
# The remaining seem to be installed by default:
use POSIX qw(:sys_wait_h);
use File::Temp qw(tempfile);
use Fcntl qw(F_SETFD);
use File::Path;

########################################
# Optional Modules (Loaded later)

# use Term::ANSIColor; # --color
# use Data::Dumper; # --option_only

########################################
# Configuration

# You may find documentation for the structure and meaning of the following
# sections from theses man pages:
# AppConfig(3pm), AppConfig::State(3pm), AppConfig::File(3pm)

my %config_initial = (
  CASE=>0, # no case-sensitivity
  CREATE=>0, # no auto creation of undefined variables
  PEDANTIC=>0, # don't exit on errors
# ERROR=>\&???, # use the default
  GLOBAL=>{
    DEFAULT=>undef,
    ARGCOUNT=>ARGCOUNT_NONE,
    EXPAND=>EXPAND_ALL,
#   VALIDATE=>\&???, # use the default
#   ACTION=>\&???, # use the default
  }
);

# You may use either the short form or the long form here
my @config_definitions = (
## General ##
["help|h|?"],
["version|v"],
["option_only"], # only process options then exit
["include|conf_file|f=s@"], # include additional configuration files
["cddb_file=s@"], # pre-specify cddb information
["max_fork=i", {DEFAULT=>2} ],
["size_order!", {DEFAULT=>1} ],

## Display ##
["display_date|date!", {DEFAULT=>1} ], # print starting and ending dates
["display_color|color!", {DEFAULT=>1} ],
["display_clear|clear!", {DEFAULT=>1} ],
["display_alarm|alarm!", {DEFAULT=>1} ],
["display_quiet|quiet!"],

## Root Paths ##
["root_final=s", {DEFAULT=>"~/music"} ],
["root_work=s", {DEFAULT=>"~/tmp"} ],

## Final Output Pattern ##
["output_name=s", {DEFAULT=>"ARTIST-TRACK_NUM-TRACK.mp3"} ],
["output_path=s", {DEFAULT=>"ENCODER/ARTIST/ALBUM"} ],
["output_track_num_format=s", {DEFAULT=>"%02d"} ],
["output_trans=s", {DEFAULT=>"s/[^a-zA-Z0-9-]+/_/g"} ],

## Album/Track CDDB Attributes ##
# global specifies a album global setting, e.g. "global YEAR=1990"
["cddb_global|global=s%"],
# track specifies an individual entry, e.g. "track 1=TRACK=Hello City ARTIST=Bare Naked Ladies"
["cddb_track|track=s%"],
["cddb_dump=s"],
["cddb_out=s", {DEFAULT=>"ARTIST-ALBUM.info"} ],
["cddb_confirm|confirm!", {DEFAULT=>1} ],

## Ripper ##
# ripper program to use, e.g. "ripper cdparanoia", may only use one
["ripper_use|ripper|r=s"],
["ripper_command=s%"], # defaults specified later due to bug (?)
["ripper_device|device|d=s", {DEFAULT=>"/dev/cdrom"} ],
["ripper_skip|skip=s@"], # which tracks to skip
["ripper_auto_skip|auto_skip:s", {DEFAULT=>60} ],
["ripper_eject|eject!", {DEFAULT=>1} ],
["ripper_min_space|min_space=i", {DEFAULT=>0} ],
["ripper_nice:i", {DEFAULT=>0} ],

## Normalize ##
# which normalize program to use, e.g. "normalize normalize", may only use one
["normalize_use|normalize=s"],
["normalize_type=s%"],  # normalize track or album at a time
["normalize_command=s%"], # defaults specified later due to bug (?)
["normalize_nice:i", {DEFAULT=>0} ],

## Encoder ##
# encoder program(s) to use, e.g. "encoder lame \n encoder bladeenc"
["encoder_use|encoder|e=s@"], # encoder program(s) to use
["encoder_command=s%"], # defaults specified later due to bug (?)
["encoder_bitrate|bitrate=i%"],
["encoder_quality|quality=f%"],
["encoder_extension|extension=s%"],
["encoder_nice:i", {DEFAULT=>10} ],
);

my $config = AppConfig->new(\%config_initial);

for my $x (@config_definitions) {
  $config->define(@$x);
}

sub config_validate {
  my ($var, $val) = @_;
  # not used for any options
  return 1;
}

sub config_action {
  our ($state, $var, $val) = @_;
  # this helper function can be used to make sure only one option of two is on
  # (like a toggle switch)
  sub config_set_exclusive {
    my ($opt1, $opt2) = @_;
    $state->set($opt1, 0) if $var eq $opt2 and $val == 1 and $state->get($opt1);
    $state->set($opt2, 0) if $var eq $opt1 and $val == 1 and $state->get($opt2);
  }
  # not used for any options
}

# for some reason the DEFAULT option does not work for ARGCOUNT of type HASH and LIST
my %config_default = (
  ripper_command=>[
    "cdparanoia=cdparanoia -z -d CD_DEV TRACK_NUM FILE_OUT",
    "cddawav=cddawav -z -H -P 1 -t -D CD_DEV TRACK_NUM FILE_OUT",
  ],
  normalize_command=>[
    "normalize_album=normalize -b -q FILE_LIST",
    "normalize_track=normalize -q FILE_LIST",
    "no_normalize=NOOP",
  ],
  normalize_type=>[
    "normalize_album=ALBUM",
    "normalize_track=TRACK",
    "no_normalize=TRACK",
  ],
  encoder_command=>[
    "wav=NOOP",
    "lame=lame --quiet -q QUALITY -t -p -b BIT_RATE -m j --tt TRACK --ta ARTIST --tl ALBUM --ty YEAR --tg GENRE --tn TRACK_NUM FILE_IN FILE_OUT",
    "bladeenc=bladeenc -crc -q -quiet -BIT_RATE FILE_IN FILE_OUT",
    "oggenc=oggenc -q QUALITY -o FILE_OUT -d YEAR -N TRACK_NUM -t TRACK -l ALBUM -a ARTIST -G GENRE FILE_IN",
    "flac=flac -o FILE_OUT -QUALITY FILE_IN",
  ],
  encoder_bitrate=>[
    "lame=128",
    "bladeenc=128",
  ],
  encoder_quality=>[
    "lame=5",
    "oggenc=3",
    "flac=5",
  ],
  encoder_extension=>[
    "wav=.wav",
    "lame=.mp3",
    "bladeenc=.mp3",
    "oggenc=.ogg",
    "flac=.flac",
  ],
);

while (my ($k, $v) = each %config_default) {
  $config->set($k,$_) for @$v;
}

sub load_all_config {
  our %done = ();
  sub load_config_file {
    my @todo = @_;
    while (my $file = shift @todo) {
      unless (exists $done{$file}) {
        $config->file($file);
        $done{$file} = 1;
        my $new = $config->conf_file();
        push @todo, @$new
      }
    }
  }
  my $yaretrc = "$ENV{HOME}/.yaretrc";
  if (-r $yaretrc) {
    load_config_file $yaretrc;
  } else {
    # please note, no color on this warning, because we have not loaded Term::ANSIColor
    warn "Perhaps you'd like to make a $yaretrc file to remove this warning (or make it readable)";
  }
  load_config_file @{$config->conf_file()};
  $config->getopt;
  load_config_file @{$config->conf_file()};
}
load_all_config;

if ($config->help) {
  print <<HELP;
YaRET $ver by Adam Luter <luterac\@auburn.edu>
and Marco Nenciarini <mnencia\@prato.linux.it>
Usage: yaret [options]
       yaret [--help|--version|--option_only]
Options are:
=============================================================================
[General]
  -h, --help				Display this help (also -?)
  -v, --version				Display the version
      --option_only			Display configuration data
  -f, --include=<file>			Include this file as configuration
					data (in addition to ~/.yaretrc and
					any command line options)
					(also --conf_file)
      --cddb_file=<file>		Include this CDDB information in
					addition to that obtained from the
					CDDB query (in same format as both
					--cddb_dump and --cddb_out produce)
      --max_fork=<value>		Maximum number of normalizers and
					encoders forked (per type)
      --size_order			Sorts tracks in order of size,
					completing smaller ones first.
[Display]
      --date				Display the start/end times
      --color				Use color
      --alarm				Send beeps when completed
      --clear				Clear the screen
      --quiet				Do not display anything except errors
[Paths and Filenames]
      --root_final=<path>		Where to place the results
      --root_work=<path>		Where to work on the results
      --output_name=<pattern>		How to name the final file
      --output_path=<pattern>		Path under root_final to place file
      --output_track_num_format\	Specifies a printf format for the
        =<format>			track numbering, e.g. "%02d"
      --output_trans=<regexp>           Specifies a regular expression
                                        that effects the output of what
                                        filename is generated, e.g. "tr/ /_/"
[CDDB Overrides]
      --global==<attr>=<val>		e.g. ARTIST=Bare Naked Ladies
					(Make sure to use escapes or quotes)
					(also --cddb_global)
      --track==<tracknum>\		e.g. 14=TRACK=Alone ARTIST=Heart
        =[<attr>=<val> ]*		(Make sure to use escapes or quotes)
      --cddb_dump=<file>		Dump cddb information to <file>
					then exit (no editing, encoding, etc.)
      --cddb_out=<pattern>		After the entire process is completed,
					output the cddb information to
					<pattern>.  This filename works with
					the same pattern rules as the other
					output files, however some keyword may
					may not be available. The information
					saved will reflect any editing.
      --confirm				Confirm cddb information
					(also --cddb_confirm)
					Use --noconfirm to turn off.
[Ripper]
  -r, --ripper=<label>			Which ripper to use (cdparanoia and
					cddawav have already been defined)
					(also --ripper_use)
      --ripper_command==\		The <command> associated with
	<label>=<command>		<label>
  -d, --device=<device>			CDROM Device to use, default is
					/dev/cdrom (also --ripper_device)
      --skip=<tracknumlist>		Skip these tracks, e.g. 3,10-13
					(also --ripper_skip)
      --auto_skip=<seconds>		Auto skip tracks that do not grow
					after <seconds>
					(also --ripper_auto_skip)
      --min_space=<megabytes>		Do not start ripping a new track
					unless this much space is free
					(also --ripper_min_space)
      --ripper_nice=<nice>		Nice the ripper process to <nice>
[Normalize]
      --normalize=<label>		As --ripper (normalize has already
					been defined)
      --normalize_type=<value>		TRACK/ALBUM normalization, ALBUM
					uses a larger amount of work space,
					and cannot start working until all
					ripping is done.
      --normalize_command==\		As --ripper_command
	<label>=<command>
      --normalize_nice=<nice>		As --ripper_nice
[Encoder]
  -e, --encoder=<label>			As --ripper except you may enable
					multiple (but make sure you use the
					ENCODE keyword in --output options)
					(lame and bladeenc have already been
					defined)
      --encoder_command==\		As --ripper_command
	<label>=<command>
      --bitrate==<label>=<bitrate>	Set bitrate for <label> encoder
					(also --encoder_bitrate)
      --quality==<label>=<quality>	Set quality for <label> encoder
					(also --encoder_quality)
      --extension==<label>=<value>	Set extension for <label> encoder,
					default is ".mp3"
					(also --encoder_extension)
      --encoder_nice=<nice>		As --ripper_nice

Please see the yaretrc file as well as the README and yaretrc files that came
with YaRET (perhaps located in /usr/doc/yaret) for more information about
configuring the many features that YaRET comes with.  (If this page is too
long to view, try 'yaret -h|less' or 'yaret -h|more').

HELP
  exit;
}

if ($config->option_only) {
  use Data::Dumper;
  my %list = $config->varlist("^");
  my $dump = Data::Dumper->new([\%list], ["OPTIONS"]);
  print $dump->Dump;
  exit;
}

if ($config->version) {
  print <<VERSION;
You have had the pleasure of running YaRET $ver
Brought to you by Adam Luter <luterac\@auburn.edu>
and Marco Nenciarini <mnencia\@prato.linux.it>
VERSION
  exit;
}

open DEVNULL, ">/dev/null";
select DEVNULL
  if $config->quiet;

# Setup Color
my $CLEARLINE = "\r\e[2K";
my $CLEARSCREEN="\e[2J\e[0;0H";

# DO NOT CHANGE the order of these colors, unless you fix
# the for loop in the follow else statement.
my @COLORS = qw(RED GREEN YELLOW BLUE MAGENTA CYAN WHITE);
my ($RED, $GREEN, $YELLOW, $BLUE, $MAGENTA, $CYAN, $WHITE);
my ($RESET, $BOLD);
if ($config->color) {
  if (eval "require Term::ANSIColor") {
    use Term::ANSIColor qw(:constants);
    # NOTE: Term::ANSIColor uses little procedures to do the escape codes.
    # We don't care for that, so we are just going to run the procedure
    # once and store it in a constant.
    $RESET=RESET;
    $BOLD=BOLD;
    eval "\$$_=$_" for @COLORS;
  } else {
    warn "Term::ANSIColor module not found, installing backup color handler!";
    $RESET="\e[0m";
    $BOLD="\e[1m";
    my $x = 31;
    eval "\$$_=\\e[".$x++."m" for @COLORS;
  }
} else {
  eval "\$$_=''" for @COLORS;
	$RESET = "";
	$BOLD = "";
}

# Though the @COLOR constants are available, the program should
# really only use the following procedures to print
my @THEMES = qw(NORM INFO START DONE ERROR ALERT);
my ($NORM, $INFO, $START, $DONE, $ERROR, $ALERT);
sub set_color_themes {
  $NORM=$RESET;
  $INFO=$BOLD.$WHITE;
  $START=$BOLD.$GREEN;
  $DONE=$BOLD.$CYAN;
  $ERROR=$BOLD.$RED;
  $ALERT=$BOLD.$YELLOW;
}

sub sprintc {
  my @fields = @_;
  my $str = "";
  $str .= $CLEARLINE;
  $str .= $_ for @fields;
  $str .= $RESET;
  return $str;
}

sub printc {
  print sprintc(@_);
}

set_color_themes;

sub create_dir {
  my ($dir, $description) = @_;
  die sprintc $ERROR, "File $description ($dir) already exists but isn't a directory!"
    if -e $dir and not -d $dir;
  return if -e $dir and -d $dir;
  eval { mkpath($dir); };
  die sprintc $ERROR, "Could not create $description directory ($dir) [$@]"
    if $@ and not (-e $dir and -d $dir);
}

my ($root_work) = glob $config->root_work;
my ($root_final) = glob $config->root_final;
$root_work .= "/$$";
create_dir $root_work, "Main Playpin";
create_dir $root_final, "Main Output";

# End Configuration
########################################

########################################
# Globals and Globals Setup

my %date; # start and end dates
my %workfile; # these files are removed if we die suddenly
my %kid; # keeps track of our children
my %skip_track; # which tracks to skip
my $cd; # Audio::CD interface to CD
my $cd_info; # Audio::CD::Info object
my $cddb = {}; # translated Audio::CD::CDDB
my $track = {}; # traslated Audio::CD::Info->tracks
my %status; # holds the filehandles to read status from
my $grand_dad = 1; # are we the main parent?

sub kill_all {
  kill "SIGINT" => keys %kid;
  -e $_ and unlink $_ for keys %workfile;
  rmdir $root_work;
  print $CLEARLINE if $grand_dad;
  exit 1;
}

sub kid_killer {
  while ((my $deadone = waitpid(-1, WNOHANG)) > 0) {
    delete $kid{$deadone};
  }
}

sub set_default_signals {
  $SIG{ALRM} = sub{};#'IGNORE';
  $SIG{CHLD} = \&kid_killer;
  $SIG{INT} = \&kill_all;
  $SIG{TERM} = \&kill_all;
  $SIG{HUP} = \&kill_all;
}

sub set_cd {
  $cd = Audio::CD->init($config->ripper_device);
  die sprintc $ERROR, "There seems to be a problem accessing the cdrom device ('", $config->ripper_device, "')"
    if not defined $cd;
  $cd->close;
  $cd_info = $cd->stat;
  die sprintc $ERROR, "There seems to be a problem getting information about the cdrom device ('", $config->ripper_device, "')"
    if not defined $cd_info;
  die sprintc $ERROR, "There seems to be no disc in the cdrom device ('", $config->ripper_device, "')"
    unless $cd_info->total_tracks;
}

sub get_cddb_attrib {
  my ($track_num, $attrib) = @_;
  return $cddb->{$1} || "Unknown \u\L$1"
	  if $attrib =~ /MAIN_(.+)/;
  return $cddb->{track}{$track_num}{$attrib} || $cddb->{$attrib} || "Unknown \u\L$attrib";
}

sub make_substitution {
  my ($template, $quote, %keywords) = @_;
  my $sub = $template;
    
  return "" if $template eq "NOOP";

  # you must substitute in order of descending length.
  # e.g. TRACK_NUM before TRACK
  for my $k (sort {length $b <=> length $a} keys %keywords) {
    my $value = $keywords{$k};
    my @value_list = ();
    my $value_str = "";
    if (ref $value eq "ARRAY") {
      @value_list = @$value;
    } else {
      @value_list = ($value);
    }
    for my $val (@value_list) {
      $val = "" if not defined $val;
      if ($k eq "TRACK_NUM") {
        my $format = $config->output_track_num_format;
        if ($format =~ /X/) {
          my $max_len = int $cd_info->total_tracks / 10 + 1;
          $format =~ s/X/$max_len/g;
        }
        $val = sprintf $format, $val;
      }
      if ($quote) {
        # with bash you cannot escape a ' inside of '',
        # so replace with '\''
        $val =~ s/'/\'\\\'\'/g;
        $val = "'$val'";
      }
      $value_str .= "$val ";
    }
    chop $value_str;
    $sub =~ s/$k/$value_str/g
  }

  return $sub;
}

sub do_output_transformation {
  my %keywords = @_;
  for my $k (keys %keywords) {
	if (defined $keywords{$k}) {
      eval "\$keywords{\$k} =~ ".$config->output_trans
        unless $config->output_trans eq "NOOP";

      # replace all '/' with '-'
      # ('/' is not a valid character in unix filenames)
      $keywords{$k} =~ s!/!-!g;
	}
  }
  return %keywords;
}

sub final_name {
  my ($track_num, $encoder) = @_;
  my ($out_name, $out_path);
  my %keywords = (
    CD_DEV=>$config->device,
    ENCODER=>$encoder,
    QUALITY=>$config->quality->{$encoder},
    BIT_RATE=>$config->bitrate->{$encoder},
    TRACK_NUM=>$track_num,
  );
  $keywords{$_} = get_cddb_attrib($track_num, $_)
    for map { ($_, "MAIN_".$_) } qw(ALBUM ARTIST GENRE TRACK YEAR);
  %keywords = do_output_transformation %keywords;
  $out_path = "$root_final/".make_substitution
                $config->output_path,
                0, %keywords;
  $out_name = make_substitution
                $config->output_name.$config->extension->{$encoder},
                0, %keywords;
				
  return (
    NAME=>$out_name,
    PATH=>$out_path,
    FULL=>"$out_path/$out_name",
  );
}

sub read_cddb_file {
  my ($fh, $overwrite) = @_;
  $cddb = {} if defined $overwrite and $overwrite;
  my %ATTR = map { ($_, 1) } qw(ARTIST ALBUM GENRE TRACK YEAR);
  while (my $line = <$fh>) {
    chomp $line;
    $line =~ s/#.*$//;
    next if $line =~ /^\s*$/;
    my %entry = splice @{[ split /([A-Z_]+)\s*=\s*/, $line ]}, 1;
    my $x;
    if (exists $entry{TRACK_NUM}) {
      $x = $entry{TRACK_NUM};
      $x =~ s/\s*$//;
      delete $entry{TRACK_NUM};
    }
    while (my ($k, $v) = each %entry) {
      $v =~ s/\s*$//;
      if (exists $ATTR{$k}) {
        if (defined $x) {
          $cddb->{track}{$x}{$k} = $v;
	} else {
	  $cddb->{$k} = $v;
	}
      } else {
        warn sprintc $ALERT, "Discarding unknown attribute $k=$v";
      }
    }
  }
}

sub collect_cddb_info {
  my $x = 0;
  for my $t (@{ $cd_info->tracks }) {
    $x++;
    my ($min, $sec) = $t->length;
    $track->{$x}{min} = $min;
    $track->{$x}{sec} = $sec;
    $track->{$x}{size} = ($min * 60 + $sec) * 176000;
  }
  my $c_conf_file = "~/.cdserverrc";
  my ($glob) = glob $c_conf_file;
  if (! -e $glob) {
    warn sprintc $ALERT, "You do not have a $c_conf_file, automagically generating one for you\n";
    open CCF, ">$glob" or
      die sprintc $ERROR, "Cannot write to $glob, please check that directories permissions";
    print CCF <<EOF;
# CD Server configuration file (libcdaudio)
# Generated automagically by YaRET
ACCESS=REMOTE
# NOTE: cddb.cddb.com refuses libcdaudio as a client so we use freedb instead
#       (11/2001, YaRET [Gryn (Adam Luter) <luterac\@auburn.edu>])
SERVER=cddbp://freedb.freedb.org:8880/
EOF
    close CCF;
  }
  my $c_key = $cd->cddb;
  printc $INFO, "Looking up cddb information (using server in $c_conf_file)\n";
  my $c_data = $c_key->lookup;
  $cddb->{ARTIST} = $c_data->artist;
  $cddb->{ALBUM} = $c_data->title;
  $cddb->{GENRE} = $c_data->genre;
	$cddb->{YEAR} = "Unknown Year";
  $x = 0;
  for my $t (@{ $c_data->tracks($cd_info) }) {
    $x++;
    $cddb->{track}{$x}{TRACK} = $t->name;
		$cddb->{track}{$x}{TRACK} =~ s/\s+$//; # Remove trailing whitespace
    $cddb->{track}{$x}{ARTIST} = $t->artist
		  if $t->artist and lc $t->artist ne lc $c_data->artist;
  }
  my %ATTR = map { ($_, 1) } qw(ARTIST ALBUM GENRE TRACK YEAR);
  for my $file (@{ $config->cddb_file }) {
    if (open my $fh, $file) {
      # no problem opening
      read_cddb_file $fh, 0; # zero means add to previous values
      close $fh;
    } else {
      # problem opening
      warn sprintc $ALERT, "Couldn't open --cddb_file='$file'";
    }
  }
  while (my ($k, $v) = each %{ $config->cddb_global }) {
    if (exists $ATTR{$k}) {
      $cddb->{$k} = $v if defined $v and $v;
    } else {
      warn sprintc $ALERT, "Discarding unknown attribute $k=$v";
    }
  }
  while (my ($track_num, $entry) = each %{ $config->cddb_track }) {
    # FIXME: should we split on a pattern like /([A-Z_]+)\s*=\s*/, or on a pattern like
    # /(ARTIST|ALBUM|GENRE|TRACK|YEAR)\s*=\s*/, they each have their own advantages
    # and disadvantages
    next if exists $skip_track{$track_num};
    # FIXME: first entry will be empty (why?) so splice @{[ ... ]}, 1 removes it
    my %entry = splice @{[ split( /([A-Z_]+)\s*=\s*/, $entry )]}, 1;
    while (my ($k, $v) = each %entry) {
       if (exists $ATTR{$k}) {
         $cddb->{track}{$track_num}{$k} = $v if defined $v and $v;
       } else {
         warn sprintc $ALERT, "Discarding unknown attribute $k=$v";
       }
    }
  }
}

sub set_skip_track {
  # skip any user passed tracks
  for my $x (split ",", join ",", @{ $config->ripper_skip }) {
    if ($x=~/-/) {
      my ($a,$b)=split /-/, $x;
      next unless defined $a and defined $b;
      $b = $cd_info->total_tracks if $b > $cd_info->total_tracks;
      $skip_track{$_} = 1 for $a..$b;
    } else {
      $skip_track{$x} = 1 unless $x > $cd_info->total_tracks;
    }
  }
  die sprintc $ERROR, "There are no audio tracks left after processing --skip=", join ",", @{ $config->ripper_skip }
    if keys %skip_track >= $cd_info->total_tracks;

  # now skip any data tracks
  my $x = 0;
  for my $track (@{ $cd_info->tracks }) {
    $x++;
    if ($track->is_data) {
      $skip_track{$x} = 1;
    }
  }
  die sprintc $ERROR, "There are no audio tracks on cdrom device ('", $config->ripper_device, "')"
    if keys %skip_track >= $cd_info->total_tracks;

  # now skip any already completed tracks (but only if all encoders have
  # completed it)
  # NOTE (possible FIXME): This loop doesn't work if the user opted to change
  # the CDDB information (previously), since this loop executes before the
  # user is asked to change the CDDB information (this time).
  my $num_encoder = @{ $config->encoder };
  my $track_num = 0;
  for my $track (@{ $cd_info->tracks }) {
    $track_num++;
    next if $skip_track{$track_num};
    my $num_done = 0;
    for my $encoder (@{ $config->encoder}) {
      my %name = final_name $track_num, $encoder;
      $num_done++ if -e $name{FULL};
    }
    $skip_track{$track_num} = 1 if $num_done == $num_encoder;
  }
  die sprintc $ERROR, "All audio tracks appear to be completed!"
    if keys %skip_track >= $cd_info->total_tracks;

  # clear informatio in $track and $cddb to make sure no one tries to use it
  for my $skipped (keys %skip_track) {
    delete $track->{$skipped};
    delete $cddb->{track}{$skipped};
  }
}

sub display_cddb {
  for my $field (sort keys %$cddb) {
    printc $INFO, sprintf "%-10s: %s\n", "\u\L$field",$cddb->{$field}
      if $field =~ /^[A-Z_]+$/;
  }
  printc $INFO, "Num   Time   Attributes\n" if keys %$track;
  for my $x (sort {$a <=> $b} keys %$track) {
    my $str = sprintf "%3d: (%02d:%02d)", $x, $track->{$x}{min}, $track->{$x}{sec};
    if (exists $cddb->{track}{$x}) {
      for my $field (sort keys %{ $cddb->{track}{$x} }) {
        $str .= " $field=".$cddb->{track}{$x}{$field};
      }
    } else {
      $str .= "Unknown Track";
    }
    printc $INFO, $str, "\n";
  }
  printc $INFO, <<MESG;
(If a track does not display here it was skipped for one of the following
 reasons:  The track is not audio (data) or the track was skipped via the
 --skip option or the track is already completed).
MESG
}

sub dump_cddb_file {
  my ($fh) = @_;
  print $fh "# Global settings (acts as the defaults for each track)\n";
  for my $field (sort keys %$cddb) {
    printf $fh "%-10s= %s\n", $field, $cddb->{$field}
      if $field =~ /^[A-Z_]+$/;
  }
  print $fh "############# End of Globals / Start of Tracks #############\n";
  for my $x (sort {$a <=> $b} keys %{ $cddb->{track} }) {
    my $str = sprintf "%-10s=%3s", "TRACK_NUM", $x;
    for my $field (sort keys %{ $cddb->{track}{$x} }) {
      $str .= sprintf "\t%-10s= %-20s", $field, $cddb->{track}{$x}{$field};
    }
    $str =~ s/\s*$//;
    print $fh $str,"\n";
  }
  print $fh <<COMMENT;
# Individual tracks are annotated above
# Format is:  ATTRIB = Value ATTRIB2 = Value2
# Unlike the command line parameter --track, you -may- specify attributes
# for the same track number on different lines.  i.e. you may say:
# TRACK_NUM = 1 TRACK = Little Queen
# TRACK_NUM = 1 ARTIST = Heart
# But with the --track option you are limited to:
# --track==1="TRACK=Little Queen ARTIST=Heart"
COMMENT
}

sub edit_cddb {
  my $editor;
  for my $test ($ENV{EDITOR}, "/usr/bin/jove", "/usr/bin/pico", "/bin/vi") {
    ($editor = $test, last) if defined $test and -e $test;
  }
  my $fh = tempfile(DIR=>$root_work);
  dump_cddb_file $fh;
  # fnctl makes sure the filehandle $fh does not closed when we execute
  # the editor
  fcntl $fh, F_SETFD, 0;
  $? = system $editor, "/dev/fd/".fileno($fh);
  die sprintc $ERROR, "Could not run editor ($editor) [$?]"
    if ($?);
  seek $fh, 0, 0;
  read_cddb_file $fh, 1; # 1 means overwrite previous information in memory
  close $fh;
}

sub confirm_cddb {
  my $done = 0;
  until ($done) {
    display_cddb;
    print "Would you like to edit the above CDDB information (Y/n)? ";
    if (<> !~ /^[Nn]/) {
      edit_cddb;
    } else {
      $done = 1;
    }
  }
}

sub space_free {
  # this should take the fourth field on the second line
  # from the df command (returns megabytes)
  return ( split ' ', ( `df -m $_[0]` )[1] )[3];
}

sub kid_done {
  my (@type) = @_;
  for my $x (keys %kid) {
    return 0 if grep { exists $kid{$x}{$_} } @type;
  }
  return 1;
}

sub num_kid {
  my (@type) = @_;
  my $count;
  for my $x (keys %kid) {
    $count += grep { exists $kid{$x}{$_} } @type;
  }
  return $count;
}

# returns kids that match (pid/pipe pair)
sub grep_kid {
  my (@type) = @_;
  my @match = ();
  for my $x (keys %kid) {
    push @match, map { [ $x, $kid{$x}{$_} ] } grep {exists $kid{$x}{$_} ? $_ : 0 } @type;
  }
  return @match;
}

sub make_kid {
  my ($type, $sub, @args) = @_;
  my ($read, $write);
  pipe $read, $write;
  if ( my $pid = fork ) { # I am the parent
    close $write;
    $kid{$pid}{$type} = $read;
    return [$pid, $read];
  } else { # I am the child
    $0 = "$0 - ($type)";
    $grand_dad = 0;
    close $read;
    select((select($write), $|=1)[0]);
    &$sub([getppid(), $write], @args);
    exit 0; # if $sub didn't already
  }
}

sub wait_for_min_space {
  my ($path) = @_;
  return unless $config->ripper_min_space;
  my $free = space_free $path;
  my $last_free = -1;
  if ($free > $config->ripper_min_space) {
    printc $INFO, "Space left on drive: $free/",$config->ripper_min_space," meg\n";
  } else {
    do {
      if ($free != $last_free) {
        printc $ALERT, "Space left on drive: $free/",$config->ripper_min_space," meg\n";
      }
      $last_free = $free;
      sleep 1;
      $free = space_free $path;
    } while ($free < $config->ripper_min_space);
    printc $ALERT, "$free meg found, continuing...\n";
  }
}

sub has_data {
  my ($fh) = @_;
  my ($r, $w, $e) = ('', '', '');
  vec( $r, fileno($fh), 1) = 1;
  return select $r, $w, $e, 0;
}

sub send_message {
  my $person = shift @_;
  my $fh = $person->[1];
  my $pid = $person->[0];
  print $fh join(',', "time",time, @_), "\n";
  kill 'ALRM', $pid;
}

sub get_message {
  my ($person) = @_;
  my $fh = $person->[1];
  # FIXME:  Put a comment here to tell me why 'and my $line = <$fh>' is necessary.
  # Because I don't know! :) -Gryn
  # (possibility: if $fh is closed has_data returns 1 and <$fh> does not block and
  #  instead returns undef?)
  return () unless has_data $fh and my $line = <$fh>;
  chomp $line;
  return split ',', $line;
}

sub ripper {
  my ($parent, $track_num) = @_;
  POSIX::nice($config->ripper_nice) if defined $config->ripper_nice;
  my $ripper = $config->ripper;
  my $filename = "$root_work/$ripper-$track_num.wav";
  my %keywords = (FILE_OUT=>$filename, CD_DEV=>$config->device, TRACK_NUM=>$track_num);
  my $template = $config->ripper_command->{$ripper};
  my $command = make_substitution $template, 1, %keywords;
  open STDOUT, ">/dev/null";
  open STDERR, ">/dev/null";
  exec $command;
}

sub sort_tracks {
  return $config->size_order ?
    $track->{$a}{size} <=> $track->{$b}{size} :
    $a <=> $b;
}

sub do_alarm {
  my $times = @_;
  print "\a";
  (sleep 1, print "\a") for 2..$times;
}

sub main_ripper {
  my ($parent) = @_;
  for my $x (sort sort_tracks keys %$track) {
    my $ripper = $config->ripper;
    my $filename = "$root_work/$ripper-$x.wav";
    my $track_size = $track->{$x}{size};
    my $size = 0;
    my $last_size = 0;
    my $no_change = -1;
    my $max_no_change = $config->auto_skip || -1;
    my $track_name = get_cddb_attrib $x, "TRACK";
    wait_for_min_space $root_work;
    printc $START,"Ripping start: ($x) $track_name\n";
    $workfile{$filename} = 1;
    send_message $parent, type=>'start', track=>$x;
    my $kid = make_kid $ripper."_kid", \&ripper, $x;
    until (kid_done $ripper."_kid" or $no_change >= $max_no_change) {
      send_message $parent, type=>'status', track=>$x, size=>int $size/1024, maxsize=>int $track_size/1024;
      sleep 1;
      $size = -s $filename || $size;
      if ($size - $last_size == 0) {
        $no_change++;
      } else {
        $no_change = 0;
      }
      $last_size = $size;
    }
    my $normalize = "$root_work/".$config->normalize."-$x.wav";
    $workfile{$normalize} = 1;
    if ($no_change == $max_no_change) {
      kill "SIGINT", $kid->[0];
      printc $ALERT, "Auto-skipped: ($x) $track_name\n";
      unlink $filename;
      open my $temp, ">", $normalize;
      close $temp;
    } else {
#     printc $DONE,"Ripping done: ($x) $track_name\n";
      rename $filename, $normalize;
    }
    delete $workfile{$filename};
    send_message $parent, type=>'done', track=>$x;
  }
  do_alarm 1;
  $cd->eject if $config->eject;
  $cd->DESTROY;
}

sub make_filename {
  my ($middle, $format) = @_;
  return $format->{pre}.$middle.$format->{post};
}

sub normalize_track {
  my ($parent, $track_num, %s) = @_;
  POSIX::nice($config->normalize_nice) if defined $config->normalize_nice;
  my $normalize = $s{name};
  my $from = make_filename $track_num, $s{from_name};
  if (-s $from > 0) {  # only run if the file is greater than zero
    my %keywords = (FILE_LIST=>$from);
    my $template = $config->normalize_command->{$normalize};
    my $command = make_substitution $template, 1, %keywords;
    $? = 0;
    $? = system "$command 2>/dev/null 1>/dev/null" if $command;
    printc $ERROR, "Had an error on track ($track_num) doing '$command' [$?]"
      if ($?);
  }
  for my $encoder (@{$config->encoder}) {
    my $to = "$root_work/$encoder-$track_num.wav";
    $workfile{$to} = 1;
    link $from, $to;
  }
  unlink $from;
  delete $workfile{$from};
  send_message $parent, track=>$track_num;
}

sub encoder {
  my ($parent, $track_num, %s) = @_;
  POSIX::nice($config->encoder_nice) if defined $config->encoder_nice;
  my $from = make_filename $track_num, $s{from_name};
  my $to = make_filename $track_num, $s{to_name};
  my %out = final_name $track_num, $s{name};
  unless (-e $out{FULL} or -s $from == 0) {  # only encode if the input file size is greater
                                             # than zero, and if the output doesn't already
                                             # exist.
    my %keywords = (FILE_IN=>$from, FILE_OUT=>$to,
                    ALBUM=>'', ARTIST=>'', BIT_RATE=>$config->bitrate->{$s{name}},
                    QUALITY=>$config->quality->{$s{name}}, GENRE=>'', TRACK=>'',
                    TRACK_NUM=>$track_num, YEAR=>'',
                   );
    $keywords{$_} = get_cddb_attrib($track_num, $_)
		  for qw(ALBUM ARTIST GENRE TRACK YEAR);
    # FIXME: We need to get a valid list of genres and match them to their number
    # FIXME: CDDB returns MISC and sometimes the UNKNOWN genre, but it's not in any list I can find
    $keywords{GENRE} = "other" if $keywords{GENRE} =~ /(misc|unknown)/i;

    my $template = $config->encoder_command->{$s{name}};
    my $command = make_substitution $template, 1, %keywords;
    $workfile{$to} = 1;
    $? = system "$command 2>/dev/null 1>/dev/null" if $command;
    if ($?) {
      unlink $to;
      die sprintc $ERROR, "Had an error on track ($track_num) doing '$command' [$?]\n";
    } else {
      # we really do need to create this path everytime here...
      # perhaps the user does something weird like output_path = ARTIST/TRACK_NUM
      # then the path changes for each track!
      create_dir $out{PATH}, "Final Output Path";
      rename $to, $out{FULL}
        # or maybe this won't work (e.g. moving between filesystems)
        # plan B! use the system's "mv" command
        or system "mv", $to, $out{FULL};
    }
    delete $workfile{$to};
  }
  unlink $from;
  delete $workfile{$from};
  send_message $parent, track=>$track_num;
}

sub normalize_album {
  my ($parent, %settings) = @_;
  POSIX::nice($config->normalize_nice) if defined $config->normalize_nice;
  printc $INFO, "Waiting for work: \u$settings{name}\n";
  send_message($parent, type=>'startup');
  my @found;
  do {
    sleep;
    my $glob = make_filename "*", $settings{from_name};
    @found = glob($glob);
    $workfile{$_} = 1 for @found;
  } while @found < keys %{$track};
  my $normalize = $settings{name};
  my $track_regex = make_filename '(\\d+)', $settings{from_name};
  $track_regex = '^'.$track_regex.'$';
  my %keywords = (FILE_LIST=>\@found);
  my $template = $config->normalize_command->{$normalize};
  my $command = make_substitution $template, 1, %keywords;
  for my $filename (@found) {
    my ($track_num) = ($filename =~ /$track_regex/);
    my $track_name = get_cddb_attrib $track_num, "TRACK";
    printc $START, "\u$settings{name} starting on: ($track_num) $track_name\n";
    send_message $parent, type=>'start', track=>$track_num;
  }
  $? = system "$command 2>/dev/null 1>/dev/null";
  printc $ERROR, "Had an error doing '$command' [$?]"
    if ($?);
  for my $filename (@found) {
    my ($track_num) = ($filename =~ /$track_regex/);
    my $track_name = get_cddb_attrib $track_num, "TRACK";
    for my $encoder (@{$config->encoder}) {
      my $to = "$root_work/$encoder-$track_num.wav";
      $workfile{$to} = 1;
      link $filename, $to;
    }
    printc $DONE, "\u$settings{name} done with: ($track_num) $track_name\n";
    send_message $parent, type=>'done', track=>$track_num;
    unlink $filename;
    delete $workfile{$filename};
  }
}

sub main_parallel {
  our ($parent, %settings) = @_;
  our %my_kid = ();
  our $done = 0;
  sub check_message {
    for my $filename (keys %my_kid) {
      my %message = get_message($my_kid{$filename});
      next unless %message;
      my $track_num = $message{track};
      $done++;
      delete $my_kid{$filename};
      my $track_name = get_cddb_attrib $track_num, "TRACK";
      printc $DONE, "\u$settings{name} done with: ($track_num) $track_name\n";
      send_message $parent, type=>'done', track=>$track_num;
    }
  }
  printc $INFO, "Waiting for work: \u$settings{name}\n";
  send_message($parent, type=>'startup');
  do {
    sleep, check_message;
    my $glob = make_filename "*", $settings{from_name};
    my $track_regex = make_filename '(\\d+)', $settings{from_name};
    $track_regex = '^'.$track_regex.'$';
    # this mouthful looks for files, removes ones we've already seen,
    # and then extracts the track number from the file name, returning
    # (finally) a hash of track numbers vs. filenames
    my %found = map { /$track_regex/; $1, $_ }
                grep { not exists $my_kid{$_} }
                glob($glob);
    for my $track_num (sort sort_tracks keys %found) {
      my $filename = $found{$track_num};
      # once we see a new file we need to take responsibility for deleting it
      # because whomever created it won't be able to keep responsible for it
      # once he is done his job (his fork will end and so won't be here to
      # clean it up).
      # FIXME: And yes, this is a race condition. (Perhaps, have the main
      # thread clear out all files in the temporary directory?  Or when we do
      # get around to doing file passing properly, we won't have to worry
      # about this).
      $workfile{$filename} = 1;

      next unless keys %my_kid < $config->max_fork;
      my $track_name = get_cddb_attrib $track_num, "TRACK";
      printc $START, "\u$settings{name} starting on: ($track_num) $track_name\n";
      send_message $parent, type=>'start', track=>$track_num;
      $my_kid{$filename} = make_kid $settings{name}."_kid", $settings{sub_call}, $track_num, %settings;
    }
  } while $done < keys %{$track};
  (sleep, check_message) until kid_done $settings{name}."_kid";
}

sub send_signal {
  my ($sig, $type) = @_;
  kill $sig, map { $_->[0] } grep_kid $type;
}

sub grep_hash {
  my ($hash, $value) = @_;
  return grep { $hash->{$_} eq $value } keys %$hash;
}

sub print_status {
  my $status_line = "|";
  if (not defined $status{group_info}) {
    $status{group_info} = {
      ripper=>   {line=>"", progress=>0, percent=>0},
      normalize=>{line=>"", progress=>0},
      encoder=>  {line=>"", progress=>0},
    };
  }
  for my $g (qw(ripper normalize encoder)) {
    my $gv = $status{group_info}{$g};
    for my $kid (grep_hash $status{group}, $g) {
      while (my %msg = get_message $status{kid}{$kid}) {
        if ($msg{type} eq 'startup') {
        } elsif ($msg{type} eq 'start') {
          $gv->{percent} = 0 if $g eq 'ripper';
        } elsif ($msg{type} eq 'status') {
          $gv->{percent} = (int($msg{size}/$msg{maxsize} * 100 * 100))/100;
        } elsif ($msg{type} eq 'done') {
          $gv->{progress}++;
          $gv->{percent} = 0 if $g eq 'ripper';
          my $alert_who = "";
          $alert_who = 'normalize' if ($status{group}{$kid} eq 'ripper');
          $alert_who = 'encoder' if ($status{group}{$kid} eq 'normalize');
          # wake up the next guy(s) in line
          send_signal 'ALRM', $_
            for grep_hash $status{group}, $alert_who;
        } else {
        }
      }
    }
    my $total = keys(%$track) * grep_hash $status{group}, $g;
    if ($g eq 'ripper') {
      $status_line .= sprintf("%s: %3d/%3d (%%%6.2f)|",
                              "\u$g",
                              $gv->{progress}, $total,
                              $gv->{percent},
                             );
    } else {
      $status_line .= sprintf("%s: %3d/%3d|",
                              "\u$g",
                              $gv->{progress}, $total,
                             );
    }
  }
  $status_line = $status_line . "."x(78-length $status_line);
  printc $INFO, $status_line;
}

sub sanity_checks { 
  # FIXME: We need to check the configuration data for consistancies!
  # For instance, if we are going to use cdparanoia, normalize, lame, etc,
  # check to see if they actually exist!
  die sprintc $ERROR, <<ERROR
No ripper specified: use --ripper=myripper option or add 'use myripper' under
the [ripper] section in your .yaretrc file.
ERROR
    unless $config->ripper;
  die sprintc $ERROR, <<ERROR
No normalize specified: use --normalize=mynormalize option or add 'use
mynormalize' under the [normalize] section in your .yaretrc file.
ERROR
    unless $config->normalize;
  die sprintc $ERROR, <<ERROR
No encoder specified: use --encoder=myencoder option or add 'use
myencoder' under the [encoder] section in your .yaretrc file.
ERROR
    unless @{ $config->encoder } > 0;
}

if ($config->cddb_dump) {
  set_cd;
  collect_cddb_info;
  open my $fh, ">".$config->cddb_dump;
  dump_cddb_file $fh;
  exit;
}

sanity_checks;

set_default_signals;

set_cd;

collect_cddb_info;

set_skip_track;

# FIXME: Move this into the main loop so that we can rip while
# the user edits the CDDB information (requires us to silence
# the program while the editor is running)
confirm_cddb
  if $config->cddb_confirm;

if ($config->clear) {
  print $CLEARSCREEN;
}

$date{start} = time;

printc $START, "Starting up...\n";

if ($config->cddb_out) {
  my $template = $config->cddb_out;
  my %keywords = (
    CD_DEV=>$config->device,
  );
  $keywords{$_} = $cddb->{$_}
    for qw(ALBUM ARTIST GENRE YEAR);
  %keywords = do_output_transformation %keywords;

  my ($path, $filename);
  if ($template =~ /^(.*)\/(.*)$/) {
    $path = $1;
	$filename = $2;
  } else {
    $path = "";
	$filename = $template;
  }
  $path = $root_final."/".$path;

  $path = make_substitution $path, 0, %keywords;
  $filename = make_substitution $filename, 0, %keywords;

  create_dir $path, "CDDB file path";
  open my $fh, ">", "$path/$filename";
  dump_cddb_file $fh;
}

$status{kid} = {};
$status{group} = {};

{
  my $ripper = $config->ripper;
  $status{kid}{$ripper} = make_kid $ripper, \&main_ripper;
  $status{group}{$ripper} = 'ripper';
  $cd->DESTROY;
}

my $normalize = $config->normalize;
if ($config->normalize_type->{$normalize} eq "ALBUM") {
  my %settings = (from_name=>{pre=>"$root_work/$normalize-", post=>".wav"},
                   name=>$normalize,
                 );
  $status{kid}{$normalize} = make_kid $normalize,
    \&normalize_album, %settings;
} else {
  my %settings = (from_name=>{pre=>"$root_work/$normalize-", post=>".wav"},
                   sub_call=>\&normalize_track,
                   name=>$normalize,
                 );
  $status{kid}{$normalize} = make_kid $normalize,
    \&main_parallel, %settings;
}
$status{group}{$normalize} = 'normalize';

for my $encoder (@{$config->encoder}) {
  my $ext = $config->encoder_extension->{$encoder} || ".mp3";
  my %settings = (from_name=>{pre=>"$root_work/$encoder-", post=>".wav"},
                    to_name=>{pre=>"$root_work/$encoder-", post=>$ext},
                   sub_call=>\&encoder,
                       name=>$encoder,
                 );
  $status{kid}{$encoder} = make_kid $encoder,
    \&main_parallel, %settings;
  $status{group}{$encoder} = 'encoder';
}

sleep, print_status
  until kid_done keys %{ $status{kid} };

rmdir $root_work;

printc $DONE, "Done!\n";

$date{end} = time;

if ($config->date) {
  my $difference = $date{end} - $date{start};
  my @div = (60, 60, 24, 365, 1);
  my @name = qw(second minute hour day year);
  my @val = ();
  while ($difference > 0) {
    last unless @div;
    my $d = shift @div;
    push @val, $difference % $d;
    $difference = int $difference/$d;
  }
  my $str = "";
  @name = reverse @name[0..@val-1];
  for my $val (reverse @val) {
    my $name = shift @name;
    $str .= "$val $name";
    $str .= ($val == 1) ? " " : "s ";
  }
  printc $INFO, "The process took: $str\n";
}

do_alarm 2;
