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 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?