[TP] EZShout is an alternate Internet Radio directory service for the SMC EZ-Stream [http://www.smc.com/index.cfm?event=viewProduct&localeCode=EN_USA&cid=11&pid=1495]. [http://www.smc.com/images/products/400/SMCWAA_G.jpg] On advice of a friend, I recently found one of these devices for $35 USD (April 2007) [http://www.buy.com/prod/smc-ez-stream-802-11g-wireless-audio-adapter-smcwaa-g/q/loc/101/202126283.html] or [http://www.google.com/products?q=smc+ez-stream&btnG=Search+Products]. Seems too cheap to pass up. The EZ-Stream plays MP3 and WMA audio, either from Internet Radio [http://en.wikipedia.org/wiki/Internet_radio], a UPnP media server, [http://en.wikipedia.org/wiki/Universal_Plug_and_Play], 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 [http://bridgeco.com/]. 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 [http://www.gnu.org/software/gmediaserver/] 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 [http://shoutcast.com] 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 [http://en.wikipedia.org/wiki/.pls] 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): 1. Configure your EZ-Stream, and get familiar with how it works, especially the settings menu. 1. You'll need a webserver running on your local network. I use thttpd [http://www.acme.com/software/thttpd/] running on my NSLU2 '''Slug''' (see [Tcl on the Slug]). 1. Create a directory in your webserver root '''/setupapp/smc2/asp/rsdb''', and enable CGI execution for this directory. 1. Copy the three files below to that directory. Make sure that '''update.asp''' and '''nph-sc-redir.cgi''' are executable. 1. Note that '''nph-sc-redir.cgi''' should be run as CGI Non-parsed headers. It will be streaming the ICY protocol [http://www.smackfu.com/stuff/programming/shoutcast.html] instead of replying with an HTTP status code. 1. Configure your directory as you want in '''ezshout.conf''', as no doubt your musical tastes are different from mine. 1. 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. 1. Install Tcl8.4 and the [tdom] extension. For Debian Linux, simply '''sudo apt-get install tcl8.4 tdom'''. 1. Instead, you can run Tclkit [http://www.equi4.com/tclkit/index.html] and tdom.kit [http://wcferril.home.mchsi.com/kits/tdom.kit]. Change the executable in '''update.asp''' to use tclkit instead of tclsh. 1. Install '''wget''' [http://www.gnu.org/software/wget/] and '''netcat''' (nc) [http://netcat.sourceforge.net/] if you don't already have those. 1. 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. 1. Enable the proxied configuration on your EZ-Stream, and disable automatic directory updates. 1. 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. 1. Switch your configuration back to your original working, non-proxied setting. 1. ''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. 1. 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. 1. 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) Now the files. These files are public domain, do what you want with them. ---- * '''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 {} 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 ---- Discussion: [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? ---- [[ [Category Embedded] | [Category Multimedia] | [Category Music] ]]