#!/usr/bin/perl -Tw
use strict;

# jukebox - http://raf.org/jukebox/
#
# Copyright (C) 2002 raf <raf@raf.org>
#
# 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
# or visit http://www.gnu.org/copyleft/gpl.html

# jukeboxd - jukebox network server
#
# 20021208 raf <raf@raf.org>

BEGIN { $ENV{PATH} = '/bin:/usr/bin'; $ENV{CDPATH} = ''; $ENV{HOME} = '/tmp' unless defined $ENV{HOME}; }

# Set defaults and load configuration

my ($name) = $0 =~ /([^\/]+)$/;
my $default_address = '0.0.0.0';
my $default_port = 1221;
my $default_gap = 2;
my $default_player = 'wav=play,mp3=mpg321 -q,ogg=ogg123 -q,flac=flac -sdc % | play -t wav -';

load('/etc/jukebox.conf');
load("$ENV{HOME}/.jukeboxrc");

sub help()
{
	print << "ENDHELP";
NAME

$name - jukebox network server

SYNOPSIS

  $name [options]
  options:
    -h, --help            - Show the help message then exit
    -V, --version         - Show the version message then exit
    -d, --debug           - Debug mode (foreground, messages to stdout)
    -a, --address address - Override default server address ($default_address)
    -p, --port port       - Override default server port ($default_port)
    -g, --gap seconds     - Override default inter track gap ($default_gap)

DESCRIPTION

This is a network server that listens for requests to run jukebox.
It allows multiple computers on a home network to initiate jukebox
requests to a jukebox host that is connected to the home's sound
system.

There is no user authentication. Use jukebox itself via ssh if
you want user authentication. If you want to control which hosts
are allowed to connect to the server, launch it from inetd or
xinetd and use tcpwrappers. Never allow packets for this server
through your firewall.

The -a, -p and -g options override defaults from the configuration
file. The -g option only applies when tracks are played in random
order. There is no extra gap between tracks played sequentially
(but there is a gap before the first track).

The -d option keeps $name in the foreground and makes it
emit messages about what's happening to standard output.

FILES

  /etc/jukebox.conf - System wide configuration file
  ~/.jukeboxrc      - User specific configuration file

SEE ALSO

  rip(1), riptrack(1), mktoc(1), toc2names(1), toc2tags(1),
  cdr(1), cdrw(1), burn(1), burnw(1), cdbackup(1), mp3backup(1),
  jukebox(1), jukeboxc(1), jukeboxc.jar(1), jukeboxd(8),
  jukeboxd-init.d(8), jukebox.conf(5),
  http://raf.org/jukebox/Jukebox-HOWTO

AUTHOR

raf <raf\@raf.org>

ENDHELP

	exit;
}

sub version
{
	print "jukeboxd-0.1\n";
	exit;
}

# Check the arguments

use POSIX;
use Socket;
use Sys::Syslog;
use Getopt::Long; #qw(:config gnu_getopt);
Getopt::Long::Configure qw(no_getopt_compat permute bundling);
$| = 1;

my %opt;
help() unless GetOptions \%opt, qw(h|help V|version d|debug a|address=s p|port=s g|gap=f);
help() if exists $opt{h};
version() if exists $opt{V};
my $debug = exists $opt{d};
my $address = $opt{a} || $default_address;
my $port = $opt{p} || $default_port;
my $gap = $opt{g} || $default_gap;
my %player = decode_player($default_player);
my $inetd = defined getsockopt(STDIN, SOL_SOCKET, SO_TYPE);

fatal("Invalid port: '$port'") unless $port =~ /^\d+$/ || ($port = getservbyname($port, 'tcp'));
fatal("Invalid gap: '$gap'") unless $gap =~ /^\d+$/;
fatal("Refusing to run as root. See README and Jukebox-HOWTO.") unless POSIX::getuid() && POSIX::getuid();

# Locate daemon and jukebox

my $daemon_cmd = locate('daemon') or fatal("Failed to locate daemon");
my $jukebox_cmd = locate('jukebox') or fatal("Failed to locate jukebox");
$daemon_cmd .= ' --errlog=daemon.err --output=daemon.info';

# If started by inetd, just serve a single request 

if ($inetd)
{
	protocol(getpeername(STDIN), \*STDIN, \*STDOUT);
	exit;
}

# Start the network server

daemon() unless $debug;
$SIG{TERM} = \&term;
socket(SERVER, PF_INET, SOCK_STREAM, getprotobyname('tcp')) or fatal("socket failed: $!");
setsockopt(SERVER, SOL_SOCKET, SO_REUSEADDR, pack("l", 1)) or fatal("setsockopt failed: $!");
bind(SERVER, sockaddr_in($port, inet_aton($address))) or fatal("bind failed: $!");
listen(SERVER, SOMAXCONN) or fatal("listen failed: $!");
logmsg("Jukebox server started listening on $address:$port");

