#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Long qw(:config no_ignore_case bundling);
use Pod::Usage;
use Cwd ('abs_path');

our $VERSION = 1.022_003;

=pod

=head1 NAME

mp3cd - Perl script to burn audio CDs from lists of MP3s/WAVs/OGGs

=head1 SYNOPSIS

mp3cd [OPTIONS] [playlist|files...]

 -s, --stage STAGE  Start at a certain stage of processing:
                        clean   Start fresh (default, requires playlist)
                        build   Does not clean (requires playlist)
                        decode  Turns mp3s/oggs into WAVs
                        correct Fix up any WAV formats
                        norm    Normalizes WAV volumes
                        toc     Builds a Table of Contents from WAVs
                        toc_ok  Checks TOC validity
                        cdr_ok  Checks for a CDR
                        burn    Burns from the TOC
 -q                 Quits after one stage of processing
 -t, --tempdir DIR  Change working directory to another location.
 -d, --device PATH  Look for CDR at "PATH" (default "/dev/cdrecorder")
 -n, --simulate     Don't actually burn a disc but do everything else.
 -E, --no-eject     Don't eject drive after the burn.
 -c, --cdrdao STR   Pass the STRing of options to cdrdao.
 -V, --version      Report which version of the script this is.
 -h, --help         Shows help summary.
     --longhelp     Shows detailed help.

=head1 OPTIONS

=over 8

=item B<-s STAGE>, B<--stage STAGE>

Starts processing in the middle at a given stage.  This is used in
case you had to stop processing, or a file was missing, or things
generally blew up.  It is especially useful if a burn fails because then
you don't have to start totally over and re-WAV the files.  If you just
want to perform a single step, use B<--quit> to abort after the stage
you request with B<--stage>.

=over 8

=item B<clean>

This is the default starting stage.  The temp directory is cleared out.
A playlist is required, since we expect to move to the B<build> stage
next, which requires it.

=item B<build>

This stage examines the playlist from the command line, and tries to
create a list of symlinks from the given playlist.  So far, C<mp3cd>
can understand ".m3u" files and XMLPlaylist files.

=item B<decode>

All the files are converted into WAVs.  So far, C<mp3cd> knows how to
decode mp3 and ogg files.  (WAVs will be left as they are during this
stage.)

=item B<correct>

The WAV files are corrected to have the correct bitrate and number of
channels, as required for an audio CD.

=item B<norm>

The WAV files' volumes are normalized so any large differences in volume
between records will be less noticable.

=item B<toc>

Generates a Table of Contents for the audio CD.

=item B<toc_ok>

Validates the TOC, just in case something went really wrong with
the WAV files.

=item B<cdr_ok>

Verifies that there is a CDR ready for burning.

=item B<burn>

Actually performs the burn of all the WAV files to the waiting CDR.

=back

=item B<-q>, B<--quit>

Aborts after one stage of processing.  See B<--stage>.

=item B<-t DIR>, B<--tempdir DIR>

Use a working directory other than "/scratch/mp3cd/B<username>".  This is
where all the file processing occurs.  You will generally need at least
650M free here (or more depending on the recording length of your destination
CD).

=item B<-d PATH>, B<--device PATH>

Use a device path other than "/dev/cdrecorder".

=item B<-c STR>, B<--cdrdao STR>

Pass the given string of options to cdrdao appropriately during each command.

=item B<-n>, B<--simulate>

Do not actually write to the disc but simulate the process instead.

=item B<-E>, B<--no-eject>

Don't eject drive after the burn.

=item B<-V>, B<--version>

Report which version of mp3cd this is.

=item B<-h>, B<--help>

Show brief help summary.

=item B<--longhelp>

Shows the full command line instructions.

=back

=head1 DESCRIPTION

This script implements the suggested methods outlined in the
Linux MP3 CD Burning mini-HOWTO:
 L<http://tldp.org/HOWTO/mini/MP3-CD-Burning/>

