--- /dev/null
+audio
+broadcaster-server
+broadcaster-radio
+test-server.conf
+test-radio.conf
+test.db
--- /dev/null
+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
+)
--- /dev/null
+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=
--- /dev/null
+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))
+}
--- /dev/null
+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"
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+ }
+ }
+}
--- /dev/null
+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)
+}
--- /dev/null
+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
+}
--- /dev/null
+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)
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+ }
+ }
+}
--- /dev/null
+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)
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+ }
+ }
+}
--- /dev/null
+<!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>
--- /dev/null
+<!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>
--- /dev/null
+<!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>
--- /dev/null
+<!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>
--- /dev/null
+<!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>
--- /dev/null
+<!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>
--- /dev/null
+<!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>
--- /dev/null
+<!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>
--- /dev/null
+{{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}}