#!/usr/bin/perl -w
# ify - a script to convert between arbitrary popular formats
# (C) 2004-2005 Joshua Kwan <joshk@triplehelix.org>.

use strict;
use File::Find ();
use File::Spec;
use File::Copy;
use FileHandle;
use Getopt::Long;
use Cwd 'abs_path';
use POSIX ();

my $working = '';
my $ft;
my $directory = '.';
my $force = 0;
my $quiet = 0;
my $delete = 0;
my $dry = 0;
my $dir = ''; # XXX HACK
my $convert_regex = '\.(mp3|ape|flac|ogg|m4a|aac|wav|shn)$';

STDOUT->autoflush(1);

# mode reference:
# 0 -> implicit; recreate directory structure
# 1 -> explicit;
#      if directory,
#          place converted directory in current dir and encode
#          files into it
#      if file,
#          place converted file in current dir
my $mode;

$SIG{INT} = sub {
	if ($working ne '')
	{
		print " (aborted)\n[ deleted ] $working\n";
		unlink $working;
		print "exiting prematurely due to interrupt\n";
		$working = "";
	}

	exit 1;
};
		
my $files_done = 0;
my $files_total = 0;
my @list;
my %dirhash;
my %modehash;

sub accumulate_process {
	my $file = @_ ? shift : $File::Find::name;
	return if ($file eq '.' or $file eq '..');

	if (-f $file and $file =~ /$convert_regex/i)
	{
		$files_total++;
		push @list, $file;
		$dirhash{$file} = $dir;
		$modehash{$file} = $mode;
	}
	elsif ($file =~ /folder\.jpg$/i or -d $file)
	{
		push @list, $file;
		$dirhash{$file} = $dir;
		$modehash{$file} = $mode;
	}
}

sub qprint {
	print @_ if !$quiet;
}

sub encode_flac {
	my ( $decode_fh, $destination, $tags ) = @_;
	my @tagargs;
	my @flacargs;
	my $buf;

	@flacargs = ( "-f", "-s", "-8", "-", "-o", $destination );
	
	open (FLACOUT, "|-", "flac", @flacargs) or die "error launching encoder!";
	while (read $decode_fh, $buf, 4096) { print FLACOUT $buf; }
	close FLACOUT;

	# now apply metadata
	if (int(keys %{$tags}) > 0)
	{
		open (METAFLAC, "|-", "metaflac", "--import-tags-from=-", $destination);
		print METAFLAC join("\n", map { "$_=" . $tags->{$_} } keys %{$tags}) . "\n";
		close METAFLAC;
	}
}

sub encode_ogg {
	my ( $decode_fh, $destination, $tags ) = @_;
	my @tagargs;
	my @oggargs;
	my $buf;

	@oggargs = ( "-q4.5", "-Q", "-", "-o", $destination );
	
	open (OGGOUT, "|-", "oggenc", @oggargs) or die "error launching encoder!";
	while (read $decode_fh, $buf, 4096) { print OGGOUT $buf; }
	close OGGOUT;

	if (int(keys %{$tags}) > 0)
	{
		open (VCOUT, "|-", "vorbiscomment", "-a", "-c", "-", $destination);
		while (my ($k, $v) = each(%{$tags})) { print VCOUT "$k=$v\n"; }
		close VCOUT;
	}
}

sub encode_mp3 {
	my ( $decode_fh, $destination, $tags ) = @_;
	my @tagargs;
	my @lameargs;
	my $buf;

	my %bind = (
		'artist' => '--ta',
		'title' => '--tt',
		'album' => '--tl',
		'date' => '--ty',
		'genre' => '--tg',
		'tracknumber' => '--tn'
	);

	while (my ($k,$v) = each(%bind))
	{
		push @tagargs, ($v, $tags->{$k}) if (defined $tags->{$k} and $tags->{$k} ne "");
	}

	@lameargs = ( "--ignore-tag-errors", "--quiet", "--add-id3v2", "--alt-preset", "standard", "-", @tagargs, $destination );
	
	open (MP3OUT, "|-", "lame", @lameargs) or die "error launching encoder!";
	while (read $decode_fh, $buf, 4096) { print MP3OUT $buf; }
	
	close MP3OUT;
}

sub encode_shn
{
	my ( $decode_fh, $destination, $tags ) = @_;
	my $buf;

	open (SHNOUT, "|-", "shorten", "-", $destination) or die "error launching encoder!";
	while (read $decode_fh, $buf, 4096) { print SHNOUT $buf; }
	close SHNOUT;
}

sub encode_wav
{
	my ( $decode_fh, $destination, $tags ) = @_;
	my $buf;
	
	open (WAVOUT, ">", "$destination") or die "error opening $destination!";

	while (read $decode_fh, $buf, 4096) { print WAVOUT $buf; }
	
	close WAVOUT;
}

