]> code.octet-stream.net Git - broadcaster/commitdiff
Initial commit, basic functionality working
authorThomas Karpiniec <tom.karpiniec@outlook.com>
Sat, 19 Oct 2024 01:59:43 +0000 (12:59 +1100)
committerThomas Karpiniec <tom.karpiniec@outlook.com>
Sat, 19 Oct 2024 01:59:43 +0000 (12:59 +1100)
27 files changed:
.gitignore [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]
protocol/protocol.go [new file with mode: 0644]
radio/config.go [new file with mode: 0644]
radio/files_machine.go [new file with mode: 0644]
radio/gpio.go [new file with mode: 0644]
radio/radio.go [new file with mode: 0644]
radio/status.go [new file with mode: 0644]
server/broadcaster.go [new file with mode: 0644]
server/config.go [new file with mode: 0644]
server/database.go [new file with mode: 0644]
server/files.go [new file with mode: 0644]
server/model.go [new file with mode: 0644]
server/radio_sync.go [new file with mode: 0644]
server/session.go [new file with mode: 0644]
server/status.go [new file with mode: 0644]
server/web_sync.go [new file with mode: 0644]
templates/files.html [new file with mode: 0644]
templates/index.html [new file with mode: 0644]
templates/login.html [new file with mode: 0644]
templates/logout.html [new file with mode: 0644]
templates/playlist.html [new file with mode: 0644]
templates/playlists.html [new file with mode: 0644]
templates/radio.html [new file with mode: 0644]
templates/radios.html [new file with mode: 0644]
templates/radios.partial.html [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..97571d1
--- /dev/null
@@ -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 (file)
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 (file)
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 (file)
index 0000000..d168bc3
--- /dev/null
@@ -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 (file)
index 0000000..3152653
--- /dev/null
@@ -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 (file)
index 0000000..ea2f775
--- /dev/null
@@ -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 (file)
index 0000000..69807ac
--- /dev/null
@@ -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 (file)
index 0000000..f6b3b50
--- /dev/null
@@ -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 (file)
index 0000000..797a28f
--- /dev/null
@@ -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 (file)
index 0000000..2e09249
--- /dev/null
@@ -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 (file)
index 0000000..4a4866c
--- /dev/null
@@ -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 (file)
index 0000000..e6ce752
--- /dev/null
@@ -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 (file)
index 0000000..4d6e293
--- /dev/null
@@ -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 (file)
index 0000000..94a6ab4
--- /dev/null
@@ -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 (file)
index 0000000..3dfd4ed
--- /dev/null
@@ -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 (file)
index 0000000..4b4c445
--- /dev/null
@@ -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 (file)
index 0000000..accbcc9
--- /dev/null
@@ -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 (file)
index 0000000..e59403d
--- /dev/null
@@ -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 (file)
index 0000000..70d666f
--- /dev/null
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Broadcaster</title>
+  </head>
+  <body>
+    <main>
+      <h1>Files! List</h1>
+      <ul>
+      {{range .Files}}
+        <li><b>{{.Name}}</b><form action="/file/delete" method="POST"><input type="hidden" name="filename" value="{{.Name}}"><input type="submit" value="Delete"></form></li>
+      {{end}}
+      </ul>
+      <h2>Upload New File</h2>
+      <p>
+      <form action="/file/upload" method="post" enctype="multipart/form-data">
+        <input type="file" name="file">
+        <input type="submit" value="Upload">
+      </form>
+      </p>
+    </main>
+  </body>
+</html>
diff --git a/templates/index.html b/templates/index.html
new file mode 100644 (file)
index 0000000..5f4d747
--- /dev/null
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Broadcaster</title>
+    <style type="text/css">
+      table.radio-status, td.outer {
+        border: 1px solid;
+      }
+      table.inner {
+        border-collapse: collapse;
+      }
+      td.clear {
+        width: 5em;
+        height: 5em;
+        text-align: center;
+      }
+      .time-table {
+        font-size: 90%;
+      }
+      .playlist-field {
+        text-align: right;
+        padding-right: 1em;
+        width: 5em;
+      }
+      .playlist-table {
+        font-size: 90%;
+        width: 30em;
+      }
+      .stop {
+        text-align: center;
+      }
+      .head {
+        text-align: center;
+      }
+    </style>
+    <script type="text/javascript">
+      function connectWebsocket() {
+        console.log("Attempting to create websocket connection for radio status sync")
+        const cookieValue = document.cookie
+          .split("; ")
+          .find((row) => row.startsWith("broadcast_session="))
+          ?.split("=")[1];
+        const socket = new WebSocket("/websync");
+        socket.addEventListener("open", (event) => {
+          socket.send(cookieValue);
+        });
+        socket.addEventListener("message", (event) => {
+          console.log("Received a status update from server")
+          const connected = document.getElementById('connected-radios');
+          connected.innerHTML = event.data;
+        });
+        socket.addEventListener("close", (event) => {
+          console.log("Websocket closed. Will retry in 10 seconds.")
+          setTimeout(connectWebsocket, 10000);
+        });
+      }
+      // initial connection on page load
+      connectWebsocket();
+    </script>
+  </head>
+  <body>
+    <main>
+      <h1>Welcome!</h1>
+      {{if .LoggedIn}}
+      <p>Your username is: {{.Username}}.</p>
+      <p><a href="/logout">Log Out</a></p>
+      {{else}}
+      <p><a href="/login">Log In</a></p>
+      {{end}}
+      <p><a href="/file/">File Management</a></p>
+      <p><a href="/playlist/">Playlist Management</a></p>
+      <p><a href="/radio/">Radio Management</a></p>
+      <h2>Connected Radios</h2>
+      <div id="connected-radios">
+        <i>Loading...</i>
+      </div>
+    </main>
+  </body>
+</html>
diff --git a/templates/login.html b/templates/login.html
new file mode 100644 (file)
index 0000000..56772b9
--- /dev/null
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Broadcaster Log In</title>
+  </head>
+  <body>
+    <main>
+      <h1>Log In</h1>
+      <form action="/login" method="post">
+        {{if ne .Error ""}}
+        <p><b>{{.Error}}</b></p>
+        {{end}}
+        <label for="username">Username:</label><br>
+        <input type="text" id="username" name="username"><br>
+        <label for="password">Password:</label><br>
+        <input type="password" id="password" name="password"><br>
+        <input type="submit" value="Log In">
+      </form>
+    </main>
+  </body>
+</html>
diff --git a/templates/logout.html b/templates/logout.html
new file mode 100644 (file)
index 0000000..b151265
--- /dev/null
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Broadcaster Log Out</title>
+  </head>
+  <body>
+    <main>
+        <h1>Logged Out</h1>
+        <p><a href="/login">Log In again</a></p>
+    </main>
+  </body>
+</html>
diff --git a/templates/playlist.html b/templates/playlist.html
new file mode 100644 (file)
index 0000000..88f17b7
--- /dev/null
@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Broadcaster</title>
+    <script type="text/javascript">
+    function deleteItem(sender) {
+      sender.parentNode.remove();
+    }
+    function addItem() {
+      const p = document.createElement('p');
+      const temp = document.getElementById('item-template');
+      p.innerHTML = temp.innerHTML;
+      const marker = document.getElementById('add-item');
+      const parent = marker.parentNode;
+      parent.insertBefore(p, marker);
+    }
+    </script>
+  </head>
+  <body>
+    <main>
+      <h1>A specific playlist</h1>
+      <h2>
+      {{if .Playlist.Id}}
+      Edit Playlist
+      {{else}}
+      Create New Playlist
+      {{end}}
+      </h2>
+      <form action="/playlist/submit" method="POST">
+        <input type="hidden" name="playlistId" value="{{.Playlist.Id}}">
+        <p>
+        <input type="checkbox" id="playlistEnabled" name="playlistEnabled" value="1" {{if .Playlist.Enabled}} checked {{end}}>
+        <label for="playlistEnabled">Playlist enabled?</label><br>
+        </p>
+        <p>
+        <label for="playlistName">Name:</label>
+        <input type="text" id="playlistName" name="playlistName" value="{{.Playlist.Name}}">
+        </p>
+        <p>
+        <label for="playlistStartTime">Transmission Start:</label>
+        <input type="datetime-local" id="playlistStartTime" name="playlistStartTime" value="{{.Playlist.StartTime}}">
+        </p>
+        <h3>Playlist Items</h3>
+        {{range .Entries}}
+        <p>
+        Wait until
+        <input type="text" name="delaySeconds" value="{{.DelaySeconds}}">
+        seconds from
+        <select name="isRelative">
+          <option value="0">start of transmission</option>
+          <option value="1" {{if .IsRelative}} selected="selected" {{end}}>previous item</option>
+        </select>
+        then play
+        <select name="filename">{{$f := .Filename}}
+          <option value="">(no file selected)</option>
+          {{range $.Files}}
+          <option value="{{.}}" {{if eq . $f }} selected="selected" {{end}}>{{.}}</option>
+          {{end}}
+        </select>
+        <a href="#" onclick="deleteItem(this)">(Delete Item)</a>
+        </p>
+        {{end}}
+        <p>
+        <a href="#" onclick="addItem()" id="add-item">Add Item</a>
+        </p>
+        <p>
+        <input type="submit" value="Save Playlist">
+        </p>
+      </form>
+      {{if .Playlist.Id}}
+      <h3>Delete</h3>
+      <form action="/playlist/delete" method="POST">
+        <input type="hidden" name="playlistId" value="{{.Playlist.Id}}">
+        <p>
+        <input type="submit" value="Delete Playlist">
+        </p>
+      </form>
+      {{end}}
+      <template id="item-template">
+        Wait until
+        <input type="text" name="delaySeconds" value="0">
+        seconds from
+        <select name="isRelative">
+          <option value="0">start of transmission</option>
+          <option value="1">previous item</option>
+        </select>
+        then play
+        <select name="filename">
+          <option value="">(no file selected)</option>
+          {{range $.Files}}
+          <option value="{{.}}">{{.}}</option>
+          {{end}}
+        </select>
+        <a href="#" onclick="deleteItem(this)">(Delete Item)</a>
+      </template>
+    </main>
+  </body>
+</html>
diff --git a/templates/playlists.html b/templates/playlists.html
new file mode 100644 (file)
index 0000000..207d020
--- /dev/null
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Broadcaster</title>
+  </head>
+  <body>
+    <main>
+      <h1>Playlists!</h1>
+      <ul>
+      {{range .Playlists}}
+        <li><b>{{.Name}}</b> {{.StartTime}} <a href="/playlist/{{.Id}}">(Edit)</a></li>
+      {{end}}
+      </ul>
+      <p><a href="/playlist/new">Add New Playlist</a></p>
+    </main>
+  </body>
+</html>
diff --git a/templates/radio.html b/templates/radio.html
new file mode 100644 (file)
index 0000000..1c3dde0
--- /dev/null
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Broadcaster</title>
+  </head>
+  <body>
+    <main>
+      <h1>A specific radio</h1>
+      <h2>
+      {{if .Radio.Id}}
+      Edit Radio
+      {{else}}
+      Register New Radio
+      {{end}}
+      </h2>
+      <form action="/radio/submit" method="POST">
+        <input type="hidden" name="radioId" value="{{.Radio.Id}}">
+        <p>
+        <label for="radioName">Name:</label>
+        <input type="text" id="radioName" name="radioName" value="{{.Radio.Name}}">
+        </p>
+        <p>
+        Authentication token: <b>{{.Radio.Token}}</b>
+        <input type="hidden" name="radioToken" value="{{.Radio.Token}}">
+        </p>
+        <p>
+        <input type="submit" value="Save Radio">
+        </p>
+      </form>
+      {{if .Radio.Id}}
+      <h3>Delete</h3>
+      <form action="/radio/delete" method="POST">
+        <input type="hidden" name="radioId" value="{{.Radio.Id}}">
+        <p>
+        <input type="submit" value="Delete Radio">
+        </p>
+      </form>
+      {{end}}
+    </main>
+  </body>
+</html>
diff --git a/templates/radios.html b/templates/radios.html
new file mode 100644 (file)
index 0000000..255fbe2
--- /dev/null
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Broadcaster</title>
+  </head>
+  <body>
+    <main>
+      <h1>Radios</h1>
+      <ul>
+      {{range .Radios}}
+        <li><b>{{.Name}}</b> {{.Token}} <a href="/radio/{{.Id}}">(Edit)</a></li>
+      {{end}}
+      </ul>
+      <p><a href="/radio/new">Register New Radio</a></p>
+    </main>
+  </body>
+</html>
diff --git a/templates/radios.partial.html b/templates/radios.partial.html
new file mode 100644 (file)
index 0000000..fb36ca8
--- /dev/null
@@ -0,0 +1,82 @@
+{{if .Radios}}
+{{range .Radios}}
+<table class="radio-status">
+<tr>
+    <td colspan="3" class="outer head">
+    <b>{{.Name}}</b>
+    </td>
+</tr>
+<tr>
+    <td colspan="3" class="outer">
+    <table class="time-table">
+        <tr>
+        <td width="100em">
+        Local Time
+        </td>
+        <td>
+        {{.LocalTime}}
+        </td>
+        </tr>
+        <tr>
+        <td>
+        Time Zone
+        </td>
+        <td>
+        {{.TimeZone}}
+        </td>
+        </tr>
+        <tr>
+        <td>
+        Files In Sync
+        </td>
+        <td>
+        {{if .FilesInSync}} Yes {{else}} No {{end}}
+        </td>
+        </tr>
+    </table>
+    </td>
+</tr>
+<tr>
+    <td class="outer {{.ChannelClass}}">
+    {{.ChannelState}}
+    </td>
+    <td class="outer">
+    <table class="playlist-table">
+        <tr>
+        <td class="playlist-field">
+            Playlist:
+        </td>
+        <td>
+            {{.Playlist}}
+        </td>
+        </tr>
+        <tr>
+        <td class="playlist-field">
+            File:
+        </td>
+        <td>
+            {{.File}}
+        </td>
+        </tr>
+        <tr>
+        <td class="playlist-field">
+            Status:
+        </td>
+        <td>
+            {{.Status}}
+        </td>
+        </tr>
+    </table>
+    </td>
+    <td class="outer stop">
+    <form action="/stop" method="post">
+        <input type="hidden" name="radioId" value="{{.Id}}">
+        <input type="submit" value="Cancel Playback" {{if .DisableCancel}} disabled {{end}}>
+    </form>
+    </td>
+</tr>
+</table>
+{{end}}
+{{else}}
+<p><i>There are no radios online.</i></p>
+{{end}}