This will burn a playlist (.m3u, XMLPlaylist or command line list) of
MP3s, OGGs, and/or WAVs to an audio CD.  The ".m3u" format is really nothing
more than a list of fully qualified filenames.  The script handles making the
WAVs sane by resampling if needed, and normalizing the volume across all
tracks.

If a failure happens, earlier stages can be skipped with the '-s' flag.
The file "tool-output.txt" in the temp directory can be examined to see what
went wrong during the stage.  Some things are time-consuming (like writing
the WAVs from MP3s) and if the CD burn failed, it's much nicer not to have to
start over from scratch.  When doing this, you will not need the m3u file any
more, since the files have already been built.  See the list of stages using
'-h'.

=head1 PREREQUISITES

Requires C<cdrdao>, and that /dev/cdrecorder is a valid symlink to the
/dev/sg device that cdrdao will use.  Use .cdrdao to edit driver
options.  (See "man cdrdao" for details.)

Requires C<mpg321> to decode mp3 to WAV files.
 http://mpg321.sourceforge.net/

Optionally requires C<oggdec> to decode ogg to WAV files.
 http://www.gnu.org/directory/audio/ogg/OggEnc.html/

Requires C<sox> to check/correct WAV formats.
 http://www.spies.com/Sox/

Requires C<normalize> to process the audio.
 http://www.cs.columbia.edu/~cvaill/normalize/

=head1 AUTHOR

 Kees Cook <kees@outflux.net>

 Contributors:

 J. Katz (ogg support)
 Alex Rhomberg (XMLPlaylist support)
 Kevin C. Krinke (filelist inspiration, cdrdao options)
 

=head1 SEE ALSO

perl(1), cdrdao(1), mpg321(1), oggdec(1), sox(1), normalize(1).

=head1 COPYRIGHT

 $Id: mp3cd,v 1.59 2004/09/10 23:41:26 nemesis Exp $
 Copyright (C) 2000-2003 Kees Cook
 kees@outflux.net, http://outflux.net/

 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.
 http://www.gnu.org/copyleft/gpl.html

=cut

# Change this to a location where you'll have at least a CD's worth of
# disk space available.  (For the WAVs)
# Its contents will be deleted, so be careful.  :)
my $BURNDIR="/scratch/mp3cd/".getpwuid($<);

# Filename to redirect sub-tool stdout/stderr
my $OUTPUT="tool-output.txt";

# Filename to write the TOC to
my $CDTOC="cdda.toc";

# List of audio files to burn (useful only for the "build" stage)
my @FILES=();

my $start=0;
my $START_CLEAN    = 0;
my $START_BUILD    = $START_CLEAN+1;
my $START_DECODE   = $START_BUILD+1;
my $START_CORRECT  = $START_DECODE+1;
my $START_NORM     = $START_CORRECT+1;
my $START_TOC      = $START_NORM+1;
my $START_TOC_OKAY = $START_TOC+1;
my $START_CDR_OKAY = $START_TOC_OKAY+1;
my $START_BURN     = $START_CDR_OKAY+1;

my %stages = (
	"clean"    => $START_CLEAN,
	"build"    => $START_BUILD,
	"decode"   => $START_DECODE,
	"correct"  => $START_CORRECT,
	"norm"     => $START_NORM,
	"toc"      => $START_TOC,
	"toc_ok"   => $START_TOC_OKAY,
	"cdr_ok"   => $START_CDR_OKAY,
	"burn"     => $START_BURN,
);

our $opt_help=undef;
our $opt_longhelp=undef;
our $opt_version=undef;
our $opt_quit=undef;
our $opt_stage="clean";
our $opt_tempdir=undef;
our $opt_cdrdao="";
our $opt_device="/dev/cdrecorder";
our $opt_simulate=undef;
our $opt_no_eject=0;

GetOptions(
	'help|h',
	'longhelp',
	'version|V',
	'stage|s=s',
	'quit|q',
	'tempdir|t=s',
	'device|d=s',
	'cdrdao|c=s',
	'simulate|n',
	'no-eject|E',
) or pod2usage( -exitval=>1, -verbose=>0 );

