From c94fef11f43279165f39680fa0b0922c86702687 Mon Sep 17 00:00:00 2001
From: Thomas Karpiniec 
Date: Sat, 19 Oct 2024 12:59:43 +1100
Subject: [PATCH] Initial commit, basic functionality working
---
 .gitignore                    |   6 +
 go.mod                        |  33 +++
 go.sum                        |  77 +++++++
 protocol/protocol.go          | 152 +++++++++++++
 radio/config.go               |  70 ++++++
 radio/files_machine.go        | 117 ++++++++++
 radio/gpio.go                 | 123 ++++++++++
 radio/radio.go                | 306 +++++++++++++++++++++++++
 radio/status.go               | 140 ++++++++++++
 server/broadcaster.go         | 408 ++++++++++++++++++++++++++++++++++
 server/config.go              |  45 ++++
 server/database.go            | 189 ++++++++++++++++
 server/files.go               |  73 ++++++
 server/model.go               |  26 +++
 server/radio_sync.go          | 134 +++++++++++
 server/session.go             |  51 +++++
 server/status.go              |  42 ++++
 server/web_sync.go            | 162 ++++++++++++++
 templates/files.html          |  25 +++
 templates/index.html          |  81 +++++++
 templates/login.html          |  23 ++
 templates/logout.html         |  14 ++
 templates/playlist.html       | 100 +++++++++
 templates/playlists.html      |  19 ++
 templates/radio.html          |  43 ++++
 templates/radios.html         |  19 ++
 templates/radios.partial.html |  82 +++++++
 27 files changed, 2560 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 go.mod
 create mode 100644 go.sum
 create mode 100644 protocol/protocol.go
 create mode 100644 radio/config.go
 create mode 100644 radio/files_machine.go
 create mode 100644 radio/gpio.go
 create mode 100644 radio/radio.go
 create mode 100644 radio/status.go
 create mode 100644 server/broadcaster.go
 create mode 100644 server/config.go
 create mode 100644 server/database.go
 create mode 100644 server/files.go
 create mode 100644 server/model.go
 create mode 100644 server/radio_sync.go
 create mode 100644 server/session.go
 create mode 100644 server/status.go
 create mode 100644 server/web_sync.go
 create mode 100644 templates/files.html
 create mode 100644 templates/index.html
 create mode 100644 templates/login.html
 create mode 100644 templates/logout.html
 create mode 100644 templates/playlist.html
 create mode 100644 templates/playlists.html
 create mode 100644 templates/radio.html
 create mode 100644 templates/radios.html
 create mode 100644 templates/radios.partial.html
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..97571d1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+audio
+broadcaster-server
+broadcaster-radio
+test-server.conf
+test-radio.conf
+test.db
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..c8401c7
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,33 @@
+module code.octet-stream.net/broadcaster
+
+go 1.23
+
+toolchain go1.23.2
+
+require (
+	github.com/BurntSushi/toml v1.4.0
+	github.com/gopxl/beep/v2 v2.1.0
+	github.com/warthog618/go-gpiocdev v0.9.0
+	golang.org/x/net v0.30.0
+	modernc.org/sqlite v1.33.1
+)
+
+require (
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/ebitengine/oto/v3 v3.2.0 // indirect
+	github.com/ebitengine/purego v0.7.1 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
+	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/ncruces/go-strftime v0.1.9 // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	golang.org/x/sys v0.26.0 // indirect
+	modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+	modernc.org/libc v1.55.3 // indirect
+	modernc.org/mathutil v1.6.0 // indirect
+	modernc.org/memory v1.8.0 // indirect
+	modernc.org/strutil v1.2.0 // indirect
+	modernc.org/token v1.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..3184d32
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,77 @@
+github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
+github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/ebitengine/oto/v3 v3.2.0 h1:FuggTJTSI3/3hEYwZEIN0CZVXYT29ZOdCu+z/f4QjTw=
+github.com/ebitengine/oto/v3 v3.2.0/go.mod h1:dOKXShvy1EQbIXhXPFcKLargdnFqH0RjptecvyAxhyw=
+github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
+github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gopxl/beep/v2 v2.1.0 h1:Jv95iHw3aNWoAa/J78YyXvOvMHH2ZGeAYD5ug8tVt8c=
+github.com/gopxl/beep/v2 v2.1.0/go.mod h1:sQvj2oSsu8fmmDWH3t0DzIe0OZzTW6/TJEHW4Ku+22o=
+github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
+github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
+github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
+github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/warthog618/go-gpiocdev v0.9.0 h1:AZWUq1WObgKCO9cJCACFpwWQw6yu8vJbIE6fRZ+6cbY=
+github.com/warthog618/go-gpiocdev v0.9.0/go.mod h1:GV4NZC82fWJERqk7Gu0+KfLSDIBEDNm6aPGiHlmT5fY=
+github.com/warthog618/go-gpiosim v0.1.0 h1:2rTMTcKUVZxpUuvRKsagnKAbKpd3Bwffp87xywEDVGI=
+github.com/warthog618/go-gpiosim v0.1.0/go.mod h1:Ngx/LYI5toxHr4E+Vm6vTgCnt0of0tktsSuMUEJ2wCI=
+golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
+golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
+golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
+golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
+golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
+modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
+modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/protocol/protocol.go b/protocol/protocol.go
new file mode 100644
index 0000000..d168bc3
--- /dev/null
+++ b/protocol/protocol.go
@@ -0,0 +1,152 @@
+package protocol
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+)
+
+const (
+	StartTimeFormat = "2006-01-02T15:04"
+	LocalTimeFormat = "Mon _2 Jan 2006 15:04:05"
+
+	// Radio to server
+
+	AuthenticateType = "authenticate"
+	StatusType       = "status"
+
+	// Server to radio
+
+	FilesType     = "files"
+	PlaylistsType = "playlists"
+
+	// Status values
+
+	StatusIdle         = "idle"
+	StatusDelay        = "delay"
+	StatusChannelInUse = "channel_in_use"
+	StatusPlaying      = "playing"
+)
+
+// Base message type to determine what type of payload is expected.
+type Message struct {
+	T string
+}
+
+// Initial message from Radio to authenticate itself with a token string.
+type AuthenticateMessage struct {
+	T     string
+	Token string
+}
+
+// Server updates the radio with the list of files that currently exist.
+// This will be provided on connect and when there are any changes.
+// The radio is expected to obtain all these files and cache them locally.
+type FilesMessage struct {
+	T     string
+	Files []FileSpec
+}
+
+type PlaylistsMessage struct {
+	T         string
+	Playlists []PlaylistSpec
+}
+
+type StatusMessage struct {
+	T string
+
+	// Status w.r.t. playing a playlist
+	Status string
+
+	// File being played or about to be played - empty string in idle status
+	Filename string
+
+	// Name of playlist being played - empty string in idle status
+	Playlist string
+
+	// Seconds until playback begins - never mind latency
+	DelaySecondsRemaining int
+
+	// Number of seconds file has been actually playing
+	PlaybackSecondsElapsed int
+
+	// Number of seconds waiting for channel to clear
+	WaitingForChannelSeconds int
+
+	PTT         bool
+	COS         bool
+	FilesInSync bool
+
+	// Timestamp of the current time on this radio, using LocalTimeFormat
+	LocalTime string
+
+	// Time zone in use, e.g. "Australia/Hobart"
+	TimeZone string
+}
+
+// Description of an individual file available in the broadcasting system.
+type FileSpec struct {
+	// Filename, e.g. "broadcast.wav"
+	Name string
+	// SHA-256 hash of the file's contents
+	Hash string
+}
+
+type PlaylistSpec struct {
+	Id        int
+	Name      string
+	StartTime string
+	Entries   []EntrySpec
+}
+
+type EntrySpec struct {
+	Filename     string
+	DelaySeconds int
+	IsRelative   bool
+}
+
+func ParseMessage(data []byte) (string, interface{}, error) {
+	var t Message
+	err := json.Unmarshal(data, &t)
+	if err != nil {
+		return "", nil, err
+	}
+
+	if t.T == AuthenticateType {
+		var auth AuthenticateMessage
+		err = json.Unmarshal(data, &auth)
+		if err != nil {
+			return "", nil, err
+		}
+		return t.T, auth, nil
+	}
+
+	if t.T == FilesType {
+		var files FilesMessage
+		err = json.Unmarshal(data, &files)
+		if err != nil {
+			return "", nil, err
+		}
+		return t.T, files, nil
+	}
+
+	if t.T == PlaylistsType {
+		var playlists PlaylistsMessage
+		err = json.Unmarshal(data, &playlists)
+		if err != nil {
+			return "", nil, err
+		}
+		return t.T, playlists, nil
+	}
+
+	if t.T == StatusType {
+		var status StatusMessage
+		err = json.Unmarshal(data, &status)
+		if err != nil {
+			return "", nil, err
+		}
+		return t.T, status, nil
+	}
+
+	return "", nil, errors.New(fmt.Sprintf("unknown message type %v", t.T))
+}
diff --git a/radio/config.go b/radio/config.go
new file mode 100644
index 0000000..3152653
--- /dev/null
+++ b/radio/config.go
@@ -0,0 +1,70 @@
+package main
+
+import (
+	"errors"
+	"log"
+	"os"
+	"strings"
+
+	"github.com/BurntSushi/toml"
+)
+
+type RadioConfig struct {
+	GpioDevice string
+	PTTPin     int
+	COSPin     int
+	ServerURL  string
+	Token      string
+	CachePath  string
+	TimeZone   string
+}
+
+func NewRadioConfig() RadioConfig {
+	return RadioConfig{
+		GpioDevice: "gpiochip0",
+		PTTPin:     -1,
+		COSPin:     -1,
+		ServerURL:  "",
+		Token:      "",
+		CachePath:  "",
+		TimeZone:   "Australia/Hobart",
+	}
+}
+
+func (c *RadioConfig) LoadFromFile(path string) {
+	_, err := toml.DecodeFile(path, &c)
+	if err != nil {
+		log.Fatal("could not read config file for reading at path:", path, err)
+	}
+	err = c.Validate()
+	if err != nil {
+		log.Fatal(err)
+	}
+	c.ApplyDefaults()
+}
+
+func (c *RadioConfig) Validate() error {
+	if c.ServerURL == "" {
+		return errors.New("ServerURL must be provided in the configuration")
+	}
+	if c.Token == "" {
+		return errors.New("Token must be provided in the configuration")
+	}
+	return nil
+}
+
+func (c *RadioConfig) ApplyDefaults() {
+	if c.CachePath == "" {
+		dir, err := os.MkdirTemp("", "broadcast")
+		if err != nil {
+			log.Fatal(err)
+		}
+		c.CachePath = dir
+	}
+}
+
+func (c *RadioConfig) WebsocketURL() string {
+	addr := strings.Replace(c.ServerURL, "https://", "wss://", -1)
+	addr = strings.Replace(addr, "http://", "ws://", -1)
+	return addr + "/radiosync"
+}
diff --git a/radio/files_machine.go b/radio/files_machine.go
new file mode 100644
index 0000000..ea2f775
--- /dev/null
+++ b/radio/files_machine.go
@@ -0,0 +1,117 @@
+package main
+
+import (
+	"code.octet-stream.net/broadcaster/protocol"
+	"crypto/sha256"
+	"encoding/hex"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+)
+
+type FilesMachine struct {
+	specs     []protocol.FileSpec
+	cachePath string
+	missing   []string
+}
+
+func NewFilesMachine(cachePath string) FilesMachine {
+	if err := os.MkdirAll(cachePath, 0750); err != nil {
+		log.Fatal(err)
+	}
+	return FilesMachine{
+		cachePath: cachePath,
+	}
+}
+
+func (m *FilesMachine) UpdateSpecs(specs []protocol.FileSpec) {
+	m.specs = specs
+	m.RefreshMissing()
+}
+
+func (m *FilesMachine) RefreshMissing() {
+	// Delete any files in the cache dir who are not in the spec
+	entries, err := os.ReadDir(m.cachePath)
+	if err != nil {
+		log.Fatal(err)
+	}
+	okay := make([]string, 0)
+	for _, file := range entries {
+		hash := ""
+		for _, spec := range m.specs {
+			if file.Name() == spec.Name {
+				hash = spec.Hash
+				break
+			}
+		}
+		// if we have an extraneous file, delete it
+		if hash == "" {
+			log.Println("Deleting extraneous cached audio file:", file.Name())
+			os.Remove(filepath.Join(m.cachePath, file.Name()))
+			continue
+		}
+		// if the hash isn't right, delete it
+		f, err := os.Open(filepath.Join(m.cachePath, file.Name()))
+		if err != nil {
+			log.Fatal(err)
+		}
+		hasher := sha256.New()
+		io.Copy(hasher, f)
+		if hex.EncodeToString(hasher.Sum(nil)) != hash {
+			log.Println("Deleting cached audio file with incorrect hash:", file.Name())
+			os.Remove(filepath.Join(m.cachePath, file.Name()))
+		} else {
+			okay = append(okay, file.Name())
+		}
+	}
+	m.missing = nil
+	for _, spec := range m.specs {
+		missing := true
+		for _, file := range okay {
+			if spec.Name == file {
+				missing = false
+			}
+		}
+		if missing {
+			m.missing = append(m.missing, spec.Name)
+		}
+	}
+	if len(m.missing) > 1 {
+		log.Println(len(m.missing), "missing files")
+	} else if len(m.missing) == 1 {
+		log.Println("1 missing file")
+	} else {
+		log.Println("All files are in sync with server")
+	}
+	statusCollector.FilesInSync <- len(m.missing) == 0
+}
+
+func (m *FilesMachine) IsCacheComplete() bool {
+	return len(m.missing) == 0
+}
+
+func (m *FilesMachine) NextFile() string {
+	next, remainder := m.missing[0], m.missing[1:]
+	m.missing = remainder
+	return next
+}
+
+func (m *FilesMachine) DownloadSingle(filename string, downloadResult chan<- error) {
+	log.Println("Downloading", filename)
+	out, err := os.Create(filepath.Join(m.cachePath, filename))
+	if err != nil {
+		downloadResult <- err
+		return
+	}
+	defer out.Close()
+	resp, err := http.Get(config.ServerURL + "/audio-files/" + filename)
+	if err != nil {
+		downloadResult <- err
+		return
+	}
+	defer resp.Body.Close()
+	_, err = io.Copy(out, resp.Body)
+	downloadResult <- err
+}
diff --git a/radio/gpio.go b/radio/gpio.go
new file mode 100644
index 0000000..69807ac
--- /dev/null
+++ b/radio/gpio.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+	gpio "github.com/warthog618/go-gpiocdev"
+	"github.com/warthog618/go-gpiocdev/device/rpi"
+	"log"
+	"strconv"
+)
+
+type PTT interface {
+	EngagePTT()
+	DisengagePTT()
+}
+
+type COS interface {
+	WaitForChannelClear()
+	COSValue() bool
+}
+
+var ptt PTT = &DefaultPTT{}
+var cos COS = &DefaultCOS{}
+
+type PiPTT struct {
+	pttLine *gpio.Line
+}
+
+type PiCOS struct {
+	cosLine   *gpio.Line
+	clearWait chan bool
+}
+
+func InitRaspberryPiPTT(pttNum int, chipName string) {
+	pttPin, err := rpi.Pin("GPIO" + strconv.Itoa(pttNum))
+	if err != nil {
+		log.Fatal("invalid PTT pin configured", ptt)
+	}
+	pttLine, err := gpio.RequestLine(chipName, pttPin, gpio.AsOutput(0))
+	if err != nil {
+		log.Fatal("unable to open requested pin for PTT GPIO:", ptt, ". Are you running as root?")
+	}
+	ptt = &PiPTT{
+		pttLine: pttLine,
+	}
+}
+
+func InitRaspberryPiCOS(cosNum int, chipName string) {
+	var piCOS PiCOS
+	piCOS.clearWait = make(chan bool)
+	cosPin, err := rpi.Pin("GPIO" + strconv.Itoa(cosNum))
+	if err != nil {
+		log.Fatal("invalid COS Pin configured", cos)
+	}
+	cosHandler := func(event gpio.LineEvent) {
+		if event.Type == gpio.LineEventFallingEdge {
+			log.Println("COS: channel clear")
+			close(piCOS.clearWait)
+			piCOS.clearWait = make(chan bool)
+			statusCollector.COS <- false
+		}
+		if event.Type == gpio.LineEventRisingEdge {
+			log.Println("COS: channel in use")
+			statusCollector.COS <- true
+		}
+	}
+	cosLine, err := gpio.RequestLine(chipName, cosPin, gpio.AsInput, gpio.WithBothEdges, gpio.WithEventHandler(cosHandler))
+	if err != nil {
+		log.Fatal("unable to open requested pin for COS GPIO:", cos, ". Are you running as root?")
+	}
+	piCOS.cosLine = cosLine
+	cos = &piCOS
+}
+
+func (g *PiCOS) COSValue() bool {
+	val, err := g.cosLine.Value()
+	if err != nil {
+		log.Fatal("Unable to read COS value")
+	}
+	return val != 0
+}
+
+func (g *PiCOS) WaitForChannelClear() {
+	ch := g.clearWait
+	val, err := g.cosLine.Value()
+	if err != nil || val == 0 {
+		return
+	}
+	// wait for close
+	<-ch
+}
+
+func (g *PiPTT) EngagePTT() {
+	log.Println("PTT: on")
+	g.pttLine.SetValue(1)
+	statusCollector.PTT <- true
+}
+
+func (g *PiPTT) DisengagePTT() {
+	log.Println("PTT: off")
+	g.pttLine.SetValue(0)
+	statusCollector.PTT <- false
+}
+
+type DefaultPTT struct {
+}
+
+func (g *DefaultPTT) EngagePTT() {
+	statusCollector.PTT <- true
+}
+
+func (g *DefaultPTT) DisengagePTT() {
+	statusCollector.PTT <- false
+}
+
+type DefaultCOS struct {
+}
+
+func (g *DefaultCOS) WaitForChannelClear() {
+	log.Println("Assuming channel is clear since COS GPIO is not configured")
+}
+
+func (g *DefaultCOS) COSValue() bool {
+	return false
+}
diff --git a/radio/radio.go b/radio/radio.go
new file mode 100644
index 0000000..f6b3b50
--- /dev/null
+++ b/radio/radio.go
@@ -0,0 +1,306 @@
+package main
+
+import (
+	"code.octet-stream.net/broadcaster/protocol"
+	"encoding/json"
+	"flag"
+	"github.com/gopxl/beep/v2"
+	"github.com/gopxl/beep/v2/mp3"
+	"github.com/gopxl/beep/v2/speaker"
+	"github.com/gopxl/beep/v2/wav"
+	"golang.org/x/net/websocket"
+	"log"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"strings"
+	"syscall"
+	"time"
+)
+
+const sampleRate = 44100
+
+var config RadioConfig = NewRadioConfig()
+
+func main() {
+	configFlag := flag.String("c", "", "path to configuration file")
+	// TODO: support this
+	//generateFlag := flag.String("g", "", "create a template config file with specified name then exit")
+	flag.Parse()
+
+	if *configFlag == "" {
+		log.Fatal("must specify a configuration file with -c")
+	}
+	config.LoadFromFile(*configFlag)
+	statusCollector.Config <- config
+
+	playbackSampleRate := beep.SampleRate(sampleRate)
+	speaker.Init(playbackSampleRate, playbackSampleRate.N(time.Second/10))
+
+	if config.PTTPin != -1 {
+		InitRaspberryPiPTT(config.PTTPin, config.GpioDevice)
+	}
+	if config.COSPin != -1 {
+		InitRaspberryPiCOS(config.COSPin, config.GpioDevice)
+	}
+
+	sig := make(chan os.Signal, 1)
+	signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
+	go func() {
+		sig := <-sig
+		log.Println("Radio shutting down due to signal", sig)
+		// Make sure we always stop PTT when program ends
+		ptt.DisengagePTT()
+		os.Exit(0)
+	}()
+
+	log.Println("Config checks out, radio coming online")
+	log.Println("Audio file cache:", config.CachePath)
+
+	fileSpecChan := make(chan []protocol.FileSpec)
+	go filesWorker(config.CachePath, fileSpecChan)
+
+	playlistSpecChan := make(chan []protocol.PlaylistSpec)
+	go playlistWorker(playlistSpecChan)
+
+	for {
+		runWebsocket(fileSpecChan, playlistSpecChan)
+		log.Println("Websocket failed, retry in 30 seconds")
+		time.Sleep(time.Second * time.Duration(30))
+	}
+}
+
+func runWebsocket(fileSpecChan chan []protocol.FileSpec, playlistSpecChan chan []protocol.PlaylistSpec) error {
+	log.Println("Establishing websocket connection to:", config.WebsocketURL())
+	ws, err := websocket.Dial(config.WebsocketURL(), "", config.ServerURL)
+	if err != nil {
+		return err
+	}
+
+	auth := protocol.AuthenticateMessage{
+		T:     "authenticate",
+		Token: config.Token,
+	}
+	msg, _ := json.Marshal(auth)
+
+	if _, err := ws.Write(msg); err != nil {
+		log.Fatal(err)
+	}
+	statusCollector.Websocket <- ws
+
+	buf := make([]byte, 16384)
+	badRead := false
+	for {
+		n, err := ws.Read(buf)
+		if err != nil {
+			log.Println("Lost websocket to server")
+			return err
+		}
+		// Ignore any massively oversize messages
+		if n == len(buf) {
+			badRead = true
+			continue
+		} else if badRead {
+			badRead = false
+			continue
+		}
+
+		t, msg, err := protocol.ParseMessage(buf[:n])
+		if err != nil {
+			log.Println("Message parse error", err)
+			return err
+		}
+
+		if t == protocol.FilesType {
+			filesMsg := msg.(protocol.FilesMessage)
+			fileSpecChan <- filesMsg.Files
+		}
+
+		if t == protocol.PlaylistsType {
+			playlistsMsg := msg.(protocol.PlaylistsMessage)
+			playlistSpecChan <- playlistsMsg.Playlists
+		}
+	}
+}
+
+func filesWorker(cachePath string, ch chan []protocol.FileSpec) {
+	machine := NewFilesMachine(cachePath)
+	isDownloading := false
+	downloadResult := make(chan error)
+	var timer *time.Timer
+
+	for {
+		var timerCh <-chan time.Time = nil
+		if timer != nil {
+			timerCh = timer.C
+		}
+		doNext := false
+		select {
+		case specs := <-ch:
+			log.Println("Received new file specs", specs)
+			machine.UpdateSpecs(specs)
+			doNext = true
+			timer = nil
+		case err := <-downloadResult:
+			isDownloading = false
+			machine.RefreshMissing()
+			if err != nil {
+				log.Println(err)
+				if !machine.IsCacheComplete() {
+					timer = time.NewTimer(30 * time.Second)
+				}
+			} else {
+				if !machine.IsCacheComplete() {
+					timer = time.NewTimer(10 * time.Millisecond)
+				}
+			}
+		case <-timerCh:
+			doNext = true
+			timer = nil
+		}
+
+		if doNext && !isDownloading && !machine.IsCacheComplete() {
+			next := machine.NextFile()
+			isDownloading = true
+			go machine.DownloadSingle(next, downloadResult)
+		}
+	}
+}
+
+func playlistWorker(ch <-chan []protocol.PlaylistSpec) {
+	var specs []protocol.PlaylistSpec
+	isPlaying := false
+	playbackFinished := make(chan error)
+	nextId := 0
+	var timer *time.Timer
+
+	for {
+		var timerCh <-chan time.Time = nil
+		if timer != nil {
+			timerCh = timer.C
+		}
+		doNext := false
+		select {
+		case specs = <-ch:
+			log.Println("Received new playlist specs", specs)
+			doNext = true
+		case <-playbackFinished:
+			isPlaying = false
+			doNext = true
+		case <-timerCh:
+			timer = nil
+			isPlaying = true
+			for _, v := range specs {
+				if v.Id == nextId {
+					go playPlaylist(v, playbackFinished)
+				}
+			}
+		}
+
+		if doNext && !isPlaying {
+			timer = nil
+			found := false
+			loc, err := time.LoadLocation(config.TimeZone)
+			if err != nil {
+				log.Fatal(err)
+			}
+			var soonestTime time.Time
+			for _, v := range specs {
+				t, err := time.ParseInLocation(protocol.StartTimeFormat, v.StartTime, loc)
+				if err != nil {
+					log.Println("Error parsing start time", err)
+					continue
+				}
+				if t.Before(time.Now()) {
+					continue
+				}
+				if !found || t.Before(soonestTime) {
+					soonestTime = t
+					found = true
+					nextId = v.Id
+				}
+			}
+			if found {
+				duration := soonestTime.Sub(time.Now())
+				log.Println("Next playlist will be id", nextId, "in", duration.Seconds(), "seconds")
+				timer = time.NewTimer(duration)
+			} else {
+				log.Println("No future playlists")
+			}
+		}
+	}
+}
+
+func playPlaylist(playlist protocol.PlaylistSpec, playbackFinished chan<- error) {
+	// TODO: possibility of on-demand cancellation
+	startTime := time.Now()
+	log.Println("Beginning playback of playlist", playlist.Name)
+	for _, p := range playlist.Entries {
+		// delay
+		var duration time.Duration
+		if p.IsRelative {
+			duration = time.Second * time.Duration(p.DelaySeconds)
+		} else {
+			duration = time.Until(startTime.Add(time.Second * time.Duration(p.DelaySeconds)))
+		}
+		statusCollector.PlaylistBeginDelay <- BeginDelayStatus{
+			Playlist: playlist.Name,
+			Seconds:  int(duration.Seconds()),
+			Filename: p.Filename,
+		}
+		<-time.After(duration)
+
+		statusCollector.PlaylistBeginWaitForChannel <- BeginWaitForChannelStatus{
+			Playlist: playlist.Name,
+			Filename: p.Filename,
+		}
+		cos.WaitForChannelClear()
+
+		// then play
+		statusCollector.PlaylistBeginPlayback <- BeginPlaybackStatus{
+			Playlist: playlist.Name,
+			Filename: p.Filename,
+		}
+		ptt.EngagePTT()
+		f, err := os.Open(filepath.Join(config.CachePath, p.Filename))
+		if err != nil {
+			log.Println("Couldn't open file for playlist", p.Filename)
+			continue
+		}
+		log.Println("Playing file", p.Filename)
+		l := strings.ToLower(p.Filename)
+		var streamer beep.StreamSeekCloser
+		var format beep.Format
+		if strings.HasSuffix(l, ".mp3") {
+			streamer, format, err = mp3.Decode(f)
+		} else if strings.HasSuffix(l, ".wav") {
+			streamer, format, err = wav.Decode(f)
+		} else {
+			log.Println("Unrecognised file extension (.wav and .mp3 supported), moving on")
+		}
+		if err != nil {
+			log.Println("Could not decode media file", err)
+			continue
+		}
+		defer streamer.Close()
+
+		done := make(chan bool)
+
+		if format.SampleRate != sampleRate {
+			resampled := beep.Resample(4, format.SampleRate, sampleRate, streamer)
+			speaker.Play(beep.Seq(resampled, beep.Callback(func() {
+				done <- true
+			})))
+		} else {
+			speaker.Play(beep.Seq(streamer, beep.Callback(func() {
+				done <- true
+			})))
+		}
+
+		<-done
+		ptt.DisengagePTT()
+	}
+	log.Println("Playlist finished", playlist.Name)
+	statusCollector.PlaylistBeginIdle <- true
+	playbackFinished <- nil
+}
diff --git a/radio/status.go b/radio/status.go
new file mode 100644
index 0000000..797a28f
--- /dev/null
+++ b/radio/status.go
@@ -0,0 +1,140 @@
+package main
+
+import (
+	"code.octet-stream.net/broadcaster/protocol"
+	"encoding/json"
+	"golang.org/x/net/websocket"
+	"time"
+)
+
+type BeginDelayStatus struct {
+	Playlist string
+	Seconds  int
+	Filename string
+}
+
+type BeginWaitForChannelStatus struct {
+	Playlist string
+	Filename string
+}
+
+type BeginPlaybackStatus struct {
+	Playlist string
+	Filename string
+}
+
+type StatusCollector struct {
+	Websocket                   chan *websocket.Conn
+	PlaylistBeginIdle           chan bool
+	PlaylistBeginDelay          chan BeginDelayStatus
+	PlaylistBeginWaitForChannel chan BeginWaitForChannelStatus
+	PlaylistBeginPlayback       chan BeginPlaybackStatus
+	PTT                         chan bool
+	COS                         chan bool
+	Config                      chan RadioConfig
+	FilesInSync                 chan bool
+}
+
+var statusCollector = NewStatusCollector()
+
+func NewStatusCollector() StatusCollector {
+	sc := StatusCollector{
+		Websocket:                   make(chan *websocket.Conn),
+		PlaylistBeginIdle:           make(chan bool),
+		PlaylistBeginDelay:          make(chan BeginDelayStatus),
+		PlaylistBeginWaitForChannel: make(chan BeginWaitForChannelStatus),
+		PlaylistBeginPlayback:       make(chan BeginPlaybackStatus),
+		PTT:                         make(chan bool),
+		COS:                         make(chan bool),
+		Config:                      make(chan RadioConfig),
+		FilesInSync:                 make(chan bool),
+	}
+	go runStatusCollector(sc)
+	return sc
+}
+
+func runStatusCollector(sc StatusCollector) {
+	config := <-sc.Config
+	var msg protocol.StatusMessage
+	var lastSent protocol.StatusMessage
+	msg.T = protocol.StatusType
+	msg.TimeZone = config.TimeZone
+	msg.Status = protocol.StatusIdle
+	var ws *websocket.Conn
+	// Go 1.23: no need to stop tickers when finished
+	var ticker = time.NewTicker(time.Second * time.Duration(30))
+
+	for {
+		select {
+		case newWebsocket := <-sc.Websocket:
+			ws = newWebsocket
+		case <-ticker.C:
+			// should always be ticking at 1 second for these
+			if msg.Status == protocol.StatusDelay {
+				if msg.DelaySecondsRemaining > 0 {
+					msg.DelaySecondsRemaining -= 1
+				}
+			}
+			if msg.Status == protocol.StatusChannelInUse {
+				msg.WaitingForChannelSeconds += 1
+			}
+			if msg.Status == protocol.StatusPlaying {
+				msg.PlaybackSecondsElapsed += 1
+			}
+		case <-sc.PlaylistBeginIdle:
+			msg.Status = protocol.StatusIdle
+			msg.DelaySecondsRemaining = 0
+			msg.WaitingForChannelSeconds = 0
+			msg.PlaybackSecondsElapsed = 0
+			msg.Playlist = ""
+			msg.Filename = ""
+			// Update things more slowly when nothing's playing
+			ticker = time.NewTicker(time.Second * time.Duration(30))
+		case delay := <-sc.PlaylistBeginDelay:
+			msg.Status = protocol.StatusDelay
+			msg.DelaySecondsRemaining = delay.Seconds
+			msg.WaitingForChannelSeconds = 0
+			msg.PlaybackSecondsElapsed = 0
+			msg.Playlist = delay.Playlist
+			msg.Filename = delay.Filename
+			// Align ticker with start of state change, make sure it's faster
+			ticker = time.NewTicker(time.Second * time.Duration(1))
+		case wait := <-sc.PlaylistBeginWaitForChannel:
+			msg.Status = protocol.StatusChannelInUse
+			msg.DelaySecondsRemaining = 0
+			msg.WaitingForChannelSeconds = 0
+			msg.PlaybackSecondsElapsed = 0
+			msg.Playlist = wait.Playlist
+			msg.Filename = wait.Filename
+			ticker = time.NewTicker(time.Second * time.Duration(1))
+		case playback := <-sc.PlaylistBeginPlayback:
+			msg.Status = protocol.StatusPlaying
+			msg.DelaySecondsRemaining = 0
+			msg.WaitingForChannelSeconds = 0
+			msg.PlaybackSecondsElapsed = 0
+			msg.Playlist = playback.Playlist
+			msg.Filename = playback.Filename
+			ticker = time.NewTicker(time.Second * time.Duration(1))
+		case ptt := <-sc.PTT:
+			msg.PTT = ptt
+		case cos := <-sc.COS:
+			msg.COS = cos
+		case inSync := <-sc.FilesInSync:
+			msg.FilesInSync = inSync
+		}
+		msg.LocalTime = time.Now().Format(protocol.LocalTimeFormat)
+		msg.COS = cos.COSValue()
+
+		if msg == lastSent {
+			continue
+		}
+		if ws != nil {
+			msgJson, _ := json.Marshal(msg)
+			if _, err := ws.Write(msgJson); err != nil {
+				// If websocket has failed, wait 'til we get a new one
+				ws = nil
+			}
+			lastSent = msg
+		}
+	}
+}
diff --git a/server/broadcaster.go b/server/broadcaster.go
new file mode 100644
index 0000000..2e09249
--- /dev/null
+++ b/server/broadcaster.go
@@ -0,0 +1,408 @@
+package main
+
+import (
+	"flag"
+	"golang.org/x/net/websocket"
+	"html/template"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+)
+
+const formatString = "2006-01-02T15:04"
+
+var config ServerConfig = NewServerConfig()
+
+// Channel that will be closed and recreated every time the playlists change
+var playlistChangeWait = make(chan bool)
+
+func main() {
+	configFlag := flag.String("c", "", "path to configuration file")
+	// TODO: support this
+	//generateFlag := flag.String("g", "", "create a template config file with specified name then exit")
+	flag.Parse()
+
+	if *configFlag == "" {
+		log.Fatal("must specify a configuration file with -c")
+	}
+	config.LoadFromFile(*configFlag)
+
+	log.Println("Hello, World! Woo broadcast time")
+	InitDatabase()
+	defer db.CloseDatabase()
+
+	InitAudioFiles(config.AudioFilesPath)
+	InitServerStatus()
+
+	http.HandleFunc("/", homePage)
+	http.HandleFunc("/login", logInPage)
+	http.HandleFunc("/logout", logOutPage)
+	http.HandleFunc("/secret", secretPage)
+
+	http.HandleFunc("/playlist/", playlistSection)
+	http.HandleFunc("/file/", fileSection)
+	http.HandleFunc("/radio/", radioSection)
+
+	http.Handle("/radiosync", websocket.Handler(RadioSync))
+	http.Handle("/websync", websocket.Handler(WebSync))
+	http.Handle("/audio-files/", http.StripPrefix("/audio-files/", http.FileServer(http.Dir(config.AudioFilesPath))))
+
+	err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+type HomeData struct {
+	LoggedIn bool
+	Username string
+}
+
+func homePage(w http.ResponseWriter, r *http.Request) {
+	tmpl := template.Must(template.ParseFiles("templates/index.html"))
+	data := HomeData{
+		LoggedIn: true,
+		Username: "Bob",
+	}
+	tmpl.Execute(w, data)
+}
+
+func secretPage(w http.ResponseWriter, r *http.Request) {
+	user, err := currentUser(w, r)
+	if err != nil {
+		http.Redirect(w, r, "/login", http.StatusFound)
+		return
+	}
+	tmpl := template.Must(template.ParseFiles("templates/index.html"))
+	data := HomeData{
+		LoggedIn: true,
+		Username: user.username + ", you are special",
+	}
+	tmpl.Execute(w, data)
+}
+
+type LogInData struct {
+	Error string
+}
+
+func logInPage(w http.ResponseWriter, r *http.Request) {
+	log.Println("Log in page!")
+	r.ParseForm()
+	username := r.Form["username"]
+	password := r.Form["password"]
+	err := ""
+	if username != nil {
+		log.Println("Looks like we have a username", username[0])
+		if username[0] == "admin" && password[0] == "test" {
+			createSessionCookie(w)
+			http.Redirect(w, r, "/", http.StatusFound)
+			return
+		} else {
+			err = "Incorrect login"
+		}
+	}
+
+	data := LogInData{
+		Error: err,
+	}
+
+	tmpl := template.Must(template.ParseFiles("templates/login.html"))
+	tmpl.Execute(w, data)
+}
+
+func playlistSection(w http.ResponseWriter, r *http.Request) {
+	path := strings.Split(r.URL.Path, "/")
+	if len(path) != 3 {
+		http.NotFound(w, r)
+		return
+	}
+	if path[2] == "new" {
+		editPlaylistPage(w, r, 0)
+	} else if path[2] == "submit" && r.Method == "POST" {
+		submitPlaylist(w, r)
+	} else if path[2] == "delete" && r.Method == "POST" {
+		deletePlaylist(w, r)
+	} else if path[2] == "" {
+		playlistsPage(w, r)
+	} else {
+		id, err := strconv.Atoi(path[2])
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		editPlaylistPage(w, r, id)
+	}
+}
+
+func fileSection(w http.ResponseWriter, r *http.Request) {
+	path := strings.Split(r.URL.Path, "/")
+	if len(path) != 3 {
+		http.NotFound(w, r)
+		return
+	}
+	if path[2] == "upload" {
+		uploadFile(w, r)
+	} else if path[2] == "delete" && r.Method == "POST" {
+		deleteFile(w, r)
+	} else if path[2] == "" {
+		filesPage(w, r)
+	} else {
+		http.NotFound(w, r)
+		return
+	}
+}
+
+func radioSection(w http.ResponseWriter, r *http.Request) {
+	path := strings.Split(r.URL.Path, "/")
+	if len(path) != 3 {
+		http.NotFound(w, r)
+		return
+	}
+	if path[2] == "new" {
+		editRadioPage(w, r, 0)
+	} else if path[2] == "submit" && r.Method == "POST" {
+		submitRadio(w, r)
+	} else if path[2] == "delete" && r.Method == "POST" {
+		deleteRadio(w, r)
+	} else if path[2] == "" {
+		radiosPage(w, r)
+	} else {
+		id, err := strconv.Atoi(path[2])
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		editRadioPage(w, r, id)
+	}
+}
+
+type PlaylistsPageData struct {
+	Playlists []Playlist
+}
+
+func playlistsPage(w http.ResponseWriter, _ *http.Request) {
+	data := PlaylistsPageData{
+		Playlists: db.GetPlaylists(),
+	}
+	tmpl := template.Must(template.ParseFiles("templates/playlists.html"))
+	err := tmpl.Execute(w, data)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+type RadiosPageData struct {
+	Radios []Radio
+}
+
+func radiosPage(w http.ResponseWriter, _ *http.Request) {
+	data := RadiosPageData{
+		Radios: db.GetRadios(),
+	}
+	tmpl := template.Must(template.ParseFiles("templates/radios.html"))
+	err := tmpl.Execute(w, data)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+type EditPlaylistPageData struct {
+	Playlist Playlist
+	Entries  []PlaylistEntry
+	Files    []string
+}
+
+func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int) {
+	var data EditPlaylistPageData
+	for _, f := range files.Files() {
+		data.Files = append(data.Files, f.Name)
+	}
+	if id == 0 {
+		data.Playlist.Enabled = true
+		data.Playlist.Name = "New Playlist"
+		data.Playlist.StartTime = time.Now().Format(formatString)
+		data.Entries = append(data.Entries, PlaylistEntry{})
+	} else {
+		playlist, err := db.GetPlaylist(id)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		data.Playlist = playlist
+		data.Entries = db.GetEntriesForPlaylist(id)
+	}
+	tmpl := template.Must(template.ParseFiles("templates/playlist.html"))
+	tmpl.Execute(w, data)
+}
+
+func submitPlaylist(w http.ResponseWriter, r *http.Request) {
+	err := r.ParseForm()
+	if err == nil {
+		var p Playlist
+		id, err := strconv.Atoi(r.Form.Get("playlistId"))
+		if err != nil {
+			return
+		}
+		_, err = time.Parse(formatString, r.Form.Get("playlistStartTime"))
+		if err != nil {
+			return
+		}
+		p.Id = id
+		p.Enabled = r.Form.Get("playlistEnabled") == "1"
+		p.Name = r.Form.Get("playlistName")
+		p.StartTime = r.Form.Get("playlistStartTime")
+
+		delays := r.Form["delaySeconds"]
+		filenames := r.Form["filename"]
+		isRelatives := r.Form["isRelative"]
+
+		entries := make([]PlaylistEntry, 0)
+		for i := range delays {
+			var e PlaylistEntry
+			delay, err := strconv.Atoi(delays[i])
+			if err != nil {
+				return
+			}
+			e.DelaySeconds = delay
+			e.Position = i
+			e.IsRelative = isRelatives[i] == "1"
+			e.Filename = filenames[i]
+			entries = append(entries, e)
+		}
+		cleanedEntries := make([]PlaylistEntry, 0)
+		for _, e := range entries {
+			if e.DelaySeconds != 0 || e.Filename != "" {
+				cleanedEntries = append(cleanedEntries, e)
+			}
+		}
+
+		if id != 0 {
+			db.UpdatePlaylist(p)
+		} else {
+			id = db.CreatePlaylist(p)
+		}
+		db.SetEntriesForPlaylist(cleanedEntries, id)
+		// Notify connected radios
+		close(playlistChangeWait)
+		playlistChangeWait = make(chan bool)
+	}
+	http.Redirect(w, r, "/playlist/", http.StatusFound)
+}
+
+func deletePlaylist(w http.ResponseWriter, r *http.Request) {
+	err := r.ParseForm()
+	if err == nil {
+		id, err := strconv.Atoi(r.Form.Get("playlistId"))
+		if err != nil {
+			return
+		}
+		db.DeletePlaylist(id)
+	}
+	http.Redirect(w, r, "/playlist/", http.StatusFound)
+}
+
+type EditRadioPageData struct {
+	Radio Radio
+}
+
+func editRadioPage(w http.ResponseWriter, r *http.Request, id int) {
+	var data EditRadioPageData
+	if id == 0 {
+		data.Radio.Name = "New Radio"
+		data.Radio.Token = generateSession()
+	} else {
+		radio, err := db.GetRadio(id)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		data.Radio = radio
+	}
+	tmpl := template.Must(template.ParseFiles("templates/radio.html"))
+	tmpl.Execute(w, data)
+}
+
+func submitRadio(w http.ResponseWriter, r *http.Request) {
+	err := r.ParseForm()
+	if err == nil {
+		var radio Radio
+		id, err := strconv.Atoi(r.Form.Get("radioId"))
+		if err != nil {
+			return
+		}
+		radio.Id = id
+		radio.Name = r.Form.Get("radioName")
+		radio.Token = r.Form.Get("radioToken")
+		if id != 0 {
+			db.UpdateRadio(radio)
+		} else {
+			db.CreateRadio(radio)
+		}
+	}
+	http.Redirect(w, r, "/radio/", http.StatusFound)
+}
+
+func deleteRadio(w http.ResponseWriter, r *http.Request) {
+	err := r.ParseForm()
+	if err == nil {
+		id, err := strconv.Atoi(r.Form.Get("radioId"))
+		if err != nil {
+			return
+		}
+		db.DeleteRadio(id)
+	}
+	http.Redirect(w, r, "/radio/", http.StatusFound)
+}
+
+type FilesPageData struct {
+	Files []FileSpec
+}
+
+func filesPage(w http.ResponseWriter, _ *http.Request) {
+	data := FilesPageData{
+		Files: files.Files(),
+	}
+	log.Println("file page data", data)
+	tmpl := template.Must(template.ParseFiles("templates/files.html"))
+	err := tmpl.Execute(w, data)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func deleteFile(w http.ResponseWriter, r *http.Request) {
+	err := r.ParseForm()
+	if err == nil {
+		filename := r.Form.Get("filename")
+		if filename == "" {
+			return
+		}
+		files.Delete(filename)
+	}
+	http.Redirect(w, r, "/file/", http.StatusFound)
+}
+
+func uploadFile(w http.ResponseWriter, r *http.Request) {
+	err := r.ParseMultipartForm(100 << 20)
+	file, handler, err := r.FormFile("file")
+	if err == nil {
+		path := filepath.Join(files.Path(), filepath.Base(handler.Filename))
+		f, _ := os.Create(path)
+		defer f.Close()
+		io.Copy(f, file)
+		log.Println("uploaded file to", path)
+		files.Refresh()
+	}
+	http.Redirect(w, r, "/file/", http.StatusFound)
+}
+
+func logOutPage(w http.ResponseWriter, r *http.Request) {
+	clearSessionCookie(w)
+	tmpl := template.Must(template.ParseFiles("templates/logout.html"))
+	tmpl.Execute(w, nil)
+}
diff --git a/server/config.go b/server/config.go
new file mode 100644
index 0000000..4a4866c
--- /dev/null
+++ b/server/config.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+	"errors"
+	"log"
+
+	"github.com/BurntSushi/toml"
+)
+
+type ServerConfig struct {
+	BindAddress    string
+	Port           int
+	SqliteDB       string
+	AudioFilesPath string
+}
+
+func NewServerConfig() ServerConfig {
+	return ServerConfig{
+		BindAddress:    "0.0.0.0",
+		Port:           55134,
+		SqliteDB:       "",
+		AudioFilesPath: "",
+	}
+}
+
+func (c *ServerConfig) LoadFromFile(path string) {
+	_, err := toml.DecodeFile(path, &c)
+	if err != nil {
+		log.Fatal("could not read config file for reading at path:", path, err)
+	}
+	err = c.Validate()
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func (c *ServerConfig) Validate() error {
+	if c.SqliteDB == "" {
+		return errors.New("Configuration must provide SqliteDB")
+	}
+	if c.AudioFilesPath == "" {
+		return errors.New("Configuration must provide AudioFilesPath")
+	}
+	return nil
+}
diff --git a/server/database.go b/server/database.go
new file mode 100644
index 0000000..e6ce752
--- /dev/null
+++ b/server/database.go
@@ -0,0 +1,189 @@
+package main
+
+import (
+	"database/sql"
+	"errors"
+	"log"
+	_ "modernc.org/sqlite"
+	"time"
+)
+
+type Database struct {
+	sqldb *sql.DB
+}
+
+var db Database
+
+func InitDatabase() {
+	sqldb, err := sql.Open("sqlite", config.SqliteDB)
+	if err != nil {
+		log.Fatal(err)
+	}
+	db.sqldb = sqldb
+
+	_, err = db.sqldb.Exec("PRAGMA foreign_keys = ON")
+	if err != nil {
+		log.Printf("%q\n", err)
+		return
+	}
+
+	sqlStmt := `
+	CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT, username TEXT, created TIMESTAMP, expiry TIMESTAMP);
+	CREATE TABLE IF NOT EXISTS playlists (id INTEGER PRIMARY KEY AUTOINCREMENT, enabled INTEGER, name TEXT, start_time TEXT);
+	CREATE TABLE IF NOT EXISTS playlist_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id INTEGER, position INTEGER, filename TEXT, delay_seconds INTEGER, is_relative INTEGER, CONSTRAINT fk_playlists FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE);
+	CREATE TABLE IF NOT EXISTS radios (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, token TEXT);
+	`
+	_, err = db.sqldb.Exec(sqlStmt)
+	if err != nil {
+		log.Printf("%q: %s\n", err, sqlStmt)
+		return
+	}
+}
+
+func (d *Database) CloseDatabase() {
+	d.sqldb.Close()
+}
+
+func (d *Database) InsertSession(user string, token string, expiry time.Time) {
+	_, err := d.sqldb.Exec("INSERT INTO sessions (token, username, created, expiry) values (?, ?, CURRENT_TIMESTAMP, ?)", token, user, expiry)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func (d *Database) GetUserForSession(token string) (string, error) {
+	var username string
+	err := d.sqldb.QueryRow("SELECT username FROM sessions WHERE token = ? AND expiry > CURRENT_TIMESTAMP", token).Scan(&username)
+	if err != nil {
+		return "", errors.New("no matching token")
+	}
+	return username, nil
+}
+
+func (d *Database) CreatePlaylist(playlist Playlist) int {
+	var id int
+	tx, _ := d.sqldb.Begin()
+	_, err := tx.Exec("INSERT INTO playlists (enabled, name, start_time) values (?, ?, ?)", playlist.Enabled, playlist.Name, playlist.StartTime)
+	if err != nil {
+		log.Fatal(err)
+	}
+	err = tx.QueryRow("SELECT last_insert_rowid()").Scan(&id)
+	if err != nil {
+		log.Fatal(err)
+	}
+	err = tx.Commit()
+	if err != nil {
+		log.Fatal(err)
+	}
+	return id
+}
+
+func (d *Database) DeletePlaylist(playlistId int) {
+	d.sqldb.Exec("DELETE FROM playlists WHERE id = ?", playlistId)
+}
+
+func (d *Database) GetPlaylists() []Playlist {
+	ret := make([]Playlist, 0)
+	rows, err := d.sqldb.Query("SELECT id, enabled, name, start_time FROM playlists ORDER BY id ASC")
+	if err != nil {
+		return ret
+	}
+	defer rows.Close()
+	for rows.Next() {
+		var p Playlist
+		if err := rows.Scan(&p.Id, &p.Enabled, &p.Name, &p.StartTime); err != nil {
+			return ret
+		}
+		ret = append(ret, p)
+	}
+	return ret
+}
+
+func (d *Database) GetPlaylist(playlistId int) (Playlist, error) {
+	var p Playlist
+	err := d.sqldb.QueryRow("SELECT id, enabled, name, start_time FROM playlists WHERE id = ?", playlistId).Scan(&p.Id, &p.Enabled, &p.Name, &p.StartTime)
+	if err != nil {
+		return p, err
+	}
+	return p, nil
+}
+
+func (d *Database) UpdatePlaylist(playlist Playlist) {
+	d.sqldb.Exec("UPDATE playlists SET enabled = ?, name = ?, start_time = ? WHERE id = ?", playlist.Enabled, playlist.Name, playlist.StartTime, playlist.Id)
+}
+
+func (d *Database) SetEntriesForPlaylist(entries []PlaylistEntry, playlistId int) {
+	tx, _ := d.sqldb.Begin()
+	_, err := tx.Exec("DELETE FROM playlist_entries WHERE playlist_id = ?", playlistId)
+	for _, e := range entries {
+		_, err = tx.Exec("INSERT INTO playlist_entries (playlist_id, position, filename, delay_seconds, is_relative) values (?, ?, ?, ?, ?)", playlistId, e.Position, e.Filename, e.DelaySeconds, e.IsRelative)
+		if err != nil {
+			log.Fatal(err)
+		}
+	}
+	tx.Commit() // ignore errors
+}
+
+func (d *Database) GetEntriesForPlaylist(playlistId int) []PlaylistEntry {
+	ret := make([]PlaylistEntry, 0)
+	rows, err := d.sqldb.Query("SELECT id, position, filename, delay_seconds, is_relative FROM playlist_entries WHERE playlist_id = ? ORDER by position ASC", playlistId)
+	if err != nil {
+		return ret
+	}
+	defer rows.Close()
+	for rows.Next() {
+		var entry PlaylistEntry
+		if err := rows.Scan(&entry.Id, &entry.Position, &entry.Filename, &entry.DelaySeconds, &entry.IsRelative); err != nil {
+			return ret
+		}
+		ret = append(ret, entry)
+	}
+	return ret
+}
+
+func (d *Database) GetRadio(radioId int) (Radio, error) {
+	var r Radio
+	err := d.sqldb.QueryRow("SELECT id, name, token FROM radios WHERE id = ?", radioId).Scan(&r.Id, &r.Name, &r.Token)
+	if err != nil {
+		return r, err
+	}
+	return r, nil
+}
+
+func (d *Database) GetRadioByToken(token string) (Radio, error) {
+	var r Radio
+	err := d.sqldb.QueryRow("SELECT id, name, token FROM radios WHERE token = ?", token).Scan(&r.Id, &r.Name, &r.Token)
+	if err != nil {
+		return r, err
+	}
+	return r, nil
+}
+
+func (d *Database) GetRadios() []Radio {
+	ret := make([]Radio, 0)
+	rows, err := d.sqldb.Query("SELECT id, name, token FROM radios ORDER BY id ASC")
+	if err != nil {
+		return ret
+	}
+	defer rows.Close()
+	for rows.Next() {
+		var r Radio
+		if err := rows.Scan(&r.Id, &r.Name, &r.Token); err != nil {
+			return ret
+		}
+		ret = append(ret, r)
+	}
+	return ret
+}
+
+func (d *Database) DeleteRadio(radioId int) {
+	d.sqldb.Exec("DELETE FROM radios WHERE id = ?", radioId)
+}
+
+func (d *Database) CreateRadio(radio Radio) {
+	d.sqldb.Exec("INSERT INTO radios (name, token) values (?, ?)", radio.Name, radio.Token)
+}
+
+func (d *Database) UpdateRadio(radio Radio) {
+	d.sqldb.Exec("UPDATE radios SET name = ?, token = ? WHERE id = ?", radio.Name, radio.Token, radio.Id)
+}
diff --git a/server/files.go b/server/files.go
new file mode 100644
index 0000000..4d6e293
--- /dev/null
+++ b/server/files.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"io"
+	"log"
+	"os"
+	"path/filepath"
+)
+
+type FileSpec struct {
+	Name string
+	Hash string
+}
+
+type AudioFiles struct {
+	path       string
+	list       []FileSpec
+	changeWait chan bool
+}
+
+var files AudioFiles
+
+func InitAudioFiles(path string) {
+	files.changeWait = make(chan bool)
+	files.path = path
+	log.Println("initing audio files")
+	files.Refresh()
+	log.Println("done")
+}
+
+func (r *AudioFiles) Refresh() {
+	entries, err := os.ReadDir(r.path)
+	if err != nil {
+		log.Println("couldn't read dir", r.path)
+		return
+	}
+	r.list = nil
+	for _, file := range entries {
+		f, err := os.Open(filepath.Join(r.path, file.Name()))
+		if err != nil {
+			log.Println("couldn't open", file.Name())
+			return
+		}
+		hash := sha256.New()
+		io.Copy(hash, f)
+		r.list = append(r.list, FileSpec{Name: file.Name(), Hash: hex.EncodeToString(hash.Sum(nil))})
+	}
+	log.Println("Files updated", r.list)
+	close(files.changeWait)
+	files.changeWait = make(chan bool)
+}
+
+func (r *AudioFiles) Path() string {
+	return r.path
+}
+
+func (r *AudioFiles) Files() []FileSpec {
+	return r.list
+}
+
+func (r *AudioFiles) Delete(filename string) {
+	path := filepath.Join(r.path, filepath.Base(filename))
+	if filepath.Clean(r.path) != filepath.Clean(path) {
+		os.Remove(path)
+		r.Refresh()
+	}
+}
+
+func (r *AudioFiles) ChangeChannel() chan bool {
+	return r.changeWait
+}
diff --git a/server/model.go b/server/model.go
new file mode 100644
index 0000000..94a6ab4
--- /dev/null
+++ b/server/model.go
@@ -0,0 +1,26 @@
+package main
+
+type PlaylistEntry struct {
+	Id           int
+	Position     int
+	Filename     string
+	DelaySeconds int
+	IsRelative   bool
+}
+
+type User struct {
+	username string
+}
+
+type Playlist struct {
+	Id        int
+	Enabled   bool
+	Name      string
+	StartTime string
+}
+
+type Radio struct {
+	Id    int
+	Name  string
+	Token string
+}
diff --git a/server/radio_sync.go b/server/radio_sync.go
new file mode 100644
index 0000000..3dfd4ed
--- /dev/null
+++ b/server/radio_sync.go
@@ -0,0 +1,134 @@
+package main
+
+import (
+	"code.octet-stream.net/broadcaster/protocol"
+	"encoding/json"
+	"golang.org/x/net/websocket"
+	"log"
+)
+
+func RadioSync(ws *websocket.Conn) {
+	log.Println("A websocket connected, I think")
+	buf := make([]byte, 16384)
+
+	badRead := false
+	isAuthenticated := false
+	var radio Radio
+	for {
+		// Ignore any massively oversize messages
+		n, err := ws.Read(buf)
+		if err != nil {
+			if radio.Name != "" {
+				log.Println("Lost websocket to radio:", radio.Name)
+				status.RadioDisconnected(radio.Id)
+			} else {
+				log.Println("Lost unauthenticated websocket")
+			}
+			return
+		}
+		if n == len(buf) {
+			badRead = true
+			continue
+		} else if badRead {
+			badRead = false
+			continue
+		}
+
+		t, msg, err := protocol.ParseMessage(buf[:n])
+		if err != nil {
+			log.Println(err)
+			return
+		}
+
+		if !isAuthenticated && t != protocol.AuthenticateType {
+			continue
+		}
+
+		if t == protocol.AuthenticateType && !isAuthenticated {
+			authMsg := msg.(protocol.AuthenticateMessage)
+			r, err := db.GetRadioByToken(authMsg.Token)
+			if err != nil {
+				log.Println("Could not find radio for offered token", authMsg.Token)
+			}
+			radio = r
+			log.Println("Radio authenticated:", radio.Name)
+			isAuthenticated = true
+
+			go KeepFilesUpdated(ws)
+
+			// send initial file message
+			err = sendFilesMessageToRadio(ws)
+			if err != nil {
+				return
+			}
+
+			go KeepPlaylistsUpdated(ws)
+
+			// send initial playlists message
+			err = sendPlaylistsMessageToRadio(ws)
+			if err != nil {
+				return
+			}
+		}
+
+		if t == protocol.StatusType {
+			statusMsg := msg.(protocol.StatusMessage)
+			log.Println("Received Status from", radio.Name, ":", statusMsg)
+			status.MergeStatus(radio.Id, statusMsg)
+		}
+	}
+}
+
+func sendPlaylistsMessageToRadio(ws *websocket.Conn) error {
+	playlistSpecs := make([]protocol.PlaylistSpec, 0)
+	for _, v := range db.GetPlaylists() {
+		if v.Enabled {
+			entrySpecs := make([]protocol.EntrySpec, 0)
+			for _, e := range db.GetEntriesForPlaylist(v.Id) {
+				entrySpecs = append(entrySpecs, protocol.EntrySpec{Filename: e.Filename, DelaySeconds: e.DelaySeconds, IsRelative: e.IsRelative})
+			}
+			playlistSpecs = append(playlistSpecs, protocol.PlaylistSpec{Id: v.Id, Name: v.Name, StartTime: v.StartTime, Entries: entrySpecs})
+		}
+	}
+	playlists := protocol.PlaylistsMessage{
+		T:         protocol.PlaylistsType,
+		Playlists: playlistSpecs,
+	}
+	msg, _ := json.Marshal(playlists)
+	_, err := ws.Write(msg)
+	return err
+}
+
+func KeepPlaylistsUpdated(ws *websocket.Conn) {
+	for {
+		<-playlistChangeWait
+		err := sendPlaylistsMessageToRadio(ws)
+		if err != nil {
+			return
+		}
+	}
+}
+
+func sendFilesMessageToRadio(ws *websocket.Conn) error {
+	specs := make([]protocol.FileSpec, 0)
+	for _, v := range files.Files() {
+		specs = append(specs, protocol.FileSpec{Name: v.Name, Hash: v.Hash})
+	}
+	files := protocol.FilesMessage{
+		T:     protocol.FilesType,
+		Files: specs,
+	}
+	msg, _ := json.Marshal(files)
+	_, err := ws.Write(msg)
+	return err
+}
+
+func KeepFilesUpdated(ws *websocket.Conn) {
+	for {
+		<-files.ChangeChannel()
+		err := sendFilesMessageToRadio(ws)
+		if err != nil {
+			return
+		}
+	}
+}
diff --git a/server/session.go b/server/session.go
new file mode 100644
index 0000000..4b4c445
--- /dev/null
+++ b/server/session.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"log"
+	"net/http"
+	"time"
+)
+
+func generateSession() string {
+	b := make([]byte, 32)
+	_, err := rand.Read(b)
+	if err != nil {
+		log.Fatal(err)
+	}
+	return hex.EncodeToString(b)
+}
+
+func currentUser(w http.ResponseWriter, r *http.Request) (User, error) {
+	// todo: check if user actually exists and is allowed to log in
+	cookie, e := r.Cookie("broadcast_session")
+	if e != nil {
+		return User{}, e
+	}
+
+	username, e := db.GetUserForSession(cookie.Value)
+	if e != nil {
+		return User{}, e
+	}
+	return User{username: username}, nil
+}
+
+func createSessionCookie(w http.ResponseWriter) {
+	sess := generateSession()
+	log.Println("Generated a random session", sess)
+	expiration := time.Now().Add(365 * 24 * time.Hour)
+	cookie := http.Cookie{Name: "broadcast_session", Value: sess, Expires: expiration, SameSite: http.SameSiteLaxMode}
+	db.InsertSession("admin", sess, expiration)
+	http.SetCookie(w, &cookie)
+}
+
+func clearSessionCookie(w http.ResponseWriter) {
+	c := &http.Cookie{
+		Name:     "broadcast_session",
+		Value:    "",
+		MaxAge:   -1,
+		HttpOnly: true,
+	}
+	http.SetCookie(w, c)
+}
diff --git a/server/status.go b/server/status.go
new file mode 100644
index 0000000..accbcc9
--- /dev/null
+++ b/server/status.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+	"code.octet-stream.net/broadcaster/protocol"
+)
+
+type ServerStatus struct {
+	statuses   map[int]protocol.StatusMessage
+	changeWait chan bool
+}
+
+var status ServerStatus
+
+func InitServerStatus() {
+	status = ServerStatus{
+		statuses:   make(map[int]protocol.StatusMessage),
+		changeWait: make(chan bool),
+	}
+}
+
+func (s *ServerStatus) MergeStatus(radioId int, status protocol.StatusMessage) {
+	s.statuses[radioId] = status
+	s.TriggerChange()
+}
+
+func (s *ServerStatus) RadioDisconnected(radioId int) {
+	delete(s.statuses, radioId)
+	s.TriggerChange()
+}
+
+func (s *ServerStatus) TriggerChange() {
+	close(s.changeWait)
+	s.changeWait = make(chan bool)
+}
+
+func (s *ServerStatus) Statuses() map[int]protocol.StatusMessage {
+	return s.statuses
+}
+
+func (s *ServerStatus) ChangeChannel() chan bool {
+	return s.changeWait
+}
diff --git a/server/web_sync.go b/server/web_sync.go
new file mode 100644
index 0000000..e59403d
--- /dev/null
+++ b/server/web_sync.go
@@ -0,0 +1,162 @@
+package main
+
+import (
+	"fmt"
+	"html/template"
+	"log"
+	"sort"
+	"strconv"
+	"strings"
+
+	"code.octet-stream.net/broadcaster/protocol"
+	"golang.org/x/net/websocket"
+)
+
+func WebSync(ws *websocket.Conn) {
+	log.Println("A web user connected with WebSocket")
+	buf := make([]byte, 16384)
+
+	badRead := false
+	isAuthenticated := false
+	var user string
+	for {
+		// Ignore any massively oversize messages
+		n, err := ws.Read(buf)
+		if err != nil {
+			if user != "" {
+				log.Println("Lost websocket to user:", user)
+			} else {
+				log.Println("Lost unauthenticated website websocket")
+			}
+			return
+		}
+		if n == len(buf) {
+			badRead = true
+			continue
+		} else if badRead {
+			badRead = false
+			continue
+		}
+
+		if !isAuthenticated {
+			token := string(buf[:n])
+			u, err := db.GetUserForSession(token)
+			if err != nil {
+				log.Println("Could not find user for offered token", token)
+				ws.Close()
+				return
+			}
+			user = u
+			log.Println("User authenticated:", user)
+			isAuthenticated = true
+
+			go KeepWebUpdated(ws)
+
+			// send initial playlists message
+			err = sendRadioStatusToWeb(ws)
+			if err != nil {
+				return
+			}
+		}
+	}
+}
+
+type WebStatusData struct {
+	Radios []WebRadioStatus
+}
+
+type WebRadioStatus struct {
+	Name          string
+	LocalTime     string
+	TimeZone      string
+	ChannelClass  string
+	ChannelState  string
+	Playlist      string
+	File          string
+	Status        string
+	Id            string
+	DisableCancel bool
+	FilesInSync   bool
+}
+
+func sendRadioStatusToWeb(ws *websocket.Conn) error {
+	webStatuses := make([]WebRadioStatus, 0)
+	radioStatuses := status.Statuses()
+	keys := make([]int, 0)
+	for i := range radioStatuses {
+		keys = append(keys, i)
+	}
+	sort.Ints(keys)
+	for _, i := range keys {
+		v := radioStatuses[i]
+		radio, err := db.GetRadio(i)
+		if err != nil {
+			continue
+		}
+		var channelClass, channelState string
+		if v.PTT {
+			channelClass = "ptt"
+			channelState = "PTT"
+		} else if v.COS {
+			channelClass = "cos"
+			channelState = "RX"
+		} else {
+			channelClass = "clear"
+			channelState = "CLEAR"
+		}
+		var statusText string
+		var disableCancel bool
+		if v.Status == protocol.StatusIdle {
+			statusText = "Idle"
+			disableCancel = true
+		} else if v.Status == protocol.StatusDelay {
+			statusText = fmt.Sprintf("Performing delay before transmit: %ds remain", v.DelaySecondsRemaining)
+			disableCancel = false
+		} else if v.Status == protocol.StatusChannelInUse {
+			statusText = fmt.Sprintf("Waiting for channel to clear: %ds", v.WaitingForChannelSeconds)
+			disableCancel = false
+		} else if v.Status == protocol.StatusPlaying {
+			statusText = fmt.Sprintf("Playing: %d:%02d", v.PlaybackSecondsElapsed/60, v.PlaybackSecondsElapsed%60)
+			disableCancel = false
+		}
+		playlist := v.Playlist
+		if playlist == "" {
+			playlist = "-"
+		}
+		filename := v.Filename
+		if filename == "" {
+			filename = "-"
+		}
+		webStatuses = append(webStatuses, WebRadioStatus{
+			Name:          radio.Name,
+			LocalTime:     v.LocalTime,
+			TimeZone:      v.TimeZone,
+			ChannelClass:  channelClass,
+			ChannelState:  channelState,
+			Playlist:      playlist,
+			File:          filename,
+			Status:        statusText,
+			Id:            strconv.Itoa(i),
+			DisableCancel: disableCancel,
+			FilesInSync:   v.FilesInSync,
+		})
+	}
+	data := WebStatusData{
+		Radios: webStatuses,
+	}
+	buf := new(strings.Builder)
+	tmpl := template.Must(template.ParseFiles("templates/radios.partial.html"))
+	tmpl.Execute(buf, data)
+	_, err := ws.Write([]byte(buf.String()))
+	return err
+}
+
+func KeepWebUpdated(ws *websocket.Conn) {
+	for {
+		<-status.ChangeChannel()
+		err := sendRadioStatusToWeb(ws)
+		if err != nil {
+			return
+		}
+	}
+}
diff --git a/templates/files.html b/templates/files.html
new file mode 100644
index 0000000..70d666f
--- /dev/null
+++ b/templates/files.html
@@ -0,0 +1,25 @@
+
+
+  
+    
+    
+    Broadcaster
+  
+  
+    
+      Files! List
+      
+      {{range .Files}}
+        - {{.Name}}
 
+      {{end}}
+      
+      Upload New File
+      
+      
+      
+    
+  
+
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..5f4d747
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,81 @@
+
+
+  
+    
+    
+    Broadcaster
+    
+    
+  
+  
+    
+      Welcome!
+      {{if .LoggedIn}}
+      Your username is: {{.Username}}.
+      Log Out
+      {{else}}
+      Log In
+      {{end}}
+      File Management
+      Playlist Management
+      Radio Management
+      Connected Radios
+      
+        Loading...
+      
+    
+  
+
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..56772b9
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,23 @@
+
+
+  
+    
+    
+    Broadcaster Log In
+  
+  
+    
+      Log In
+      
+    
+  
+
diff --git a/templates/logout.html b/templates/logout.html
new file mode 100644
index 0000000..b151265
--- /dev/null
+++ b/templates/logout.html
@@ -0,0 +1,14 @@
+
+
+  
+    
+    
+    Broadcaster Log Out
+  
+  
+    
+        Logged Out
+        Log In again
+    
+  
+
diff --git a/templates/playlist.html b/templates/playlist.html
new file mode 100644
index 0000000..88f17b7
--- /dev/null
+++ b/templates/playlist.html
@@ -0,0 +1,100 @@
+
+
+  
+    
+    
+    Broadcaster
+    
+  
+  
+    
+      A specific playlist
+      
+      {{if .Playlist.Id}}
+      Edit Playlist
+      {{else}}
+      Create New Playlist
+      {{end}}
+      
+      
+      {{if .Playlist.Id}}
+      Delete
+      
+      {{end}}
+      
+        Wait until
+        
+        seconds from
+        
+        then play
+        
+        (Delete Item)
+      
+    
+  
+
diff --git a/templates/playlists.html b/templates/playlists.html
new file mode 100644
index 0000000..207d020
--- /dev/null
+++ b/templates/playlists.html
@@ -0,0 +1,19 @@
+
+
+  
+    
+    
+    Broadcaster
+  
+  
+    
+      Playlists!
+      
+      {{range .Playlists}}
+        - {{.Name}} {{.StartTime}} (Edit)
 
+      {{end}}
+      
+      Add New Playlist
+    
+  
+
diff --git a/templates/radio.html b/templates/radio.html
new file mode 100644
index 0000000..1c3dde0
--- /dev/null
+++ b/templates/radio.html
@@ -0,0 +1,43 @@
+
+
+  
+    
+    
+    Broadcaster
+  
+  
+    
+      A specific radio
+      
+      {{if .Radio.Id}}
+      Edit Radio
+      {{else}}
+      Register New Radio
+      {{end}}
+      
+      
+      {{if .Radio.Id}}
+      Delete
+      
+      {{end}}
+    
+  
+
diff --git a/templates/radios.html b/templates/radios.html
new file mode 100644
index 0000000..255fbe2
--- /dev/null
+++ b/templates/radios.html
@@ -0,0 +1,19 @@
+
+
+  
+    
+    
+    Broadcaster
+  
+  
+    
+      Radios
+      
+      {{range .Radios}}
+        - {{.Name}} {{.Token}} (Edit)
 
+      {{end}}
+      
+      Register New Radio
+    
+  
+
diff --git a/templates/radios.partial.html b/templates/radios.partial.html
new file mode 100644
index 0000000..fb36ca8
--- /dev/null
+++ b/templates/radios.partial.html
@@ -0,0 +1,82 @@
+{{if .Radios}}
+{{range .Radios}}
+
+
+    | 
+    {{.Name}}
+     | 
+
+
+    
+    
+        
+        | 
+        Local Time
+         | 
+        
+        {{.LocalTime}}
+         | 
+         
+        
+        | 
+        Time Zone
+         | 
+        
+        {{.TimeZone}}
+         | 
+         
+        
+        | 
+        Files In Sync
+         | 
+        
+        {{if .FilesInSync}} Yes {{else}} No {{end}}
+         | 
+         
+     
+     | 
+
+
+    | 
+    {{.ChannelState}}
+     | 
+    
+    
+        
+        | 
+            Playlist:
+         | 
+        
+            {{.Playlist}}
+         | 
+         
+        
+        | 
+            File:
+         | 
+        
+            {{.File}}
+         | 
+         
+        
+        | 
+            Status:
+         | 
+        
+            {{.Status}}
+         | 
+         
+     
+     | 
+    
+    
+     | 
+
+
+{{end}}
+{{else}}
+There are no radios online.
+{{end}}
-- 
2.39.5