sub encode
{
	my ( $meta_fh, $decode_fh, $destination, $separator, $tags ) = @_;
	my @args = ( $decode_fh, $destination );
	my $buf;
	
	$separator = '=' if not defined $separator;

	if (defined $meta_fh)
	{
		while ($buf = readline $meta_fh)
		{
			my @pair;
			chomp $buf;
		  
			@pair = split(/$separator/, $buf, 2);
	
			next if int(@pair) != 2;
	
			$pair[0] =~ y/A-Z/a-z/;
	
			$tags->{$pair[0]} = $pair[1];
		}
	
		close $meta_fh;
	}

	
	push @args, $tags;
	
	if ($ft eq "mp3") { encode_mp3(@args); }
	elsif ($ft eq "ogg") { encode_ogg(@args); }
	elsif ($ft eq "flac") { encode_flac(@args); }
	elsif ($ft eq "wav") { encode_wav(@args); }
	elsif ($ft eq "shn") { encode_shn(@args); }
	else { die "Bogus filetype '$ft'!"; }

	close $decode_fh;
}

sub convert_mp3 {
	my ( $filename, $destination ) = @_;
	my @args = ("-w", "-", "-q");
	my $decode_fh;

	open $decode_fh, "-|", "mpg123", (@args, $filename );

	# TODO: tag support, Perl library?
	encode(undef, $decode_fh, $destination);
}

sub convert_ogg {
	my ( $filename, $destination ) = @_;
	my @args = ( "-d", "wav", "-q", "-f", "-");
	my ( $fh, $decode_fh );

	open $fh, "-|", "vorbiscomment", ( "-l", $filename );
	open $decode_fh, "-|", "ogg123", (@args, $filename);

	encode($fh, $decode_fh, $destination);
}

sub convert_flac {
	my ( $filename, $destination ) = @_;
	my @args = ( "-s", "-d", "-o", "-" );
	my ( $fh, $decode_fh );

	open $fh, "-|", "metaflac", "--show-md5sum", "--export-tags-to=-", $filename;
	open $decode_fh, "-|", "flac", (@args, $filename);

	# Get the FLAC MD5SUM and encode it in to the result.
	my $md5 = <$fh>;
	chomp $md5;
	my %extratags = ( FLAC_MD5SUM => $md5 );

	encode($fh, $decode_fh, $destination, undef, \%extratags);
}

sub convert_wav {
	my ( $filename, $destination ) = @_;
	my $decode_fh;
	
	open $decode_fh, "<", $filename;
	
	encode(undef, $decode_fh, $destination);
}

sub convert_ape {
	my ( $filename, $destination ) = @_;
	my $decode_fh;

	open $decode_fh, "-|", "mac", ($filename, "-", "-d");
	
	encode(undef, $decode_fh, $destination);
}

sub convert_alac {
	my ( $filename, $destination ) = @_;
	my ( $fh, $decode_fh );

	open $fh, "-|", "alac", "-i", $filename;
	open $decode_fh, "-|", "alac", $filename;

	encode($fh, $decode_fh, $destination, ": ");
}

sub convert_shn {
	my ( $filename, $destination ) = @_;
	my $decode_fh;

	open $decode_fh, "-|", "shorten", ("-x", $filename, "-");

	encode(undef, $decode_fh, $destination);
}

sub convert_mp4a {
	my ( $filename, $destination ) = @_;
	my @args = ( "-o", "-");
	my $pid;
	my ( $fh, $decode_fh );

	my ($fd0, $fd1) = POSIX::pipe;
	open $fh, "<&=", $fd0;

	# this is SO FUCKING GHETTO XXX
	if ($pid = fork)
	{
		wait;
		POSIX::close($fd1);
		open $decode_fh, "-|", "faad", (@args, $filename);
		encode($fh, $decode_fh, $destination, ": ");
	}
	elsif ($pid == 0)
	{
		POSIX::dup2($fd1, 2);
		POSIX::close(1);
				
		exec "faad", "-i", "$filename"; 
	}
	else
	{
		die "error forking faad";
	}
}

sub dirname {
	my $d = shift;
	unless ($d =~ s:/[^/]+/?$::) { $d = '.' }
	return $d;
}

sub basename {
	my $d = shift;
	$d =~ s:.*/([^/]+)$:$1:;
	return $d;
}

