PreviousINDEXNext
Upgrading packages for FreeBSD 6.3Sendmail configuration to support rDNS change

Building a Shoutcast Server

I visited a friend of mine who had copied all his CDs to hard disk, using the Itunes system, and buying a dedicated player that hooked into his hifi. I began to wonder if I could do the same, but for free (or, more accurately, for no money).

First, I copied all my CDs to mp3 files using ripit, although I installed the standard package available under Debian etch.

Next challenge was how to get these mp3's playing on the hifi. Some sort of internet radio broadcasting thing, I guessed. I poked around the Shoutcast site and discovered I could pretty easily do what I wanted using the Shoutcast server (agreeing to a licence required) and broadcasting utilities (now no longer available) from http://www.shoutcast.com/downloads/sc_trans_posix_040.tgz. The server handles the delivery of content to clients, while sc_trans defines what the content is. Both of these come as Linux binaries, so if you are leary of binaries, you might want to look at other sources of supply.

I installed both of these packages on my main Debian box and configured them. The server is fairly easy, and the config file is well commented. I configured sc_trans to deliver random content from a playlist file (just a list of mp3 files).

In order to ease the creation of playlist files for my collection of mp3 files, I wrote a small python program playlist.py, which created playlists based on a simple database of categorised mp3 directories. All the ripped music lives is the /rep/music/mp3 directory, each subdirectory holding a CD's tracks. E.g:

  johnny_lytle_-_the_loop_-_new_and_groovy/
  josh_rouse_-_nashville/
  kent,_stacey_-_dreamsville/
  kevin_ayers_-_the_confessions_of_dr_dream/

The database is a text file, each line consisting of a directory name (i.e. CD) and category, separated by a colon ":". More than one category may be assigned; multiple categories are comma "," separated. So, the above album directories could be represented as:

  johnny_lytle_-_the_loop_-_new_and_groovy:jazz
  josh_rouse_-_nashville:rock
  kent,_stacey_-_dreamsville:jazz
  kevin_ayers_-_the_confessions_of_dr_dream:rock,prog

playlist.py allows the creation of playlists, based on categories one specifies on the command line. For full details, see the source comments below.

To control the server and broadcast playlists, I generated a GNU make file. The targets provided are:

Once this thing was broadcasting on the LAN, I could use yet another knackered laptop from work, now running Debian, to act as the client. It had a wireless card, so all I had to do was fire up xmms and plug the headphone outlet into the hifi. How easy was that?

Here's the Makefile:


# Make file to control shoutcast server

.SILENT:

.PHONY: start stop clean skip reload playlists use

MR=/rep/music

PID-TRANS=${MR}/pid.sc_trans
PID-SERV=${MR}/pid.sc_serv

start:
	if [ -r ${PID-TRANS} -o -r ${PID-SERV} ]; then \
		echo "Server already running" ; \
		exit 1 ; \
	fi
	cd ${MR}/sc/sc_trans_040 ;\
	./sc_trans_linux  </dev/null >/dev/null 2>&1  & echo $$! >${PID-TRANS}
	cd ${MR}/sc/server ;\
	./sc_serv  </dev/null >/dev/null 2>&1  & echo $$! >${PID-SERV}
	echo "Shoutcast server and transcoder started."

stop:
	if [ -r ${PID-TRANS} ]; then \
		/bin/kill -TERM `cat ${PID-TRANS}` ; \
	fi
	if [ -r ${PID-SERV} ]; then \
		/bin/kill -TERM `cat ${PID-SERV}` ; \
	fi
	rm -f pid.*

playlists:
	find `tools/playlist.py -a` -type f -name "*.mp3" >playlists/all
	for genre in `tools/playlist.py -l`; do \
		find `tools/playlist.py $$genre` -type f -name "*.mp3" \
				>playlists/$$genre  ;\
	done

use:
ifndef cat
	${error Must specifiy category with cat=}