pod2usage( -exitval=>0, -verbose=>1 ) if ($opt_longhelp);
pod2usage( -exitval=>0, -verbose=>0 ) if ($opt_help);
Version() if ($opt_version);

if (!defined($stages{$opt_stage})) {
	pod2usage(	-exitval=>1, -verbose=>0,
			-msg=>"Unknown stage '$opt_stage'!" );
}
$start=$stages{$opt_stage};

if ($opt_stage eq "clean" ||
    $opt_stage eq "build")
{
	if (!defined($ARGV[0])) {
		pod2usage(-exitval=>1, -verbose=>0,
			  -msg=>"Playlist/File list is required!" );
	}
}
elsif (@ARGV) {
	pod2usage( -exitval=>1, -verbose=>0,
		   -msg=> "Playlists/Files are ignored past stage 'build'!" );
}

$BURNDIR=$opt_tempdir if (defined($opt_tempdir));


# check for directory
if (!opendir(DIR, $BURNDIR)) {
	eval { mkdir($BURNDIR) };
	if ($@) {
		die "Can't create working directory '$BURNDIR': $@\n";
	}
	opendir(DIR, $BURNDIR) || die "Can't open directory '$BURNDIR': $!\n";
}
closedir DIR;

# For-sure needed tools
my %PREREQS = (
		'sox' => 1,
		'normalize' => 1,
		'cdrdao' => 1,
		);
# Load file list, update needed tools
Load_file_list();

# check for required tools
my %found;
foreach my $prog (sort keys %PREREQS) {
	foreach my $dir (split(/:/,$ENV{'PATH'})) {
		if (!defined($found{$prog}) && -x "$dir/$prog") {
			$found{$prog}="$dir/$prog";
			last;
		}
	}
}
my $abort=undef;
foreach my $prog (sort keys %PREREQS) {
	if (!defined($found{$prog})) {
		warn "Cannot find '$prog'!\n";
		$abort=1;
	}
}
pod2usage( -exitval => 1, -verbose => 0 ) if ($abort);

# check for CDR device
if (! -w $opt_device) {
	pod2usage(	-exitval=>1, -verbose=>0,
			-msg=> "Cannot write to $opt_device!" );
}


Do_Clean()      if ($start <= $START_CLEAN);
Do_Build()      if ($start <= $START_BUILD);
Do_Decode()     if ($start <= $START_DECODE);
Do_Correct()    if ($start <= $START_CORRECT);
Do_Normalize()  if ($start <= $START_NORM);
Do_TOC()        if ($start <= $START_TOC);
Do_TOC_Verify() if ($start <= $START_TOC_OKAY);
Do_CDR_Check()  if ($start <= $START_CDR_OKAY);
Do_Burn()       if ($start <= $START_BURN);