sub process {
	my $filename = shift;
	my $canon = File::Spec->canonpath($filename);
	my $newfile;
	my $canonnew;
	my $d = $dirhash{$filename};
	my $m = $modehash{$filename};

	# Handles top-level directory creation
	if (-d $filename)
	{
		if ($filename eq $d)
		{
			$newfile = basename($filename);
		}
		else
		{
			($newfile = $filename) =~ s/^$d//g;
		}
		$newfile = $directory . $newfile;

		$canonnew = File::Spec->canonpath($newfile);

		qprint "[directory] $canon\n";

		if (! -d $newfile and !$dry)
		{
			system("mkdir", "-p", $newfile) == 0
				or die "Exit status $? creating directory '$newfile'.";
		}

		return;
	}
	
	$filename =~ s/^$d//g if $d ne '';
	
	if ($m == 0) { $newfile = $directory . basename($filename); }
	elsif ($m == 1) { $newfile = $directory . $filename; }
	else { die "bad mode"; }
	
	if ($directory ne '' and $filename =~ /folder\.jpg$/i)
	{
		if (not -f $newfile)
		{
			qprint "[album-art] $canon\n";
		
			copy($canon, $newfile) if not $dry;
		}
		return;
	}
	
	$newfile =~ s/\.[^\.]+$/\.$ft/i;
	$canonnew = File::Spec->canonpath($newfile);

	return if ($directory eq '' and
		not /\.(mp3|ape|flac|shn|ogg|m4a|aac|wav|jpg)$/i);

	if (!$force and -f $newfile and (stat($newfile))[7] > 0)
	{
		qprint "[up-to-date] $canonnew\n";
		return;
	}
	
	$working = $canonnew;

	my $stype;
	my $fn;
	
	# XXX i'm so lazy
	$_ = $filename;
	
	if (/\.flac$/i and ($force or $ft ne "flac"))
	{
		$stype = 'flac';
		$fn = \&convert_flac;
	}
	elsif (/\.ogg$/i and ($force or $ft ne "ogg"))
	{
		$stype = 'ogg';
		$fn = \&convert_ogg;
	}
	elsif (/\.mp3$/i and ($force or $ft ne "mp3"))
	{
		$stype = 'mp3';
		$fn = \&convert_mp3;
	}
	elsif (/\.wav$/i and ($force or $ft ne "wav"))
	{
		$stype = 'wav';
		$fn = \&convert_wav;
	}
	elsif (/\.ape$/i)
	{
		$stype = 'ape';
		$fn = \&convert_ape;
	}
	elsif (/\.shn$/i)
	{
		$stype = 'shn';
		$fn = \&convert_shn;
	}
	elsif (/\.m4a/i or /\.aac/i)
	{
		# Do some fancy heuristics
		my $streamtype;
		my $offset = 0x19a;
		open FH, '<', $_;
		seek FH, $offset, 1;
		read FH, $streamtype, 4;
		close FH;

		if (defined $streamtype)
		{
			if ($streamtype eq "alac") {
				$stype = 'alac';
				$fn = \&convert_alac;
			} elsif ($streamtype eq "mp4a") {
				$stype = 'mp4a';
				$fn = \&convert_mp4a;
			} else {
				qprint "[unknown] $canon\n";
			}
		}
		else
		{
			if ($offset == 0x311a0) {
				qprint "[unknown] $canon\n";
			} else {
				$offset = 0x311a0;
				goto tryagain;
			}
			
		}
	}
	else
	{
		qprint "[ignore] $canon\n";
		$working = '';
		return;
	}

	my $percentage = ++$files_done / $files_total * 100;
	if (defined $stype and defined $fn)
	{
		my $msg = sprintf "[%s->%s] %s", $stype, $ft, $canon;
		qprint $msg;
		
		$fn->($canon, $newfile) if not $dry;
		
		$msg = sprintf " (%.1f%%)\n", $percentage;
		qprint $msg;
	}

	$working = '';

	if ($delete)
	{
		unlink $canon if not $dry;
		print "[ deleted ] $canon\n";
	}
}

my $o_mp3;
my $o_ogg;
my $o_flac;
my $o_wav;

# Get options
GetOptions("d|destination=s" => \$directory,
	"convert-regex=s" => \$convert_regex,
	"mp3" => \$o_mp3,
	"ogg" => \$o_ogg,
	"flac" => \$o_flac,
	"wav" => \$o_wav,
	"f|force" => \$force,
	"q|quiet" => \$quiet,
	"delete" => \$delete,
	"dry-run" => \$dry);

$directory .= '/' if (not $directory =~ /\/$/ and $directory ne '');

if (defined $o_mp3 and $o_mp3 == 1) {
	$ft = "mp3";
} elsif (defined $o_ogg and $o_ogg == 1) {
	$ft = "ogg";
} elsif (defined $o_flac and $o_flac == 1) {
	$ft = "flac";
} elsif (defined $o_wav and $o_wav == 1) {
	$ft = "wav";
}

if (basename($0) =~ /^(.+)ify$/)
{
	if (defined $ft and $ft ne '')
	{
		warn "Not changing output file type from '$ft'";
		last;
	}

	($ft = $1) =~ y/A-Z/a-z/;

	die "Unrecognized file type '$ft'" unless
		$ft eq 'mp3' or $ft eq 'ogg' or $ft eq 'flac' or $ft eq 'wav';
}

if ($directory ne '' and ! -d $directory)
{
	system("mkdir", "-p", $directory) == 0
		or die "creating '$directory' failed: $?";
}

if (not defined $ft or $ft eq '')
{
	die "Need a output file type.";
}

if (int(@ARGV) == 0)
{
	$mode = 1;
	File::Find::find({ wanted => \&accumulate_process, no_chdir => 1 }, ".");
}
else
{
	foreach (@ARGV)
	{
		if (-d)	{
			$mode = 1;
			$dir = dirname(Cwd::abs_path($_));
			File::Find::find({wanted => \&accumulate_process, no_chdir => 1}, $_);
			$dir = '';
		} elsif (-f) {
			$mode = 0;
			accumulate_process $_;
		} else {
			print STDERR "Warning: could not find '$_'. Continuing.\n";	
		}
	}
}

if (int(@list) > 0)
{
	foreach my $x (@list) { process $x; }
}
