#!/usr/bin/perl
#
# vlorb, a CD to Ogg Vorbis ripper.
# Copyright (c) 2002, Jochem Kossen <j.kossen@home.nl>
#
# see README for instructions and other information.
#
# 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 Library 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.
#
# $Id: vlorb,v 1.26.2.7.2.3 2003/05/13 12:50:37 jochem Exp $
#

use CDDB;
use English;
use Getopt::Std;
use strict;
use warnings;

#------------------------------------------------------------------------------
# Default configuration options.
#------------------------------------------------------------------------------
# NOTE: You can change these in /etc/vlorbrc or $HOME/.vlorbrc, see vlorb -g

my %config;

# Path to device name of CD-ROM to use.
$config{device} = '/dev/cd0c';

# 1 = Use bitrate mode, 0 = use quality mode for ogg encoding.
$config{use_bitrate} = 0;

# Sets encoding to the bitrate closest to n (in kb/s).
$config{bitrate} = 192;

# Sets encoding quality to n, between -1 (low) and 10 (high).
$config{quality} = 6;

# 1 = Use VorbisGain, 0 = don't use VorbisGain.
$config{use_vorbisgain} = 0;

# 1 = Overwrite filenames, 0 = generate non-existing filenames.
$config{overwrite_tracks} = 0;

# 1 = Overwrite album dir, 0 = generate non-existing album dir.
$config{overwrite_dir} = 0;

# 1 = Show only error messages, 0 = show all output.
$config{quiet} = 0;

# Name for CD's of which no CDDB data exists.
$config{unknown} = 'Unknown Album';

# Which host to use for CDDB connections
$config{cddb_host} = 'freedb.freedb.org';

# Which TCP/IP port to use for connection.
$config{cddb_port} = '8880';

# Show encoding progress.
$config{show_progress} = 0;

# Seperator to use between parts of filename
$config{filemask_seperator} = ' - ';

# A = Artist, D = Disctitle, G = Genre, N = Tracknumber, T = Tracktitle.
#
# Order of tags to use for filename generation for multi-artist CD's.
# available tags: A, D, G, N, T
$config{filemask_m} = 'DNAT';

# Order of tags to use for filename generation for single-artist CD's.
# available tags: A, D, G, N, T
$config{filemask_s} = 'ADNT';

# Order of tags to use for directory name generation for single-artist CD's.
# available tags: A, D
$config{dirmask_s} = 'AD';

#------------------------------------------------------------------------------
# Global vars, don't change these
#------------------------------------------------------------------------------
my %global;

# vlorb version
$global{version} = 'vlorb 1.1';

# 1 = Rip individual tracks, 0 = rip all tracks.
$global{individual} = 0;

# filemask, set by parse_cmdline()
$global{filemask} = 0;

# dirmask, set by parse_cmdline()
$global{dirmask} = 0;

# 1 = CD is single-artist, 0 = CD is multi-artist.
# You can change these in /etc/vlorbrc or $HOME/.vlorbrc
$global{singleartist} = 0;

#------------------------------------------------------------------------------
# Functions
#------------------------------------------------------------------------------

#         "--- dummy 80 chars line --------------------------------------------------------"
sub usage() {
    print "Usage: vlorb [-ghoOpQsvV] [-d <device>] [-i <tracklist>]\n";
    print "  [-m <mask>] [-M <mask>] [-b <bitrate>|-q <quality>]\n";
}

sub help() {
    print "\n";
    &show_version;
    print "\n";
    &usage;
#         "--- dummy 80 chars line --------------------------------------------------------"
    print "\n";
    print "    -g                 Generate configuration file\n";
    print "    -h                 Show this help message\n";
    print "    -o                 Toggle overwrite tracks mode\n";
    print "    -O                 Toggle overwrite album dir mode\n";
    print "    -p                 Toggle show encoding progress\n";
    print "    -Q                 Toggle quiet mode\n";
    print "    -s                 Toggle single-artist CD mode\n";
    print "    -v                 Toggle VorbisGain (Ogg Vorbis ReplayGain) mode\n";
    print "    -V                 Show version info\n";
    print "    -d <device>        Specify which device to use\n";
    print "    -i <tracklist>     Which tracks to rip, eg.: vlorb -i \"1,3,5,12\"\n";
    print "    -m <mask>          Generate filenames using this mask\n";
    print "    -M <mask>          Generate album directories using this mask\n";
    print "    -b <bitrate>       Bitrate in kbps\n";
    print "    -q <quality>       Ogg quality, between -1 (low) and 10 (high)\n";
    print "    NOTE: you can only specify one of -b and -q\n\n";
}

