EZShout

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

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) [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):

  1. Configure your EZ-Stream, and get familiar with how it works, especially the settings menu.
  2. You'll need a webserver running on your local network. I use thttpd [L10 ] running on my NSLU2 Slug (see Tcl on the Slug).
  3. Create a directory in your webserver root /setupapp/smc2/asp/rsdb, and enable CGI execution for this directory.
  4. Copy the three files below to that directory. Make sure that update.asp and nph-sc-redir.cgi are executable.
  5. Note that nph-sc-redir.cgi should be run as CGI Non-parsed headers. It will be streaming the ICY protocol [L11 ] instead of replying with an HTTP status code.
  6. Configure your directory as you want in ezshout.conf, as no doubt your musical tastes are different from mine.
  7. 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.
  8. Install Tcl8.4 and the tdom extension. For Debian Linux, simply sudo apt-get install tcl8.4 tdom.
  9. Instead, you can run Tclkit [L12 ] and tdom.kit [L13 ]. Change the executable in update.asp to use tclkit instead of tclsh.
  10. Install wget [L14 ] and netcat (nc) [L15 ] if you don't already have those.
  11. 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.
  12. Enable the proxied configuration on your EZ-Stream, and disable automatic directory updates.
  13. 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.
  14. Switch your configuration back to your original working, non-proxied setting.
  15. 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.
  16. 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.
  17. 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  {<?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?


See also