endif
	if [ -r ${MR}/playlists/${cat} ]; then \
		cp ${MR}/playlists/${cat} ${MR}/sc/sc_trans_040/playlist ; \
	else \
		echo "No such playlist: ${cat}" ; \
	fi

skip:
	kill -WINCH `cat ${PID-TRANS}`

reload:
	kill -USR1 `cat ${PID-TRANS}`

clean:
	find ${MR} -type f -name "*.log" -delete

And, as trailed above, here's the source for playlist.py:

#!/usr/bin/python
"""
  NAME
    playlist.py: Output album entries matching category criteria

  SYNOPSIS
    playlist.py [-a] [-x cat[,cat ...]] [-l] [-f album-list] [-d dir]
                   [-U] [-n] [-c cat[,cat ...]] [cat cat  ...]

    Switches:
      -a output all album entries
      -x exclude categories
      -l list all categories defined in album-list file
      -f read albums and categories from album-list file; default
         is album.list in the working directory
      -d path location for all album entry names; default is
         /rep/music/mp3
      -U update album list with any new directories found in the mp3
         directory, as specified by the -d switch
      -n don't prompt for categories when adding new albums via the -U
         switch; a default category of NEW will be assigned, unless
         overridden by the -c switch.
      -c use category list as default when adding new albums via the
         -U switch
         
      One more more categories may be provided.  Categories are implicitly
      or'ed together.  To 'and' categories (i.e select those albums that
      have been tagged with all the categories given, use the plus (+)
      character to join categories.

  DESCRIPTION
    playlist.py is driven by the album-list file, which consists of
    album directory names, one per line, followed by a comma-separated
    list of assigned categories.  Categories are separated from the
    album directory name by a colon.

    For example:
      various_-_stuff:prog,rock
      
    The program will return a list of album directories which match
    the desired catagories, each album directory prefixed by the -d dir
    argument (or /rep/music/mp3 if -d dir is not given).

    This list is designed to be used by find in order to create a
    playlist of song files.

    The -x argument may be used to exclude certain categories, e.g.
    "playlist.py -a -x rock" will omit rock genre music from an
    otherwise complete playlist.  As another example, "playlist.py
    -x funk jazz" will return all jazz music that is not also
    categorised as funk.

    If -U is specified, any playlist options and categories are
    ignored.  By default, the -U switch will cause playlist.py to
    prompt for the category to assign for each new album located in
    the directory identified by the -d switch.  This prompting may be
    suppressed using the -n switch, in which case new entries are
    given the category NEW.  This default may be modified by providing
    a preferred default category (or list) using -c.

  EXAMPLES
    Generate complete playlist:
      find `playlist.py -a` -type f -name "*.mp3" -print >playlist

    Generate 'pure' jazz playlist:
      find `playlist.py -x rock,funk,world jazz` \
            -type f -name "*.mp3" -print >jazz.pl

    Generate jazz rock playlist:
      find `playlist.py -x world,funk jazz+rock` \
            -type f -name "*.mp3" -print >jazzrock.pl

    Update album.list file with new album entries in the mp3 directory,
    do not prompt for categories but assign the category of "rock":
      python playlist.py -U -n -c rock
      
"""

import sys
import getopt
import os

def build_lists(file_name):
    "Build and return dictionary of category lists."
    meta = dict()
    try:
        for line in open(file_name):
            toks = line.strip().split(":")
            cats = toks[1].split(",")
            for cat in cats:
                if cat in meta.keys():
                    meta[cat].append(toks[0])
                else:
                    meta[cat] = [toks[0],]
    except IOError,e:
        print >>sys.stderr,"%s: unable to process album file: %s" %\
              (sys.argv[0],e)
        sys.exit(1)
    return meta

def read_album_list(file_name):
    "Build in-memory version of album-list file."
    albums = dict()
    try:
        for line in open(file_name):
            toks = line.strip().split(":")
            albums[toks[0]] = toks[1]
    except IOError,e:
        print >>sys.stderr,"%s: unable to process album file: %s" %\
              (sys.argv[0],e)
        sys.exit(1)
    return albums