sub Load_file_list
{
    # Keep a count of how many files we've examined, and stop after, say,
    # 1000, in case an m3u lists itself (which is REALLY unlikely, but would
    # effectively put this code into a memory-eating endless loop.
    my $toomany=1000;
    while (my $file=shift @ARGV) {
	# Known audio file format?
	if ($file =~ m/\.(ogg|mp3|wav)$/i) {
		my $ext=$1;
		$PREREQS{"oggdec"}=1 if ($ext eq "ogg");
		$PREREQS{"mpg321"}=1 if ($ext eq "mp3");

		push(@FILES,$file);
	}
	else {
            open(M3U,$file) || die "Cannot open '$file': $!\n";
            my @lines=<M3U>;
            close(M3U);

            my @files;
            if ($lines[0] =~ /<!DOCTYPE\s+XMLPlaylist>/i) { 
	        # kaffeine playlists
                require XML::Simple;
                my $contents = XML::Simple::XMLin($file);
                if (ref($contents->{entry}) eq 'ARRAY') {
                        @files = map {$_->{url}} @{$contents->{entry}};
                        s/^file:// for @files;
                } else {
                    @files = ($contents->{entry}->{url});
                }
            }
            else {
                # regular list of files
		foreach (@lines) {
			chomp;
			next if (/^#/);
			push(@files,$_);
		}
            }
	    unshift(@ARGV,@files);
        }
	die ">1000 files in the list?!  I must have started looping forever.\n"
		if (--$toomany<0);
    }
    # Get absolute locations
    @FILES = map { abs_path($_) } @FILES;
}

sub Do_Clean
{
        print "Cleaning up...\n";

        # clear out burn dir
        system("rm -f $BURNDIR/*.mp3 $BURNDIR/*.wav $BURNDIR/*.ogg $BURNDIR/$OUTPUT $BURNDIR/$CDTOC");

        die "Stopping at user request...\n" if (defined($opt_quit));
}

sub Do_Build
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

	my $error=undef;
        my $count=0;
        # make link for each file, and retain extension
        foreach my $file (@FILES)
        {
                chomp($file);
                next if ($file =~ /^#/);
                my @parts=split(/\./,$file);
                my $ext=pop(@parts);
                $ext=~tr/A-Z/a-z/;

                @parts=split(/\//,$file);
                my $name=pop(@parts);

                if ($ext ne "mp3" && $ext ne "wav" && $ext ne "ogg")
                {
                        warn "Error: '$file': unknown extension '$ext'!\n";
                        $error=1;
                        next;
                }

                if (!-f $file)
                {
                        warn "Error: '$file': $!\n";
                        $error=1;
                        next;
                }

                $count++;
                my $track=sprintf("%02d",$count);
                print "$track: [...]/$name\n";
                symlink($file,"$track.$ext") || die "symlink('$file','$count.$ext'): $!\n";
        }

        die "Stopping due to errors...\n" if (defined($error));

        # make sure we have some tracks
        die("No tracks?!\n") unless ($count>0);

        die "Stopping at user request...\n" if (defined($opt_quit));
}

sub Do_Decode
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        # get list of mp3s & oggs from directory (leaves any WAVs in playlist alone)
        opendir(DIR, $BURNDIR) || die "Can't read directory '$BURNDIR': $!\n";
        my @need_decode = grep { /^\d+\.(mp3|ogg)$/i && -f "$BURNDIR/$_" } readdir(DIR);
        closedir DIR;

        # turn mp3s & oggs into wav files
        foreach my $to_decode (sort {$a cmp $b} @need_decode)
        {
                my @parts=split(/\./,$to_decode);
                my $name=shift(@parts);
                my $ext=pop(@parts);
                my $file="${name}.wav";

                if (-f $file)
                {
                        print "Skipping track $name: $file exists.\n";
                }
                else
                {
                        print "Creating WAV for track $name ...\n";
                        if ($ext eq "mp3") {
			   #system("lame -S --decode $to_decode $file >$OUTPUT 2>&1") == 0
                           system("mpg321 --wav $file $to_decode >$OUTPUT 2>&1") == 0
                                   or die("Decoding failed!\n");
                        } elsif ($ext eq "ogg") {
                           system("oggdec $to_decode >$OUTPUT 2>&1") == 0
                                   or die("Decoding ogg failed!\n");
                        } else {
                           die("Invalid extension - decoding failed!\n");
                        }
                }
        }

        die "Stopping at user request...\n" if (defined($opt_quit));
}

sub Do_Correct
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        # get list of wavs from directory
        opendir(DIR, $BURNDIR) || die "Can't read directory '$BURNDIR': $!\n";
        my @wavs = grep { /^\d+\.wav$/i && -f "$BURNDIR/$_" } readdir(DIR);
        closedir DIR;

        # correct any wav file formats
        foreach my $wav (sort {$a cmp $b} @wavs)
        {
                my @parts=split(/\./,$wav);
                my $name=shift(@parts);
		print "Checking WAV format for track $name ...\n";
                my $report=`sox -V $wav $wav.raw trim 0.1 1 2>&1`;
# sox: Reading Wave file: Microsoft PCM format, 2 channels, 44100 samp/sec
# sox:         176400 byte/sec, 4 block align, 16 bits/samp, 44886528 data bytes
                unless ($report =~ m|2 channels|s &&
                        $report =~ m|44100 samp/sec|s &&
		        $report =~ m|16 bits/samp|s)
                {

                        # only do a "resample" if rates aren't correct
                        my $resample="resample";
                        $resample="" if ($report =~ m|44100 samp/sec|s);
                        print "Correcting WAV format for track $name ...\n";
                        system("sox $wav -r 44100 -c 2 -w new-$wav $resample >$OUTPUT 2>&1") == 0
                                or die("Correction failed!\n");
                        unlink($wav) || die "unlink('$wav'): $!\n";
                        rename("new-$wav",$wav) || die "rename('new-$wav','$wav'): $!\n";
                }
		unlink("$wav.raw");
        }

        die "Stopping at user request...\n" if (defined($opt_quit));
}