# Start listening for and reponding to network requests

while (my $peeraddr = accept(CLIENT, SERVER))
{
	protocol($peeraddr, \*CLIENT, \*CLIENT);
	close(CLIENT);
}

# Interact with a client

sub protocol
{
	my ($peer_addr, $in, $out) = @_;
	my ($port, $iaddr) = sockaddr_in($peer_addr);
	my $name = gethostbyaddr($iaddr, AF_INET);
	logmsg("Connection from $name:$port");

	vec(my $rin = '', fileno($in), 1) = 1;

	my $timeout = 0;
	my $request = '';
	my $length = 0;

	for (;;)
	{
		++$timeout, last unless select(my $rout = $rin, undef, undef, 5);
		my $bytes = sysread($in, $request, 512, $length);

		last unless $bytes;
		$length += $bytes;

		if ($request =~ /\bcommand:(?:start|stop|list)/m || $request =~ /\015\012\015\012/m)
		{
			$request =~ s/\015\012/\n/mg;
			logmsg(">>>$request>>>") if $debug;
			my $response = handle($request);
			$response .= "\n" unless substr($response, length($response) - 1) eq "\n";
			logmsg("<<<$response<<<") if $debug;
			$response =~ s/\n/\015\012/mg;
			print $out $response if defined $response;
			last;
		}
	}

	print $out "Timeout\015\012" if $timeout;
}

# Handle each start/stop/list/file request

# REQUEST:
# user:[a-zA-Z0-9_.]+ command:[a-z]+ options:[aejmscnxvfT]* criteria:[0-9 a-zA-Z'\",.?!+-]*
# where:
#   "user" is a user name (i.e. jukebox favourite tag)
#   "command" may be "start", "stop", "list", or "file"
#     start - starts jukebox using options and criteria
#     stop  - stops jukebox
#     list  - prints the tracks selected by options and criteria
#     file  - starts jukebox with a playlist specified by the client
#   "options" are command line options (without arguments) to be passed to jukebox
#   "criteria" are command line arguments to be passed to jukebox (i.e. search terms)
#
# "options" and "criteria" are only necessary for the "start" and "list" commands.
#
# When the command is "file", a track list is read with one file path per
# line. Each line ends with "\015\012". The last line after the last track
# must be an empty line (So the graphical jukebox client works with java-1.2).
#
# RESPONSE:
# if command is "start", "Jukebox started" or error message
# if command is "stop", "Jukebox stopped" or error message
# if command is "list", list of selected tracks or error message
# if command is "file", "Jukebox started" or error message
#
# If options contains "x", the list of tracks returned by the list command
# takes the form of four tab separated fields per line. The fields are:
# artist, cd title, track title and file path. Otherwise, it it is just
# one file path per line. Each line ends with "\015\012".