def write_album_list(albums,file_name):
    "Write in-memory version of album-list to file."
    f = open(file_name,"w")
    for album in sorted(albums.keys()):
        f.write("%s:%s\n"%(album,albums[album]))
    f.close()
    return
    
def add_from_dir(albums,album_dir,ask,default_cat):
    "Add categories to new directory entries; prompt user if requested."
    count = 0
    dirs = os.listdir(album_dir)
    for dir in dirs:
        if dir not in albums:
            if ask:
                albums[dir] = get_input("%s: "%(dir,))
            else:
                albums[dir] = default_cat
            count += 1
    return count

def get_input(prompt):
    "Get category from user."
    try:
        cat = raw_input(prompt)
        if cat == "q": sys.exit(1)
    except EOFError:
        print >>sys.stderr,"End of file reading from stdin."
        sys.exit(1)
    return cat

def parse_catands(album_cats,cat_string):
    "Return sequence of albums that match anded categories."
    cats = cat_string.split('+')
    if len(cats) <= 1:
        print >>sys.stderr,"%s: '%s' is not a catand - internal error." % \
              (sys.argv[0],cat_string)
        sys.exit(1)
    cat = cats[0]
    try:
        s = set(album_cats[cat])
        for cat in cats[1:]:
            s = s.intersection(set(album_cats[cat]))
    except KeyError:
        print >>sys.stderr,"%s: no such category as %s"% \
              (sys.argv[0],cat)
        sys.exit(1)
    return s

def process_cats(meta_list,cats,exclude_list,root_dir,all):
    "Generate list of albums as determined by arguments."
    names = dict()
    try:
        if all:
            for albums in meta_list.values():
                for album in albums:
                    names[album] = 0
        else:
            # split category arg list into two: those with
            # ands (+) and ors (implicit)
            catands = list()
            cators = list()
            for cat in cats:
                if cat.find('+') > 0:
                    catands.append(cat)
                else:
                    cators.append(cat)
            # get all anded categories first
            for catand in catands:
                albums = parse_catands(meta_list,catand)
                for album in albums:
                    names[album] = 0
            for cat in cators:
                for album in meta_list[cat]:
                    names[album] = 0
        if exclude_list:
            excludes = exclude_list.strip().split(",")
            for cat in excludes:
                for album in meta_list[cat]:
                    try:
                        del names[album]
                    except:
                        continue
    except KeyError:
        print >>sys.stderr,"%s: no such category as %s" % (sys.argv[0],cat)
        sys.exit(1)

    for album in names.keys():
        print "%s/%s"%(root_dir,album)
    return

###################################################################
# program starts here                                             #
###################################################################

ls = False
all = False
album_file = "album.list"
exclude_list = None
root_dir = "/rep/music/mp3"
update_list = False
ask = True
default_new = "NEW"

try:
    opts,args = getopt.getopt(sys.argv[1:],'arx:lfUnc:')
    for o,v in opts:
        if o == '-l': ls = True
        elif o == '-a': all = True
        elif o == '-f': album_file = v
        elif o == '-x': exclude_list = v
        elif o == '-d': root_dir = v
        elif o == "-U": update_list = True
        elif o == "-n": ask = False
        elif o == "-c": default_new = v
except getopt.GetoptError,e:
    print >>sys.stderr,"%s: illegal argument -%s" % (sys.argv[0],e.opt)
    sys.exit(1)

# if update of album list file required, that's all we'll do
if update_list:
    albums = read_album_list(album_file)
    added = add_from_dir(albums,root_dir,ask,default_new)
    write_album_list(albums,album_file)
    print "Added",added,"albums."
else:
    # read in album file and build category lists
    meta_list = build_lists(album_file)
    # if a list of the categories desired, that's it.
    if ls:
        for cat in meta_list.keys():
            print cat
    else:
        process_cats(meta_list,args,exclude_list,root_dir,all)
    

PreviousINDEXNext
Upgrading packages for FreeBSD 6.3Sendmail configuration to support rDNS change