On advice of a friend, I recently found one of these devices for $35 USD (April 2007) [2] or [3]. Seems too cheap to pass up.The EZ-Stream plays MP3 and WMA audio, either from Internet Radio [4], a UPnP media server, [5], 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 [6].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 [7] 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 [8] 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 [9] 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):- Configure your EZ-Stream, and get familiar with how it works, especially the settings menu.
- You'll need a webserver running on your local network. I use thttpd [10] running on my NSLU2 Slug (see Tcl on the Slug).
- Create a directory in your webserver root /setupapp/smc2/asp/rsdb, and enable CGI execution for this directory.
- Copy the three files below to that directory. Make sure that update.asp and nph-sc-redir.cgi are executable.
- Note that nph-sc-redir.cgi should be run as CGI Non-parsed headers. It will be streaming the ICY protocol [11] instead of replying with an HTTP status code.
- Configure your directory as you want in ezshout.conf, as no doubt your musical tastes are different from mine.
- Also note other settings in ezshout.conf and nph-sc-redir.cgi that you may need to adjust. I limit usage to clients from my local network, as thttpd doesn't limit CGI or URL by IP address.
- Install Tcl8.4 and the tdom extension. For Debian Linux, simply sudo apt-get install tcl8.4 tdom.
- Instead, you can run Tclkit [12] and tdom.kit [13]. Change the executable in update.asp to use tclkit instead of tclsh.
- Install wget [14] and netcat (nc) [15] if you don't already have those.
- Setup a second network configuration on your EZ-Stream to use your webserver as an HTTP proxy. Instead of proxying the directory update request to the default site, your local webserver will process the request.
- Enable the proxied configuration on your EZ-Stream, and disable automatic directory updates.
- Now, perform a manual update. Mine often fails the first time, but works on second tries. Make sure that the directory is updated, and reports EZSHOUT as the directory service.
- Switch your configuration back to your original working, non-proxied setting.
- Now enjoy your music your way!! Note that you can update with the SMC Internet Directory service anytime, just update without configuring the EZ-Stream to use the proxy configuration.
- Debug any changes to ezshout.conf by cd'ing to /var/www/setupapp/smc2/asp/rsdb and executing ./update.asp -debug 1. This parses your config, prints your directory, and also retrieves a list of Shoutcast genres.
- Run GMediaServer also if you want. I run it on the same box as my webserver. (Debian Linux users: sudo apt-get install gmediaserver, configure to point to your .MP3 files)
- update.asp
#!/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
# 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} } } } } } }}}
- nph-sc-redir.cgi
#!/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 exit
TP 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?