sub Do_Normalize
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        # normalize the volumes
        print "Normalizing volume levels...\n";
        system("normalize -m [0-9]*.wav") == 0
                or die("Normalizing failed!\n");
        print "Normalizing finished.\n";

        die "Stopping at user request...\n" if (defined($opt_quit));
}

sub Do_TOC
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        # create a TOC for cdrdao
        open(TOC,">$CDTOC") || die("Cannot write to '$CDTOC': $!\n");
        print TOC "CD_DA\n\n";

        # get list of wavs
        opendir(DIR, $BURNDIR) || die "Can't read directory '$BURNDIR': $!\n";
        my @wavs = grep { /^\d+\.wav$/i && -f "$BURNDIR/$_" } readdir(DIR);
        closedir DIR;

        foreach my $wav (sort {$a cmp $b} @wavs)
        {
                die ("Yikes!  What happened to '$wav'?!\n") unless (-f $wav);
                print TOC "TRACK AUDIO\n";
                print TOC "FILE \"$wav\" 0 \n\n";
        }
	close TOC;

        die "Stopping at user request...\n" if (defined($opt_quit));
}

sub Do_TOC_Verify
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        print "Verifying generated Table of Contents...\n";
        system(cdrdao('read-test')." $CDTOC >$OUTPUT 2>&1") == 0
                or die "Failed to create CD Table of Contents?!\n";

        die "Stopping at user request...\n" if (defined($opt_quit));
}

sub Do_CDR_Check
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        print "Checking for CDR...\n";
        system(cdrdao('disk-info')." >$OUTPUT 2>&1") == 0
                or die "CDR not loaded?!\n";
        print "\tCDR found.\n";

        die "Stopping at user request...\n" if (defined($opt_quit));
}

sub Do_Burn
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

	my $cmd = cdrdao('write');
	$cmd.=" --eject" if (!$opt_no_eject);
	$cmd.=" -n $CDTOC";
        system($cmd) == 0
        	or die "BURN FAILED!\n";

        die "Stopping at user request...\n" if (defined($opt_quit));
}

sub Version
{
	# Create human-readable version with un-human-readable code
	print "mp3cd version ".
		join(".",map{$_+0} (sprintf("%.6f",$VERSION)
					=~/^(\d+)\.?(\d{3})?(\d{3})?$/))."\n";
        print <<'EOM';
Copyright 2003-2004 Kees Cook <kees@outflux.net>
This program is free software; you may redistribute it under the terms of
the GNU General Public License.  This program has absolutely no warranty.
EOM
	exit(0);
}

# return a good cdrdao command string prefix
sub cdrdao {
    my $operation = $_[0] || 'simulate';
    $operation = 'simulate' if ($opt_simulate && $operation eq 'write');

   return "cdrdao $operation $opt_cdrdao";
}
