]> code.octet-stream.net Git - broadcaster/commitdiff
Simplify package names
authorThomas Karpiniec <tom.karpiniec@outlook.com>
Tue, 22 Oct 2024 13:22:38 +0000 (00:22 +1100)
committerThomas Karpiniec <tom.karpiniec@outlook.com>
Tue, 22 Oct 2024 23:14:51 +0000 (10:14 +1100)
58 files changed:
broadcaster-radio/config.go [deleted file]
broadcaster-radio/files_machine.go [deleted file]
broadcaster-radio/gpio.go [deleted file]
broadcaster-radio/main.go [deleted file]
broadcaster-radio/status.go [deleted file]
broadcaster-server/command.go [deleted file]
broadcaster-server/config.go [deleted file]
broadcaster-server/database.go [deleted file]
broadcaster-server/files.go [deleted file]
broadcaster-server/main.go [deleted file]
broadcaster-server/model.go [deleted file]
broadcaster-server/playlist.go [deleted file]
broadcaster-server/radio_sync.go [deleted file]
broadcaster-server/session.go [deleted file]
broadcaster-server/status.go [deleted file]
broadcaster-server/templates/change_password.html [deleted file]
broadcaster-server/templates/files.html [deleted file]
broadcaster-server/templates/footer.html [deleted file]
broadcaster-server/templates/header.html [deleted file]
broadcaster-server/templates/index.html [deleted file]
broadcaster-server/templates/login.html [deleted file]
broadcaster-server/templates/logout.html [deleted file]
broadcaster-server/templates/playlist.html [deleted file]
broadcaster-server/templates/playlists.html [deleted file]
broadcaster-server/templates/radio.html [deleted file]
broadcaster-server/templates/radios.html [deleted file]
broadcaster-server/templates/radios.partial.html [deleted file]
broadcaster-server/user.go [deleted file]
broadcaster-server/web_sync.go [deleted file]
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/main.go [new file with mode: 0644]
radio/status.go [new file with mode: 0644]
server/command.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/main.go [new file with mode: 0644]
server/model.go [new file with mode: 0644]
server/playlist.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/templates/change_password.html [new file with mode: 0644]
server/templates/files.html [new file with mode: 0644]
server/templates/footer.html [new file with mode: 0644]
server/templates/header.html [new file with mode: 0644]
server/templates/index.html [new file with mode: 0644]
server/templates/login.html [new file with mode: 0644]
server/templates/logout.html [new file with mode: 0644]
server/templates/playlist.html [new file with mode: 0644]
server/templates/playlists.html [new file with mode: 0644]
server/templates/radio.html [new file with mode: 0644]
server/templates/radios.html [new file with mode: 0644]
server/templates/radios.partial.html [new file with mode: 0644]
server/user.go [new file with mode: 0644]
server/web_sync.go [new file with mode: 0644]

