#!/usr/bin/perl -Tw use strict; # jukebox - http://raf.org/jukebox/ # # Copyright (C) 2002 raf # # 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 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 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 () { 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