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>