sub show_version() {
    print "$global{version}\n" . '$Date: 2003/05/13 12:50:37 $' . "\nCopyright (c) 2003 by Jochem Kossen\n";
}

#------------------------------------------------------------------------------
# Main program loop
#------------------------------------------------------------------------------
sub main() {
    my @seltracks;

    &get_config;
    &parse_cmdline(\@seltracks);

    # Check $config{filemask_seperator} for illegal characters
    if ($config{filemask_seperator} =~ /[\t\n\r\f]/) {
	&error('configuration option "filemask_seperator" contains illegal characters');
    }

    # Check if vorbisgain is in path
    if ($config{use_vorbisgain}) {
	my $garbage = `vorbisgain -v 2>&1`;
	&error("vorbisgain not found in path") if ($? == -1);
    }

    # Check if device exists
    if ( -e "$config{device}") {
	# Check if device is readable
	if (! -r $config{device}) {
	    &error("device '" . $config{device} . "' is not readable. Incorrect permissions.");
	} else {
	    # Check if device is a valid block device
	    if (! -b "$config{device}") {
		&error("$config{device} is not a valid block device.");
	    }
	}
    } else {
	&error("device '" . $config{device} . "' does not exist.");
    }

    # album directory mask (dirmask)
    my $dirmask = $config{dirmask_s};
    $dirmask = $global{dirmask} if $global{dirmask};

    # check dirmask for errors
    my @dname = split(//, $dirmask);
    for (@dname) {
	($_ ne 'A' && $_ ne 'D') && do { &error('error in dirmask: ' . $dirmask) };
    }

    # retrieve cddb info
    my ($artist,
	$disctitle,
	$genre,
	$highest_tracknum,
	@tracktitles) = &get_cddb_info(&get_toc);

    # try to guess if this is a single-artist cd
    my @multiartist_tracks = grep / - /, @tracktitles;

    if ($#multiartist_tracks <= ($highest_tracknum+1)/2) {
	$global{singleartist} = &toggle($global{singleartist});
    }

    # filemask
    my $filemask = $config{filemask_m};
    $filemask = $config{filemask_s} if ($global{singleartist});
    $filemask = $global{filemask} if ($global{filemask});

    # check filemask for errors
    my @fname = split(//, $filemask);
    for (@fname) {
	($_ ne 'A' && $_ ne 'D' && $_ ne 'G' && $_ ne 'N' && $_ ne 'T') && do {
	    &error('error in filemask: ' . $filemask)
	    };
    }

    # set $disctitle to $unknown if no cddb data is available
    $disctitle = $config{unknown} unless ($disctitle);

    # make array of all tracks, unless user rips individual tracks
    unless ($global{individual}) {
	my $tracknum = 1;
	foreach my $track (@tracktitles) {
	    push @seltracks, $tracknum;
	    $tracknum++;
	}
    }

    # check if tracknumbers are not higher than the last tracknumber of the CD
    for (@seltracks) {
	if ($_ > $highest_tracknum) {
	    &error("wrong tracknumber: $_");
	}
    }

    # Here the real work starts, create album dir, print info and
    # start vlorbing
    my $albumdir = &mk_album_directory($artist, $disctitle, $dirmask);

    # print info on the screen about the ripping
    &print_info($artist, $disctitle, $albumdir, \@seltracks, \@tracktitles);

    &msg("\n");

    # actually start ripping and encoding
    &vlorb($artist, $genre, $disctitle, $filemask, \@seltracks, \@tracktitles);
}

#------------------------------------------------------------------------------
# Make all error messages look alike
#------------------------------------------------------------------------------
sub error() {
    die " [!!] @_\n";
}

#------------------------------------------------------------------------------
# Handy dandy function for showing normal program messages
#------------------------------------------------------------------------------
sub msg() {
    unless ($config{quiet}) {
	print "@_";
    }
}

#------------------------------------------------------------------------------
# Return 0 if given var is defined, otherwise return 1
#------------------------------------------------------------------------------
sub toggle() {
    ($_[0] == 1) ? return 0 : return 1;
}

#------------------------------------------------------------------------------
# Generate non-existing filename
#------------------------------------------------------------------------------
sub get_nonexistant_filename() {
    my $file = $_[0];
    my $ext = $_[1];
    my $nr = 1;

    my $complete_file = $file;
    $complete_file = $file . $ext if ($ext);

    if (-e $complete_file) {
	while() {
	    if ($ext) {
		(-e "${file}.${nr}${ext}") ?
		    $nr++ :
		    return "${file}.${nr}${ext}";
	    } else {
		(-e "${file}.${nr}") ?
		    $nr++ :
		    return "${file}.${nr}";
	    }
	}
    } else {
	($ext) ?
	    return $file . $ext :
	    return $file;
    }
}

#------------------------------------------------------------------------------
# Create album directory
#------------------------------------------------------------------------------
sub mk_album_directory() {
    my ($artist, $disctitle, $mask) = @_;
    my $albumdir;

    if (($global{singleartist}) && ($disctitle ne $config{unknown})) {
	my @dname = split(//, $mask);
	for (@dname) {
	    ($_ ne 'A' && $_ ne 'D') && do { &error('error in dirmask: ' . $mask) };
	}
	my $i = 0;
	for (@dname) {
	  SWITCH: {
	      $_ eq 'A' && do { ($artist) ? ($dname[$i] = $artist) : ($dname[$i] = 0) };
	      $_ eq 'D' && do { ($disctitle) ? ($dname[$i] = $disctitle) : ($dname[$i] = 0) };
	  }
	    $i++;
	}

	my $dirname = $dname[0];
	$i = 1;
	while ($i <= $#dname) {
	    $dirname = $dirname . $config{filemask_seperator} . $dname[$i] if ($dname[$i]);
	    $i++;
	}
	$albumdir = $dirname;
    } else {
	$albumdir = $disctitle;
    }

    $albumdir = &get_nonexistant_filename($albumdir, 0) unless ($config{overwrite_dir});

    mkdir($albumdir, 0777);
    chdir($albumdir);

    return $albumdir;
}

#------------------------------------------------------------------------------
# Parse commandline options
#------------------------------------------------------------------------------
sub parse_cmdline() {
    my %opts;
    my $seltracks_ref = $_[0];

    # Check for wrong options
    if (! getopts('i:d:ghm:M:oOpQsvVb:q:', \%opts)) {
	&usage;
	die "\n";
    }

    # Help
    if ($opts{h}) {
	&help;
	exit;
    }

    # Overwrite files if they exist
    if ($opts{o}) {
	$config{overwrite_tracks} = &toggle($config{overwrite_tracks});
    }

    # Overwrite album dir if it exists
    if ($opts{O}) {
	$config{overwrite_dir} = &toggle($config{overwrite_dir});
    }

    # Quiet, only output errors
    if ($opts{Q}) {
	$config{quiet} = &toggle($config{quiet});
	$config{show_progress} = &toggle($config{show_progress});
    }

    # Multi-artist cd or single-artist cd
    if ($opts{s}) {
	$global{singleartist} = &toggle($global{singleartist});
    }

    # Show encoding progress
    if ($opts{p}) {
	$config{show_progress} = &toggle($config{show_progress});
    }

    # VorbisGain
    if ($opts{v}) {
	$config{use_vorbisgain} = &toggle($config{use_vorbisgain});
    }

    # Show version info
    if ($opts{V}) {
	&show_version();
	exit;
    }

    # Device name
    if ($opts{d}) {
	$config{device} = $opts{d};
    }

    # Rip individual tracks
    if ($opts{i}) {
	$global{individual} = 1;
	my $seltracks = $opts{i};
	$seltracks =~ s/\s+//g; # strip whitespace
	push(@$seltracks_ref, (split /,/, $seltracks));

	for (@$seltracks_ref) {
	    if (/[^\d]/) {
		&error("\"$_\" is not a valid number");
	    }
	}
    }

    # Filemask
    if ($opts{m}) {
	$global{filemask} = $opts{m};
    }

    # Dirmask
    if ($opts{M}) {
	$global{dirmask} = $opts{M};
    }

    # Bitrate sanity check
    if ($opts{b}) {
	my $is_bitrate;

	$config{bitrate} = $opts{b};
	$config{use_bitrate} = 1;

	my @bitrates = (48, 56, 64, 72, 80, 88, 96, 104, 112, 120,
			128, 136, 144, 152, 160, 168, 176, 184, 192,
			200, 208, 216, 224, 232, 240, 248, 256, 264,
			272, 280, 288, 296, 304, 312, 320);

	for (@bitrates) {
	    vec($is_bitrate, $_, 1) = 1;
	}

	if (($opts{b} =~ /[^\d]/) || (! vec($is_bitrate, $opts{b}, 1))) {
	    &error("bitrate should be one of: @bitrates");
	}
    }

    # Quality sanity check
    if ($opts{q}) {
	$config{quality} = $opts{q};
	$config{use_bitrate} = 0;

	if (($config{quality} =~ /[^-*\d]/) || ($config{quality} < -1) || ($config{quality} > 10)) {
	    &error('quality should be an integer between -1 and 10');
	}
    }

    # Generate configuration file
    if ($opts{g}) {
	&gen_config;
	exit;
    }

    # Either -b or -q, but not both
    if ($opts{b} && $opts{q}) {
	&error('you can only specify one of -b and -q');
    }
}

#------------------------------------------------------------------------------
# Parse configuration file
#------------------------------------------------------------------------------
sub parse_config() {
    my $file = $_[0];
    open(FILE, $file) || &error("Failed to read $file");
    while (<FILE>) {
	if ((/^\#/) || (/^$/)) {
	    next;
	} else {
	    my ($key, $val) = split(/=/, $_, 2);
	    chomp $val;
	    if (exists($config{$key})) {
		$config{$key} = $val;
	    } else {
		&error("Unknown option on line $. of $file");
	    }
	    &error("Error on line $. of $file:\n$@\n") if ($@);
	}
    }
    close(FILE);
}

#------------------------------------------------------------------------------
# Get configuration
#------------------------------------------------------------------------------
sub get_config() {
    &parse_config('/etc/vlorbrc') if (-e '/etc/vlorbrc');
    &parse_config("$ENV{HOME}/.vlorbrc") if (-e "$ENV{HOME}/.vlorbrc");
}

#------------------------------------------------------------------------------
# Generate configuration file, print it to STDOUT
#------------------------------------------------------------------------------
sub gen_config() {
    print "#\n# vlorb configuration file\n";
    print "#   format: key=value\n#\n";
    for (sort (keys %config)) {
	print "$_=$config{$_}\n";
    }
}

#------------------------------------------------------------------------------
# Read TOC using cdparanoia
#------------------------------------------------------------------------------
sub get_toc() {
    &msg('Reading TOC: ');

    my @toc = ();
    my $output = `cdparanoia -d $config{device} -Q 2>&1`;
    &error('cdparanoia not found in path') if ($? == -1);

    if ($?) {
	&error('Could not get TOC. No audio CD in drive?');
    } else {
	my @toc_raw = split(/\=+/, $output);
	@toc_raw = split(/\n/, $toc_raw[1]);

	shift @toc_raw;
	foreach my $raw_track (@toc_raw) {
	    if ($raw_track =~ /\bTOTAL\b/) {
		my $begin = (split(/\bTOTAL\b +\d+ \[/, $raw_track, 2))[1];
		my ($first, $second, $third) = (split(/:|\.|\]/, $begin, 4))[0,1,2];
		my $good_track = '999' . ' ' . $first . ' ' . $second . ' ' . $third;
		chomp($good_track);
		push @toc, $good_track;
	    } else {
		my ($tracknum, $begin) = split(/\. +\d+ \[\d+:\d+\.\d+\] +\d+ \[/, $raw_track, 2);
		$tracknum = (split(/\s+/, $tracknum, 2))[1];
		my ($first, $second, $third) = (split(/:|\.|\]/, $begin, 4))[0,1,2];
		my $good_track = $tracknum . ' ' . $first . ' ' . $second . ' ' . $third;
		push @toc, $good_track;
	    }
	}
	&msg("done.\n");
	return @toc;
    }
}

#------------------------------------------------------------------------------
# Get CDDB info (with timeout exception)
#------------------------------------------------------------------------------
sub get_cddb_info() {
    my @toc = @_;
    my ($artist, $disctitle, $genre, $highest_tracknum, @tracktitles);

    &msg('Connecting to CDDB: ');
    eval {
	local $SIG{ALRM} = sub {
	    &error('alarm clock restart');
	};
	# timeout CDDB connection after 15 seconds
	alarm 15;
	eval {
	    my $cddbp = new CDDB(Host => $config{cddb_host},
			      Port => $config{cddb_port}
			      ) and &msg("done.\n") or &error($!);

	    &msg('Retrieving CDDB info: ');

	    my ($cddbp_id,
		$track_numbers,
		$track_lengths,
		$track_offsets,
		$total_seconds
		) = $cddbp->calculate_id(@toc);

	    $highest_tracknum = @$track_numbers[-1];
	    @tracktitles = @$track_numbers;

	    my $disc = ($cddbp->get_discs($cddbp_id, $track_offsets, $total_seconds))[0];

	    # Query disc based on cddbp ID and other information
	    ($genre, $cddbp_id) = @$disc[0,1];

	    my $disc_info = $cddbp->get_disc_details($genre, $cddbp_id);
	    $disctitle = $disc_info->{dtitle};

	    # put a space between info (e.g. Pink-Missundaztood)
	    $disctitle =~ s/\s*-\s*/ - /g if ($disctitle);

	    # replace slashes (/) by ' - '
	    $disctitle =~ s/\s*\/\s*/ - /g if ($disctitle);

	    ($artist, $disctitle) = split(/ - /, $disctitle, 2) if ($disctitle);

	    @tracktitles = @{$disc_info->{ttitles}};

	    # replace slashes (/) by ' - ' and '-' by ' - '
	    for (@tracktitles) {
		s/\s*-\s*/ - /g;
		s/\s*\/\s*/ - /g;
	    }

	    # Need a test condition for a failed cddb connection;
	    # every cd has a disctitle
	    unless ($disctitle) {
		&msg("failed.\n");
		$disctitle = $config{unknown};
	    } else {
		&msg("done.\n");
	    }
	};
	alarm 0;
    };
    alarm 0;
    return ($artist, $disctitle, $genre, $highest_tracknum, @tracktitles);
}

#------------------------------------------------------------------------------
# Print some info about the rip
#------------------------------------------------------------------------------
sub print_info() {
    my ($artist, $disctitle, $albumdir, $seltracks, $tracktitles) = @_;

    my $tracknum = 0;

    unless ($disctitle eq $config{unknown}) {
	&msg("\nYou are about to rip from the CD:");

	($global{singleartist}) ?
	    &msg("\n      " . $artist . $config{filemask_seperator} . $disctitle . "\n") :
	    &msg("\n      " . $disctitle . "\n");

	&msg("\nThe following tracks will be ripped:");

	if ($global{individual}) {
	    foreach $tracknum (@$seltracks) {
		my $track = @$tracktitles[$tracknum-1];
		$track =~ s/\//-/g;
		($tracknum > 9) ?
		    &msg("\n      $tracknum. $track") :
		    &msg("\n       $tracknum. $track");
	    }
	} else {
	    for (@$tracktitles) {
		s/\//-/g;
		$tracknum++;
		$tracknum = " $tracknum" unless ($tracknum > 9);
		&msg("\n      $tracknum. $_");
	    }
	}
	&msg("\n\n");
    } else {
	&msg("\nno CDDB data.\n\n");
    }

    ($config{use_bitrate} == 0) ?
	&msg("quality:         $config{quality}\n") :
	&msg("bitrate:         $config{bitrate} kbps\n");

    &msg('single-artist:   ');
    ($global{singleartist} == 0) ?
	&msg("no\n") :
	&msg("yes\n");

    &msg('vorbisgain:      ');
    ($config{use_vorbisgain} == 0) ?
	&msg("no\n") :
	&msg("yes\n");

    &msg("album directory: ./$albumdir/\n");
}

#------------------------------------------------------------------------------
# Rip and encode
#------------------------------------------------------------------------------
sub vlorb() {
    my ($artist, $genre, $disctitle, $filemask, $seltracks, $tracktitles) = @_;

    foreach my $tracknum (@$seltracks) {
	my $tracktitle = '';
	my $filename = '';

	$tracknum = "0$tracknum" unless ($tracknum > 9);

	if ($disctitle eq $config{unknown}) {
	    $filename = "track$tracknum";
	    $tracktitle = $filename;
	} else {
	    if ($global{singleartist}) {
		$tracktitle = $$tracktitles[$tracknum - 1];
	    } else {
		($artist,$tracktitle) = split(/ - /, $$tracktitles[$tracknum - 1], 2);

		# sometimes, artist info is not available. If so, this
		# makes sure the $tracktitle tag is correctly set.
		unless ($tracktitle) {
		    $tracktitle = $artist;
		    $artist = '';
		}
	    }

	    my @fname = split(//, $filemask);
	    my $i = 0;
	    for (@fname) {
	      SWITCH: {
		  $_ eq 'A' && do { ($artist) ? ($fname[$i] = $artist) : ($fname[$i] = 0) };
		  $_ eq 'D' && do { ($disctitle) ? ($fname[$i] = $disctitle) : ($fname[$i] = 0) };
		  $_ eq 'G' && do { ($genre) ? ($fname[$i] = $genre) : ($fname[$i] = 0) };
		  $_ eq 'N' && do { ($tracknum) ? ($fname[$i] = $tracknum) : ($fname[$i] = 0) };
		  $_ eq 'T' && do { ($tracktitle) ? ($fname[$i] = $tracktitle) : ($fname[$i] = 0) };
	      }
		$i++;
	    }

	    $filename = shift @fname;
	    for (@fname) {
		$filename = $filename . $config{filemask_seperator} . $_ if ($_);
	    }
	}

	($config{overwrite_tracks}) ?
	    $filename = $filename . '.ogg' :
	    $filename = &get_nonexistant_filename($filename, '.ogg');

	&msg("vlorbing track $tracknum to: $filename: ");
	&msg("\n") if $config{show_progress};

	# escape double quotes in strings
	$tracktitle =~ s/"/\\"/g if ($tracktitle);
	$disctitle =~ s/"/\\"/g if ($disctitle);
	$artist =~ s/"/\\"/g if ($artist);
	$genre =~ s/"/\\"/g if ($genre);
	$filename =~ s/"/\\"/g if ($filename);

	my $bq = "-q $config{quality}";
	$bq = "-b $config{bitrate}" if ($config{use_bitrate});

	# Encode track with oggenc and insert correct ID3 tags where applicable
	my $rip_command = "cdparanoia -q -d $config{device} -r -- $tracknum -";
	my $encode_command = "oggenc - -r $bq -N $tracknum -t \"$tracktitle\" -o \"$filename\"";
	unless ($disctitle eq $config{unknown}) {
	    $encode_command = $encode_command . " -l \"$disctitle\" -a \"$artist\" -G \"$genre\"";
	}
	$encode_command = $encode_command . ' -Q' if (! $config{show_progress});

	my $command = "$rip_command | $encode_command";
	`$command`;

	&error('rip or encode error') if $?;

	if ($config{use_vorbisgain}) {
	    my $command = 'vorbisgain';
	    $command = $command . ' -q' if (! $config{show_progress});
	    `$command "$filename"`;
	    &error('vorbisgain error') if $?;
	}

	&msg("done.\n");
    }

    # Game Over! The End! Done! Finished!
    &msg("vlorbed!\n");
}

#------------------------------------------------------------------------------
# Run
#------------------------------------------------------------------------------
&main;
