initial repo, twitch.tv webapp, project to learn Go - twitch-go - twitch.tv web application in Go
(HTM) git clone git://git.codemadness.org/twitch-go
(DIR) Log
(DIR) Files
(DIR) Refs
(DIR) README
(DIR) LICENSE
---
(DIR) commit 7fefdcf41bd3945f0331f93817829abad7d143c9
(HTM) Author: Hiltjo Posthuma <hiltjo@codemadness.org>
Date: Sat, 11 Apr 2015 20:01:56 +0200
initial repo, twitch.tv webapp, project to learn Go
Diffstat:
A main.go | 524 +++++++++++++++++++++++++++++++
A static/twitch.css | 104 +++++++++++++++++++++++++++++++
A static/twitch.sh | 26 ++++++++++++++++++++++++++
A templates/pages/featured.html | 35 +++++++++++++++++++++++++++++++
A templates/pages/game.html | 34 +++++++++++++++++++++++++++++++
A templates/pages/games.html | 23 +++++++++++++++++++++++
A templates/pages/links.html | 12 ++++++++++++
A templates/pages/playlist.html | 7 +++++++
A templates/themes/default/page.html | 25 +++++++++++++++++++++++++
9 files changed, 790 insertions(+), 0 deletions(-)
---
(DIR) diff --git a/main.go b/main.go
@@ -0,0 +1,524 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "html/template"
+ "io"
+ "io/ioutil"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "syscall"
+ "time"
+)
+
+type AppConfig struct {
+ Addr string
+ AddrType string
+ Password string // password to reload templates etc, see /admin route.
+ TemplateThemeDir string
+ TemplatePageDir string
+ Pidfile string
+ Name string // application name
+}
+
+type TwitchToken struct {
+ Mobile_restricted bool
+ Sig string
+ Token string
+}
+
+type TwitchFeatured struct {
+ Featured []struct {
+ Image string
+ Priority int
+ Scheduled bool
+ Sponsored bool
+ Stream struct {
+ Id int
+ Average_fps float64
+ Created_at string
+ Channel struct {
+ Broadcaster_language string
+ Delay int
+ Followers int
+ Display_name string
+ Language string
+ Name string
+ Logo string
+ Mature bool
+ Partner bool
+ Status string
+ Updated_at string
+ Url string
+ Views int
+ }
+ Game string
+ Video_height int
+ Viewers int
+ }
+ Text string
+ Title string
+ }
+}
+
+type TwitchGame struct {
+ Streams []struct {
+ Id int
+ Average_fps float64
+ Channel struct {
+ Broadcaster_language string
+ Delay int
+ Followers int
+ Display_name string
+ Language string
+ Name string
+ Logo string
+ Mature bool
+ Partner bool
+ Status string
+ Updated_at string
+ Url string
+ Views int
+ }
+ Created_at string
+ Game string
+ Video_height int
+ Viewers int
+ }
+}
+
+type TwitchGames struct {
+ Top []struct {
+ Channels int
+ Game struct {
+ Box struct {
+ Large string
+ Medium string
+ Small string
+ Template string
+ }
+ Giantbomb_id int
+ Logo struct {
+ Large string
+ Medium string
+ Small string
+ Template string
+ }
+ Name string
+ }
+ Viewers int
+ }
+}
+
+type Page struct {
+ CacheTimeout int // <= 0 is never.
+}
+
+type Route struct {
+ Regexstr string
+ Re *regexp.Regexp
+ Fn HandlerCallback
+ Method string // POST, GET, HEAD etc.
+}
+
+type RouteMatch struct {
+ Route *Route
+ Names map[string]string
+}
+
+type RouteHandler struct{
+ Routes []Route
+}
+
+type HandlerCallback func(http.ResponseWriter, *http.Request, *RouteMatch) error
+
+var pages map[string]*template.Template
+var themes map[string]*template.Template
+var appconfig AppConfig
+
+func (r *RouteHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
+ match := FindRouteMatch(req, r.Routes)
+ if match != nil {
+ err := match.Route.Fn(res, req, match)
+ if err != nil {
+ res.WriteHeader(http.StatusInternalServerError)
+ res.Write([]byte(err.Error()))
+ return
+ }
+ } else {
+ http.FileServer(http.Dir("static")).ServeHTTP(res, req)
+ }
+}
+
+func NewRoute(method, restr string, fn HandlerCallback) Route {
+ return Route{
+ Method: method,
+ Regexstr: restr,
+ Re: regexp.MustCompile(restr),
+ Fn: fn,
+ }
+}
+
+func FindRouteMatch(req *http.Request, rs []Route) *RouteMatch {
+ for _, r := range rs {
+ // check HTTP method, empty in route always matches.
+ if len(r.Method) > 0 && req.Method != r.Method {
+ continue
+ }
+ // check path (regex).
+ matches := r.Re.FindStringSubmatch(req.URL.Path)
+ matcheslen := len(matches)
+ if matcheslen == 0 {
+ continue
+ }
+ names := r.Re.SubexpNames()
+ nameslen := len(names)
+ // make map of named group matches in regex match.
+ tomap := map[string]string{}
+ for i := 1; i < matcheslen && i < nameslen; i++ {
+ tomap[names[i]] = matches[i]
+ }
+ return &RouteMatch{
+ Route: &r,
+ Names: tomap,
+ }
+ }
+ return nil
+}
+
+// NOTE: uses global "themes" and "pages" variable: map[string]*Template.
+func RenderTemplate(w io.Writer, pagename string, themename string, data interface{}) error {
+ if _, ok := themes[themename]; !ok {
+ return errors.New(fmt.Sprintf("theme template \"%s\" not found", themename))
+ }
+ if _, ok := pages[pagename]; !ok {
+ return errors.New(fmt.Sprintf("page template \"%s\" not found", pagename))
+ }
+ var render *template.Template
+ var err error
+
+ render, err = pages[pagename].Clone()
+ if err != nil {
+ return err
+ }
+ // NOTE: the template.Tree must be copied after Clone() too.
+ _, err = render.AddParseTree("render", themes[themename].Tree.Copy())
+ if err != nil {
+ return err
+ }
+ err = render.ExecuteTemplate(w, "render", data)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func LoadPages(path string) (map[string]*template.Template, error) {
+ var err error
+ m := make(map[string]*template.Template)
+ path, err = filepath.Abs(path)
+ if err != nil {
+ return m, err
+ }
+ err = filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
+ if info.IsDir() {
+ return nil
+ }
+ // strip prefix.
+ name := strings.TrimPrefix(p, path)
+ // replace potentially inconsistent paths (Windows).
+ name = strings.Replace(name, "\\", "/", -1)
+ name = strings.TrimPrefix(name, "/")
+ // read data.
+ data, err := ioutil.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ t := template.New(name)
+ _, err = t.Parse(string(data))
+ if err != nil {
+ return err
+ }
+ m[name] = t
+ return err
+ })
+ return m, err
+}
+
+func ReadAllUrl(url string) ([]byte, error) {
+ resp, err := http.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ return body, nil
+}
+
+func GetToken(channel string) (*TwitchToken, error) {
+ url := fmt.Sprintf("https://api.twitch.tv/api/channels/%s/access_token", channel)
+ body, err := ReadAllUrl(url)
+ if err != nil {
+ return nil, err
+ }
+ var v TwitchToken
+ err = json.Unmarshal(body, &v)
+ if err != nil {
+ return nil, err
+ }
+ return &v, nil
+}
+
+func GetPlaylist(channel string) ([]byte, error) {
+ token, err := GetToken(channel)
+ if err != nil {
+ return nil, err
+ }
+ s := fmt.Sprintf("http://usher.justin.tv/api/channel/hls/%s.m3u8?token=%s&sig=%s",
+ channel, token.Token, token.Sig)
+ body, err := ReadAllUrl(s)
+ if err != nil {
+ return nil, err
+ }
+ return body, nil
+}
+
+func GetFeatured() (*TwitchFeatured, error) {
+ url := "https://api.twitch.tv/kraken/streams/featured?limit=100"
+ body, err := ReadAllUrl(url)
+ if err != nil {
+ return nil, err
+ }
+ var v TwitchFeatured
+ err = json.Unmarshal(body, &v)
+ if err != nil {
+ return nil, err
+ }
+ return &v, nil
+}
+
+func GetGames() (*TwitchGames, error) {
+ url := "https://api.twitch.tv/kraken/games/top?limit=100"
+ body, err := ReadAllUrl(url)
+ if err != nil {
+ return nil, err
+ }
+ var v TwitchGames
+ err = json.Unmarshal(body, &v)
+ if err != nil {
+ return nil, err
+ }
+ return &v, nil
+}
+
+func GetGame(game string) (*TwitchGame, error) {
+ s := fmt.Sprintf("https://api.twitch.tv/kraken/streams?game=%s", url.QueryEscape(game))
+ body, err := ReadAllUrl(s)
+ if err != nil {
+ return nil, err
+ }
+ var v TwitchGame
+ err = json.Unmarshal(body, &v)
+ if err != nil {
+ return nil, err
+ }
+ return &v, nil
+}
+
+func FeaturedHandler(w http.ResponseWriter, r *http.Request, m *RouteMatch) error {
+ featured, err := GetFeatured()
+ if err != nil {
+ return err
+ }
+ return RenderTemplate(w, "featured.html", "page.html", featured)
+}
+
+func PlaylistHandler(w http.ResponseWriter, r *http.Request, m *RouteMatch) error {
+ channel := m.Names["channel"]
+ format := m.Names["format"]
+ if channel == "" {
+ return errors.New("no channel specified")
+ }
+ token, err := GetToken(channel)
+ if err != nil {
+ return err
+ }
+ url := fmt.Sprintf("http://usher.justin.tv/api/channel/hls/%s.m3u8?token=%s&sig=%s", channel, token.Token, token.Sig)
+ switch format {
+ case "html":
+ return RenderTemplate(w, "playlist.html", "page.html", struct {
+ Url string
+ }{
+ Url: url,
+ })
+ break
+ case "plain":
+ w.Write([]byte(url))
+ break
+ default: // redirect
+ w.Header().Set("Location", url)
+ w.WriteHeader(http.StatusFound)
+ w.Write([]byte(url))
+ }
+ return nil
+}
+
+func GameHandler(w http.ResponseWriter, r *http.Request, m *RouteMatch) error {
+ if m.Names["game"] == "" {
+ return errors.New("no channel specified")
+ }
+ game, err := GetGame(m.Names["game"])
+ if err != nil {
+ return err
+ }
+ v := struct {
+ Name string
+ TwitchGame TwitchGame
+ } {
+ Name: m.Names["game"],
+ TwitchGame: *game,
+ }
+ return RenderTemplate(w, "game.html", "page.html", v)
+}
+
+func GamesHandler(w http.ResponseWriter, r *http.Request, m *RouteMatch) error {
+ games, err := GetGames()
+ if err != nil {
+ return err
+ }
+ return RenderTemplate(w, "games.html", "page.html", games)
+}
+
+func LinksHandler(w http.ResponseWriter, r *http.Request, m *RouteMatch) error {
+ return RenderTemplate(w, "links.html", "page.html", make(map[string]string))
+}
+
+// Reload templates, write status to client.
+// on error the old templates are kept in memory.
+func ReloadTemplateHandler(w http.ResponseWriter, r *http.Request, m *RouteMatch) error {
+ if appconfig.Password == "" || m.Names["password"] != appconfig.Password {
+ w.Write([]byte("401: unauthorized"))
+ w.WriteHeader(http.StatusUnauthorized)
+ return nil
+ }
+ newpages, err := LoadPages(appconfig.TemplatePageDir)
+ if err != nil {
+ return err
+ }
+ newthemes, err := LoadPages(appconfig.TemplateThemeDir)
+ if err != nil {
+ return err
+ }
+ pages = newpages
+ themes = newthemes
+ w.Write([]byte("OK"))
+ return nil
+}
+
+func usage() {
+ fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
+ flag.PrintDefaults()
+}
+
+func main() {
+ var err error
+
+ appconfig = AppConfig{
+ TemplateThemeDir: "templates/themes/default",
+ TemplatePageDir: "templates/pages/",
+ }
+ var arg_pidfile = flag.String("f", "", "PID file")
+ var arg_addr = flag.String("l", "127.0.0.1:8080", "listen addres")
+ var arg_password = flag.String("p", "", "admin password")
+ var arg_addrtype = flag.String("t", "tcp4", `listen type: "tcp", "tcp4", "tcp6", "unix" or "unixpacket"`)
+ flag.Parse()
+
+ appconfig.Pidfile = *arg_pidfile
+ appconfig.Addr = *arg_addr
+ appconfig.AddrType = *arg_addrtype
+ appconfig.Password = *arg_password
+
+ l, err := net.Listen(appconfig.AddrType, appconfig.Addr)
+ if err != nil {
+ panic(err)
+ }
+
+ routes := []Route{
+ NewRoute("", `^/$`, FeaturedHandler),
+ NewRoute("", `^/featured[/]?$`, FeaturedHandler),
+ NewRoute("", `^/games[/]?$`, GamesHandler),
+ NewRoute("", `^/game[/]?$`, GamesHandler),
+ NewRoute("", `^/game/(?P<game>[a-zA-Z0-9_ :+'"\-]*)[/]?$`, GameHandler),
+ NewRoute("", `/playlist/(?P<channel>[^/]*)/(?P<format>.*)[/]?$`, PlaylistHandler),
+ NewRoute("", `/playlist/(?P<channel>[^/]*)[/]?$`, PlaylistHandler),
+ NewRoute("", `^/links[/]?$`, LinksHandler),
+ // special admin handlers: should require a password.
+ NewRoute("", `^/admin/reloadtemplates/(?P<password>.*)$`, ReloadTemplateHandler),
+ }
+ r := &RouteHandler{
+ Routes: routes,
+ }
+
+ // Write PID to pid file.
+ if appconfig.Pidfile != "" {
+ pid := os.Getpid()
+ if err = ioutil.WriteFile(appconfig.Pidfile, []byte(fmt.Sprintf("%d", pid)), 0644); err != nil {
+ panic(err)
+ }
+ }
+
+ // Delete PID file on exit.
+ Cleaned := false
+ Cleanup := func() {
+ if Cleaned == true {
+ return
+ }
+ Cleaned = true
+ if appconfig.Pidfile != "" {
+ os.Remove(appconfig.Pidfile)
+ }
+ }
+
+ // Setup cleanup handler.
+ c := make(chan os.Signal, 1)
+ signal.Notify(c, os.Interrupt)
+ go func(){
+ for sig := range c {
+ if sig == syscall.SIGINT {
+ Cleanup()
+ os.Exit(1)
+ }
+ }
+ }()
+
+ // Parse templates and keep in-memory.
+ pages, err = LoadPages(appconfig.TemplatePageDir)
+ if err != nil {
+ panic(err)
+ }
+ themes, err = LoadPages(appconfig.TemplateThemeDir)
+ if err != nil {
+ panic(err)
+ }
+
+ s := &http.Server{
+ Handler: r,
+ ReadTimeout: 10 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ MaxHeaderBytes: 1 << 20,
+ }
+ s.Serve(l)
+ Cleanup()
+}
(DIR) diff --git a/static/twitch.css b/static/twitch.css
@@ -0,0 +1,104 @@
+body {
+ font-family: sans-serif, monospace;
+ text-align: center;
+ overflow-y: scroll;
+ color: #000;
+ background-color: #fff;
+ margin: 0;
+ padding: 0;
+}
+table {
+ border: 0;
+}
+hr {
+ height: 1px;
+ color: #ccc;
+ background-color: #ccc;
+ border: 0;
+}
+h1 {
+ font-size: 140%;
+}
+h2 {
+ font-size: 120%;
+}
+h3 {
+ font-size: 100%;
+}
+h1, h1 a, h1 a:visited,
+h2, h2 a, h2 a:visited,
+h3, h3 a, h3 a:visited,
+h1 a:hover, h2 a:hover, h3 a:hover {
+ color: inherit;
+}
+
+table.table {
+ border-collapse: collapse;
+ width: 100%;
+}
+table tr th {
+ text-align: left;
+ font-weight: bold;
+}
+table.table tr th,
+table.table tr td {
+ padding: 3px;
+ border: 1px solid #333;
+}
+table.table tr th {
+ background-color: #eee;
+}
+table.table tr td {
+ white-space: nowrap;
+}
+
+table.table tr th.viewers,
+table.table tr th.channels {
+ text-align: right;
+}
+
+table.table tr td.title {
+ max-width: 30ex;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+pre, code {
+ border: 1px dashed #777;
+ background-color: #eee;
+ padding: 5px;
+ display: block;
+ overflow-x: auto;
+}
+code {
+ white-space: nowrap;
+ word-wrap: normal;
+}
+#menuwrap {
+ background-color: #eee;
+ padding: 1ex;
+ border-bottom: 1px solid #ccc;
+}
+#main {
+ padding: 1ex;
+}
+#menu,
+#main {
+ margin: 0px auto;
+ text-align: left;
+ max-width: 100ex;
+}
+#menu a {
+ font-weight: bold;
+ vertical-align: middle;
+}
+#links-contact {
+ float: right;
+}
+.hidden {
+ display: none;
+}
+
+label {
+ display: inline-block;
+ width: 10ex;
+}
(DIR) diff --git a/static/twitch.sh b/static/twitch.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+# Twitch.tv playlist grab script.
+# License: WTFPL
+# Author: Hiltjo Posthuma <hiltjo@codemadness.org>
+# NOTE: channel name is very case-sensitive ;_;
+
+err() {
+ printf '%s\n' "$1" >&2
+ exit 1
+}
+
+[ "$1" = "" ] && err "Usage: $0 <channel>"
+channel="$1"
+tokenurl="https://api.twitch.tv/api/channels/${channel}/access_token"
+tokendata=$(curl -s -L "$tokenurl")
+token=$(printf '%s' "${tokendata}" | sed -E -e 's@.*"token":"(.*)","sig".*@\1@g' -e 's@\\"@"@g')
+sig=$(printf '%s' "${tokendata}" | sed -E 's@.*"sig":"([^"]*)".*@\1@g')
+
+if test x"$token" != x"" && test x"$sig" != x""; then
+ curl -G -L "http://usher.justin.tv/api/channel/hls/${channel}.m3u8" \
+ --data-urlencode "token=${token}" \
+ --data-urlencode "sig=${sig}"
+else
+ err 'no token found: channel possibly gone offline'
+fi
+
(DIR) diff --git a/templates/pages/featured.html b/templates/pages/featured.html
@@ -0,0 +1,35 @@
+{{define "title"}}Featured streams{{end}}
+{{define "class"}}featured{{end}}
+
+{{define "content"}}
+
+<table class="table" border="0">
+<thead>
+ <tr>
+ <th class="game"><b>Game</b></th>
+ <th class="name"><b>Name</b></th>
+ <th class="title"><b>Title</b></th>
+ <th class="playlist"><b>Playlist</b></th>
+ <th class="viewers" align="right"><b>Viewers</b></th>
+ </tr>
+</thead>
+<tbody>
+
+{{range .Featured }}
+<tr>
+ <td class="game"><a href="/game/{{.Stream.Game}}">{{.Stream.Game}}</a></td>
+ <td class="name"><a href="{{.Stream.Channel.Url}}">{{.Stream.Channel.Display_name}}</a></td>
+ <td class="title"><a href="{{.Stream.Channel.Url}}" title="{{.Stream.Channel.Status}}">{{.Stream.Channel.Status}}</a></td>
+ <td class="playlist">
+ <a href="/playlist/{{.Stream.Channel.Name}}" title="redirect to playlist file">m3u8</a> |
+ <a href="/playlist/{{.Stream.Channel.Name}}/html" title="page with playlist link">page</a> |
+ <a href="/playlist/{{.Stream.Channel.Name}}/plain" title="get link to url in plain-text">plain</a>
+ </td>
+ <td align="right">{{.Stream.Viewers}}</td>
+</tr>
+{{end}}
+
+</tbody>
+</table>
+
+{{end}}
(DIR) diff --git a/templates/pages/game.html b/templates/pages/game.html
@@ -0,0 +1,34 @@
+{{define "title"}}Game: {{.Name}}{{end}}
+{{define "class"}}game{{end}}
+
+{{define "content"}}
+
+<table class="table" border="0">
+<thead>
+ <tr>
+ <th class="name"><b>Name</b></th>
+ <th class="title"><b>Title</b></th>
+ <th class="playlist"><b>Playlist</b></th>
+ <th class="viewers" align="right"><b>Viewers</b></th>
+ </tr>
+</thead>
+<tbody>
+{{with .TwitchGame}}
+ {{range .Streams}}
+ <tr>
+ <td class="name"><a href="{{.Channel.Url}}">{{.Channel.Display_name}}</a></td>
+ <td class="title"><a href="{{.Channel.Url}}" title="{{.Channel.Status}}">{{.Channel.Status}}</a></td>
+ <td class="playlist">
+ <a href="/playlist/{{.Channel.Name}}" title="redirect to playlist file">m3u8</a> |
+ <a href="/playlist/{{.Channel.Name}}/html" title="page with playlist link">page</a> |
+ <a href="/playlist/{{.Channel.Name}}/plain" title="get link to url in plain-text">plain</a>
+ </td>
+ <td align="right">{{.Viewers}}</td>
+ </tr>
+
+ {{end}}
+{{end}}
+</tbody>
+</table>
+
+{{end}}
(DIR) diff --git a/templates/pages/games.html b/templates/pages/games.html
@@ -0,0 +1,23 @@
+{{define "title"}}Games{{end}}
+{{define "class"}}games{{end}}
+
+{{define "content"}}
+<table class="table" border="0">
+<thead>
+<tr>
+ <th class="game"><b>Game</b></th>
+ <th class="viewers" align="right"><b>Viewers</b></th>
+ <th class="channels" align="right"><b>Channels</b></th>
+</tr>
+</thead>
+<tbody>
+{{range .Top}}
+<tr>
+ <td class="game"><a href="/game/{{.Game.Name}}">{{.Game.Name}}</a></td>
+ <td class="viewers" align="right">{{.Viewers}}</td>
+ <td class="channels" align="right">{{.Channels}}</td>
+</tr>
+{{end}}
+</tbody>
+</table>
+{{end}}
(DIR) diff --git a/templates/pages/links.html b/templates/pages/links.html
@@ -0,0 +1,12 @@
+{{define "title"}}Links{{end}}
+{{define "class"}}links{{end}}
+
+{{define "content"}}
+<ul>
+ <li><a href="http://mpv.io/installation/">mpv player</a>
+ <li><a href="http://livestreamer.tanuki.se/en/latest/">Livestreamer</a></li>
+ <li><a href="http://quvi.sourceforge.net/">quvi</a></li>
+ <li><a href="http://rtmpdump.mplayerhq.hu/">rtmpdump</a></li>
+ <li><a href="https://github.com/justintv/Twitch-API">Twitch.tv API</a></li>
+</ul>
+{{end}}
(DIR) diff --git a/templates/pages/playlist.html b/templates/pages/playlist.html
@@ -0,0 +1,7 @@
+{{define "title"}}Get playlist{{end}}
+{{define "class"}}playlist{{end}}
+
+{{define "content"}}
+ <p>Copy paste the following link in <a href="http://www.videolan.org/">VLC</a> / <a href="http://mpv.io/installation/">mpv</a> as a network stream:</p>
+ <a href="{{.Url}}">{{.Url}}</a>
+{{end}}
(DIR) diff --git a/templates/themes/default/page.html b/templates/themes/default/page.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>{{template "title" .}} - Twitch.tv</title>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+ <meta http-equiv="Content-Language" content="en" />
+ <link href="/twitch.css" rel="stylesheet" type="text/css" />
+</head>
+<body class="{{template "class"}}">
+<div id="menuwrap"><div id="menu"><span id="links">
+ <a href="/featured">Featured</a> |
+ <a href="/games">Games</a> |
+ <a href="http://git.codemadness.nl/twitch-go/">Source-code</a> |
+ <a href="/twitch.sh">SH version</a> |
+ <a href="/links">Links</a>
+</span></div></div>
+<hr class="hidden" />
+<div id="mainwrap">
+<div id="main">
+<h1><a href="">{{template "title" .}}</a></h1>
+{{template "content" .}}
+</div>
+</div>
+</body>
+</html>