sub handle
{
	my ($request) = @_;

	# Identify user and command

	my ($user, $cmd, $rest) =
		$request =~ /^user:([a-zA-Z0-9_.]+)\s+command:([a-z]+)\s*(.*)$/m;

	return "Invalid command" unless defined $user && defined $cmd;
	return "No such command: '$cmd'" unless $cmd eq 'start' or $cmd eq 'stop' or $cmd eq 'list' or $cmd eq 'file';

	# Identify options and search criteria for start and list commands

	my ($opts, $crit) = $rest =~ /^options:([aejmscnxvfT]*)\s+criteria:(.*)$/;
	return "Invalid search terms" if defined $crit && $crit !~ /^[\d\sa-zA-Z'\",.?!+-]*$/;

	# Read track list if command is file

	my $playlist = '';

	# Stop the jukebox (if any) first (if stop command, nothing more to do)

	if ($cmd eq 'start' || $cmd eq 'stop' || $cmd eq 'file')
	{
		run_cmd("$daemon_cmd --name=jukebox --running");
		run_cmd("$daemon_cmd --name=jukebox --stop") if !$?;
	}

	# Start the jukebox

	if ($cmd eq 'start' or $cmd eq 'list' or $cmd eq 'file')
	{
		# Simulate default behaviour (user's favourites if no criteria)

		if ($crit eq '' && $opts !~ /a/ && $cmd ne 'file')
		{
			$opts .= 'j' unless $opts =~ /j/;
			$crit = '+' . $user;
		}

		$opts .= 'n' if $cmd eq 'list' and $opts !~ /[nxevT]/;
		$opts = '-' . $opts if $opts ne '';

		# Create a playlist for jukebox if 'file' command

		if ($cmd eq 'file')
		{
			my (@tracks) = split /\n/, $request;
			my $tracks = join("\n", grep { !/'/ && /\.(@{[join '|', keys %player]})$/i && -f } @tracks[1..$#tracks]) . "\n";
			return 'No matching tracks' if $tracks eq "\n";

			my $file = "/tmp/jukeboxd.$$";
			open PLAY, "> $file" or return "Failed to open $file for writing ($!)";
			print PLAY $tracks;
			close PLAY;

			$opts =~ s/f//g;
			$opts = '' if $opts eq '-';
			$opts .= " -u -f $file";
		}
		else
		{
			# Check if any tracks have been selected

			my $nopts = $opts;
			$nopts =~ s/T//g;
			chop(my $selection = `$jukebox_cmd -n $nopts -- $crit 2>&1`);
			return $selection if $selection eq 'No matching tracks';
			return $selection if $selection =~ /^Abort:/;
			return "Invalid search terms" if $selection eq '';

			# Send histogram (if -T) or list of matching tracks (if -nxev)

			return `$jukebox_cmd $opts -- $crit 2>&1` if $cmd eq 'list' && $opts =~ /T/;
			return $selection if $cmd eq 'list';
		}

		# Construct the jukebox command

		$opts = "-g $gap " . $opts unless $opts =~ /s/;
		my $jukebox = "$daemon_cmd --name=jukebox -- $jukebox_cmd -i $opts -- $crit";
		$jukebox .= ' < /dev/null' if $inetd; # Hide inetd from daemon
		$jukebox =~ s/^\s+//;
		$jukebox =~ s/\s+$//;
		$jukebox =~ s/\s+/ /g;

		# Launch the jukebox as a named daemon (so we can stop it later)

		run_cmd($jukebox);
		my $exit_value = $? >> 8;
		my $signal_num = $? & 127;
		my $dumped_core = ($? & 128) ? 1 : 0;

		return "Error: exit=$exit_value signal=$signal_num core=$dumped_core" if $?;
	}

	return 'Jukebox stopped' if $cmd eq 'stop';
	return 'Jukebox started';
}

# Setup a suitable daemon process context

sub daemon
{
	my $svr4 = 1;

	# Background process to lose session/group leadership

	my $pid = fork();
	fatal("failed to fork") if $pid == -1;
	exit if $pid;

	# Become a process session leader

	POSIX::setsid();

	# Under SVR4, background process again to lose process session
	# leadership again to prevent gaining a controlling tty

	if ($svr4)
	{
		$SIG{HUP} = 'IGNORE';
		$pid = fork();
		fatal("failed to fork") if $pid == -1;
		exit if $pid;
	}

	# Change to the root directory to prevent hampering umounts

	chdir('/') or fatal("failed to chdir /");

	# Clear umask to enable explicit file modes

	umask(0);

	# Point stdin, stdout and stderr to /dev/null just in case

	close STDIN;
	close STDOUT;
	close STDERR;
	open(STDIN, '/dev/null');
	open(STDOUT, '> /dev/null');
	open(STDERR, '> /dev/null');
}

# Handle SIGTERM (stop jukebox if running, then exit)

sub term
{
	run_cmd("$daemon_cmd --name=jukebox --running");
	run_cmd("$daemon_cmd --name=jukebox --stop") if !$?;
	exit;
}

# Log and execute an external process

sub run_cmd
{
	my ($cmd) = @_;
	logmsg($cmd);
	system($cmd);
}

# Send log messages to stdout or syslog

sub logmsg
{
	my ($name) = $0 =~ /([^\/]+)$/;
	print "$name: @_\n" if $debug;
	syslog('daemon|debug', "$name: @_");
}

# logmsg then die

sub fatal
{
	my ($msg) = @_;
	logmsg($msg);
	exit(1);
}

# Decode the juke_player variable

sub decode_player
{
	my ($spec) = @_;
	my %player;

	# If only a single player is given, assume mp3 format

	if ($spec =~ /^([^:=,]+)$/)
	{
		$player{mp3} = $1;
		return %player;
	}

	for my $player (split /,/, $spec)
	{
		$player{lc($1)} = $2 if $player =~ /^([^=:]+)[=:](.+)$/;
	}

	return %player;
}

# Load the configuration file

sub load
{
	my ($config) = @_;
	return unless -r $config;
	return unless open(CONFIG, $config);

	while (<CONFIG>)
	{
		s/#.*$//;
		s/^\s+//;
		s/\s+$//;
		s/\s+/ /g;
		next if /^$/;
		$default_address = $1 if /^juke_addr=['"]?([^'"]+)['"]?$/;
		$default_port = $1 if /^juke_port=['"]?([^'"]+)['"]?$/;
		$default_gap = $1 if /^juke_gap=['"]?([^'"]+)['"]?$/;
		$default_player = $1 if /^juke_player=['"]?([^'"]+)['"]?$/;
	}

	close(CONFIG);
}

# Locate a program

sub locate
{
	my ($cmd, $args) = @_;

	for ("/opt/$cmd/bin/$cmd", "/usr/local/bin/$cmd", "/usr/bin/$cmd", "/bin/$cmd")
	{
		return (defined $args) ? "$_ $args" : $_ if -x;
	}

	return undef;
}

# vi:set ts=4 sw=4
