TP EZShout is an alternate Internet Radio directory service for the SMC EZ-Stream [L1 ].

On advice of a friend, I recently found one of these devices for $35 USD (April 2007) [L2 ] or [L3 ]. Seems too cheap to pass up.
The EZ-Stream plays MP3 and WMA audio, either from Internet Radio [L4 ], a UPnP media server, [L5 ], or from RHAPSODY Digital Music Server (which I haven't used since it's Windows only.) The EZ-Stream seems to be an implementation of a design by BridgeCo [L6 ].
My main interest was to get Internet Radio through my home audio system, and this device works well for that purpose. As a bonus, I found that it can also access my personal library of ripped CDs through use of a UPnP media server. I'm running GMediaServer [L7 ] for that usage. Other formats (AAC, Ogg, etc.) are not supported by the EZ-Stream.
One downside of the EZ-Stream is that the Internet Radio directory is a subscription service after a 60-day trial. While the expense is not that great (one-time fee, $30 USD), this fee was not mentioned anywhere on the SMC product site, product packaging, retailers pages, etc. (Can you say Bait and Switch?) Also, I wanted to be able to control my Internet Radio directory, accessing stations not in the SMC list, and excluding others of which I would never listen. Shoutcast.com [L8 ] is a rich source of Internet Radio stations listed by genre. Shoutcast directories by genre can be downloaded in XML format, and a .pls playlist of an internet radio station can be retrieved by a Shoutcast ID.
Through use of an old friend, sockspy, I was quickly able to see how the EZ-Stream updates its directory list: a simple HTTP request that retrieves an unencrypted XML file containing a list of station URLs and a directory structure.
Seems like a plan.
I ended up writing a Shoutcast to EZ-Stream directory converter, EZShout. One problem I ran into is that the EZ-Stream seems to only recognize a URL to the actual MP3 or WMA stream of an Internet Radio station. Shoutcast returns a playlist in .pls format [L9 ] when queried, instead of an actual URL to a stream. It's also not recommended to pre-fetch possibly thousands of playlists from Shoutcast, so some sort of on-the-fly translation is needed.
My solution is a small CGI redirector, nph-sc-redir.cgi, that fetches a Shoutcast .pls playlist, parses out the stream URL(s), connects to the stream and uses my local webserver as a proxy. If anyone figures out how to make the EZ-Stream play a .pls (or .m3u) playlist file, feel free to share. This is a shell script that evolved from a simple one-liner during testing. Probably could have been a nice little Tcl script implementing a custom HTTP-to-ICY server.
Here's how to make all of this work (Linux/Unix only at this time):
Now the files. These files are public domain, do what you want with them.
#!/usr/bin/tclsh
# ezshout.tcl
# aka /setupapp/smc2/asp/rsdb/update.asp
# if running with tclkit, source the tdom.kit, from
# same location as tclkit or current directory
if {[catch {source [file join [file dirname [info nameofexecutable]] tdom.kit]}]} {
catch {source tdom.kit}
}
package require http
package require tdom
# the following variables can be set in the config file
set webserver 192.168.0.40
set genreListURL http://www.shoutcast.com/sbin/newxml.phtml
set stationsByGenreURL http://www.shoutcast.com/sbin/newxml.phtml?genre=
set stationRedirectURL http://$webserver/setupapp/smc2/asp/rsdb/nph-sc-redir.cgi?id=
set sortBy name
set localNet 192.168
set directory {}
# the list of config file variables parsed
set configVars [list webserver genreListURL stationsByGenreURL stationRedirectURL sortBy localNet directory]
# stationArray holds station information by Id
array set stationArray {}
# genreArray holds station-id pairs by genre name
array set genreArray {}
# m3uArray keeps list of fakeId for each user defined station
array set m3uArray {}
# fake id base
set fakeId 1
# station length - the length of the name displayed (without size_limit="off")
set stationLen 32
proc getShoutcastGenres {} {
global genreListURL
set genreList [list]
set tok [http::geturl $genreListURL]
if {[http::ncode $tok] == 200} {
set doc [dom parse [http::data $tok]]
set root [$doc documentElement]
set nodeList [$root selectNodes //genre]
foreach node $nodeList {
lappend genreList [$node getAttribute name]
}
$doc delete
}
http::cleanup $tok
return [lsort $genreList]
}
proc getShoutcastStationsByGenre {genre {minRate 0} {typeList audio/mpeg}} {
global stationsByGenreURL stationArray stationRedirectURL fakeId sortBy stationLen
set stationIdList [list]
set urlType SC_PLS
if {! [string is integer -strict $minRate]} {
set minRate 0
}
set tok [http::geturl $stationsByGenreURL$genre]
if {[http::ncode $tok] == 200} {
if {[catch {set doc [dom parse [http::data $tok]]}]} {
set fd [open /tmp/ezshout.log a]
puts $fd "genre: $genre parse error: [http::data $tok]"
close $fd
http::cleanup $tok
return ""
}
set root [$doc documentElement]
set nodeList [$root selectNodes //station]
foreach node $nodeList {
set include 1
set name [$node getAttribute name]
set id [$node getAttribute id]
set br [$node getAttribute br]
set mt [$node getAttribute mt]
set lc [$node getAttribute lc]
# clean up station name, shorten to display length, add bitrate
regsub -all {^[^A-Za-z0-9]*} $name {} name
set name "[string range $name 0 [expr $stationLen - 5]]-$br"
if {[llength $typeList]} {
if {[lsearch $typeList $mt] == -1} {
set include 0
}
}
if {[string is integer -strict $br]} {
if {$br < $minRate} {
set include 0
}
}
if {$include} {
lappend stationIdList [list $name $lc $fakeId]
set stationArray($fakeId) [list $name $br $mt $urlType $stationRedirectURL$id]
incr fakeId
}
}
$doc delete
}
http::cleanup $tok
set sortIdx 0
set sortDir -increasing
switch -- $sortBy {
name {set sortIdx 0 ; set sortDir -increasing}
listenercount {set sortIdx 1 ; set sortDir -decreasing}
}
return [lsort -index $sortIdx $sortDir $stationIdList]
}
proc insertStationList {ezDoc} {
global stationRedirectURL stationArray stationLen
set root [$ezDoc documentElement]
$root appendChild [$ezDoc createElement station_list station_list]
foreach {id} [lsort -integer [array names stationArray]] {
foreach {name br mt urlType urlAddr} $stationArray($id) {break}
$station_list appendChild [$ezDoc createElement station station]
$station appendChild [$ezDoc createElement id station_id]
$station_id appendChild [$ezDoc createTextNode $id]
$station appendChild [$ezDoc createElement station_name station_name]
$station_name appendChild [$ezDoc createTextNode $name]
$station appendChild [$ezDoc createElement description description]
$description appendChild [$ezDoc createTextNode ""]
$station appendChild [$ezDoc createElement bw bw]
$bw appendChild [$ezDoc createTextNode $br]
$station appendChild [$ezDoc createElement url url]
$url appendChild [$ezDoc createTextNode $urlAddr]
$station appendChild [$ezDoc createElement mime_type mime_type]
$mime_type appendChild [$ezDoc createTextNode m3u]
}
}
proc getStationsInDirectory {directoryList} {
global fakeId m3uArray stationArray genreArray
foreach dirInfo $directoryList {
foreach {type name data} $dirInfo {break}
if {$type eq "DIR"} {
# data is a sub-diectory, recursively parse it
getStationsInDirectory $data
} elseif {$type eq "SC_GENRE"} {
# data is either a genre or a list of {genre minRate}
set minRate 0
foreach {genre minRate} $data {break}
set genreArray($data) [getShoutcastStationsByGenre $genre $minRate]
} elseif {$type eq "M3U_URL"} {
# data is a direct URL to a stream
set stationArray($fakeId) [list $name 128 audio/mpeg M3U_URL $data]
set m3uArray($name) $fakeId
incr fakeId
}
}
}
proc printDirectory {directoryList {indent 0} } {
if {$indent == 0} {
puts "Top Menu"
}
foreach dirInfo $directoryList {
foreach {type name data} $dirInfo {break}
if {$type eq "DIR"} {
puts -nonewline [string repeat . $indent]..
puts "$name"
printDirectory $data [expr {$indent + 2}]
} elseif {$type eq "SC_GENRE"} {
puts -nonewline [string repeat . $indent]..
puts "$name"
puts -nonewline [string repeat . $indent]....
puts "station 1 (SC_GENRE $data)"
puts -nonewline [string repeat . $indent]....
puts "station 2 (SC_GENRE $data)"
puts -nonewline [string repeat . $indent]....
puts "station n (SC_GENRE $data)"
} elseif {$type eq "M3U_URL"} {
puts -nonewline [string repeat . $indent]..
puts "$name (M3U_URL $data)"
}
}
}
proc insertDirectoryList {ezDoc directoryNode directoryList} {
global stationArray genreArray m3uArray
foreach dirInfo $directoryList {
foreach {type name data} $dirInfo {break}
if {$type eq "DIR"} {
set dirNum [llength $data]
$directoryNode appendChild [$ezDoc createElement dir dir]
$dir setAttribute name $name subdir_count $dirNum station_count 0
insertDirectoryList $ezDoc $dir $data
} elseif {$type eq "SC_GENRE"} {
#set stationIdList [getShoutcastStationsByGenre $data]
set stationIdList $genreArray($data)
if {[llength $stationIdList] > 0} {
$directoryNode appendChild [$ezDoc createElement dir dir]
$dir setAttribute name $name subdir_count 0 station_count [llength $stationIdList]
foreach stationData $stationIdList {
foreach {stationName listnerCount stationId} $stationData {break}
$dir appendChild [$ezDoc createElement station station]
$station appendChild [$ezDoc createTextNode $stationId]
}
}
} elseif {$type eq "M3U_URL"} {
$directoryNode appendChild [$ezDoc createElement station station]
$station appendChild [$ezDoc createTextNode $m3uArray($name)]
}
}
}
proc createEzStreamDoc {version numStations} {
global ezDoc
set ezDoc [dom createDocument station_db]
set root [$ezDoc documentElement]
$root setAttribute version $version format_version 2.0 station_count $numStations
$root appendChild [$ezDoc createElement database_info database_info]
$database_info appendChild [$ezDoc createElement format_version format_version]
$format_version appendChild [$ezDoc createTextNode 2.0]
$database_info appendChild [$ezDoc createElement name name]
$name appendChild [$ezDoc createTextNode vTuner]
$database_info appendChild [$ezDoc createElement server_url server_url]
$server_url appendChild [$ezDoc createTextNode http://www.radio678.com/setupapp/smc2/asp/rsdb/update.asp]
$database_info appendChild [$ezDoc createElement service service]
$service appendChild [$ezDoc createTextNode EZSHOUT]
return $ezDoc
}
proc createEzXML {directoryList} {
global stationArray
set version [clock format [clock seconds] -gmt 1 -format %Y-%m-%dT%H:%M:%SZ]
set ezDoc [createEzStreamDoc $version [array size stationArray]]
insertStationList $ezDoc
set root [$ezDoc documentElement]
$root appendChild [$ezDoc createElement directory_list directory_list]
insertDirectoryList $ezDoc $directory_list $directoryList
set xml {<?xml version="1.0" encoding="iso-8859-1" standalone="yes"?>}
append xml \n [$ezDoc asXML -indent 0]
return $xml
}
proc getConfigVars {configFile {debug 0}} {
global configVars
if {! [file isfile $configFile]} {
if {! [file isfile ezshout.conf]} {
error "cannot file config file \"$configFile\" or default \"ezshout.conf\""
}
set configFile ezshout.conf
}
set fd [open $configFile]
set conf [read $fd]
close $fd
interp create -safe safe
set configResult ""
set rc [catch {safe eval $conf} configResult]
foreach var $configVars {
if {! [catch {set value [safe eval "set $var"]}] } {
global $var
set $var $value
}
}
interp delete safe
if {$debug} {
puts "config file \"$configFile\""
puts "parse results: [expr {($rc == 0) ? "ok" : "$configResult"}]"
puts ""
foreach var $configVars {
set valResult ""
catch {set $var} valResult
puts "var $var: $valResult"
}
}
}
##############################################################################
# main
# recognized options:
# -c configfile
# -debug 1
array set options {-c ezshout.conf -debug 0}
if {[llength $argv] > 0 && [llength $argv] % 2 == 0} {
array set options $argv
if {$options(-debug) eq "1"} {
puts "\n\n===============================================================\n"
getConfigVars $options(-c) $options(-debug)
puts "\n\n===============================================================\n"
puts "Directory structure:\n"
printDirectory $directory
puts "\n\n===============================================================\n"
puts "Shoutcast genres:\n"
set c 0
foreach g [getShoutcastGenres] {
puts -nonewline $g
if {[incr c] % 5 == 0} {
puts ""
set c 0
} else {
puts -nonewline \t
}
}
puts ""
exit
}
}
puts "Content-type: text/html\n"
getConfigVars $options(-c)
if {$options(-debug) eq "0" && [string length $localNet]} {
set remote_addr ""
catch {set remote_addr $env(REMOTE_ADDR)}
if {! [regexp "^$localNet" $remote_addr]} {
puts bogus.
exit
}
}
getStationsInDirectory $directory
set xml [createEzXML $directory]
puts -nonewline $xml
exit
# ezshout.conf
# NOTE - This file is sourced (in a safe interpreter), so it must
# be in Tcl syntax!!
##############################################################################
# genreListURL : This URL that returns the Shoutcast list of genres.
set genreListURL http://www.shoutcast.com/sbin/newxml.phtml
##############################################################################
# stationsByGenreURL : This URL returns a list of stations when appended
# with a Shoutcast genre
set stationsByGenreURL http://www.shoutcast.com/sbin/newxml.phtml?genre=
##############################################################################
# stationRedirectURL : This URL streams an audio stream (.m3u) when given
# a Shoutcast playlist id (.pls)
# CHANGE THIS FOR YOUR WEBSERVER
set webserver 192.168.0.40
set stationRedirectURL http://$webserver/setupapp/smc2/asp/rsdb/nph-sc-redir.cgi?id=
##############################################################################
# localNet : prefix of your local network, so we don't serve other
# outside requesters. some webservers (like thttpd) don't limit URLs by
# network, so do it manually. set to "" if you don't want to check
# CHANGE THIS FOR YOUR NETWORK
set localNet 192.168
##############################################################################
# sortBy : how to sort the shoutcast genre stations
# current choices are "name" or "listenercount"
# Note: your remote 'Jump' function won't work if your choose "listenercount"
set sortBy name
##############################################################################
# directory : This defines the directory when the EZ Stream requests an
# update. The directory has a specific format to allow nested
# directories, and definitions of Shoutcast genres or specific mpg streams.
#
# directory is a Tcl list of one or more elements.
#
# Each element is a sub-list of three elements:
# {type name data}
#
# type: is one of:
# DIR Specifies a directory structure, data is sub-directory list
# SC_GENRE Specifes the data is a Shoutcast genre, which will be expanded
# to each station. data is a single element genre, or a list of
# {genre minBitRate}. minBitRate should be an integer in kb/s
# M3U_URL Specifies the data is a URL that directly plays a stream
# name: The name of the directory or station
# data: As specified by type
#
# Restrictions:
# 1. The first element must be a DIR. This name shows up on the
# EZ-Stream main menu.
# 2. Any sub-directory that contains an M3U_URL element may not contain sibling
# DIR or SC_GENRE types. See "Favorite and Unlisted" in sample. In other
# words, a DIR may contain other DIR and SC_GENRE -or- M3U, but not both.
# 3. SC_GENRE data must specify a valid Shoutcast genre. If bogus, bad things
# can happen. Run with argument "-debug 1" to get a list of Shoutcast genre
set directory { { DIR "Shoutcast Radio" {
{ DIR "Private Selection" {
{ DIR "Favorite and Unlisted" {
{ M3U_URL KUNC http://pubint.ic.llnwd.net/stream/pubint_kunc}
{ M3U_URL NPR http://207.200.96.225:8002}
{ M3U_URL "RadioParadise" http://scfire-ntc0l-1.stream.aol.com:80/stream/1048}
{ M3U_URL "Groove Salad" http://scfire-chi0l-1.stream.aol.com:80/stream/1018}
{ M3U_URL "BellyUp4Blues" http://64.62.252.136:5100}
}
}
{ SC_GENRE "Beer Tent" Beer }
}
}
{ DIR "Genres" {
{ SC_GENRE Ambient {Ambient 96} }
{ SC_GENRE "Alt/College/Indie" {Alternative 96} }
{ SC_GENRE Blues {Blues 96} }
{ SC_GENRE "Classic Rock" {Classic 112} }
{ SC_GENRE "Classical/Symphonic" {Classical 96} }
{ SC_GENRE Eclectic {Eclectic 96} }
{ DIR "Other" {
{ SC_GENRE Americana {Americana 56} }
{ SC_GENRE Bluegrass {Bluegrass 56} }
{ SC_GENRE Comedy Comedy }
{ SC_GENRE Funk {Funk 128} }
{ SC_GENRE Indie {Indie 96} }
{ SC_GENRE Reggae {Reggae 128} }
{ SC_GENRE Techno {Techno 96} }
}
}
}
}
}}}
#!/bin/bash
# nph-sc-redir.cgi
#
# get the shoutcast playlist (.pls) file from shoutcast.com, find the
# first open url, connect to that url and streams
#
# requires standard unix utilities, wget, and netcat (nc)
#
# expects one cgi parameter: id (shoutcast id for station playlist)
# note that this script should be executed as "Non-parsed headers"
# some webservers use the convention of prefixing the cgi
# name with 'nph-' to run as "Non-parsed headers"
#
# for testing: use xmms, mpg123, etc.
# mpg123 http://localhost/cgi-bin/nph-sc-redir.cgi?id=1553
#
# additional notes at the bottom of this file
# simple test for local network, in case your webserver can't
# limit some url by remote host (such as thttpd).
# comment this out if you don't need it, or change the
# localnet value to suit your network
localnet=192.168
islocal=`echo $REMOTE_ADDR | egrep "^$localnet"`
if [ -z "$islocal" ] ; then
exit
fi
file=1
# get the id of the shoutcast playlist
# id=xxx must be the only request parameter
id=`echo $QUERY_STRING | sed -e 's/id=//'`
# simply exit if we didn't find an id
if [ -z "$id" ] ; then
exit
fi
# fetch the playlist from shoutcast
pls=`wget -O - -q "http://www.shoutcast.com/sbin/shoutcast-playlist.pls?rn=$id&file=filename.pls"`
if [ -z "$pls" ] ; then
# oops, couldn't get the playlist from shoutcast
echo -e "HTTP/1.0 404 Not Found\n"
exit
fi
# find the first url that has open slots and return ICY 200
while [ $file -gt 0 ] ; do
url=`echo "$pls" | grep File${file}= | sed -e "s~File${file}=http://~~"`
if [ -n "$url" ] ; then
slot=`echo "$pls" | grep Title${file}= | sed -e "s/Title${file}=([^-]*- //" | sed -e 's/).*//'`
isfull=`echo $slot | bc`
if [ "$isfull" -eq 1 ] ; then
file=`expr $file + 1`
url=''
else
# try to connect, read the first line back, check for 200 (OK) status
port=80
doc=/
hostport=`echo "$url" | sed -e 's~/.*~~'`
host=`echo "$hostport" | sed -e 's/:.*//'`
urlport=`echo "$hostport" | sed -e 's/^[^:]*://'`
if [ -n "$urlport" ] ; then
port="$urlport"
fi
urldoc=`echo "$url" | sed -e 's/^[^/]*//'`
if [ -n "$urldoc" ] ; then
doc="$urldoc"
fi
# wait 10 seconds for a connect (-w 10)
status=`echo -e "GET $doc HTTP/1.0\nHost: $host\nUser-Agent: xmms/1.2.10\nIcy-MetaData: 1\n" | nc -w 10 $host $port 2>/dev/null | head -1 | grep 200`
if [ -n "$status" ] ; then
# the url has open slots and we can connect, so use this url
file=0
else
file=`expr $file + 1`
url=''
fi
fi
else
# could find a 'Filex=' so stop parsing
file=0
url=''
fi
done
if [ -n $url ] ; then
# found the url, and it has open slots. parse out the
# host name, port, and document.
port=80
doc=/
hostport=`echo "$url" | sed -e 's~/.*~~'`
host=`echo "$hostport" | sed -e 's/:.*//'`
urlport=`echo "$hostport" | sed -e 's/^[^:]*://'`
if [ -n "$urlport" ] ; then
port="$urlport"
fi
urldoc=`echo "$url" | sed -e 's/^[^/]*//'`
if [ -n "$urldoc" ] ; then
doc="$urldoc"
fi
# start streaming. send a minimal request to the url via netcat.
# since we are running as nph, all headers returned by the
# url are returned to the client, followed by the audio data stream
echo -e "GET $doc HTTP/1.0\nHost: $host\nUser-Agent: xmms/1.2.10\nIcy-MetaData: 1\n" | nc $host $port
exit
else
# either we couldn't parse the playlist, or there are not open slots on
# any of the URLs
echo -e "HTTP/1.0 404 Not Found\n"
fi
exitTP My wife thinks I'm crazy for not going ahead and forking over the $30 ransom money, but why pass up a nice opportunity to play with Tcl for a few hours?