-audio
-broadcaster-server
-broadcaster-radio
-test-server.conf
-test-radio.conf
-test.db*
+build
+*.kate-swp
--- /dev/null
+MIT License
+
+Copyright (c) 2024 Thomas Karpiniec
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--- /dev/null
+package main
+
+import (
+ "errors"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+)
+
+type RadioConfig struct {
+ GpioDevice string
+ PTTPin int
+ COSPin int
+ ServerURL string
+ Token string
+ CachePath string
+ TimeZone string
+}
+
+func NewRadioConfig() RadioConfig {
+ return RadioConfig{
+ GpioDevice: "gpiochip0",
+ PTTPin: -1,
+ COSPin: -1,
+ ServerURL: "",
+ Token: "",
+ CachePath: "",
+ TimeZone: "Australia/Hobart",
+ }
+}
+
+func (c *RadioConfig) LoadFromFile(path string) {
+ _, err := toml.DecodeFile(path, &c)
+ if err != nil {
+ log.Fatal("could not read config file for reading at path:", path, err)
+ }
+ err = c.Validate()
+ if err != nil {
+ log.Fatal(err)
+ }
+ c.ApplyDefaults()
+}
+
+func (c *RadioConfig) Validate() error {
+ if c.ServerURL == "" {
+ return errors.New("ServerURL must be provided in the configuration")
+ }
+ if c.Token == "" {
+ return errors.New("Token must be provided in the configuration")
+ }
+ return nil
+}
+
+func (c *RadioConfig) ApplyDefaults() {
+ if c.CachePath == "" {
+ dir, err := os.MkdirTemp("", "broadcast")
+ if err != nil {
+ log.Fatal(err)
+ }
+ c.CachePath = dir
+ }
+}
+
+func (c *RadioConfig) WebsocketURL() string {
+ addr := strings.Replace(c.ServerURL, "https://", "wss://", -1)
+ addr = strings.Replace(addr, "http://", "ws://", -1)
+ return addr + "/radio-ws"
+}
--- /dev/null
+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
+}
--- /dev/null
+package main
+
+import (
+ gpio "github.com/warthog618/go-gpiocdev"
+ "github.com/warthog618/go-gpiocdev/device/rpi"
+ "log"
+ "strconv"
+)
+
+type PTT interface {
+ EngagePTT()
+ DisengagePTT()
+}
+
+type COS interface {
+ WaitForChannelClear()
+ COSValue() bool
+}
+
+var ptt PTT = &DefaultPTT{}
+var cos COS = &DefaultCOS{}
+
+type PiPTT struct {
+ pttLine *gpio.Line
+}
+
+type PiCOS struct {
+ cosLine *gpio.Line
+ clearWait chan bool
+}
+
+func InitRaspberryPiPTT(pttNum int, chipName string) {
+ pttPin, err := rpi.Pin("GPIO" + strconv.Itoa(pttNum))
+ if err != nil {
+ log.Fatal("invalid PTT pin configured", ptt)
+ }
+ pttLine, err := gpio.RequestLine(chipName, pttPin, gpio.AsOutput(0))
+ if err != nil {
+ log.Fatal("unable to open requested pin for PTT GPIO:", ptt, ". Are you running as root?")
+ }
+ ptt = &PiPTT{
+ pttLine: pttLine,
+ }
+}
+
+func InitRaspberryPiCOS(cosNum int, chipName string) {
+ var piCOS PiCOS
+ piCOS.clearWait = make(chan bool)
+ cosPin, err := rpi.Pin("GPIO" + strconv.Itoa(cosNum))
+ if err != nil {
+ log.Fatal("invalid COS Pin configured", cos)
+ }
+ cosHandler := func(event gpio.LineEvent) {
+ if event.Type == gpio.LineEventFallingEdge {
+ log.Println("COS: channel clear")
+ close(piCOS.clearWait)
+ piCOS.clearWait = make(chan bool)
+ statusCollector.COS <- false
+ }
+ if event.Type == gpio.LineEventRisingEdge {
+ log.Println("COS: channel in use")
+ statusCollector.COS <- true
+ }
+ }
+ cosLine, err := gpio.RequestLine(chipName, cosPin, gpio.AsInput, gpio.WithBothEdges, gpio.WithEventHandler(cosHandler))
+ if err != nil {
+ log.Fatal("unable to open requested pin for COS GPIO:", cos, ". Are you running as root?")
+ }
+ piCOS.cosLine = cosLine
+ cos = &piCOS
+}
+
+func (g *PiCOS) COSValue() bool {
+ val, err := g.cosLine.Value()
+ if err != nil {
+ log.Fatal("Unable to read COS value")
+ }
+ return val != 0
+}
+
+func (g *PiCOS) WaitForChannelClear() {
+ ch := g.clearWait
+ val, err := g.cosLine.Value()
+ if err != nil || val == 0 {
+ return
+ }
+ // wait for close
+ <-ch
+}
+
+func (g *PiPTT) EngagePTT() {
+ log.Println("PTT: on")
+ g.pttLine.SetValue(1)
+ statusCollector.PTT <- true
+}
+
+func (g *PiPTT) DisengagePTT() {
+ log.Println("PTT: off")
+ g.pttLine.SetValue(0)
+ statusCollector.PTT <- false
+}
+
+type DefaultPTT struct {
+}
+
+func (g *DefaultPTT) EngagePTT() {
+ statusCollector.PTT <- true
+}
+
+func (g *DefaultPTT) DisengagePTT() {
+ statusCollector.PTT <- false
+}
+
+type DefaultCOS struct {
+}
+
+func (g *DefaultCOS) WaitForChannelClear() {
+ log.Println("Assuming channel is clear since COS GPIO is not configured")
+}
+
+func (g *DefaultCOS) COSValue() bool {
+ return false
+}
--- /dev/null
+package main
+
+import (
+ "code.octet-stream.net/broadcaster/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
+}
--- /dev/null
+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
+ }
+ }
+}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+package main
+
+import (
+ "errors"
+ "log"
+
+ "github.com/BurntSushi/toml"
+)
+
+type ServerConfig struct {
+ BindAddress string
+ Port int
+ SqliteDB string
+ AudioFilesPath string
+}
+
+func NewServerConfig() ServerConfig {
+ return ServerConfig{
+ BindAddress: "0.0.0.0",
+ Port: 55134,
+ SqliteDB: "",
+ AudioFilesPath: "",
+ }
+}
+
+func (c *ServerConfig) LoadFromFile(path string) {
+ _, err := toml.DecodeFile(path, &c)
+ if err != nil {
+ log.Fatal("could not read config file for reading at path:", path, err)
+ }
+ err = c.Validate()
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+func (c *ServerConfig) Validate() error {
+ if c.SqliteDB == "" {
+ return errors.New("Configuration must provide SqliteDB")
+ }
+ if c.AudioFilesPath == "" {
+ return errors.New("Configuration must provide AudioFilesPath")
+ }
+ return nil
+}
--- /dev/null
+package main
+
+import (
+ "database/sql"
+ "errors"
+ "log"
+ _ "modernc.org/sqlite"
+ "time"
+)
+
+type Database struct {
+ sqldb *sql.DB
+}
+
+var db Database
+
+func InitDatabase() {
+ sqldb, err := sql.Open("sqlite", config.SqliteDB)
+ if err != nil {
+ log.Fatal(err)
+ }
+ db.sqldb = sqldb
+
+ _, err = db.sqldb.Exec("PRAGMA 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)
+}
--- /dev/null
+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
+}
--- /dev/null
+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 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 HomeData struct {
+ LoggedIn bool
+ Username string
+}
+
+func homePage(w http.ResponseWriter, r *http.Request) {
+ tmpl := template.Must(template.ParseFS(content, "templates/index.html"))
+ data := HomeData{
+ LoggedIn: true,
+ Username: "Bob",
+ }
+ tmpl.Execute(w, data)
+}
+
+type LogInData struct {
+ Error string
+}
+
+func logInPage(w http.ResponseWriter, r *http.Request) {
+ log.Println("Log in page!")
+ r.ParseForm()
+ username := r.Form["username"]
+ password := r.Form["password"]
+ 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,
+ }
+
+ tmpl := template.Must(template.ParseFS(content, "templates/login.html"))
+ tmpl.Execute(w, data)
+}
+
+func playlistSection(w http.ResponseWriter, r *http.Request) {
+ path := strings.Split(r.URL.Path, "/")
+ if len(path) != 3 {
+ http.NotFound(w, r)
+ return
+ }
+ if path[2] == "new" {
+ editPlaylistPage(w, r, 0)
+ } else if path[2] == "submit" && r.Method == "POST" {
+ submitPlaylist(w, r)
+ } else if path[2] == "delete" && r.Method == "POST" {
+ deletePlaylist(w, r)
+ } else if path[2] == "" {
+ playlistsPage(w, r)
+ } else {
+ id, err := strconv.Atoi(path[2])
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ editPlaylistPage(w, r, id)
+ }
+}
+
+func fileSection(w http.ResponseWriter, r *http.Request) {
+ path := strings.Split(r.URL.Path, "/")
+ if len(path) != 3 {
+ http.NotFound(w, r)
+ return
+ }
+ if path[2] == "upload" {
+ uploadFile(w, r)
+ } else if path[2] == "delete" && r.Method == "POST" {
+ deleteFile(w, r)
+ } else if path[2] == "" {
+ filesPage(w, r)
+ } else {
+ http.NotFound(w, r)
+ return
+ }
+}
+
+func radioSection(w http.ResponseWriter, r *http.Request) {
+ path := strings.Split(r.URL.Path, "/")
+ if len(path) != 3 {
+ http.NotFound(w, r)
+ return
+ }
+ if path[2] == "new" {
+ editRadioPage(w, r, 0)
+ } else if path[2] == "submit" && r.Method == "POST" {
+ submitRadio(w, r)
+ } else if path[2] == "delete" && r.Method == "POST" {
+ deleteRadio(w, r)
+ } else if path[2] == "" {
+ radiosPage(w, r)
+ } else {
+ id, err := strconv.Atoi(path[2])
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ editRadioPage(w, r, id)
+ }
+}
+
+type 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
+ }
+ tmpl := template.Must(template.ParseFS(content, "templates/change_password.html"))
+ err = tmpl.Execute(w, data)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+type PlaylistsPageData struct {
+ Playlists []Playlist
+}
+
+func playlistsPage(w http.ResponseWriter, _ *http.Request) {
+ 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)
+ }
+}
+
+type RadiosPageData struct {
+ Radios []Radio
+}
+
+func radiosPage(w http.ResponseWriter, _ *http.Request) {
+ 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)
+ }
+}
+
+type EditPlaylistPageData struct {
+ Playlist Playlist
+ Entries []PlaylistEntry
+ Files []string
+}
+
+func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int) {
+ var data EditPlaylistPageData
+ for _, f := range files.Files() {
+ data.Files = append(data.Files, f.Name)
+ }
+ if id == 0 {
+ data.Playlist.Enabled = true
+ data.Playlist.Name = "New Playlist"
+ data.Playlist.StartTime = time.Now().Format(formatString)
+ data.Entries = append(data.Entries, PlaylistEntry{})
+ } else {
+ playlist, err := db.GetPlaylist(id)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ data.Playlist = playlist
+ data.Entries = db.GetEntriesForPlaylist(id)
+ }
+ tmpl := template.Must(template.ParseFS(content, "templates/playlist.html"))
+ tmpl.Execute(w, data)
+}
+
+func submitPlaylist(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err == nil {
+ var p Playlist
+ id, err := strconv.Atoi(r.Form.Get("playlistId"))
+ if err != nil {
+ return
+ }
+ _, err = time.Parse(formatString, r.Form.Get("playlistStartTime"))
+ if err != nil {
+ return
+ }
+ p.Id = id
+ p.Enabled = r.Form.Get("playlistEnabled") == "1"
+ p.Name = r.Form.Get("playlistName")
+ p.StartTime = r.Form.Get("playlistStartTime")
+
+ delays := r.Form["delaySeconds"]
+ filenames := r.Form["filename"]
+ isRelatives := r.Form["isRelative"]
+
+ entries := make([]PlaylistEntry, 0)
+ for i := range delays {
+ var e PlaylistEntry
+ delay, err := strconv.Atoi(delays[i])
+ if err != nil {
+ return
+ }
+ e.DelaySeconds = delay
+ e.Position = i
+ e.IsRelative = isRelatives[i] == "1"
+ e.Filename = filenames[i]
+ entries = append(entries, e)
+ }
+ cleanedEntries := make([]PlaylistEntry, 0)
+ for _, e := range entries {
+ if e.DelaySeconds != 0 || e.Filename != "" {
+ cleanedEntries = append(cleanedEntries, e)
+ }
+ }
+
+ if id != 0 {
+ db.UpdatePlaylist(p)
+ } else {
+ id = db.CreatePlaylist(p)
+ }
+ db.SetEntriesForPlaylist(cleanedEntries, id)
+ // Notify connected radios
+ 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
+ }
+ tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
+ tmpl.Execute(w, data)
+}
+
+func submitRadio(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err == nil {
+ var radio Radio
+ id, err := strconv.Atoi(r.Form.Get("radioId"))
+ if err != nil {
+ return
+ }
+ radio.Id = id
+ radio.Name = r.Form.Get("radioName")
+ radio.Token = r.Form.Get("radioToken")
+ if id != 0 {
+ db.UpdateRadio(radio)
+ } else {
+ db.CreateRadio(radio)
+ }
+ }
+ http.Redirect(w, r, "/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) {
+ 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)
+ }
+}
+
+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)
+ tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
+ tmpl.Execute(w, nil)
+}
+
+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)
+}
--- /dev/null
+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
+}
--- /dev/null
+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)
+}
--- /dev/null
+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
+ }
+}
--- /dev/null
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "log"
+ "net/http"
+ "time"
+)
+
+func generateSession() string {
+ b := make([]byte, 32)
+ _, err := rand.Read(b)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return hex.EncodeToString(b)
+}
+
+func currentUser(_ 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)
+}
--- /dev/null
+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
+}
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Broadcaster</title>
+ </head>
+ <body>
+ <main>
+ <h1>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}}
+ </main>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Broadcaster</title>
+ </head>
+ <body>
+ <main>
+ <h1>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>
+ </main>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Broadcaster</title>
+ <style type="text/css">
+ table.radio-status, td.outer {
+ border: 1px solid;
+ }
+ table.inner {
+ border-collapse: collapse;
+ }
+ td.clear {
+ width: 5em;
+ height: 5em;
+ text-align: center;
+ }
+ .time-table {
+ font-size: 90%;
+ }
+ .playlist-field {
+ text-align: right;
+ padding-right: 1em;
+ width: 5em;
+ }
+ .playlist-table {
+ font-size: 90%;
+ width: 30em;
+ }
+ .stop {
+ text-align: center;
+ }
+ .head {
+ text-align: center;
+ }
+ </style>
+ <script type="text/javascript">
+ function connectWebsocket() {
+ console.log("Attempting to create websocket connection for radio status sync")
+ const cookieValue = document.cookie
+ .split("; ")
+ .find((row) => row.startsWith("broadcast_session="))
+ ?.split("=")[1];
+ const socket = new WebSocket("/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>
+ </head>
+ <body>
+ <main>
+ <h1>Welcome!</h1>
+ {{if .LoggedIn}}
+ <p>Your username is: {{.Username}}.</p>
+ <p><a href="/logout">Log Out</a></p>
+ {{else}}
+ <p><a href="/login">Log In</a></p>
+ {{end}}
+ <p><a href="/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>
+ </main>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Broadcaster Log In</title>
+ </head>
+ <body>
+ <main>
+ <h1>Log In</h1>
+ <form action="/login" method="post">
+ {{if ne .Error ""}}
+ <p><b>{{.Error}}</b></p>
+ {{end}}
+ <label for="username">Username:</label><br>
+ <input type="text" id="username" name="username"><br>
+ <label for="password">Password:</label><br>
+ <input type="password" id="password" name="password"><br>
+ <input type="submit" value="Log In">
+ </form>
+ </main>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Broadcaster Log Out</title>
+ </head>
+ <body>
+ <main>
+ <h1>Logged Out</h1>
+ <p><a href="/login">Log In again</a></p>
+ </main>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Broadcaster</title>
+ <script type="text/javascript">
+ function deleteItem(sender) {
+ sender.parentNode.remove();
+ }
+ function addItem() {
+ const p = document.createElement('p');
+ const temp = document.getElementById('item-template');
+ p.innerHTML = temp.innerHTML;
+ const marker = document.getElementById('add-item');
+ const parent = marker.parentNode;
+ parent.insertBefore(p, marker);
+ }
+ </script>
+ </head>
+ <body>
+ <main>
+ <h1>A specific playlist</h1>
+ <h2>
+ {{if .Playlist.Id}}
+ Edit Playlist
+ {{else}}
+ Create New Playlist
+ {{end}}
+ </h2>
+ <form action="/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>
+ </main>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Broadcaster</title>
+ </head>
+ <body>
+ <main>
+ <h1>Playlists!</h1>
+ <ul>
+ {{range .Playlists}}
+ <li><b>{{.Name}}</b> {{.StartTime}} <a href="/playlists/{{.Id}}">(Edit)</a></li>
+ {{end}}
+ </ul>
+ <p><a href="/playlists/new">Add New Playlist</a></p>
+ </main>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Broadcaster</title>
+ </head>
+ <body>
+ <main>
+ <h1>A specific radio</h1>
+ <h2>
+ {{if .Radio.Id}}
+ Edit Radio
+ {{else}}
+ Register New Radio
+ {{end}}
+ </h2>
+ <form action="/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}}
+ </main>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Broadcaster</title>
+ </head>
+ <body>
+ <main>
+ <h1>Radios</h1>
+ <ul>
+ {{range .Radios}}
+ <li><b>{{.Name}}</b> {{.Token}} <a href="/radios/{{.Id}}">(Edit)</a></li>
+ {{end}}
+ </ul>
+ <p><a href="/radios/new">Register New Radio</a></p>
+ </main>
+ </body>
+</html>
--- /dev/null
+{{if .Radios}}
+{{range .Radios}}
+<table class="radio-status">
+<tr>
+ <td colspan="3" class="outer head">
+ <b>{{.Name}}</b>
+ </td>
+</tr>
+<tr>
+ <td colspan="3" class="outer">
+ <table class="time-table">
+ <tr>
+ <td width="100em">
+ Local Time
+ </td>
+ <td>
+ {{.LocalTime}}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ Time Zone
+ </td>
+ <td>
+ {{.TimeZone}}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ Files In Sync
+ </td>
+ <td>
+ {{if .FilesInSync}} Yes {{else}} No {{end}}
+ </td>
+ </tr>
+ </table>
+ </td>
+</tr>
+<tr>
+ <td class="outer {{.ChannelClass}}">
+ {{.ChannelState}}
+ </td>
+ <td class="outer">
+ <table class="playlist-table">
+ <tr>
+ <td class="playlist-field">
+ Playlist:
+ </td>
+ <td>
+ {{.Playlist}}
+ </td>
+ </tr>
+ <tr>
+ <td class="playlist-field">
+ File:
+ </td>
+ <td>
+ {{.File}}
+ </td>
+ </tr>
+ <tr>
+ <td class="playlist-field">
+ Status:
+ </td>
+ <td>
+ {{.Status}}
+ </td>
+ </tr>
+ </table>
+ </td>
+ <td class="outer stop">
+ <form action="/stop" method="post">
+ <input type="hidden" name="radioId" value="{{.Id}}">
+ <input type="submit" value="Cancel Playback" {{if .DisableCancel}} disabled {{end}}>
+ </form>
+ </td>
+</tr>
+</table>
+{{end}}
+{{else}}
+<p><i>There are no radios online.</i></p>
+{{end}}
--- /dev/null
+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()
+}
--- /dev/null
+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
+ }
+ }
+}
github.com/BurntSushi/toml v1.4.0
github.com/gopxl/beep/v2 v2.1.0
github.com/warthog618/go-gpiocdev v0.9.0
+ golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
modernc.org/sqlite v1.33.1
)
github.com/warthog618/go-gpiocdev v0.9.0/go.mod h1:GV4NZC82fWJERqk7Gu0+KfLSDIBEDNm6aPGiHlmT5fY=
github.com/warthog618/go-gpiosim v0.1.0 h1:2rTMTcKUVZxpUuvRKsagnKAbKpd3Bwffp87xywEDVGI=
github.com/warthog618/go-gpiosim v0.1.0/go.mod h1:Ngx/LYI5toxHr4E+Vm6vTgCnt0of0tktsSuMUEJ2wCI=
+golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
+golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
--- /dev/null
+package protocol
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+)
+
+const (
+ StartTimeFormat = "2006-01-02T15:04"
+ LocalTimeFormat = "Mon _2 Jan 2006 15:04:05"
+
+ // Radio to server
+
+ AuthenticateType = "authenticate"
+ StatusType = "status"
+
+ // Server to radio
+
+ FilesType = "files"
+ PlaylistsType = "playlists"
+ StopType = "stop"
+
+ // Status values
+
+ StatusIdle = "idle"
+ StatusDelay = "delay"
+ StatusChannelInUse = "channel_in_use"
+ StatusPlaying = "playing"
+)
+
+// Base message type to determine what type of payload is expected.
+type Message struct {
+ T string
+}
+
+// Initial message from Radio to authenticate itself with a token string.
+type AuthenticateMessage struct {
+ T string
+ Token string
+}
+
+// Server updates the radio with the list of files that currently exist.
+// This will be provided on connect and when there are any changes.
+// The radio is expected to obtain all these files and cache them locally.
+type FilesMessage struct {
+ T string
+ Files []FileSpec
+}
+
+type PlaylistsMessage struct {
+ T string
+ Playlists []PlaylistSpec
+}
+
+// Any playlist currently being played should be stopped and PTT disengaged.
+type StopMessage struct {
+ T string
+}
+
+type StatusMessage struct {
+ T string
+
+ // Status w.r.t. playing a playlist
+ Status string
+
+ // File being played or about to be played - empty string in idle status
+ Filename string
+
+ // Name of playlist being played - empty string in idle status
+ Playlist string
+
+ // Seconds until playback begins - never mind latency
+ DelaySecondsRemaining int
+
+ // Number of seconds file has been actually playing
+ PlaybackSecondsElapsed int
+
+ // Number of seconds waiting for channel to clear
+ WaitingForChannelSeconds int
+
+ PTT bool
+ COS bool
+ FilesInSync bool
+
+ // Timestamp of the current time on this radio, using LocalTimeFormat
+ LocalTime string
+
+ // Time zone in use, e.g. "Australia/Hobart"
+ TimeZone string
+}
+
+// Description of an individual file available in the broadcasting system.
+type FileSpec struct {
+ // Filename, e.g. "broadcast.wav"
+ Name string
+ // SHA-256 hash of the file's contents
+ Hash string
+}
+
+type PlaylistSpec struct {
+ Id int
+ Name string
+ StartTime string
+ Entries []EntrySpec
+}
+
+type EntrySpec struct {
+ Filename string
+ DelaySeconds int
+ IsRelative bool
+}
+
+func ParseMessage(data []byte) (string, interface{}, error) {
+ var t Message
+ err := json.Unmarshal(data, &t)
+ if err != nil {
+ return "", nil, err
+ }
+
+ if t.T == AuthenticateType {
+ var auth AuthenticateMessage
+ err = json.Unmarshal(data, &auth)
+ if err != nil {
+ return "", nil, err
+ }
+ return t.T, auth, nil
+ }
+
+ if t.T == FilesType {
+ var files FilesMessage
+ err = json.Unmarshal(data, &files)
+ if err != nil {
+ return "", nil, err
+ }
+ return t.T, files, nil
+ }
+
+ if t.T == PlaylistsType {
+ var playlists PlaylistsMessage
+ err = json.Unmarshal(data, &playlists)
+ if err != nil {
+ return "", nil, err
+ }
+ return t.T, playlists, nil
+ }
+
+ if t.T == StatusType {
+ var status StatusMessage
+ err = json.Unmarshal(data, &status)
+ if err != nil {
+ return "", nil, err
+ }
+ return t.T, status, nil
+ }
+
+ if t.T == StopType {
+ var stop StopMessage
+ err = json.Unmarshal(data, &stop)
+ if err != nil {
+ return "", nil, err
+ }
+ return t.T, stop, nil
+ }
+
+ return "", nil, errors.New(fmt.Sprintf("unknown message type %v", t.T))
+}
+++ /dev/null
-package protocol
-
-import (
- "encoding/json"
- "errors"
- "fmt"
-)
-
-const (
- StartTimeFormat = "2006-01-02T15:04"
- LocalTimeFormat = "Mon _2 Jan 2006 15:04:05"
-
- // Radio to server
-
- AuthenticateType = "authenticate"
- StatusType = "status"
-
- // Server to radio
-
- FilesType = "files"
- PlaylistsType = "playlists"
- StopType = "stop"
-
- // Status values
-
- StatusIdle = "idle"
- StatusDelay = "delay"
- StatusChannelInUse = "channel_in_use"
- StatusPlaying = "playing"
-)
-
-// Base message type to determine what type of payload is expected.
-type Message struct {
- T string
-}
-
-// Initial message from Radio to authenticate itself with a token string.
-type AuthenticateMessage struct {
- T string
- Token string
-}
-
-// Server updates the radio with the list of files that currently exist.
-// This will be provided on connect and when there are any changes.
-// The radio is expected to obtain all these files and cache them locally.
-type FilesMessage struct {
- T string
- Files []FileSpec
-}
-
-type PlaylistsMessage struct {
- T string
- Playlists []PlaylistSpec
-}
-
-// Any playlist currently being played should be stopped and PTT disengaged.
-type StopMessage struct {
- T string
-}
-
-type StatusMessage struct {
- T string
-
- // Status w.r.t. playing a playlist
- Status string
-
- // File being played or about to be played - empty string in idle status
- Filename string
-
- // Name of playlist being played - empty string in idle status
- Playlist string
-
- // Seconds until playback begins - never mind latency
- DelaySecondsRemaining int
-
- // Number of seconds file has been actually playing
- PlaybackSecondsElapsed int
-
- // Number of seconds waiting for channel to clear
- WaitingForChannelSeconds int
-
- PTT bool
- COS bool
- FilesInSync bool
-
- // Timestamp of the current time on this radio, using LocalTimeFormat
- LocalTime string
-
- // Time zone in use, e.g. "Australia/Hobart"
- TimeZone string
-}
-
-// Description of an individual file available in the broadcasting system.
-type FileSpec struct {
- // Filename, e.g. "broadcast.wav"
- Name string
- // SHA-256 hash of the file's contents
- Hash string
-}
-
-type PlaylistSpec struct {
- Id int
- Name string
- StartTime string
- Entries []EntrySpec
-}
-
-type EntrySpec struct {
- Filename string
- DelaySeconds int
- IsRelative bool
-}
-
-func ParseMessage(data []byte) (string, interface{}, error) {
- var t Message
- err := json.Unmarshal(data, &t)
- if err != nil {
- return "", nil, err
- }
-
- if t.T == AuthenticateType {
- var auth AuthenticateMessage
- err = json.Unmarshal(data, &auth)
- if err != nil {
- return "", nil, err
- }
- return t.T, auth, nil
- }
-
- if t.T == FilesType {
- var files FilesMessage
- err = json.Unmarshal(data, &files)
- if err != nil {
- return "", nil, err
- }
- return t.T, files, nil
- }
-
- if t.T == PlaylistsType {
- var playlists PlaylistsMessage
- err = json.Unmarshal(data, &playlists)
- if err != nil {
- return "", nil, err
- }
- return t.T, playlists, nil
- }
-
- if t.T == StatusType {
- var status StatusMessage
- err = json.Unmarshal(data, &status)
- if err != nil {
- return "", nil, err
- }
- return t.T, status, nil
- }
-
- if t.T == StopType {
- var stop StopMessage
- err = json.Unmarshal(data, &stop)
- if err != nil {
- return "", nil, err
- }
- return t.T, stop, nil
- }
-
- return "", nil, errors.New(fmt.Sprintf("unknown message type %v", t.T))
-}
+++ /dev/null
-package main
-
-import (
- "errors"
- "log"
- "os"
- "strings"
-
- "github.com/BurntSushi/toml"
-)
-
-type RadioConfig struct {
- GpioDevice string
- PTTPin int
- COSPin int
- ServerURL string
- Token string
- CachePath string
- TimeZone string
-}
-
-func NewRadioConfig() RadioConfig {
- return RadioConfig{
- GpioDevice: "gpiochip0",
- PTTPin: -1,
- COSPin: -1,
- ServerURL: "",
- Token: "",
- CachePath: "",
- TimeZone: "Australia/Hobart",
- }
-}
-
-func (c *RadioConfig) LoadFromFile(path string) {
- _, err := toml.DecodeFile(path, &c)
- if err != nil {
- log.Fatal("could not read config file for reading at path:", path, err)
- }
- err = c.Validate()
- if err != nil {
- log.Fatal(err)
- }
- c.ApplyDefaults()
-}
-
-func (c *RadioConfig) Validate() error {
- if c.ServerURL == "" {
- return errors.New("ServerURL must be provided in the configuration")
- }
- if c.Token == "" {
- return errors.New("Token must be provided in the configuration")
- }
- return nil
-}
-
-func (c *RadioConfig) ApplyDefaults() {
- if c.CachePath == "" {
- dir, err := os.MkdirTemp("", "broadcast")
- if err != nil {
- log.Fatal(err)
- }
- c.CachePath = dir
- }
-}
-
-func (c *RadioConfig) WebsocketURL() string {
- addr := strings.Replace(c.ServerURL, "https://", "wss://", -1)
- addr = strings.Replace(addr, "http://", "ws://", -1)
- return addr + "/radiosync"
-}
+++ /dev/null
-package main
-
-import (
- "code.octet-stream.net/broadcaster/protocol"
- "crypto/sha256"
- "encoding/hex"
- "io"
- "log"
- "net/http"
- "os"
- "path/filepath"
-)
-
-type FilesMachine struct {
- specs []protocol.FileSpec
- cachePath string
- missing []string
-}
-
-func NewFilesMachine(cachePath string) FilesMachine {
- if err := os.MkdirAll(cachePath, 0750); err != nil {
- log.Fatal(err)
- }
- return FilesMachine{
- cachePath: cachePath,
- }
-}
-
-func (m *FilesMachine) UpdateSpecs(specs []protocol.FileSpec) {
- m.specs = specs
- m.RefreshMissing()
-}
-
-func (m *FilesMachine) RefreshMissing() {
- // Delete any files in the cache dir who are not in the spec
- entries, err := os.ReadDir(m.cachePath)
- if err != nil {
- log.Fatal(err)
- }
- okay := make([]string, 0)
- for _, file := range entries {
- hash := ""
- for _, spec := range m.specs {
- if file.Name() == spec.Name {
- hash = spec.Hash
- break
- }
- }
- // if we have an extraneous file, delete it
- if hash == "" {
- log.Println("Deleting extraneous cached audio file:", file.Name())
- os.Remove(filepath.Join(m.cachePath, file.Name()))
- continue
- }
- // if the hash isn't right, delete it
- f, err := os.Open(filepath.Join(m.cachePath, file.Name()))
- if err != nil {
- log.Fatal(err)
- }
- hasher := sha256.New()
- io.Copy(hasher, f)
- if hex.EncodeToString(hasher.Sum(nil)) != hash {
- log.Println("Deleting cached audio file with incorrect hash:", file.Name())
- os.Remove(filepath.Join(m.cachePath, file.Name()))
- } else {
- okay = append(okay, file.Name())
- }
- }
- m.missing = nil
- for _, spec := range m.specs {
- missing := true
- for _, file := range okay {
- if spec.Name == file {
- missing = false
- }
- }
- if missing {
- m.missing = append(m.missing, spec.Name)
- }
- }
- if len(m.missing) > 1 {
- log.Println(len(m.missing), "missing files")
- } else if len(m.missing) == 1 {
- log.Println("1 missing file")
- } else {
- log.Println("All files are in sync with server")
- }
- statusCollector.FilesInSync <- len(m.missing) == 0
-}
-
-func (m *FilesMachine) IsCacheComplete() bool {
- return len(m.missing) == 0
-}
-
-func (m *FilesMachine) NextFile() string {
- next, remainder := m.missing[0], m.missing[1:]
- m.missing = remainder
- return next
-}
-
-func (m *FilesMachine) DownloadSingle(filename string, downloadResult chan<- error) {
- log.Println("Downloading", filename)
- out, err := os.Create(filepath.Join(m.cachePath, filename))
- if err != nil {
- downloadResult <- err
- return
- }
- defer out.Close()
- resp, err := http.Get(config.ServerURL + "/audio-files/" + filename)
- if err != nil {
- downloadResult <- err
- return
- }
- defer resp.Body.Close()
- _, err = io.Copy(out, resp.Body)
- downloadResult <- err
-}
+++ /dev/null
-package main
-
-import (
- gpio "github.com/warthog618/go-gpiocdev"
- "github.com/warthog618/go-gpiocdev/device/rpi"
- "log"
- "strconv"
-)
-
-type PTT interface {
- EngagePTT()
- DisengagePTT()
-}
-
-type COS interface {
- WaitForChannelClear()
- COSValue() bool
-}
-
-var ptt PTT = &DefaultPTT{}
-var cos COS = &DefaultCOS{}
-
-type PiPTT struct {
- pttLine *gpio.Line
-}
-
-type PiCOS struct {
- cosLine *gpio.Line
- clearWait chan bool
-}
-
-func InitRaspberryPiPTT(pttNum int, chipName string) {
- pttPin, err := rpi.Pin("GPIO" + strconv.Itoa(pttNum))
- if err != nil {
- log.Fatal("invalid PTT pin configured", ptt)
- }
- pttLine, err := gpio.RequestLine(chipName, pttPin, gpio.AsOutput(0))
- if err != nil {
- log.Fatal("unable to open requested pin for PTT GPIO:", ptt, ". Are you running as root?")
- }
- ptt = &PiPTT{
- pttLine: pttLine,
- }
-}
-
-func InitRaspberryPiCOS(cosNum int, chipName string) {
- var piCOS PiCOS
- piCOS.clearWait = make(chan bool)
- cosPin, err := rpi.Pin("GPIO" + strconv.Itoa(cosNum))
- if err != nil {
- log.Fatal("invalid COS Pin configured", cos)
- }
- cosHandler := func(event gpio.LineEvent) {
- if event.Type == gpio.LineEventFallingEdge {
- log.Println("COS: channel clear")
- close(piCOS.clearWait)
- piCOS.clearWait = make(chan bool)
- statusCollector.COS <- false
- }
- if event.Type == gpio.LineEventRisingEdge {
- log.Println("COS: channel in use")
- statusCollector.COS <- true
- }
- }
- cosLine, err := gpio.RequestLine(chipName, cosPin, gpio.AsInput, gpio.WithBothEdges, gpio.WithEventHandler(cosHandler))
- if err != nil {
- log.Fatal("unable to open requested pin for COS GPIO:", cos, ". Are you running as root?")
- }
- piCOS.cosLine = cosLine
- cos = &piCOS
-}
-
-func (g *PiCOS) COSValue() bool {
- val, err := g.cosLine.Value()
- if err != nil {
- log.Fatal("Unable to read COS value")
- }
- return val != 0
-}
-
-func (g *PiCOS) WaitForChannelClear() {
- ch := g.clearWait
- val, err := g.cosLine.Value()
- if err != nil || val == 0 {
- return
- }
- // wait for close
- <-ch
-}
-
-func (g *PiPTT) EngagePTT() {
- log.Println("PTT: on")
- g.pttLine.SetValue(1)
- statusCollector.PTT <- true
-}
-
-func (g *PiPTT) DisengagePTT() {
- log.Println("PTT: off")
- g.pttLine.SetValue(0)
- statusCollector.PTT <- false
-}
-
-type DefaultPTT struct {
-}
-
-func (g *DefaultPTT) EngagePTT() {
- statusCollector.PTT <- true
-}
-
-func (g *DefaultPTT) DisengagePTT() {
- statusCollector.PTT <- false
-}
-
-type DefaultCOS struct {
-}
-
-func (g *DefaultCOS) WaitForChannelClear() {
- log.Println("Assuming channel is clear since COS GPIO is not configured")
-}
-
-func (g *DefaultCOS) COSValue() bool {
- return false
-}
+++ /dev/null
-package main
-
-import (
- "code.octet-stream.net/broadcaster/protocol"
- "encoding/json"
- "flag"
- "github.com/gopxl/beep/v2"
- "github.com/gopxl/beep/v2/mp3"
- "github.com/gopxl/beep/v2/speaker"
- "github.com/gopxl/beep/v2/wav"
- "golang.org/x/net/websocket"
- "log"
- "os"
- "os/signal"
- "path/filepath"
- "strings"
- "syscall"
- "time"
-)
-
-const sampleRate = 44100
-
-var config RadioConfig = NewRadioConfig()
-
-func main() {
- configFlag := flag.String("c", "", "path to configuration file")
- // TODO: support this
- //generateFlag := flag.String("g", "", "create a template config file with specified name then exit")
- flag.Parse()
-
- if *configFlag == "" {
- log.Fatal("must specify a configuration file with -c")
- }
- config.LoadFromFile(*configFlag)
- statusCollector.Config <- config
-
- playbackSampleRate := beep.SampleRate(sampleRate)
- speaker.Init(playbackSampleRate, playbackSampleRate.N(time.Second/10))
-
- if config.PTTPin != -1 {
- InitRaspberryPiPTT(config.PTTPin, config.GpioDevice)
- }
- if config.COSPin != -1 {
- InitRaspberryPiCOS(config.COSPin, config.GpioDevice)
- }
-
- sig := make(chan os.Signal, 1)
- signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
- go func() {
- sig := <-sig
- log.Println("Radio shutting down due to signal", sig)
- // Make sure we always stop PTT when program ends
- ptt.DisengagePTT()
- os.Exit(0)
- }()
-
- log.Println("Config checks out, radio coming online")
- log.Println("Audio file cache:", config.CachePath)
-
- fileSpecChan := make(chan []protocol.FileSpec)
- go filesWorker(config.CachePath, fileSpecChan)
-
- 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
-}
+++ /dev/null
-package main
-
-import (
- "code.octet-stream.net/broadcaster/protocol"
- "encoding/json"
- "golang.org/x/net/websocket"
- "time"
-)
-
-type BeginDelayStatus struct {
- Playlist string
- Seconds int
- Filename string
-}
-
-type BeginWaitForChannelStatus struct {
- Playlist string
- Filename string
-}
-
-type BeginPlaybackStatus struct {
- Playlist string
- Filename string
-}
-
-type StatusCollector struct {
- Websocket chan *websocket.Conn
- PlaylistBeginIdle chan bool
- PlaylistBeginDelay chan BeginDelayStatus
- PlaylistBeginWaitForChannel chan BeginWaitForChannelStatus
- PlaylistBeginPlayback chan BeginPlaybackStatus
- PTT chan bool
- COS chan bool
- Config chan RadioConfig
- FilesInSync chan bool
-}
-
-var statusCollector = NewStatusCollector()
-
-func NewStatusCollector() StatusCollector {
- sc := StatusCollector{
- Websocket: make(chan *websocket.Conn),
- PlaylistBeginIdle: make(chan bool),
- PlaylistBeginDelay: make(chan BeginDelayStatus),
- PlaylistBeginWaitForChannel: make(chan BeginWaitForChannelStatus),
- PlaylistBeginPlayback: make(chan BeginPlaybackStatus),
- PTT: make(chan bool),
- COS: make(chan bool),
- Config: make(chan RadioConfig),
- FilesInSync: make(chan bool),
- }
- go runStatusCollector(sc)
- return sc
-}
-
-func runStatusCollector(sc StatusCollector) {
- config := <-sc.Config
- var msg protocol.StatusMessage
- var lastSent protocol.StatusMessage
- msg.T = protocol.StatusType
- msg.TimeZone = config.TimeZone
- msg.Status = protocol.StatusIdle
- var ws *websocket.Conn
- // Go 1.23: no need to stop tickers when finished
- var ticker = time.NewTicker(time.Second * time.Duration(30))
-
- for {
- select {
- case newWebsocket := <-sc.Websocket:
- ws = newWebsocket
- case <-ticker.C:
- // should always be ticking at 1 second for these
- if msg.Status == protocol.StatusDelay {
- if msg.DelaySecondsRemaining > 0 {
- msg.DelaySecondsRemaining -= 1
- }
- }
- if msg.Status == protocol.StatusChannelInUse {
- msg.WaitingForChannelSeconds += 1
- }
- if msg.Status == protocol.StatusPlaying {
- msg.PlaybackSecondsElapsed += 1
- }
- case <-sc.PlaylistBeginIdle:
- msg.Status = protocol.StatusIdle
- msg.DelaySecondsRemaining = 0
- msg.WaitingForChannelSeconds = 0
- msg.PlaybackSecondsElapsed = 0
- msg.Playlist = ""
- msg.Filename = ""
- // Update things more slowly when nothing's playing
- ticker = time.NewTicker(time.Second * time.Duration(30))
- case delay := <-sc.PlaylistBeginDelay:
- msg.Status = protocol.StatusDelay
- msg.DelaySecondsRemaining = delay.Seconds
- msg.WaitingForChannelSeconds = 0
- msg.PlaybackSecondsElapsed = 0
- msg.Playlist = delay.Playlist
- msg.Filename = delay.Filename
- // Align ticker with start of state change, make sure it's faster
- ticker = time.NewTicker(time.Second * time.Duration(1))
- case wait := <-sc.PlaylistBeginWaitForChannel:
- msg.Status = protocol.StatusChannelInUse
- msg.DelaySecondsRemaining = 0
- msg.WaitingForChannelSeconds = 0
- msg.PlaybackSecondsElapsed = 0
- msg.Playlist = wait.Playlist
- msg.Filename = wait.Filename
- ticker = time.NewTicker(time.Second * time.Duration(1))
- case playback := <-sc.PlaylistBeginPlayback:
- msg.Status = protocol.StatusPlaying
- msg.DelaySecondsRemaining = 0
- msg.WaitingForChannelSeconds = 0
- msg.PlaybackSecondsElapsed = 0
- msg.Playlist = playback.Playlist
- msg.Filename = playback.Filename
- ticker = time.NewTicker(time.Second * time.Duration(1))
- case ptt := <-sc.PTT:
- msg.PTT = ptt
- case cos := <-sc.COS:
- msg.COS = cos
- case inSync := <-sc.FilesInSync:
- msg.FilesInSync = inSync
- }
- msg.LocalTime = time.Now().Format(protocol.LocalTimeFormat)
- msg.COS = cos.COSValue()
-
- if msg == lastSent {
- continue
- }
- if ws != nil {
- msgJson, _ := json.Marshal(msg)
- if _, err := ws.Write(msgJson); err != nil {
- // If websocket has failed, wait 'til we get a new one
- ws = nil
- }
- lastSent = msg
- }
- }
-}
+++ /dev/null
-package main
-
-import (
- "flag"
- "golang.org/x/net/websocket"
- "html/template"
- "io"
- "log"
- "net/http"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "time"
-)
-
-const formatString = "2006-01-02T15:04"
-
-var config ServerConfig = NewServerConfig()
-
-func main() {
- configFlag := flag.String("c", "", "path to configuration file")
- // TODO: support this
- //generateFlag := flag.String("g", "", "create a template config file with specified name then exit")
- flag.Parse()
-
- if *configFlag == "" {
- log.Fatal("must specify a configuration file with -c")
- }
- config.LoadFromFile(*configFlag)
-
- log.Println("Hello, World! Woo broadcast time")
- InitDatabase()
- defer db.CloseDatabase()
-
- InitCommandRouter()
- InitPlaylists()
- InitAudioFiles(config.AudioFilesPath)
- InitServerStatus()
-
- http.HandleFunc("/", homePage)
- http.HandleFunc("/login", logInPage)
- http.HandleFunc("/logout", logOutPage)
- http.HandleFunc("/secret", secretPage)
- http.HandleFunc("/stop", stopPage)
-
- http.HandleFunc("/playlist/", playlistSection)
- http.HandleFunc("/file/", fileSection)
- http.HandleFunc("/radio/", radioSection)
-
- http.Handle("/radiosync", websocket.Handler(RadioSync))
- http.Handle("/websync", websocket.Handler(WebSync))
- http.Handle("/audio-files/", http.StripPrefix("/audio-files/", http.FileServer(http.Dir(config.AudioFilesPath))))
-
- err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil)
- if err != nil {
- log.Fatal(err)
- }
-}
-
-type HomeData struct {
- LoggedIn bool
- Username string
-}
-
-func homePage(w http.ResponseWriter, r *http.Request) {
- tmpl := template.Must(template.ParseFiles("templates/index.html"))
- data := HomeData{
- LoggedIn: true,
- Username: "Bob",
- }
- tmpl.Execute(w, data)
-}
-
-func secretPage(w http.ResponseWriter, r *http.Request) {
- user, err := currentUser(w, r)
- if err != nil {
- http.Redirect(w, r, "/login", http.StatusFound)
- return
- }
- tmpl := template.Must(template.ParseFiles("templates/index.html"))
- data := HomeData{
- LoggedIn: true,
- Username: user.username + ", you are special",
- }
- tmpl.Execute(w, data)
-}
-
-type LogInData struct {
- Error string
-}
-
-func logInPage(w http.ResponseWriter, r *http.Request) {
- log.Println("Log in page!")
- r.ParseForm()
- username := r.Form["username"]
- password := r.Form["password"]
- err := ""
- if username != nil {
- log.Println("Looks like we have a username", username[0])
- if username[0] == "admin" && password[0] == "test" {
- createSessionCookie(w)
- http.Redirect(w, r, "/", http.StatusFound)
- return
- } else {
- err = "Incorrect login"
- }
- }
-
- data := LogInData{
- Error: err,
- }
-
- tmpl := template.Must(template.ParseFiles("templates/login.html"))
- tmpl.Execute(w, data)
-}
-
-func playlistSection(w http.ResponseWriter, r *http.Request) {
- path := strings.Split(r.URL.Path, "/")
- if len(path) != 3 {
- http.NotFound(w, r)
- return
- }
- if path[2] == "new" {
- editPlaylistPage(w, r, 0)
- } else if path[2] == "submit" && r.Method == "POST" {
- submitPlaylist(w, r)
- } else if path[2] == "delete" && r.Method == "POST" {
- deletePlaylist(w, r)
- } else if path[2] == "" {
- playlistsPage(w, r)
- } else {
- id, err := strconv.Atoi(path[2])
- if err != nil {
- http.NotFound(w, r)
- return
- }
- editPlaylistPage(w, r, id)
- }
-}
-
-func fileSection(w http.ResponseWriter, r *http.Request) {
- path := strings.Split(r.URL.Path, "/")
- if len(path) != 3 {
- http.NotFound(w, r)
- return
- }
- if path[2] == "upload" {
- uploadFile(w, r)
- } else if path[2] == "delete" && r.Method == "POST" {
- deleteFile(w, r)
- } else if path[2] == "" {
- filesPage(w, r)
- } else {
- http.NotFound(w, r)
- return
- }
-}
-
-func radioSection(w http.ResponseWriter, r *http.Request) {
- path := strings.Split(r.URL.Path, "/")
- if len(path) != 3 {
- http.NotFound(w, r)
- return
- }
- if path[2] == "new" {
- editRadioPage(w, r, 0)
- } else if path[2] == "submit" && r.Method == "POST" {
- submitRadio(w, r)
- } else if path[2] == "delete" && r.Method == "POST" {
- deleteRadio(w, r)
- } else if path[2] == "" {
- radiosPage(w, r)
- } else {
- id, err := strconv.Atoi(path[2])
- if err != nil {
- http.NotFound(w, r)
- return
- }
- editRadioPage(w, r, id)
- }
-}
-
-type PlaylistsPageData struct {
- Playlists []Playlist
-}
-
-func playlistsPage(w http.ResponseWriter, _ *http.Request) {
- data := PlaylistsPageData{
- Playlists: db.GetPlaylists(),
- }
- tmpl := template.Must(template.ParseFiles("templates/playlists.html"))
- err := tmpl.Execute(w, data)
- if err != nil {
- log.Fatal(err)
- }
-}
-
-type RadiosPageData struct {
- Radios []Radio
-}
-
-func radiosPage(w http.ResponseWriter, _ *http.Request) {
- data := RadiosPageData{
- Radios: db.GetRadios(),
- }
- tmpl := template.Must(template.ParseFiles("templates/radios.html"))
- err := tmpl.Execute(w, data)
- if err != nil {
- log.Fatal(err)
- }
-}
-
-type EditPlaylistPageData struct {
- Playlist Playlist
- Entries []PlaylistEntry
- Files []string
-}
-
-func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int) {
- var data EditPlaylistPageData
- for _, f := range files.Files() {
- data.Files = append(data.Files, f.Name)
- }
- if id == 0 {
- data.Playlist.Enabled = true
- data.Playlist.Name = "New Playlist"
- data.Playlist.StartTime = time.Now().Format(formatString)
- data.Entries = append(data.Entries, PlaylistEntry{})
- } else {
- playlist, err := db.GetPlaylist(id)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- data.Playlist = playlist
- data.Entries = db.GetEntriesForPlaylist(id)
- }
- tmpl := template.Must(template.ParseFiles("templates/playlist.html"))
- tmpl.Execute(w, data)
-}
-
-func submitPlaylist(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- var p Playlist
- id, err := strconv.Atoi(r.Form.Get("playlistId"))
- if err != nil {
- return
- }
- _, err = time.Parse(formatString, r.Form.Get("playlistStartTime"))
- if err != nil {
- return
- }
- p.Id = id
- p.Enabled = r.Form.Get("playlistEnabled") == "1"
- p.Name = r.Form.Get("playlistName")
- p.StartTime = r.Form.Get("playlistStartTime")
-
- delays := r.Form["delaySeconds"]
- filenames := r.Form["filename"]
- isRelatives := r.Form["isRelative"]
-
- entries := make([]PlaylistEntry, 0)
- for i := range delays {
- var e PlaylistEntry
- delay, err := strconv.Atoi(delays[i])
- if err != nil {
- return
- }
- e.DelaySeconds = delay
- e.Position = i
- e.IsRelative = isRelatives[i] == "1"
- e.Filename = filenames[i]
- entries = append(entries, e)
- }
- cleanedEntries := make([]PlaylistEntry, 0)
- for _, e := range entries {
- if e.DelaySeconds != 0 || e.Filename != "" {
- cleanedEntries = append(cleanedEntries, e)
- }
- }
-
- if id != 0 {
- db.UpdatePlaylist(p)
- } else {
- id = db.CreatePlaylist(p)
- }
- db.SetEntriesForPlaylist(cleanedEntries, id)
- // Notify connected radios
- playlists.NotifyChanges()
- }
- http.Redirect(w, r, "/playlist/", http.StatusFound)
-}
-
-func deletePlaylist(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- id, err := strconv.Atoi(r.Form.Get("playlistId"))
- if err != nil {
- return
- }
- db.DeletePlaylist(id)
- playlists.NotifyChanges()
- }
- http.Redirect(w, r, "/playlist/", http.StatusFound)
-}
-
-type EditRadioPageData struct {
- Radio Radio
-}
-
-func editRadioPage(w http.ResponseWriter, r *http.Request, id int) {
- var data EditRadioPageData
- if id == 0 {
- data.Radio.Name = "New Radio"
- data.Radio.Token = generateSession()
- } else {
- radio, err := db.GetRadio(id)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- data.Radio = radio
- }
- tmpl := template.Must(template.ParseFiles("templates/radio.html"))
- tmpl.Execute(w, data)
-}
-
-func submitRadio(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- var radio Radio
- id, err := strconv.Atoi(r.Form.Get("radioId"))
- if err != nil {
- return
- }
- radio.Id = id
- radio.Name = r.Form.Get("radioName")
- radio.Token = r.Form.Get("radioToken")
- if id != 0 {
- db.UpdateRadio(radio)
- } else {
- db.CreateRadio(radio)
- }
- }
- http.Redirect(w, r, "/radio/", http.StatusFound)
-}
-
-func deleteRadio(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- id, err := strconv.Atoi(r.Form.Get("radioId"))
- if err != nil {
- return
- }
- db.DeleteRadio(id)
- }
- http.Redirect(w, r, "/radio/", http.StatusFound)
-}
-
-type FilesPageData struct {
- Files []FileSpec
-}
-
-func filesPage(w http.ResponseWriter, _ *http.Request) {
- data := FilesPageData{
- Files: files.Files(),
- }
- log.Println("file page data", data)
- tmpl := template.Must(template.ParseFiles("templates/files.html"))
- err := tmpl.Execute(w, data)
- if err != nil {
- log.Fatal(err)
- }
-}
-
-func deleteFile(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- filename := r.Form.Get("filename")
- if filename == "" {
- return
- }
- files.Delete(filename)
- }
- http.Redirect(w, r, "/file/", http.StatusFound)
-}
-
-func uploadFile(w http.ResponseWriter, r *http.Request) {
- err := r.ParseMultipartForm(100 << 20)
- file, handler, err := r.FormFile("file")
- if err == nil {
- path := filepath.Join(files.Path(), filepath.Base(handler.Filename))
- f, _ := os.Create(path)
- defer f.Close()
- io.Copy(f, file)
- log.Println("uploaded file to", path)
- files.Refresh()
- }
- http.Redirect(w, r, "/file/", http.StatusFound)
-}
-
-func logOutPage(w http.ResponseWriter, r *http.Request) {
- clearSessionCookie(w)
- tmpl := template.Must(template.ParseFiles("templates/logout.html"))
- tmpl.Execute(w, nil)
-}
-
-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)
-}
+++ /dev/null
-package main
-
-import (
- "code.octet-stream.net/broadcaster/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)
- }
-}
+++ /dev/null
-package main
-
-import (
- "errors"
- "log"
-
- "github.com/BurntSushi/toml"
-)
-
-type ServerConfig struct {
- BindAddress string
- Port int
- SqliteDB string
- AudioFilesPath string
-}
-
-func NewServerConfig() ServerConfig {
- return ServerConfig{
- BindAddress: "0.0.0.0",
- Port: 55134,
- SqliteDB: "",
- AudioFilesPath: "",
- }
-}
-
-func (c *ServerConfig) LoadFromFile(path string) {
- _, err := toml.DecodeFile(path, &c)
- if err != nil {
- log.Fatal("could not read config file for reading at path:", path, err)
- }
- err = c.Validate()
- if err != nil {
- log.Fatal(err)
- }
-}
-
-func (c *ServerConfig) Validate() error {
- if c.SqliteDB == "" {
- return errors.New("Configuration must provide SqliteDB")
- }
- if c.AudioFilesPath == "" {
- return errors.New("Configuration must provide AudioFilesPath")
- }
- return nil
-}
+++ /dev/null
-package main
-
-import (
- "database/sql"
- "errors"
- "log"
- _ "modernc.org/sqlite"
- "time"
-)
-
-type Database struct {
- sqldb *sql.DB
-}
-
-var db Database
-
-func InitDatabase() {
- sqldb, err := sql.Open("sqlite", config.SqliteDB)
- if err != nil {
- log.Fatal(err)
- }
- db.sqldb = sqldb
-
- _, err = db.sqldb.Exec("PRAGMA 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);
- `
- _, err = db.sqldb.Exec(sqlStmt)
- if err != nil {
- log.Printf("%q: %s\n", err, sqlStmt)
- return
- }
-}
-
-func (d *Database) CloseDatabase() {
- d.sqldb.Close()
-}
-
-func (d *Database) InsertSession(user string, token string, expiry time.Time) {
- _, err := d.sqldb.Exec("INSERT INTO sessions (token, username, created, expiry) values (?, ?, CURRENT_TIMESTAMP, ?)", token, user, expiry)
- if err != nil {
- log.Fatal(err)
- }
-}
-
-func (d *Database) GetUserForSession(token string) (string, error) {
- var username string
- err := d.sqldb.QueryRow("SELECT username FROM sessions WHERE token = ? AND expiry > CURRENT_TIMESTAMP", token).Scan(&username)
- if err != nil {
- return "", errors.New("no matching token")
- }
- return username, nil
-}
-
-func (d *Database) CreatePlaylist(playlist Playlist) int {
- var id int
- tx, _ := d.sqldb.Begin()
- _, err := tx.Exec("INSERT INTO playlists (enabled, name, start_time) values (?, ?, ?)", playlist.Enabled, playlist.Name, playlist.StartTime)
- if err != nil {
- log.Fatal(err)
- }
- err = tx.QueryRow("SELECT last_insert_rowid()").Scan(&id)
- if err != nil {
- log.Fatal(err)
- }
- err = tx.Commit()
- if err != nil {
- log.Fatal(err)
- }
- return id
-}
-
-func (d *Database) DeletePlaylist(playlistId int) {
- d.sqldb.Exec("DELETE FROM playlists WHERE id = ?", playlistId)
-}
-
-func (d *Database) GetPlaylists() []Playlist {
- ret := make([]Playlist, 0)
- rows, err := d.sqldb.Query("SELECT id, enabled, name, start_time FROM playlists ORDER BY id ASC")
- if err != nil {
- return ret
- }
- defer rows.Close()
- for rows.Next() {
- var p Playlist
- if err := rows.Scan(&p.Id, &p.Enabled, &p.Name, &p.StartTime); err != nil {
- return ret
- }
- ret = append(ret, p)
- }
- return ret
-}
-
-func (d *Database) GetPlaylist(playlistId int) (Playlist, error) {
- var p Playlist
- err := d.sqldb.QueryRow("SELECT id, enabled, name, start_time FROM playlists WHERE id = ?", playlistId).Scan(&p.Id, &p.Enabled, &p.Name, &p.StartTime)
- if err != nil {
- return p, err
- }
- return p, nil
-}
-
-func (d *Database) UpdatePlaylist(playlist Playlist) {
- d.sqldb.Exec("UPDATE playlists SET enabled = ?, name = ?, start_time = ? WHERE id = ?", playlist.Enabled, playlist.Name, playlist.StartTime, playlist.Id)
-}
-
-func (d *Database) SetEntriesForPlaylist(entries []PlaylistEntry, playlistId int) {
- tx, _ := d.sqldb.Begin()
- _, err := tx.Exec("DELETE FROM playlist_entries WHERE playlist_id = ?", playlistId)
- for _, e := range entries {
- _, err = tx.Exec("INSERT INTO playlist_entries (playlist_id, position, filename, delay_seconds, is_relative) values (?, ?, ?, ?, ?)", playlistId, e.Position, e.Filename, e.DelaySeconds, e.IsRelative)
- if err != nil {
- log.Fatal(err)
- }
- }
- tx.Commit() // ignore errors
-}
-
-func (d *Database) GetEntriesForPlaylist(playlistId int) []PlaylistEntry {
- ret := make([]PlaylistEntry, 0)
- rows, err := d.sqldb.Query("SELECT id, position, filename, delay_seconds, is_relative FROM playlist_entries WHERE playlist_id = ? ORDER by position ASC", playlistId)
- if err != nil {
- return ret
- }
- defer rows.Close()
- for rows.Next() {
- var entry PlaylistEntry
- if err := rows.Scan(&entry.Id, &entry.Position, &entry.Filename, &entry.DelaySeconds, &entry.IsRelative); err != nil {
- return ret
- }
- ret = append(ret, entry)
- }
- return ret
-}
-
-func (d *Database) GetRadio(radioId int) (Radio, error) {
- var r Radio
- err := d.sqldb.QueryRow("SELECT id, name, token FROM radios WHERE id = ?", radioId).Scan(&r.Id, &r.Name, &r.Token)
- if err != nil {
- return r, err
- }
- return r, nil
-}
-
-func (d *Database) GetRadioByToken(token string) (Radio, error) {
- var r Radio
- err := d.sqldb.QueryRow("SELECT id, name, token FROM radios WHERE token = ?", token).Scan(&r.Id, &r.Name, &r.Token)
- if err != nil {
- return r, err
- }
- return r, nil
-}
-
-func (d *Database) GetRadios() []Radio {
- ret := make([]Radio, 0)
- rows, err := d.sqldb.Query("SELECT id, name, token FROM radios ORDER BY id ASC")
- if err != nil {
- return ret
- }
- defer rows.Close()
- for rows.Next() {
- var r Radio
- if err := rows.Scan(&r.Id, &r.Name, &r.Token); err != nil {
- return ret
- }
- ret = append(ret, r)
- }
- return ret
-}
-
-func (d *Database) DeleteRadio(radioId int) {
- d.sqldb.Exec("DELETE FROM radios WHERE id = ?", radioId)
-}
-
-func (d *Database) CreateRadio(radio Radio) {
- d.sqldb.Exec("INSERT INTO radios (name, token) values (?, ?)", radio.Name, radio.Token)
-}
-
-func (d *Database) UpdateRadio(radio Radio) {
- d.sqldb.Exec("UPDATE radios SET name = ?, token = ? WHERE id = ?", radio.Name, radio.Token, radio.Id)
-}
+++ /dev/null
-package main
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "io"
- "log"
- "os"
- "path/filepath"
- "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
-}
+++ /dev/null
-package main
-
-type PlaylistEntry struct {
- Id int
- Position int
- Filename string
- DelaySeconds int
- IsRelative bool
-}
-
-type User struct {
- username string
-}
-
-type Playlist struct {
- Id int
- Enabled bool
- Name string
- StartTime string
-}
-
-type Radio struct {
- Id int
- Name string
- Token string
-}
+++ /dev/null
-package main
-
-import (
- "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)
-}
+++ /dev/null
-package main
-
-import (
- "code.octet-stream.net/broadcaster/protocol"
- "encoding/json"
- "golang.org/x/net/websocket"
- "log"
-)
-
-func RadioSync(ws *websocket.Conn) {
- log.Println("A websocket connected, I think")
- buf := make([]byte, 16384)
-
- badRead := false
- isAuthenticated := false
- var radio Radio
- for {
- // Ignore any massively oversize messages
- n, err := ws.Read(buf)
- if err != nil {
- if radio.Name != "" {
- log.Println("Lost websocket to radio:", radio.Name)
- status.RadioDisconnected(radio.Id)
- } else {
- log.Println("Lost unauthenticated websocket")
- }
- return
- }
- if n == len(buf) {
- badRead = true
- continue
- } else if badRead {
- badRead = false
- continue
- }
-
- t, msg, err := protocol.ParseMessage(buf[:n])
- if err != nil {
- log.Println(err)
- return
- }
-
- if !isAuthenticated && t != protocol.AuthenticateType {
- continue
- }
-
- if t == protocol.AuthenticateType && !isAuthenticated {
- authMsg := msg.(protocol.AuthenticateMessage)
- r, err := db.GetRadioByToken(authMsg.Token)
- if err != nil {
- log.Println("Could not find radio for offered token", authMsg.Token)
- }
- radio = r
- log.Println("Radio authenticated:", radio.Name)
- isAuthenticated = true
- 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
- }
-}
+++ /dev/null
-package main
-
-import (
- "crypto/rand"
- "encoding/hex"
- "log"
- "net/http"
- "time"
-)
-
-func generateSession() string {
- b := make([]byte, 32)
- _, err := rand.Read(b)
- if err != nil {
- log.Fatal(err)
- }
- return hex.EncodeToString(b)
-}
-
-func currentUser(w http.ResponseWriter, r *http.Request) (User, error) {
- // todo: check if user actually exists and is allowed to log in
- cookie, e := r.Cookie("broadcast_session")
- if e != nil {
- return User{}, e
- }
-
- username, e := db.GetUserForSession(cookie.Value)
- if e != nil {
- return User{}, e
- }
- return User{username: username}, nil
-}
-
-func createSessionCookie(w http.ResponseWriter) {
- sess := generateSession()
- log.Println("Generated a random session", sess)
- expiration := time.Now().Add(365 * 24 * time.Hour)
- cookie := http.Cookie{Name: "broadcast_session", Value: sess, Expires: expiration, SameSite: http.SameSiteLaxMode}
- db.InsertSession("admin", sess, expiration)
- http.SetCookie(w, &cookie)
-}
-
-func clearSessionCookie(w http.ResponseWriter) {
- c := &http.Cookie{
- Name: "broadcast_session",
- Value: "",
- MaxAge: -1,
- HttpOnly: true,
- }
- http.SetCookie(w, c)
-}
+++ /dev/null
-package main
-
-import (
- "code.octet-stream.net/broadcaster/protocol"
- "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
-}
+++ /dev/null
-package main
-
-import (
- "fmt"
- "html/template"
- "log"
- "sort"
- "strconv"
- "strings"
-
- "code.octet-stream.net/broadcaster/protocol"
- "golang.org/x/net/websocket"
-)
-
-func WebSync(ws *websocket.Conn) {
- log.Println("A web user connected with WebSocket")
- buf := make([]byte, 16384)
-
- badRead := false
- isAuthenticated := false
- var user string
- for {
- // Ignore any massively oversize messages
- n, err := ws.Read(buf)
- if err != nil {
- if user != "" {
- log.Println("Lost websocket to user:", user)
- } else {
- log.Println("Lost unauthenticated website websocket")
- }
- return
- }
- if n == len(buf) {
- badRead = true
- continue
- } else if badRead {
- badRead = false
- continue
- }
-
- if !isAuthenticated {
- token := string(buf[:n])
- u, err := db.GetUserForSession(token)
- if err != nil {
- log.Println("Could not find user for offered token", token)
- ws.Close()
- return
- }
- user = u
- log.Println("User authenticated:", user)
- isAuthenticated = true
-
- go KeepWebUpdated(ws)
-
- // send initial playlists message
- err = sendRadioStatusToWeb(ws)
- if err != nil {
- return
- }
- }
- }
-}
-
-type WebStatusData struct {
- Radios []WebRadioStatus
-}
-
-type WebRadioStatus struct {
- Name string
- LocalTime string
- TimeZone string
- ChannelClass string
- ChannelState string
- Playlist string
- File string
- Status string
- Id string
- DisableCancel bool
- FilesInSync bool
-}
-
-func sendRadioStatusToWeb(ws *websocket.Conn) error {
- webStatuses := make([]WebRadioStatus, 0)
- radioStatuses := status.Statuses()
- keys := make([]int, 0)
- for i := range radioStatuses {
- keys = append(keys, i)
- }
- sort.Ints(keys)
- for _, i := range keys {
- v := radioStatuses[i]
- radio, err := db.GetRadio(i)
- if err != nil {
- continue
- }
- var channelClass, channelState string
- if v.PTT {
- channelClass = "ptt"
- channelState = "PTT"
- } else if v.COS {
- channelClass = "cos"
- channelState = "RX"
- } else {
- channelClass = "clear"
- channelState = "CLEAR"
- }
- var statusText string
- var disableCancel bool
- if v.Status == protocol.StatusIdle {
- statusText = "Idle"
- disableCancel = true
- } else if v.Status == protocol.StatusDelay {
- statusText = fmt.Sprintf("Performing delay before transmit: %ds remain", v.DelaySecondsRemaining)
- disableCancel = false
- } else if v.Status == protocol.StatusChannelInUse {
- statusText = fmt.Sprintf("Waiting for channel to clear: %ds", v.WaitingForChannelSeconds)
- disableCancel = false
- } else if v.Status == protocol.StatusPlaying {
- statusText = fmt.Sprintf("Playing: %d:%02d", v.PlaybackSecondsElapsed/60, v.PlaybackSecondsElapsed%60)
- disableCancel = false
- }
- playlist := v.Playlist
- if playlist == "" {
- playlist = "-"
- }
- filename := v.Filename
- if filename == "" {
- filename = "-"
- }
- webStatuses = append(webStatuses, WebRadioStatus{
- Name: radio.Name,
- LocalTime: v.LocalTime,
- TimeZone: v.TimeZone,
- ChannelClass: channelClass,
- ChannelState: channelState,
- Playlist: playlist,
- File: filename,
- Status: statusText,
- Id: strconv.Itoa(i),
- DisableCancel: disableCancel,
- FilesInSync: v.FilesInSync,
- })
- }
- data := WebStatusData{
- Radios: webStatuses,
- }
- buf := new(strings.Builder)
- tmpl := template.Must(template.ParseFiles("templates/radios.partial.html"))
- tmpl.Execute(buf, data)
- _, err := ws.Write([]byte(buf.String()))
- return err
-}
-
-func KeepWebUpdated(ws *websocket.Conn) {
- for {
- <-status.ChangeChannel()
- err := sendRadioStatusToWeb(ws)
- if err != nil {
- return
- }
- }
-}
+++ /dev/null
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Broadcaster</title>
- </head>
- <body>
- <main>
- <h1>Files! List</h1>
- <ul>
- {{range .Files}}
- <li><b>{{.Name}}</b><form action="/file/delete" method="POST"><input type="hidden" name="filename" value="{{.Name}}"><input type="submit" value="Delete"></form></li>
- {{end}}
- </ul>
- <h2>Upload New File</h2>
- <p>
- <form action="/file/upload" method="post" enctype="multipart/form-data">
- <input type="file" name="file">
- <input type="submit" value="Upload">
- </form>
- </p>
- </main>
- </body>
-</html>
+++ /dev/null
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Broadcaster</title>
- <style type="text/css">
- table.radio-status, td.outer {
- border: 1px solid;
- }
- table.inner {
- border-collapse: collapse;
- }
- td.clear {
- width: 5em;
- height: 5em;
- text-align: center;
- }
- .time-table {
- font-size: 90%;
- }
- .playlist-field {
- text-align: right;
- padding-right: 1em;
- width: 5em;
- }
- .playlist-table {
- font-size: 90%;
- width: 30em;
- }
- .stop {
- text-align: center;
- }
- .head {
- text-align: center;
- }
- </style>
- <script type="text/javascript">
- function connectWebsocket() {
- console.log("Attempting to create websocket connection for radio status sync")
- const cookieValue = document.cookie
- .split("; ")
- .find((row) => row.startsWith("broadcast_session="))
- ?.split("=")[1];
- const socket = new WebSocket("/websync");
- socket.addEventListener("open", (event) => {
- socket.send(cookieValue);
- });
- socket.addEventListener("message", (event) => {
- console.log("Received a status update from server")
- const connected = document.getElementById('connected-radios');
- connected.innerHTML = event.data;
- });
- socket.addEventListener("close", (event) => {
- console.log("Websocket closed. Will retry in 10 seconds.")
- setTimeout(connectWebsocket, 10000);
- });
- }
- // initial connection on page load
- connectWebsocket();
- </script>
- </head>
- <body>
- <main>
- <h1>Welcome!</h1>
- {{if .LoggedIn}}
- <p>Your username is: {{.Username}}.</p>
- <p><a href="/logout">Log Out</a></p>
- {{else}}
- <p><a href="/login">Log In</a></p>
- {{end}}
- <p><a href="/file/">File Management</a></p>
- <p><a href="/playlist/">Playlist Management</a></p>
- <p><a href="/radio/">Radio Management</a></p>
- <h2>Connected Radios</h2>
- <div id="connected-radios">
- <i>Loading...</i>
- </div>
- </main>
- </body>
-</html>
+++ /dev/null
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Broadcaster Log In</title>
- </head>
- <body>
- <main>
- <h1>Log In</h1>
- <form action="/login" method="post">
- {{if ne .Error ""}}
- <p><b>{{.Error}}</b></p>
- {{end}}
- <label for="username">Username:</label><br>
- <input type="text" id="username" name="username"><br>
- <label for="password">Password:</label><br>
- <input type="password" id="password" name="password"><br>
- <input type="submit" value="Log In">
- </form>
- </main>
- </body>
-</html>
+++ /dev/null
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Broadcaster Log Out</title>
- </head>
- <body>
- <main>
- <h1>Logged Out</h1>
- <p><a href="/login">Log In again</a></p>
- </main>
- </body>
-</html>
+++ /dev/null
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Broadcaster</title>
- <script type="text/javascript">
- function deleteItem(sender) {
- sender.parentNode.remove();
- }
- function addItem() {
- const p = document.createElement('p');
- const temp = document.getElementById('item-template');
- p.innerHTML = temp.innerHTML;
- const marker = document.getElementById('add-item');
- const parent = marker.parentNode;
- parent.insertBefore(p, marker);
- }
- </script>
- </head>
- <body>
- <main>
- <h1>A specific playlist</h1>
- <h2>
- {{if .Playlist.Id}}
- Edit Playlist
- {{else}}
- Create New Playlist
- {{end}}
- </h2>
- <form action="/playlist/submit" method="POST">
- <input type="hidden" name="playlistId" value="{{.Playlist.Id}}">
- <p>
- <input type="checkbox" id="playlistEnabled" name="playlistEnabled" value="1" {{if .Playlist.Enabled}} checked {{end}}>
- <label for="playlistEnabled">Playlist enabled?</label><br>
- </p>
- <p>
- <label for="playlistName">Name:</label>
- <input type="text" id="playlistName" name="playlistName" value="{{.Playlist.Name}}">
- </p>
- <p>
- <label for="playlistStartTime">Transmission Start:</label>
- <input type="datetime-local" id="playlistStartTime" name="playlistStartTime" value="{{.Playlist.StartTime}}">
- </p>
- <h3>Playlist Items</h3>
- {{range .Entries}}
- <p>
- Wait until
- <input type="text" name="delaySeconds" value="{{.DelaySeconds}}">
- seconds from
- <select name="isRelative">
- <option value="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="/playlist/delete" method="POST">
- <input type="hidden" name="playlistId" value="{{.Playlist.Id}}">
- <p>
- <input type="submit" value="Delete Playlist">
- </p>
- </form>
- {{end}}
- <template id="item-template">
- Wait until
- <input type="text" name="delaySeconds" value="0">
- seconds from
- <select name="isRelative">
- <option value="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>
- </main>
- </body>
-</html>
+++ /dev/null
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Broadcaster</title>
- </head>
- <body>
- <main>
- <h1>Playlists!</h1>
- <ul>
- {{range .Playlists}}
- <li><b>{{.Name}}</b> {{.StartTime}} <a href="/playlist/{{.Id}}">(Edit)</a></li>
- {{end}}
- </ul>
- <p><a href="/playlist/new">Add New Playlist</a></p>
- </main>
- </body>
-</html>
+++ /dev/null
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Broadcaster</title>
- </head>
- <body>
- <main>
- <h1>A specific radio</h1>
- <h2>
- {{if .Radio.Id}}
- Edit Radio
- {{else}}
- Register New Radio
- {{end}}
- </h2>
- <form action="/radio/submit" method="POST">
- <input type="hidden" name="radioId" value="{{.Radio.Id}}">
- <p>
- <label for="radioName">Name:</label>
- <input type="text" id="radioName" name="radioName" value="{{.Radio.Name}}">
- </p>
- <p>
- Authentication token: <b>{{.Radio.Token}}</b>
- <input type="hidden" name="radioToken" value="{{.Radio.Token}}">
- </p>
- <p>
- <input type="submit" value="Save Radio">
- </p>
- </form>
- {{if .Radio.Id}}
- <h3>Delete</h3>
- <form action="/radio/delete" method="POST">
- <input type="hidden" name="radioId" value="{{.Radio.Id}}">
- <p>
- <input type="submit" value="Delete Radio">
- </p>
- </form>
- {{end}}
- </main>
- </body>
-</html>
+++ /dev/null
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Broadcaster</title>
- </head>
- <body>
- <main>
- <h1>Radios</h1>
- <ul>
- {{range .Radios}}
- <li><b>{{.Name}}</b> {{.Token}} <a href="/radio/{{.Id}}">(Edit)</a></li>
- {{end}}
- </ul>
- <p><a href="/radio/new">Register New Radio</a></p>
- </main>
- </body>
-</html>
+++ /dev/null
-{{if .Radios}}
-{{range .Radios}}
-<table class="radio-status">
-<tr>
- <td colspan="3" class="outer head">
- <b>{{.Name}}</b>
- </td>
-</tr>
-<tr>
- <td colspan="3" class="outer">
- <table class="time-table">
- <tr>
- <td width="100em">
- Local Time
- </td>
- <td>
- {{.LocalTime}}
- </td>
- </tr>
- <tr>
- <td>
- Time Zone
- </td>
- <td>
- {{.TimeZone}}
- </td>
- </tr>
- <tr>
- <td>
- Files In Sync
- </td>
- <td>
- {{if .FilesInSync}} Yes {{else}} No {{end}}
- </td>
- </tr>
- </table>
- </td>
-</tr>
-<tr>
- <td class="outer {{.ChannelClass}}">
- {{.ChannelState}}
- </td>
- <td class="outer">
- <table class="playlist-table">
- <tr>
- <td class="playlist-field">
- Playlist:
- </td>
- <td>
- {{.Playlist}}
- </td>
- </tr>
- <tr>
- <td class="playlist-field">
- File:
- </td>
- <td>
- {{.File}}
- </td>
- </tr>
- <tr>
- <td class="playlist-field">
- Status:
- </td>
- <td>
- {{.Status}}
- </td>
- </tr>
- </table>
- </td>
- <td class="outer stop">
- <form action="/stop" method="post">
- <input type="hidden" name="radioId" value="{{.Id}}">
- <input type="submit" value="Cancel Playback" {{if .DisableCancel}} disabled {{end}}>
- </form>
- </td>
-</tr>
-</table>
-{{end}}
-{{else}}
-<p><i>There are no radios online.</i></p>
-{{end}}