diff --git a/broadcaster-radio/config.go b/broadcaster-radio/config.go
deleted file mode 100644 (file)
index 6837b7d..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-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 + "/radio-ws"
-}
diff --git a/broadcaster-radio/files_machine.go b/broadcaster-radio/files_machine.go
deleted file mode 100644 (file)
index 143397a..0000000
+++ /dev/null
@@ -1,117 +0,0 @@
-package main
-
-import (
-       "code.octet-stream.net/broadcaster/internal/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 + "/file-downloads/" + filename)
-       if err != nil {
-               downloadResult <- err
-               return
-       }
-       defer resp.Body.Close()
-       _, err = io.Copy(out, resp.Body)
-       downloadResult <- err
-}
diff --git a/broadcaster-radio/gpio.go b/broadcaster-radio/gpio.go
deleted file mode 100644 (file)
index 69807ac..0000000
+++ /dev/null
@@ -1,123 +0,0 @@
-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/broadcaster-radio/main.go b/broadcaster-radio/main.go
deleted file mode 100644 (file)
index 6e31772..0000000
+++ /dev/null
@@ -1,334 +0,0 @@
-package main
-
-import (
-       "code.octet-stream.net/broadcaster/internal/protocol"
-       "encoding/json"
-       "flag"
-       "fmt"
-       "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 version = "v1.0.0"
-const sampleRate = 44100
-
-var config RadioConfig = NewRadioConfig()
-
-func main() {
-       configFlag := flag.String("c", "", "path to configuration file")
-       versionFlag := flag.Bool("v", false, "print version and exit")
-       flag.Parse()
-
-       if *versionFlag {
-               fmt.Println("Broadcaster Radio", version)
-               os.Exit(0)
-       }
-       if *configFlag == "" {
-               log.Fatal("must specify a configuration file with -c")
-       }
-
-       log.Println("Broadcaster Radio", version, "starting up")
-       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)
-
-       stop := make(chan bool)
-       playlistSpecChan := make(chan []protocol.PlaylistSpec)
-       go playlistWorker(playlistSpecChan, stop)
-
-       for {
-               runWebsocket(fileSpecChan, playlistSpecChan, stop)
-               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, stop chan bool) 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
-               }
-
-               if t == protocol.StopType {
-                       stop <- true
-               }
-       }
-}
-
-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, stop <-chan bool) {
-       var specs []protocol.PlaylistSpec
-       isPlaying := false
-       playbackFinished := make(chan error)
-       cancel := make(chan bool)
-       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
-                       cancel = make(chan bool)
-               case <-timerCh:
-                       timer = nil
-                       isPlaying = true
-                       for _, v := range specs {
-                               if v.Id == nextId {
-                                       go playPlaylist(v, playbackFinished, cancel)
-                               }
-                       }
-               case <-stop:
-                       if isPlaying {
-                               log.Println("Cancelling playlist in progress")
-                               cancel <- true
-                       }
-               }
-
-               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, cancel <-chan bool) {
-       startTime := time.Now()
-       log.Println("Beginning playback of playlist", playlist.Name)
-entries:
-       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,
-               }
-               select {
-               case <-time.After(duration):
-               case <-cancel:
-                       break entries
-               }
-
-               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
-                       })))
-               }
-
-               select {
-               case <-done:
-               case <-cancel:
-                       ptt.DisengagePTT()
-                       break entries
-               }
-               ptt.DisengagePTT()
-       }
-       log.Println("Playlist finished", playlist.Name)
-       statusCollector.PlaylistBeginIdle <- true
-       playbackFinished <- nil
-}
diff --git a/broadcaster-radio/status.go b/broadcaster-radio/status.go
deleted file mode 100644 (file)
index 7836815..0000000
+++ /dev/null
@@ -1,140 +0,0 @@
-package main
-
-import (
-       "code.octet-stream.net/broadcaster/internal/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/broadcaster-server/command.go b/broadcaster-server/command.go
deleted file mode 100644 (file)
index 346e842..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-package main
-
-import (
-       "code.octet-stream.net/broadcaster/internal/protocol"
-       "encoding/json"
-       "golang.org/x/net/websocket"
-       "sync"
-)
-
-type CommandRouter struct {
-       connsMutex sync.Mutex
-       conns      map[int]*websocket.Conn
-}
-
-var commandRouter CommandRouter
-
-func InitCommandRouter() {
-       commandRouter.conns = make(map[int]*websocket.Conn)
-}
-
-func (c *CommandRouter) AddWebsocket(radioId int, ws *websocket.Conn) {
-       c.connsMutex.Lock()
-       defer c.connsMutex.Unlock()
-       c.conns[radioId] = ws
-}
-
-func (c *CommandRouter) RemoveWebsocket(ws *websocket.Conn) {
-       c.connsMutex.Lock()
-       defer c.connsMutex.Unlock()
-       key := -1
-       for k, v := range c.conns {
-               if v == ws {
-                       key = k
-               }
-       }
-       if key != -1 {
-               delete(c.conns, key)
-       }
-
-}
-
-func (c *CommandRouter) Stop(radioId int) {
-       c.connsMutex.Lock()
-       defer c.connsMutex.Unlock()
-       ws := c.conns[radioId]
-       if ws != nil {
-               stop := protocol.StopMessage{
-                       T: protocol.StopType,
-               }
-               msg, _ := json.Marshal(stop)
-               ws.Write(msg)
-       }
-}
diff --git a/broadcaster-server/config.go b/broadcaster-server/config.go
deleted file mode 100644 (file)
index 4a4866c..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-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/broadcaster-server/database.go b/broadcaster-server/database.go
deleted file mode 100644 (file)
index 1312208..0000000
+++ /dev/null
@@ -1,247 +0,0 @@
-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 journal_mode = WAL")
-       if err != nil {
-               log.Fatal(err)
-       }
-
-       _, err = db.sqldb.Exec("PRAGMA foreign_keys = ON")
-       if err != nil {
-               log.Fatal(err)
-       }
-
-       _, err = db.sqldb.Exec("PRAGMA busy_timeout = 5000")
-       if err != nil {
-               log.Fatal(err)
-       }
-
-       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);
-       CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT, is_admin INTEGER);
-       `
-       _, 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) GetUserNameForSession(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) GetUser(username string) (User, error) {
-       var user User
-       err := d.sqldb.QueryRow("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", username).Scan(&user.Id, &user.Username, &user.PasswordHash, &user.IsAdmin)
-       if err != nil {
-               return User{}, errors.New("no user with that username")
-       }
-       return user, nil
-}
-
-func (d *Database) GetUsers() []User {
-       ret := make([]User, 0)
-       rows, err := d.sqldb.Query("SELECT id, username, password_hash, is_admin FROM users ORDER BY username ASC")
-       if err != nil {
-               return ret
-       }
-       defer rows.Close()
-       for rows.Next() {
-               var u User
-               if err := rows.Scan(&u.Id, &u.Username, &u.PasswordHash, &u.IsAdmin); err != nil {
-                       return ret
-               }
-               ret = append(ret, u)
-       }
-       return ret
-}
-
-func (d *Database) SetUserPassword(username string, passwordHash string) {
-       d.sqldb.Exec("UPDATE users SET password_hash = ? WHERE username = ?", passwordHash, username)
-}
-
-func (d *Database) ClearOtherSessions(username string, token string) {
-       d.sqldb.Exec("DELETE FROM sessions WHERE username = ? AND token != ?", username, token)
-}
-
-func (d *Database) SetUserIsAdmin(username string, isAdmin bool) {
-       d.sqldb.Exec("UPDATE users SET is_admin = ? WHERE username = ?", isAdmin, username)
-}
-
-func (d *Database) CreateUser(user User) error {
-       _, err := d.sqldb.Exec("INSERT INTO users (username, password_hash, is_admin) values (?, ?, ?)", user.Username, user.PasswordHash, user.IsAdmin)
-       return err
-}
-
-func (d *Database) DeleteUser(username string) error {
-       _, err := d.sqldb.Exec("DELETE FROM users WHERE username = ?", username)
-       return err
-}
-
-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/broadcaster-server/files.go b/broadcaster-server/files.go
deleted file mode 100644 (file)
index b452997..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-package main
-
-import (
-       "crypto/sha256"
-       "encoding/hex"
-       "io"
-       "log"
-       "os"
-       "path/filepath"
-       "sync"
-)
-
-type FileSpec struct {
-       Name string
-       Hash string
-}
-
-type AudioFiles struct {
-       path       string
-       list       []FileSpec
-       changeWait chan bool
-       filesMutex sync.Mutex
-}
-
-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.filesMutex.Lock()
-       defer r.filesMutex.Unlock()
-       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 {
-       r.filesMutex.Lock()
-       defer r.filesMutex.Unlock()
-       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) WatchForChanges() ([]FileSpec, chan bool) {
-       r.filesMutex.Lock()
-       defer r.filesMutex.Unlock()
-       return r.list, r.changeWait
-}
diff --git a/broadcaster-server/main.go b/broadcaster-server/main.go
deleted file mode 100644 (file)
index 5a3cbb9..0000000
+++ /dev/null
@@ -1,537 +0,0 @@
-package main
-
-import (
-       "bufio"
-       _ "embed"
-       "flag"
-       "fmt"
-       "golang.org/x/net/websocket"
-       "html/template"
-       "io"
-       "log"
-       "net/http"
-       "os"
-       "path/filepath"
-       "strconv"
-       "strings"
-       "time"
-)
-
-const version = "v1.0.0"
-const formatString = "2006-01-02T15:04"
-
-// //go:embed templates/*
-//var content embed.FS
-var content = os.DirFS("../broadcaster-server/")
-
-var config ServerConfig = NewServerConfig()
-
-func main() {
-       configFlag := flag.String("c", "", "path to configuration file")
-       addUserFlag := flag.Bool("a", false, "interactively add an admin user then exit")
-       versionFlag := flag.Bool("v", false, "print version then exit")
-       flag.Parse()
-
-       if *versionFlag {
-               fmt.Println("Broadcaster Server", version)
-               os.Exit(0)
-       }
-       if *configFlag == "" {
-               log.Fatal("must specify a configuration file with -c")
-       }
-       config.LoadFromFile(*configFlag)
-
-       InitDatabase()
-       defer db.CloseDatabase()
-
-       if *addUserFlag {
-               scanner := bufio.NewScanner(os.Stdin)
-               fmt.Println("Enter new admin username:")
-               if !scanner.Scan() {
-                       os.Exit(1)
-               }
-               username := scanner.Text()
-               fmt.Println("Enter new admin password (will be printed in the clear):")
-               if !scanner.Scan() {
-                       os.Exit(1)
-               }
-               password := scanner.Text()
-               if username == "" || password == "" {
-                       fmt.Println("Both username and password must be specified")
-                       os.Exit(1)
-               }
-               if err := users.CreateUser(username, password, true); err != nil {
-                       log.Fatal(err)
-               }
-               os.Exit(0)
-       }
-
-       log.Println("Broadcaster Server", version, "starting up")
-       InitCommandRouter()
-       InitPlaylists()
-       InitAudioFiles(config.AudioFilesPath)
-       InitServerStatus()
-
-       // Public routes
-
-       http.HandleFunc("/login", logInPage)
-       http.Handle("/file-downloads/", http.StripPrefix("/file-downloads/", http.FileServer(http.Dir(config.AudioFilesPath))))
-
-       // Authenticated routes
-
-       http.HandleFunc("/", homePage)
-       http.HandleFunc("/logout", logOutPage)
-       http.HandleFunc("/change-password", changePasswordPage)
-
-       http.HandleFunc("/playlists/", playlistSection)
-       http.HandleFunc("/files/", fileSection)
-       http.HandleFunc("/radios/", radioSection)
-
-       http.Handle("/radio-ws", websocket.Handler(RadioSync))
-       http.Handle("/web-ws", websocket.Handler(WebSync))
-       http.HandleFunc("/stop", stopPage)
-
-       // Admin routes
-
-       err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil)
-       if err != nil {
-               log.Fatal(err)
-       }
-}
-
-type HeaderData struct {
-       SelectedMenu string
-       Username string
-}
-
-func renderHeader(w http.ResponseWriter, selectedMenu string) {
-       tmpl := template.Must(template.ParseFS(content, "templates/header.html"))
-       data := HeaderData{
-               SelectedMenu: selectedMenu,
-               Username: "username",
-       }
-       err := tmpl.Execute(w, data)
-       if err != nil {
-               log.Fatal(err)
-       }
-}
-
-func renderFooter(w http.ResponseWriter) {
-       tmpl := template.Must(template.ParseFS(content, "templates/footer.html"))
-       err := tmpl.Execute(w, nil)
-       if err != nil {
-               log.Fatal(err)
-       }
-}
-
-type HomeData struct {
-       LoggedIn bool
-       Username string
-}
-
-func homePage(w http.ResponseWriter, r *http.Request) {
-       renderHeader(w, "status")
-       tmpl := template.Must(template.ParseFS(content, "templates/index.html"))
-       data := HomeData{
-               LoggedIn: true,
-               Username: "Bob",
-       }
-       tmpl.Execute(w, data)
-       renderFooter(w)
-}
-
-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"]
-       errText := ""
-       if username != nil {
-               user, err := users.Authenticate(username[0], password[0])
-               if err != nil {
-                       errText = "Incorrect login"
-               } else {
-                       createSessionCookie(w, user.Username)
-                       http.Redirect(w, r, "/", http.StatusFound)
-                       return
-               }
-       }
-
-       data := LogInData{
-               Error: errText,
-       }
-       renderHeader(w, "")
-       tmpl := template.Must(template.ParseFS(content, "templates/login.html"))
-       tmpl.Execute(w, data)
-       renderFooter(w)
-}
-
-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 ChangePasswordPageData struct {
-       Message  string
-       ShowForm bool
-}
-
-func changePasswordPage(w http.ResponseWriter, r *http.Request) {
-       user, err := currentUser(w, r)
-       if err != nil {
-               http.Redirect(w, r, "/login", http.StatusFound)
-               return
-       }
-       var data ChangePasswordPageData
-       if r.Method == "POST" {
-               err := r.ParseForm()
-               if err != nil {
-                       w.WriteHeader(http.StatusBadRequest)
-                       return
-               }
-               oldPassword := r.Form.Get("oldPassword")
-               newPassword := r.Form.Get("newPassword")
-               err = users.UpdatePassword(user.Username, oldPassword, newPassword)
-               if err != nil {
-                       data.Message = "Failed to change password: " + err.Error()
-                       data.ShowForm = true
-               } else {
-                       data.Message = "Successfully changed password"
-                       data.ShowForm = false
-                       cookie, err := r.Cookie("broadcast_session")
-                       if err == nil {
-                               log.Println("clearing other sessions for username", user.Username, "token", cookie.Value)
-                               db.ClearOtherSessions(user.Username, cookie.Value)
-                       }
-               }
-       } else {
-               data.Message = ""
-               data.ShowForm = true
-       }
-       renderHeader(w, "change-password")
-       tmpl := template.Must(template.ParseFS(content, "templates/change_password.html"))
-       err = tmpl.Execute(w, data)
-       if err != nil {
-               log.Fatal(err)
-       }
-       renderFooter(w)
-}
-
-type PlaylistsPageData struct {
-       Playlists []Playlist
-}
-
-func playlistsPage(w http.ResponseWriter, _ *http.Request) {
-       renderHeader(w, "playlists")
-       data := PlaylistsPageData{
-               Playlists: db.GetPlaylists(),
-       }
-       tmpl := template.Must(template.ParseFS(content, "templates/playlists.html"))
-       err := tmpl.Execute(w, data)
-       if err != nil {
-               log.Fatal(err)
-       }
-       renderFooter(w)
-}
-
-type RadiosPageData struct {
-       Radios []Radio
-}
-
-func radiosPage(w http.ResponseWriter, _ *http.Request) {
-       renderHeader(w, "radios")
-       data := RadiosPageData{
-               Radios: db.GetRadios(),
-       }
-       tmpl := template.Must(template.ParseFS(content, "templates/radios.html"))
-       err := tmpl.Execute(w, data)
-       if err != nil {
-               log.Fatal(err)
-       }
-       renderFooter(w)
-}
-
-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)
-       }
-       renderHeader(w, "radios")
-       tmpl := template.Must(template.ParseFS(content, "templates/playlist.html"))
-       tmpl.Execute(w, data)
-       renderFooter(w)
-}
-
-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
-               playlists.NotifyChanges()
-       }
-       http.Redirect(w, r, "/playlists/", 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)
-               playlists.NotifyChanges()
-       }
-       http.Redirect(w, r, "/playlists/", 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
-       }
-       renderHeader(w, "radios")
-       tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
-       tmpl.Execute(w, data)
-       renderFooter(w)
-}
-
-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, "/radios/", 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, "/radios/", http.StatusFound)
-}
-
-type FilesPageData struct {
-       Files []FileSpec
-}
-
-func filesPage(w http.ResponseWriter, _ *http.Request) {
-       renderHeader(w, "files")
-       data := FilesPageData{
-               Files: files.Files(),
-       }
-       log.Println("file page data", data)
-       tmpl := template.Must(template.ParseFS(content, "templates/files.html"))
-       err := tmpl.Execute(w, data)
-       if err != nil {
-               log.Fatal(err)
-       }
-       renderFooter(w)
-}
-
-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, "/files/", 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, "/files/", http.StatusFound)
-}
-
-func logOutPage(w http.ResponseWriter, r *http.Request) {
-       clearSessionCookie(w)
-       renderHeader(w, "")
-       tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
-       tmpl.Execute(w, nil)
-       renderFooter(w)
-}
-
-func stopPage(w http.ResponseWriter, r *http.Request) {
-       _, err := currentUser(w, r)
-       if err != nil {
-               http.Redirect(w, r, "/login", http.StatusFound)
-               return
-       }
-       r.ParseForm()
-       radioId, err := strconv.Atoi(r.Form.Get("radioId"))
-       if err != nil {
-               http.NotFound(w, r)
-               return
-       }
-       commandRouter.Stop(radioId)
-       http.Redirect(w, r, "/", http.StatusFound)
-}
diff --git a/broadcaster-server/model.go b/broadcaster-server/model.go
deleted file mode 100644 (file)
index 11f94e7..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-package main
-
-type PlaylistEntry struct {
-       Id           int
-       Position     int
-       Filename     string
-       DelaySeconds int
-       IsRelative   bool
-}
-
-type User struct {
-       Id           int
-       Username     string
-       PasswordHash string
-       IsAdmin      bool
-}
-
-type Playlist struct {
-       Id        int
-       Enabled   bool
-       Name      string
-       StartTime string
-}
-
-type Radio struct {
-       Id    int
-       Name  string
-       Token string
-}
diff --git a/broadcaster-server/playlist.go b/broadcaster-server/playlist.go
deleted file mode 100644 (file)
index f0a6c89..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-package main
-
-import (
-       "sync"
-)
-
-type Playlists struct {
-       changeWait    chan bool
-       playlistMutex sync.Mutex
-}
-
-var playlists Playlists
-
-func InitPlaylists() {
-       playlists.changeWait = make(chan bool)
-}
-
-func (p *Playlists) GetPlaylists() []Playlist {
-       p.playlistMutex.Lock()
-       defer p.playlistMutex.Unlock()
-       return db.GetPlaylists()
-}
-
-func (p *Playlists) WatchForChanges() ([]Playlist, chan bool) {
-       p.playlistMutex.Lock()
-       defer p.playlistMutex.Unlock()
-       return db.GetPlaylists(), p.changeWait
-}
-
-func (p *Playlists) NotifyChanges() {
-       p.playlistMutex.Lock()
-       defer p.playlistMutex.Unlock()
-       close(p.changeWait)
-       p.changeWait = make(chan bool)
-}
diff --git a/broadcaster-server/radio_sync.go b/broadcaster-server/radio_sync.go
deleted file mode 100644 (file)
index 66521f8..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-package main
-
-import (
-       "code.octet-stream.net/broadcaster/internal/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
-                       commandRouter.AddWebsocket(r.Id, ws)
-                       defer commandRouter.RemoveWebsocket(ws)
-
-                       go KeepFilesUpdated(ws)
-                       go KeepPlaylistsUpdated(ws)
-               }
-
-               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, p []Playlist) error {
-       playlistSpecs := make([]protocol.PlaylistSpec, 0)
-       for _, v := range p {
-               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 {
-               p, ch := playlists.WatchForChanges()
-               err := sendPlaylistsMessageToRadio(ws, p)
-               if err != nil {
-                       return
-               }
-               <-ch
-       }
-}
-
-func sendFilesMessageToRadio(ws *websocket.Conn, f []FileSpec) error {
-       specs := make([]protocol.FileSpec, 0)
-       for _, v := range f {
-               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 {
-               f, ch := files.WatchForChanges()
-               err := sendFilesMessageToRadio(ws, f)
-               if err != nil {
-                       return
-               }
-               <-ch
-       }
-}
diff --git a/broadcaster-server/session.go b/broadcaster-server/session.go
deleted file mode 100644 (file)
index a097989..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-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(_ http.ResponseWriter, r *http.Request) (User, error) {
-       cookie, e := r.Cookie("broadcast_session")
-       if e != nil {
-               return User{}, e
-       }
-
-       return users.GetUserForSession(cookie.Value)
-}
-
-func createSessionCookie(w http.ResponseWriter, username string) {
-       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(username, 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/broadcaster-server/status.go b/broadcaster-server/status.go
deleted file mode 100644 (file)
index b2edd49..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-package main
-
-import (
-       "code.octet-stream.net/broadcaster/internal/protocol"
-       "sync"
-)
-
-type ServerStatus struct {
-       statuses      map[int]protocol.StatusMessage
-       statusesMutex sync.Mutex
-       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.statusesMutex.Lock()
-       defer s.statusesMutex.Unlock()
-       s.statuses[radioId] = status
-       s.TriggerChange()
-}
-
-func (s *ServerStatus) RadioDisconnected(radioId int) {
-       s.statusesMutex.Lock()
-       defer s.statusesMutex.Unlock()
-       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 {
-       s.statusesMutex.Lock()
-       defer s.statusesMutex.Unlock()
-       c := make(map[int]protocol.StatusMessage)
-       for k, v := range s.statuses {
-               c[k] = v
-       }
-       return c
-}
-
-func (s *ServerStatus) ChangeChannel() chan bool {
-       return s.changeWait
-}
diff --git a/broadcaster-server/templates/change_password.html b/broadcaster-server/templates/change_password.html
deleted file mode 100644 (file)
index bab3a82..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-
-      <h1>Change Password</h1>
-      {{if ne .Message ""}}
-      <p><b>{{.Message}}</b></p>
-      {{end}}
-      {{if .ShowForm}}
-      <form action="/change-password" method="post">
-        <label for="oldPassword">Old Password:</label><br>
-        <input type="password" id="oldPassword" name="oldPassword"><br>
-        <label for="newPassword">New Password:</label><br>
-        <input type="password" id="newPassword" name="newPassword"><br>
-        <input type="submit" value="Change Password">
-      </form>
-      {{end}}
diff --git a/broadcaster-server/templates/files.html b/broadcaster-server/templates/files.html
deleted file mode 100644 (file)
index c86c6ce..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-
-      <h1>Files! List</h1>
-      <p>All files can be downloaded from the <a href="/file-downloads/">public file listing</a>.</p>
-      <ul>
-      {{range .Files}}
-        <li><b>{{.Name}}</b><form action="/files/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="/files/upload" method="post" enctype="multipart/form-data">
-        <input type="file" name="file">
-        <input type="submit" value="Upload">
-      </form>
-      </p>
diff --git a/broadcaster-server/templates/footer.html b/broadcaster-server/templates/footer.html
deleted file mode 100644 (file)
index df60a12..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-      </div>
-    </main>
-  </body>
-</html>
diff --git a/broadcaster-server/templates/header.html b/broadcaster-server/templates/header.html
deleted file mode 100644 (file)
index ae59b16..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-<!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 {
-        text-align: center;
-        background-color: #eeeeee;
-      }
-      .time-table {
-        font-size: 90%;
-      }
-      .playlist-field {
-        text-align: right;
-        padding-right: 1em;
-        width: 5em;
-      }
-      .playlist-table {
-        font-size: 90%;
-      }
-      .stop {
-        text-align: center;
-      }
-      .head {
-        text-align: center;
-      }
-      body {
-        background-color: #447744;
-        font-family: "sans-serif";
-      }
-      main {
-        background-color: #dddddd;
-        max-width: 80em;
-        margin-left: auto;
-        margin-right: auto;
-        display: flex;
-        flex-wrap: wrap;
-        flex-direction: row;
-        border: 2px solid black;
-      }
-      .menu {
-        min-width: 12em;
-        display: flex;
-        flex-grow: 1;
-        flex-direction: column;
-        /* border: black solid; */
-        /* border-width: 0px 2px 0px 0px; */
-        text-align: center;
-      }
-      .menu-item {
-        height: 2em;
-        text-align: center;
-        line-height: 2em;
-        background-color: #aaaaaa;
-        border: black solid;
-        border-width: 1px 1px 0px 0px;
-      }
-      .menu-item a {
-        text-decoration: none;
-        color: black;
-      }
-      .menu-item.logout {
-        border-width: 1px 1px 1px 0px;
-      }
-      .menu-item:first-of-type {
-        border-width: 0px 1px 0px 0px;
-      }
-      .menu-item.selected {
-        background-color: #aaccaa;
-      }
-      .logged-in {
-        padding-top: 2em;
-        padding-bottom: 2em;
-        text-align: center;
-        border: black solid;
-        border-width: 0px 1px 0px 0px;
-      }
-      .menu-tail {
-        flex-grow: 1;
-        height: 2em;
-        line-height: 2em;
-        border: black solid;
-        border-width: 0px 1px 0px 0px;
-      }
-      .content {
-        flex-grow: 3;
-        padding: 5em;
-        padding-top: 1em;
-        overflow-wrap: break-word;
-        max-width: 50em;
-      }
-    </style>
-  </head>
-  <body>
-    <main>
-    {{if .SelectedMenu}}
-        <div class="menu">
-            <div class="menu-item {{if eq .SelectedMenu "status"}}selected{{end}}"><a href="/">Status</a></div>
-            <div class="menu-item {{if eq .SelectedMenu "files"}}selected{{end}}"><a href="/files/">Files</a></div>
-            <div class="menu-item {{if eq .SelectedMenu "playlists"}}selected{{end}}"><a href="/playlists/">Playlists</a></div>
-            <div class="menu-item {{if eq .SelectedMenu "radios"}}selected{{end}}"><a href="/radios/">Radios</a></div>
-            <div class="menu-item {{if eq .SelectedMenu "users"}}selected{{end}}"><a href="/users/">Users</a></div>
-            <div class="menu-item {{if eq .SelectedMenu "change-password"}}selected{{end}}"><a href="/change-password">Change Password</a></div>
-            <div class="menu-item logout"><a href="/logout">Log Out</a></div>
-            {{if .Username}}
-            <div class="logged-in">Logged in as:<br><i>{{.Username}}</i></div>
-            {{end}}
-            <div class="menu-tail"><small><i>broadcaster-server v1.0.0</i></small></div>
-        </div>
-    {{end}}
-    <div class="content">
diff --git a/broadcaster-server/templates/index.html b/broadcaster-server/templates/index.html
deleted file mode 100644 (file)
index b688947..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-    <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("/web-ws");
-        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>
-
-      <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="/files/">File Management</a></p>
-      <p><a href="/playlists/">Playlist Management</a></p>
-      <p><a href="/radios/">Radio Management</a></p>
-      <h2>Connected Radios</h2>
-      <div id="connected-radios">
-        <i>Loading...</i>
-      </div>
diff --git a/broadcaster-server/templates/login.html b/broadcaster-server/templates/login.html
deleted file mode 100644 (file)
index 2211b2f..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-
-      <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>
diff --git a/broadcaster-server/templates/logout.html b/broadcaster-server/templates/logout.html
deleted file mode 100644 (file)
index 8b7ee1c..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-
-        <h1>Logged Out</h1>
-        <p><a href="/login">Log In again</a></p>
diff --git a/broadcaster-server/templates/playlist.html b/broadcaster-server/templates/playlist.html
deleted file mode 100644 (file)
index d778250..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-
-    <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>
-
-    <h1>A specific playlist</h1>
-      <h2>
-      {{if .Playlist.Id}}
-      Edit Playlist
-      {{else}}
-      Create New Playlist
-      {{end}}
-      </h2>
-      <form action="/playlists/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="1">previous item</option>
-          <option value="0" {{if not .IsRelative}} selected="selected" {{end}}>start of transmission</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="/playlists/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="1">previous item</option>
-          <option value="0">start of transmission</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>
diff --git a/broadcaster-server/templates/playlists.html b/broadcaster-server/templates/playlists.html
deleted file mode 100644 (file)
index e1ba60f..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-
-      <h1>Playlists!</h1>
-      <ul>
-      {{range .Playlists}}
-        <li><b>{{.Name}}</b> {{.StartTime}} <a href="/playlists/{{.Id}}">(Edit)</a></li>
-      {{end}}
-      </ul>
-      <p><a href="/playlists/new">Add New Playlist</a></p>
diff --git a/broadcaster-server/templates/radio.html b/broadcaster-server/templates/radio.html
deleted file mode 100644 (file)
index c2ec9dd..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-
-      <h1>A specific radio</h1>
-      <h2>
-      {{if .Radio.Id}}
-      Edit Radio
-      {{else}}
-      Register New Radio
-      {{end}}
-      </h2>
-      <form action="/radios/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="/radios/delete" method="POST">
-        <input type="hidden" name="radioId" value="{{.Radio.Id}}">
-        <p>
-        <input type="submit" value="Delete Radio">
-        </p>
-      </form>
-      {{end}}
diff --git a/broadcaster-server/templates/radios.html b/broadcaster-server/templates/radios.html
deleted file mode 100644 (file)
index 0c1288f..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-
-      <h1>Radios</h1>
-      <ul>
-      {{range .Radios}}
-        <li><b>{{.Name}}</b> {{.Token}} <a href="/radios/{{.Id}}">(Edit)</a></li>
-      {{end}}
-      </ul>
-      <p><a href="/radios/new">Register New Radio</a></p>
diff --git a/broadcaster-server/templates/radios.partial.html b/broadcaster-server/templates/radios.partial.html
deleted file mode 100644 (file)
index 010fe0a..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-{{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" colspan="2">
-    <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>
-    <tr>
-    <td class="outer stop" colspan="3">
-    <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>
-</tr>
-</table>
-{{end}}
-{{else}}
-<p><i>There are no radios online.</i></p>
-{{end}}
diff --git a/broadcaster-server/user.go b/broadcaster-server/user.go
deleted file mode 100644 (file)
index 275f0ec..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-package main
-
-import (
-       "errors"
-       "golang.org/x/crypto/bcrypt"
-)
-
-var users Users
-
-type Users struct{}
-
-func (u *Users) GetUserForSession(token string) (User, error) {
-       username, err := db.GetUserNameForSession(token)
-       if err != nil {
-               return User{}, err
-       }
-       user, err := db.GetUser(username)
-       if err != nil {
-               return User{}, err
-       }
-       return user, nil
-}
-
-func (u *Users) Authenticate(username string, clearPassword string) (User, error) {
-       user, err := db.GetUser(username)
-       if err != nil {
-               return User{}, err
-       }
-       err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(clearPassword))
-       if err != nil {
-               return User{}, err
-       }
-       return user, nil
-}
-
-func (u *Users) CreateUser(username string, clearPassword string, isAdmin bool) error {
-       if clearPassword == "" {
-               return errors.New("password cannot be empty")
-       }
-       hashed, err := bcrypt.GenerateFromPassword([]byte(clearPassword), bcrypt.DefaultCost)
-       if err != nil {
-               return err
-       }
-       return db.CreateUser(User{
-               Id:           0,
-               Username:     username,
-               PasswordHash: string(hashed),
-               IsAdmin:      isAdmin,
-       })
-}
-
-func (u *Users) DeleteUser(username string) {
-       db.DeleteUser(username)
-}
-
-func (u *Users) UpdatePassword(username string, oldClearPassword string, newClearPassword string) error {
-       user, err := db.GetUser(username)
-       if err != nil {
-               return err
-       }
-       err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldClearPassword))
-       if err != nil {
-               return errors.New("old password is incorrect")
-       }
-       if newClearPassword == "" {
-               return errors.New("password cannot be empty")
-       }
-       hashed, err := bcrypt.GenerateFromPassword([]byte(newClearPassword), bcrypt.DefaultCost)
-       if err != nil {
-               return err
-       }
-       db.SetUserPassword(username, string(hashed))
-       return nil
-}
-
-func (u *Users) UpdateIsAdmin(username string, isAdmin bool) {
-       db.SetUserIsAdmin(username, isAdmin)
-}
-
-func (u *Users) Users() []User {
-       return db.GetUsers()
-}
diff --git a/broadcaster-server/web_sync.go b/broadcaster-server/web_sync.go
deleted file mode 100644 (file)
index a6b1a6e..0000000
+++ /dev/null
@@ -1,162 +0,0 @@
-package main
-
-import (
-       "fmt"
-       "html/template"
-       "log"
-       "sort"
-       "strconv"
-       "strings"
-
-       "code.octet-stream.net/broadcaster/internal/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 User
-       for {
-               // Ignore any massively oversize messages
-               n, err := ws.Read(buf)
-               if err != nil {
-                       if user.Username != "" {
-                               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 := users.GetUserForSession(token)
-                       if err != nil {
-                               log.Println("Could not find user for offered token", token, err)
-                               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.ParseFS(content, "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/radio/config.go b/radio/config.go
new file mode 100644 (file)
index 0000000..6837b7d
--- /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 + "/radio-ws"
+}
diff --git a/radio/files_machine.go b/radio/files_machine.go
new file mode 100644 (file)
index 0000000..143397a
--- /dev/null
@@ -0,0 +1,117 @@
+package main
+
+import (
+       "code.octet-stream.net/broadcaster/internal/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 + "/file-downloads/" + 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/main.go b/radio/main.go
new file mode 100644 (file)
index 0000000..6e31772
--- /dev/null
@@ -0,0 +1,334 @@
+package main
+
+import (
+       "code.octet-stream.net/broadcaster/internal/protocol"
+       "encoding/json"
+       "flag"
+       "fmt"
+       "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 version = "v1.0.0"
+const sampleRate = 44100
+
+var config RadioConfig = NewRadioConfig()
+
+func main() {
+       configFlag := flag.String("c", "", "path to configuration file")
+       versionFlag := flag.Bool("v", false, "print version and exit")
+       flag.Parse()
+
+       if *versionFlag {
+               fmt.Println("Broadcaster Radio", version)
+               os.Exit(0)
+       }
+       if *configFlag == "" {
+               log.Fatal("must specify a configuration file with -c")
+       }
+
+       log.Println("Broadcaster Radio", version, "starting up")
+       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)
+
+       stop := make(chan bool)
+       playlistSpecChan := make(chan []protocol.PlaylistSpec)
+       go playlistWorker(playlistSpecChan, stop)
+
+       for {
+               runWebsocket(fileSpecChan, playlistSpecChan, stop)
+               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, stop chan bool) 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
+               }
+
+               if t == protocol.StopType {
+                       stop <- true
+               }
+       }
+}
+
+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, stop <-chan bool) {
+       var specs []protocol.PlaylistSpec
+       isPlaying := false
+       playbackFinished := make(chan error)
+       cancel := make(chan bool)
+       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
+                       cancel = make(chan bool)
+               case <-timerCh:
+                       timer = nil
+                       isPlaying = true
+                       for _, v := range specs {
+                               if v.Id == nextId {
+                                       go playPlaylist(v, playbackFinished, cancel)
+                               }
+                       }
+               case <-stop:
+                       if isPlaying {
+                               log.Println("Cancelling playlist in progress")
+                               cancel <- true
+                       }
+               }
+
+               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, cancel <-chan bool) {
+       startTime := time.Now()
+       log.Println("Beginning playback of playlist", playlist.Name)
+entries:
+       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,
+               }
+               select {
+               case <-time.After(duration):
+               case <-cancel:
+                       break entries
+               }
+
+               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
+                       })))
+               }
+
+               select {
+               case <-done:
+               case <-cancel:
+                       ptt.DisengagePTT()
+                       break entries
+               }
+               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..7836815
--- /dev/null
@@ -0,0 +1,140 @@
+package main
+
+import (
+       "code.octet-stream.net/broadcaster/internal/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/command.go b/server/command.go
new file mode 100644 (file)
index 0000000..346e842
--- /dev/null
@@ -0,0 +1,53 @@
+package main
+
+import (
+       "code.octet-stream.net/broadcaster/internal/protocol"
+       "encoding/json"
+       "golang.org/x/net/websocket"
+       "sync"
+)
+
+type CommandRouter struct {
+       connsMutex sync.Mutex
+       conns      map[int]*websocket.Conn
+}
+
+var commandRouter CommandRouter
+
+func InitCommandRouter() {
+       commandRouter.conns = make(map[int]*websocket.Conn)
+}
+
+func (c *CommandRouter) AddWebsocket(radioId int, ws *websocket.Conn) {
+       c.connsMutex.Lock()
+       defer c.connsMutex.Unlock()
+       c.conns[radioId] = ws
+}
+
+func (c *CommandRouter) RemoveWebsocket(ws *websocket.Conn) {
+       c.connsMutex.Lock()
+       defer c.connsMutex.Unlock()
+       key := -1
+       for k, v := range c.conns {
+               if v == ws {
+                       key = k
+               }
+       }
+       if key != -1 {
+               delete(c.conns, key)
+       }
+
+}
+
+func (c *CommandRouter) Stop(radioId int) {
+       c.connsMutex.Lock()
+       defer c.connsMutex.Unlock()
+       ws := c.conns[radioId]
+       if ws != nil {
+               stop := protocol.StopMessage{
+                       T: protocol.StopType,
+               }
+               msg, _ := json.Marshal(stop)
+               ws.Write(msg)
+       }
+}
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..1312208
--- /dev/null
@@ -0,0 +1,247 @@
+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 journal_mode = WAL")
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       _, err = db.sqldb.Exec("PRAGMA foreign_keys = ON")
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       _, err = db.sqldb.Exec("PRAGMA busy_timeout = 5000")
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       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);
+       CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT, is_admin INTEGER);
+       `
+       _, 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) GetUserNameForSession(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) GetUser(username string) (User, error) {
+       var user User
+       err := d.sqldb.QueryRow("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", username).Scan(&user.Id, &user.Username, &user.PasswordHash, &user.IsAdmin)
+       if err != nil {
+               return User{}, errors.New("no user with that username")
+       }
+       return user, nil
+}
+
+func (d *Database) GetUsers() []User {
+       ret := make([]User, 0)
+       rows, err := d.sqldb.Query("SELECT id, username, password_hash, is_admin FROM users ORDER BY username ASC")
+       if err != nil {
+               return ret
+       }
+       defer rows.Close()
+       for rows.Next() {
+               var u User
+               if err := rows.Scan(&u.Id, &u.Username, &u.PasswordHash, &u.IsAdmin); err != nil {
+                       return ret
+               }
+               ret = append(ret, u)
+       }
+       return ret
+}
+
+func (d *Database) SetUserPassword(username string, passwordHash string) {
+       d.sqldb.Exec("UPDATE users SET password_hash = ? WHERE username = ?", passwordHash, username)
+}
+
+func (d *Database) ClearOtherSessions(username string, token string) {
+       d.sqldb.Exec("DELETE FROM sessions WHERE username = ? AND token != ?", username, token)
+}
+
+func (d *Database) SetUserIsAdmin(username string, isAdmin bool) {
+       d.sqldb.Exec("UPDATE users SET is_admin = ? WHERE username = ?", isAdmin, username)
+}
+
+func (d *Database) CreateUser(user User) error {
+       _, err := d.sqldb.Exec("INSERT INTO users (username, password_hash, is_admin) values (?, ?, ?)", user.Username, user.PasswordHash, user.IsAdmin)
+       return err
+}
+
+func (d *Database) DeleteUser(username string) error {
+       _, err := d.sqldb.Exec("DELETE FROM users WHERE username = ?", username)
+       return err
+}
+
+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..b452997
--- /dev/null
@@ -0,0 +1,81 @@
+package main
+
+import (
+       "crypto/sha256"
+       "encoding/hex"
+       "io"
+       "log"
+       "os"
+       "path/filepath"
+       "sync"
+)
+
+type FileSpec struct {
+       Name string
+       Hash string
+}
+
+type AudioFiles struct {
+       path       string
+       list       []FileSpec
+       changeWait chan bool
+       filesMutex sync.Mutex
+}
+
+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.filesMutex.Lock()
+       defer r.filesMutex.Unlock()
+       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 {
+       r.filesMutex.Lock()
+       defer r.filesMutex.Unlock()
+       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) WatchForChanges() ([]FileSpec, chan bool) {
+       r.filesMutex.Lock()
+       defer r.filesMutex.Unlock()
+       return r.list, r.changeWait
+}
diff --git a/server/main.go b/server/main.go
new file mode 100644 (file)
index 0000000..8f76cbf
--- /dev/null
@@ -0,0 +1,537 @@
+package main
+
+import (
+       "bufio"
+       "embed"
+       "flag"
+       "fmt"
+       "golang.org/x/net/websocket"
+       "html/template"
+       "io"
+       "log"
+       "net/http"
+       "os"
+       "path/filepath"
+       "strconv"
+       "strings"
+       "time"
+)
+
+const version = "v1.0.0"
+const formatString = "2006-01-02T15:04"
+
+//go:embed templates/*
+var content embed.FS
+//var content = os.DirFS("../broadcaster-server/")
+
+var config ServerConfig = NewServerConfig()
+
+func main() {
+       configFlag := flag.String("c", "", "path to configuration file")
+       addUserFlag := flag.Bool("a", false, "interactively add an admin user then exit")
+       versionFlag := flag.Bool("v", false, "print version then exit")
+       flag.Parse()
+
+       if *versionFlag {
+               fmt.Println("Broadcaster Server", version)
+               os.Exit(0)
+       }
+       if *configFlag == "" {
+               log.Fatal("must specify a configuration file with -c")
+       }
+       config.LoadFromFile(*configFlag)
+
+       InitDatabase()
+       defer db.CloseDatabase()
+
+       if *addUserFlag {
+               scanner := bufio.NewScanner(os.Stdin)
+               fmt.Println("Enter new admin username:")
+               if !scanner.Scan() {
+                       os.Exit(1)
+               }
+               username := scanner.Text()
+               fmt.Println("Enter new admin password (will be printed in the clear):")
+               if !scanner.Scan() {
+                       os.Exit(1)
+               }
+               password := scanner.Text()
+               if username == "" || password == "" {
+                       fmt.Println("Both username and password must be specified")
+                       os.Exit(1)
+               }
+               if err := users.CreateUser(username, password, true); err != nil {
+                       log.Fatal(err)
+               }
+               os.Exit(0)
+       }
+
+       log.Println("Broadcaster Server", version, "starting up")
+       InitCommandRouter()
+       InitPlaylists()
+       InitAudioFiles(config.AudioFilesPath)
+       InitServerStatus()
+
+       // Public routes
+
+       http.HandleFunc("/login", logInPage)
+       http.Handle("/file-downloads/", http.StripPrefix("/file-downloads/", http.FileServer(http.Dir(config.AudioFilesPath))))
+
+       // Authenticated routes
+
+       http.HandleFunc("/", homePage)
+       http.HandleFunc("/logout", logOutPage)
+       http.HandleFunc("/change-password", changePasswordPage)
+
+       http.HandleFunc("/playlists/", playlistSection)
+       http.HandleFunc("/files/", fileSection)
+       http.HandleFunc("/radios/", radioSection)
+
+       http.Handle("/radio-ws", websocket.Handler(RadioSync))
+       http.Handle("/web-ws", websocket.Handler(WebSync))
+       http.HandleFunc("/stop", stopPage)
+
+       // Admin routes
+
+       err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil)
+       if err != nil {
+               log.Fatal(err)
+       }
+}
+
+type HeaderData struct {
+       SelectedMenu string
+       Username string
+}
+
+func renderHeader(w http.ResponseWriter, selectedMenu string) {
+       tmpl := template.Must(template.ParseFS(content, "templates/header.html"))
+       data := HeaderData{
+               SelectedMenu: selectedMenu,
+               Username: "username",
+       }
+       err := tmpl.Execute(w, data)
+       if err != nil {
+               log.Fatal(err)
+       }
+}
+
+func renderFooter(w http.ResponseWriter) {
+       tmpl := template.Must(template.ParseFS(content, "templates/footer.html"))
+       err := tmpl.Execute(w, nil)
+       if err != nil {
+               log.Fatal(err)
+       }
+}
+
+type HomeData struct {
+       LoggedIn bool
+       Username string
+}
+
+func homePage(w http.ResponseWriter, r *http.Request) {
+       renderHeader(w, "status")
+       tmpl := template.Must(template.ParseFS(content, "templates/index.html"))
+       data := HomeData{
+               LoggedIn: true,
+               Username: "Bob",
+       }
+       tmpl.Execute(w, data)
+       renderFooter(w)
+}
+
+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"]
+       errText := ""
+       if username != nil {
+               user, err := users.Authenticate(username[0], password[0])
+               if err != nil {
+                       errText = "Incorrect login"
+               } else {
+                       createSessionCookie(w, user.Username)
+                       http.Redirect(w, r, "/", http.StatusFound)
+                       return
+               }
+       }
+
+       data := LogInData{
+               Error: errText,
+       }
+       renderHeader(w, "")
+       tmpl := template.Must(template.ParseFS(content, "templates/login.html"))
+       tmpl.Execute(w, data)
+       renderFooter(w)
+}
+
+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 ChangePasswordPageData struct {
+       Message  string
+       ShowForm bool
+}
+
+func changePasswordPage(w http.ResponseWriter, r *http.Request) {
+       user, err := currentUser(w, r)
+       if err != nil {
+               http.Redirect(w, r, "/login", http.StatusFound)
+               return
+       }
+       var data ChangePasswordPageData
+       if r.Method == "POST" {
+               err := r.ParseForm()
+               if err != nil {
+                       w.WriteHeader(http.StatusBadRequest)
+                       return
+               }
+               oldPassword := r.Form.Get("oldPassword")
+               newPassword := r.Form.Get("newPassword")
+               err = users.UpdatePassword(user.Username, oldPassword, newPassword)
+               if err != nil {
+                       data.Message = "Failed to change password: " + err.Error()
+                       data.ShowForm = true
+               } else {
+                       data.Message = "Successfully changed password"
+                       data.ShowForm = false
+                       cookie, err := r.Cookie("broadcast_session")
+                       if err == nil {
+                               log.Println("clearing other sessions for username", user.Username, "token", cookie.Value)
+                               db.ClearOtherSessions(user.Username, cookie.Value)
+                       }
+               }
+       } else {
+               data.Message = ""
+               data.ShowForm = true
+       }
+       renderHeader(w, "change-password")
+       tmpl := template.Must(template.ParseFS(content, "templates/change_password.html"))
+       err = tmpl.Execute(w, data)
+       if err != nil {
+               log.Fatal(err)
+       }
+       renderFooter(w)
+}
+
+type PlaylistsPageData struct {
+       Playlists []Playlist
+}
+
+func playlistsPage(w http.ResponseWriter, _ *http.Request) {
+       renderHeader(w, "playlists")
+       data := PlaylistsPageData{
+               Playlists: db.GetPlaylists(),
+       }
+       tmpl := template.Must(template.ParseFS(content, "templates/playlists.html"))
+       err := tmpl.Execute(w, data)
+       if err != nil {
+               log.Fatal(err)
+       }
+       renderFooter(w)
+}
+
+type RadiosPageData struct {
+       Radios []Radio
+}
+
+func radiosPage(w http.ResponseWriter, _ *http.Request) {
+       renderHeader(w, "radios")
+       data := RadiosPageData{
+               Radios: db.GetRadios(),
+       }
+       tmpl := template.Must(template.ParseFS(content, "templates/radios.html"))
+       err := tmpl.Execute(w, data)
+       if err != nil {
+               log.Fatal(err)
+       }
+       renderFooter(w)
+}
+
+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)
+       }
+       renderHeader(w, "radios")
+       tmpl := template.Must(template.ParseFS(content, "templates/playlist.html"))
+       tmpl.Execute(w, data)
+       renderFooter(w)
+}
+
+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
+               playlists.NotifyChanges()
+       }
+       http.Redirect(w, r, "/playlists/", 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)
+               playlists.NotifyChanges()
+       }
+       http.Redirect(w, r, "/playlists/", 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
+       }
+       renderHeader(w, "radios")
+       tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
+       tmpl.Execute(w, data)
+       renderFooter(w)
+}
+
+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, "/radios/", 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, "/radios/", http.StatusFound)
+}
+
+type FilesPageData struct {
+       Files []FileSpec
+}
+
+func filesPage(w http.ResponseWriter, _ *http.Request) {
+       renderHeader(w, "files")
+       data := FilesPageData{
+               Files: files.Files(),
+       }
+       log.Println("file page data", data)
+       tmpl := template.Must(template.ParseFS(content, "templates/files.html"))
+       err := tmpl.Execute(w, data)
+       if err != nil {
+               log.Fatal(err)
+       }
+       renderFooter(w)
+}
+
+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, "/files/", 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, "/files/", http.StatusFound)
+}
+
+func logOutPage(w http.ResponseWriter, r *http.Request) {
+       clearSessionCookie(w)
+       renderHeader(w, "")
+       tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
+       tmpl.Execute(w, nil)
+       renderFooter(w)
+}
+
+func stopPage(w http.ResponseWriter, r *http.Request) {
+       _, err := currentUser(w, r)
+       if err != nil {
+               http.Redirect(w, r, "/login", http.StatusFound)
+               return
+       }
+       r.ParseForm()
+       radioId, err := strconv.Atoi(r.Form.Get("radioId"))
+       if err != nil {
+               http.NotFound(w, r)
+               return
+       }
+       commandRouter.Stop(radioId)
+       http.Redirect(w, r, "/", http.StatusFound)
+}
diff --git a/server/model.go b/server/model.go
new file mode 100644 (file)
index 0000000..11f94e7
--- /dev/null
@@ -0,0 +1,29 @@
+package main
+
+type PlaylistEntry struct {
+       Id           int
+       Position     int
+       Filename     string
+       DelaySeconds int
+       IsRelative   bool
+}
+
+type User struct {
+       Id           int
+       Username     string
+       PasswordHash string
+       IsAdmin      bool
+}
+
+type Playlist struct {
+       Id        int
+       Enabled   bool
+       Name      string
+       StartTime string
+}
+
+type Radio struct {
+       Id    int
+       Name  string
+       Token string
+}
diff --git a/server/playlist.go b/server/playlist.go
new file mode 100644 (file)
index 0000000..f0a6c89
--- /dev/null
@@ -0,0 +1,35 @@
+package main
+
+import (
+       "sync"
+)
+
+type Playlists struct {
+       changeWait    chan bool
+       playlistMutex sync.Mutex
+}
+
+var playlists Playlists
+
+func InitPlaylists() {
+       playlists.changeWait = make(chan bool)
+}
+
+func (p *Playlists) GetPlaylists() []Playlist {
+       p.playlistMutex.Lock()
+       defer p.playlistMutex.Unlock()
+       return db.GetPlaylists()
+}
+
+func (p *Playlists) WatchForChanges() ([]Playlist, chan bool) {
+       p.playlistMutex.Lock()
+       defer p.playlistMutex.Unlock()
+       return db.GetPlaylists(), p.changeWait
+}
+
+func (p *Playlists) NotifyChanges() {
+       p.playlistMutex.Lock()
+       defer p.playlistMutex.Unlock()
+       close(p.changeWait)
+       p.changeWait = make(chan bool)
+}
diff --git a/server/radio_sync.go b/server/radio_sync.go
new file mode 100644 (file)
index 0000000..66521f8
--- /dev/null
@@ -0,0 +1,125 @@
+package main
+
+import (
+       "code.octet-stream.net/broadcaster/internal/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
+                       commandRouter.AddWebsocket(r.Id, ws)
+                       defer commandRouter.RemoveWebsocket(ws)
+
+                       go KeepFilesUpdated(ws)
+                       go KeepPlaylistsUpdated(ws)
+               }
+
+               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, p []Playlist) error {
+       playlistSpecs := make([]protocol.PlaylistSpec, 0)
+       for _, v := range p {
+               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 {
+               p, ch := playlists.WatchForChanges()
+               err := sendPlaylistsMessageToRadio(ws, p)
+               if err != nil {
+                       return
+               }
+               <-ch
+       }
+}
+
+func sendFilesMessageToRadio(ws *websocket.Conn, f []FileSpec) error {
+       specs := make([]protocol.FileSpec, 0)
+       for _, v := range f {
+               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 {
+               f, ch := files.WatchForChanges()
+               err := sendFilesMessageToRadio(ws, f)
+               if err != nil {
+                       return
+               }
+               <-ch
+       }
+}
diff --git a/server/session.go b/server/session.go
new file mode 100644 (file)
index 0000000..a097989
--- /dev/null
@@ -0,0 +1,46 @@
+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(_ http.ResponseWriter, r *http.Request) (User, error) {
+       cookie, e := r.Cookie("broadcast_session")
+       if e != nil {
+               return User{}, e
+       }
+
+       return users.GetUserForSession(cookie.Value)
+}
+
+func createSessionCookie(w http.ResponseWriter, username string) {
+       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(username, 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..b2edd49
--- /dev/null
@@ -0,0 +1,54 @@
+package main
+
+import (
+       "code.octet-stream.net/broadcaster/internal/protocol"
+       "sync"
+)
+
+type ServerStatus struct {
+       statuses      map[int]protocol.StatusMessage
+       statusesMutex sync.Mutex
+       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.statusesMutex.Lock()
+       defer s.statusesMutex.Unlock()
+       s.statuses[radioId] = status
+       s.TriggerChange()
+}
+
+func (s *ServerStatus) RadioDisconnected(radioId int) {
+       s.statusesMutex.Lock()
+       defer s.statusesMutex.Unlock()
+       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 {
+       s.statusesMutex.Lock()
+       defer s.statusesMutex.Unlock()
+       c := make(map[int]protocol.StatusMessage)
+       for k, v := range s.statuses {
+               c[k] = v
+       }
+       return c
+}
+
+func (s *ServerStatus) ChangeChannel() chan bool {
+       return s.changeWait
+}
diff --git a/server/templates/change_password.html b/server/templates/change_password.html
new file mode 100644 (file)
index 0000000..bab3a82
--- /dev/null
@@ -0,0 +1,14 @@
+
+      <h1>Change Password</h1>
+      {{if ne .Message ""}}
+      <p><b>{{.Message}}</b></p>
+      {{end}}
+      {{if .ShowForm}}
+      <form action="/change-password" method="post">
+        <label for="oldPassword">Old Password:</label><br>
+        <input type="password" id="oldPassword" name="oldPassword"><br>
+        <label for="newPassword">New Password:</label><br>
+        <input type="password" id="newPassword" name="newPassword"><br>
+        <input type="submit" value="Change Password">
+      </form>
+      {{end}}
diff --git a/server/templates/files.html b/server/templates/files.html
new file mode 100644 (file)
index 0000000..c86c6ce
--- /dev/null
@@ -0,0 +1,15 @@
+
+      <h1>Files! List</h1>
+      <p>All files can be downloaded from the <a href="/file-downloads/">public file listing</a>.</p>
+      <ul>
+      {{range .Files}}
+        <li><b>{{.Name}}</b><form action="/files/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="/files/upload" method="post" enctype="multipart/form-data">
+        <input type="file" name="file">
+        <input type="submit" value="Upload">
+      </form>
+      </p>
diff --git a/server/templates/footer.html b/server/templates/footer.html
new file mode 100644 (file)
index 0000000..df60a12
--- /dev/null
@@ -0,0 +1,4 @@
+      </div>
+    </main>
+  </body>
+</html>
diff --git a/server/templates/header.html b/server/templates/header.html
new file mode 100644 (file)
index 0000000..49d734f
--- /dev/null
@@ -0,0 +1,117 @@
+<!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 {
+        text-align: center;
+        background-color: #eeeeee;
+      }
+      .time-table {
+        font-size: 90%;
+      }
+      .playlist-field {
+        text-align: right;
+        padding-right: 1em;
+        width: 5em;
+      }
+      .playlist-table {
+        font-size: 90%;
+      }
+      .stop {
+        text-align: center;
+      }
+      .head {
+        text-align: center;
+      }
+      body {
+        background-color: #447744;
+        font-family: "sans-serif";
+      }
+      main {
+        background-color: #dddddd;
+        max-width: 80em;
+        margin-left: auto;
+        margin-right: auto;
+        display: flex;
+        flex-wrap: wrap;
+        flex-direction: row;
+        border: 2px solid black;
+      }
+      .menu {
+        min-width: 12em;
+        display: flex;
+        flex-grow: 1;
+        flex-direction: column;
+        text-align: center;
+      }
+      .menu-item {
+        height: 2em;
+        text-align: center;
+        line-height: 2em;
+        background-color: #aaaaaa;
+        border: black solid;
+        border-width: 1px 1px 0px 0px;
+      }
+      .menu-item a {
+        text-decoration: none;
+        color: black;
+      }
+      .menu-item.logout {
+        border-width: 1px 1px 1px 0px;
+      }
+      .menu-item:first-of-type {
+        border-width: 0px 1px 0px 0px;
+      }
+      .menu-item.selected {
+        background-color: #aaccaa;
+      }
+      .logged-in {
+        padding-top: 2em;
+        padding-bottom: 2em;
+        text-align: center;
+        border: black solid;
+        border-width: 0px 1px 0px 0px;
+      }
+      .menu-tail {
+        flex-grow: 1;
+        height: 2em;
+        line-height: 2em;
+        border: black solid;
+        border-width: 0px 1px 0px 0px;
+      }
+      .content {
+        flex-grow: 3;
+        padding: 5em;
+        padding-top: 1em;
+        overflow-wrap: break-word;
+        max-width: 50em;
+      }
+    </style>
+  </head>
+  <body>
+    <main>
+    {{if .SelectedMenu}}
+        <div class="menu">
+            <div class="menu-item {{if eq .SelectedMenu "status"}}selected{{end}}"><a href="/">Status</a></div>
+            <div class="menu-item {{if eq .SelectedMenu "files"}}selected{{end}}"><a href="/files/">Files</a></div>
+            <div class="menu-item {{if eq .SelectedMenu "playlists"}}selected{{end}}"><a href="/playlists/">Playlists</a></div>
+            <div class="menu-item {{if eq .SelectedMenu "radios"}}selected{{end}}"><a href="/radios/">Radios</a></div>
+            <div class="menu-item {{if eq .SelectedMenu "users"}}selected{{end}}"><a href="/users/">Users</a></div>
+            <div class="menu-item {{if eq .SelectedMenu "change-password"}}selected{{end}}"><a href="/change-password">Change Password</a></div>
+            <div class="menu-item logout"><a href="/logout">Log Out</a></div>
+            {{if .Username}}
+            <div class="logged-in">Logged in as:<br><i>{{.Username}}</i></div>
+            {{end}}
+            <div class="menu-tail"><small><i>broadcaster-server vTODO</i></small></div>
+        </div>
+    {{end}}
+    <div class="content">
diff --git a/server/templates/index.html b/server/templates/index.html
new file mode 100644 (file)
index 0000000..b688947
--- /dev/null
@@ -0,0 +1,39 @@
+    <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("/web-ws");
+        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>
+
+      <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="/files/">File Management</a></p>
+      <p><a href="/playlists/">Playlist Management</a></p>
+      <p><a href="/radios/">Radio Management</a></p>
+      <h2>Connected Radios</h2>
+      <div id="connected-radios">
+        <i>Loading...</i>
+      </div>
diff --git a/server/templates/login.html b/server/templates/login.html
new file mode 100644 (file)
index 0000000..2211b2f
--- /dev/null
@@ -0,0 +1,12 @@
+
+      <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>
diff --git a/server/templates/logout.html b/server/templates/logout.html
new file mode 100644 (file)
index 0000000..8b7ee1c
--- /dev/null
@@ -0,0 +1,3 @@
+
+        <h1>Logged Out</h1>
+        <p><a href="/login">Log In again</a></p>
diff --git a/server/templates/playlist.html b/server/templates/playlist.html
new file mode 100644 (file)
index 0000000..d778250
--- /dev/null
@@ -0,0 +1,90 @@
+
+    <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>
+
+    <h1>A specific playlist</h1>
+      <h2>
+      {{if .Playlist.Id}}
+      Edit Playlist
+      {{else}}
+      Create New Playlist
+      {{end}}
+      </h2>
+      <form action="/playlists/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="1">previous item</option>
+          <option value="0" {{if not .IsRelative}} selected="selected" {{end}}>start of transmission</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="/playlists/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="1">previous item</option>
+          <option value="0">start of transmission</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>
diff --git a/server/templates/playlists.html b/server/templates/playlists.html
new file mode 100644 (file)
index 0000000..e1ba60f
--- /dev/null
@@ -0,0 +1,8 @@
+
+      <h1>Playlists!</h1>
+      <ul>
+      {{range .Playlists}}
+        <li><b>{{.Name}}</b> {{.StartTime}} <a href="/playlists/{{.Id}}">(Edit)</a></li>
+      {{end}}
+      </ul>
+      <p><a href="/playlists/new">Add New Playlist</a></p>
diff --git a/server/templates/radio.html b/server/templates/radio.html
new file mode 100644 (file)
index 0000000..c2ec9dd
--- /dev/null
@@ -0,0 +1,32 @@
+
+      <h1>A specific radio</h1>
+      <h2>
+      {{if .Radio.Id}}
+      Edit Radio
+      {{else}}
+      Register New Radio
+      {{end}}
+      </h2>
+      <form action="/radios/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="/radios/delete" method="POST">
+        <input type="hidden" name="radioId" value="{{.Radio.Id}}">
+        <p>
+        <input type="submit" value="Delete Radio">
+        </p>
+      </form>
+      {{end}}
diff --git a/server/templates/radios.html b/server/templates/radios.html
new file mode 100644 (file)
index 0000000..0c1288f
--- /dev/null
@@ -0,0 +1,8 @@
+
+      <h1>Radios</h1>
+      <ul>
+      {{range .Radios}}
+        <li><b>{{.Name}}</b> {{.Token}} <a href="/radios/{{.Id}}">(Edit)</a></li>
+      {{end}}
+      </ul>
+      <p><a href="/radios/new">Register New Radio</a></p>
diff --git a/server/templates/radios.partial.html b/server/templates/radios.partial.html
new file mode 100644 (file)
index 0000000..010fe0a
--- /dev/null
@@ -0,0 +1,84 @@
+{{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" colspan="2">
+    <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>
+    <tr>
+    <td class="outer stop" colspan="3">
+    <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>
+</tr>
+</table>
+{{end}}
+{{else}}
+<p><i>There are no radios online.</i></p>
+{{end}}
diff --git a/server/user.go b/server/user.go
new file mode 100644 (file)
index 0000000..275f0ec
--- /dev/null
@@ -0,0 +1,82 @@
+package main
+
+import (
+       "errors"
+       "golang.org/x/crypto/bcrypt"
+)
+
+var users Users
+
+type Users struct{}
+
+func (u *Users) GetUserForSession(token string) (User, error) {
+       username, err := db.GetUserNameForSession(token)
+       if err != nil {
+               return User{}, err
+       }
+       user, err := db.GetUser(username)
+       if err != nil {
+               return User{}, err
+       }
+       return user, nil
+}
+
+func (u *Users) Authenticate(username string, clearPassword string) (User, error) {
+       user, err := db.GetUser(username)
+       if err != nil {
+               return User{}, err
+       }
+       err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(clearPassword))
+       if err != nil {
+               return User{}, err
+       }
+       return user, nil
+}
+
+func (u *Users) CreateUser(username string, clearPassword string, isAdmin bool) error {
+       if clearPassword == "" {
+               return errors.New("password cannot be empty")
+       }
+       hashed, err := bcrypt.GenerateFromPassword([]byte(clearPassword), bcrypt.DefaultCost)
+       if err != nil {
+               return err
+       }
+       return db.CreateUser(User{
+               Id:           0,
+               Username:     username,
+               PasswordHash: string(hashed),
+               IsAdmin:      isAdmin,
+       })
+}
+
+func (u *Users) DeleteUser(username string) {
+       db.DeleteUser(username)
+}
+
+func (u *Users) UpdatePassword(username string, oldClearPassword string, newClearPassword string) error {
+       user, err := db.GetUser(username)
+       if err != nil {
+               return err
+       }
+       err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldClearPassword))
+       if err != nil {
+               return errors.New("old password is incorrect")
+       }
+       if newClearPassword == "" {
+               return errors.New("password cannot be empty")
+       }
+       hashed, err := bcrypt.GenerateFromPassword([]byte(newClearPassword), bcrypt.DefaultCost)
+       if err != nil {
+               return err
+       }
+       db.SetUserPassword(username, string(hashed))
+       return nil
+}
+
+func (u *Users) UpdateIsAdmin(username string, isAdmin bool) {
+       db.SetUserIsAdmin(username, isAdmin)
+}
+
+func (u *Users) Users() []User {
+       return db.GetUsers()
+}
diff --git a/server/web_sync.go b/server/web_sync.go
new file mode 100644 (file)
index 0000000..a6b1a6e
--- /dev/null
@@ -0,0 +1,162 @@
+package main
+
+import (
+       "fmt"
+       "html/template"
+       "log"
+       "sort"
+       "strconv"
+       "strings"
+
+       "code.octet-stream.net/broadcaster/internal/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 User
+       for {
+               // Ignore any massively oversize messages
+               n, err := ws.Read(buf)
+               if err != nil {
+                       if user.Username != "" {
+                               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 := users.GetUserForSession(token)
+                       if err != nil {
+                               log.Println("Could not find user for offered token", token, err)
+                               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.ParseFS(content, "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
+               }
+       }
+}