The Blog of Charles Daniels

A Jukebox in sh with netcat

Written 2018-12-17

Over winter break, some friends and I decided to spend a week working on a programming project. One challenge was playing music - deciding whose computer to use, which person's music library to use, and so on. To make it easier for everyone to play the tracks they wanted, we set up a spare laptop as a jukebox.

A simple shell script which we'll discuss below served as a server - people could send it a URL (such as a YouTube video) via netcat and it would automatically play each link using mpv one at a time in a first-come-first-serve order.

This approach is definitely not the most robust - for one thing anyone could easily overload the server, skip other people's tracks, or cause the server to exit. It makes the assumption that nobody on your network is a bad actor. But, for a temporary setup on a secure LAN, it served us well.

How it Works

The first script, muserver.sh, uses netcat to listen for incoming connections, parse out either a command or a URL, then take the appropriate action. In the case where the data the client sent was a URL, it gets sent into a named pipe for the music playback script to pick up.

The second script, muplayer.sh, reads the named pipe from the former one line at a time. It tries to play each line using mpv (which uses youtube-dl under the hood to retrieve the music files). It waits until mpv exists before retrieving the next line.

Using the Server

On the server - the machine which you want the music to play out of, you just need to run muserver.sh path/to/fifo, where path/to/fifo is where you would like the named pipe to be placed. The server should print the IP and port number which it is using.

Now, anyone who can access your IP can use netcat to send URLs to be played like so: echo 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' | nc the_ip the_port.

If you want to skip a track: echo 'SKIP' | nc the_ip the_port

When you're ready to close the server: echo 'DIE' | nc the_ip the_port

muserver.sh

#!/bin/sh

# the user needs to tell us where to put the FIFO
if [ $# -ne 1 ] ; then
    echo "$0 [FIFO path]"
    exit 1
fi
FIFO="$1"

# get the current LAN IP via get-ip:
# https://git.sr.ht/%7Echarles/charles-util/tree/master/bin/get-ip
IP="$(get-ip)"

# set a port for netcat to use
PORT=5656

# create the named pipe
rm -f "$FIFO"
mkfifo "$FIFO"

# Keep the FIFO open - when we use 'echo >> thefifo' an EOF gets sent into the
# FIFO implicitly, this causes it to close unless another process has an
# open write handle; we spawn a background process that keeps a write handle
# open but does not actually write any data to it.
(while true ; do sleep 1 ; done > "$FIFO") &

# we want to keep track of the PID of the background process so we can kill
# it when we exit cleanly
SLEEPER_PID=$!
echo "sleeper PID=$SLEEPER_PID"

# run the music player script in the background
./muplayer.sh "$FIFO" &

# we want to keep track of the PID of the player so we can kill and restart
# it for skips, and also when we exit cleanly
PLAYER_PID=$!
echo "player PID=$PLAYER_PID"

echo "starting muserver... "
echo "my IP is $IP"
echo "listening on port $PORT"

while true ; do
    # wait until a client sends us a message
    RECV="$(nc -l $PORT)"
    echo "muserver: got message '$RECV'"

    # if the client asked us to skip, kick over the music player and
    # restart it - we also kill mpv, since for some reason killing the
    # parent does not also kill mpv.
    if [ "$RECV" = "SKIP" ] ; then
        echo "muserver: skip requested, kicking over player ($PLAYER_PID)"
        kill -9 $PLAYER_PID
        pkill mpv
        ./muplayer.sh "$FIFO" &
        PLAYER_PID=$!
        echo "muserver: player PID=$PLAYER_PID"

    # if the client asks us to exit, kill all of the background proceses
    # and exit
    elif [ "$RECV" = "DIE" ] ; then
        echo "muserver: clean shutdown"
        kill -9 $PLAYER_PID
        kill -9 $SLEEPER_PID
        pkill mpv
        exit

    # assume the message is a link foor us to play and send it into the
    # FIFO
    else
        echo "muserver: appended to queue"
        echo "$RECV" >> "$FIFO"
    fi
done

muplayer.sh

#!/bin/sh

# make sure we were passed in a FIFO to read from
if [ $# -ne 1 ] ; then
    echo "$0 [FIFO path]"
    exit 1
fi

FIFO="$1"

if [ ! -e "$FIFO" ] ; then
    echo "$FIFO does not exist"
    exit 1
fi

# read a line at a time from the FIFO
while read -r line ; do
    echo "muplayer: begin playing '$line'"

    # wait until mpv exits to proceed
    mpv --no-video "$line"  --quiet | while read -r ol ; do echo "mpv: $ol" ; done
    echo "muplayer: finished playing (rc=$?)"

done < "$FIFO"