+++ /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 content = os.DirFS("../broadcaster-server/")
-
-var config ServerConfig = NewServerConfig()
-
-func main() {
- configFlag := flag.String("c", "", "path to configuration file")
- addUserFlag := flag.Bool("a", false, "interactively add an admin user then exit")
- versionFlag := flag.Bool("v", false, "print version then exit")
- flag.Parse()
-
- if *versionFlag {
- fmt.Println("Broadcaster Server", version)
- os.Exit(0)
- }
- if *configFlag == "" {
- log.Fatal("must specify a configuration file with -c")
- }
- config.LoadFromFile(*configFlag)
-
- InitDatabase()
- defer db.CloseDatabase()
-
- if *addUserFlag {
- scanner := bufio.NewScanner(os.Stdin)
- fmt.Println("Enter new admin username:")
- if !scanner.Scan() {
- os.Exit(1)
- }
- username := scanner.Text()
- fmt.Println("Enter new admin password (will be printed in the clear):")
- if !scanner.Scan() {
- os.Exit(1)
- }
- password := scanner.Text()
- if username == "" || password == "" {
- fmt.Println("Both username and password must be specified")
- os.Exit(1)
- }
- if err := users.CreateUser(username, password, true); err != nil {
- log.Fatal(err)
- }
- os.Exit(0)
- }
-
- log.Println("Broadcaster Server", version, "starting up")
- InitCommandRouter()
- InitPlaylists()
- InitAudioFiles(config.AudioFilesPath)
- InitServerStatus()
-
- // Public routes
-
- http.HandleFunc("/login", logInPage)
- http.Handle("/file-downloads/", http.StripPrefix("/file-downloads/", http.FileServer(http.Dir(config.AudioFilesPath))))
-
- // Authenticated routes
-
- http.HandleFunc("/", homePage)
- http.HandleFunc("/logout", logOutPage)
- http.HandleFunc("/change-password", changePasswordPage)
-
- http.HandleFunc("/playlists/", playlistSection)
- http.HandleFunc("/files/", fileSection)
- http.HandleFunc("/radios/", radioSection)
-
- http.Handle("/radio-ws", websocket.Handler(RadioSync))
- http.Handle("/web-ws", websocket.Handler(WebSync))
- http.HandleFunc("/stop", stopPage)
-
- // Admin routes
-
- err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil)
- if err != nil {
- log.Fatal(err)
- }
-}
-
-type HeaderData struct {
- SelectedMenu string
- Username string
-}
-
-func renderHeader(w http.ResponseWriter, selectedMenu string) {
- tmpl := template.Must(template.ParseFS(content, "templates/header.html"))
- data := HeaderData{
- SelectedMenu: selectedMenu,
- Username: "username",
- }
- err := tmpl.Execute(w, data)
- if err != nil {
- log.Fatal(err)
- }
-}
-
-func renderFooter(w http.ResponseWriter) {
- tmpl := template.Must(template.ParseFS(content, "templates/footer.html"))
- err := tmpl.Execute(w, nil)
- if err != nil {
- log.Fatal(err)
- }
-}
-
-type HomeData struct {
- LoggedIn bool
- Username string
-}
-
-func homePage(w http.ResponseWriter, r *http.Request) {
- renderHeader(w, "status")
- tmpl := template.Must(template.ParseFS(content, "templates/index.html"))
- data := HomeData{
- LoggedIn: true,
- Username: "Bob",
- }
- tmpl.Execute(w, data)
- renderFooter(w)
-}
-
-type LogInData struct {
- Error string
-}
-
-func logInPage(w http.ResponseWriter, r *http.Request) {
- log.Println("Log in page!")
- r.ParseForm()
- username := r.Form["username"]
- password := r.Form["password"]
- errText := ""
- if username != nil {
- user, err := users.Authenticate(username[0], password[0])
- if err != nil {
- errText = "Incorrect login"
- } else {
- createSessionCookie(w, user.Username)
- http.Redirect(w, r, "/", http.StatusFound)
- return
- }
- }
-
- data := LogInData{
- Error: errText,
- }
- renderHeader(w, "")
- tmpl := template.Must(template.ParseFS(content, "templates/login.html"))
- tmpl.Execute(w, data)
- renderFooter(w)
-}
-
-func playlistSection(w http.ResponseWriter, r *http.Request) {
- path := strings.Split(r.URL.Path, "/")
- if len(path) != 3 {
- http.NotFound(w, r)
- return
- }
- if path[2] == "new" {
- editPlaylistPage(w, r, 0)
- } else if path[2] == "submit" && r.Method == "POST" {
- submitPlaylist(w, r)
- } else if path[2] == "delete" && r.Method == "POST" {
- deletePlaylist(w, r)
- } else if path[2] == "" {
- playlistsPage(w, r)
- } else {
- id, err := strconv.Atoi(path[2])
- if err != nil {
- http.NotFound(w, r)
- return
- }
- editPlaylistPage(w, r, id)
- }
-}
-
-func fileSection(w http.ResponseWriter, r *http.Request) {
- path := strings.Split(r.URL.Path, "/")
- if len(path) != 3 {
- http.NotFound(w, r)
- return
- }
- if path[2] == "upload" {
- uploadFile(w, r)
- } else if path[2] == "delete" && r.Method == "POST" {
- deleteFile(w, r)
- } else if path[2] == "" {
- filesPage(w, r)
- } else {
- http.NotFound(w, r)
- return
- }
-}
-
-func radioSection(w http.ResponseWriter, r *http.Request) {
- path := strings.Split(r.URL.Path, "/")
- if len(path) != 3 {
- http.NotFound(w, r)
- return
- }
- if path[2] == "new" {
- editRadioPage(w, r, 0)
- } else if path[2] == "submit" && r.Method == "POST" {
- submitRadio(w, r)
- } else if path[2] == "delete" && r.Method == "POST" {
- deleteRadio(w, r)
- } else if path[2] == "" {
- radiosPage(w, r)
- } else {
- id, err := strconv.Atoi(path[2])
- if err != nil {
- http.NotFound(w, r)
- return
- }
- editRadioPage(w, r, id)
- }
-}
-
-type ChangePasswordPageData struct {
- Message string
- ShowForm bool
-}
-
-func changePasswordPage(w http.ResponseWriter, r *http.Request) {
- user, err := currentUser(w, r)
- if err != nil {
- http.Redirect(w, r, "/login", http.StatusFound)
- return
- }
- var data ChangePasswordPageData
- if r.Method == "POST" {
- err := r.ParseForm()
- if err != nil {
- w.WriteHeader(http.StatusBadRequest)
- return
- }
- oldPassword := r.Form.Get("oldPassword")
- newPassword := r.Form.Get("newPassword")
- err = users.UpdatePassword(user.Username, oldPassword, newPassword)
- if err != nil {
- data.Message = "Failed to change password: " + err.Error()
- data.ShowForm = true
- } else {
- data.Message = "Successfully changed password"
- data.ShowForm = false
- cookie, err := r.Cookie("broadcast_session")
- if err == nil {
- log.Println("clearing other sessions for username", user.Username, "token", cookie.Value)
- db.ClearOtherSessions(user.Username, cookie.Value)
- }
- }
- } else {
- data.Message = ""
- data.ShowForm = true
- }
- renderHeader(w, "change-password")
- tmpl := template.Must(template.ParseFS(content, "templates/change_password.html"))
- err = tmpl.Execute(w, data)
- if err != nil {
- log.Fatal(err)
- }
- renderFooter(w)
-}
-
-type PlaylistsPageData struct {
- Playlists []Playlist
-}
-
-func playlistsPage(w http.ResponseWriter, _ *http.Request) {
- renderHeader(w, "playlists")
- data := PlaylistsPageData{
- Playlists: db.GetPlaylists(),
- }
- tmpl := template.Must(template.ParseFS(content, "templates/playlists.html"))
- err := tmpl.Execute(w, data)
- if err != nil {
- log.Fatal(err)
- }
- renderFooter(w)
-}
-
-type RadiosPageData struct {
- Radios []Radio
-}
-
-func radiosPage(w http.ResponseWriter, _ *http.Request) {
- renderHeader(w, "radios")
- data := RadiosPageData{
- Radios: db.GetRadios(),
- }
- tmpl := template.Must(template.ParseFS(content, "templates/radios.html"))
- err := tmpl.Execute(w, data)
- if err != nil {
- log.Fatal(err)
- }
- renderFooter(w)
-}
-
-type EditPlaylistPageData struct {
- Playlist Playlist
- Entries []PlaylistEntry
- Files []string
-}
-
-func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int) {
- var data EditPlaylistPageData
- for _, f := range files.Files() {
- data.Files = append(data.Files, f.Name)
- }
- if id == 0 {
- data.Playlist.Enabled = true
- data.Playlist.Name = "New Playlist"
- data.Playlist.StartTime = time.Now().Format(formatString)
- data.Entries = append(data.Entries, PlaylistEntry{})
- } else {
- playlist, err := db.GetPlaylist(id)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- data.Playlist = playlist
- data.Entries = db.GetEntriesForPlaylist(id)
- }
- renderHeader(w, "radios")
- tmpl := template.Must(template.ParseFS(content, "templates/playlist.html"))
- tmpl.Execute(w, data)
- renderFooter(w)
-}
-
-func submitPlaylist(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- var p Playlist
- id, err := strconv.Atoi(r.Form.Get("playlistId"))
- if err != nil {
- return
- }
- _, err = time.Parse(formatString, r.Form.Get("playlistStartTime"))
- if err != nil {
- return
- }
- p.Id = id
- p.Enabled = r.Form.Get("playlistEnabled") == "1"
- p.Name = r.Form.Get("playlistName")
- p.StartTime = r.Form.Get("playlistStartTime")
-
- delays := r.Form["delaySeconds"]
- filenames := r.Form["filename"]
- isRelatives := r.Form["isRelative"]
-
- entries := make([]PlaylistEntry, 0)
- for i := range delays {
- var e PlaylistEntry
- delay, err := strconv.Atoi(delays[i])
- if err != nil {
- return
- }
- e.DelaySeconds = delay
- e.Position = i
- e.IsRelative = isRelatives[i] == "1"
- e.Filename = filenames[i]
- entries = append(entries, e)
- }
- cleanedEntries := make([]PlaylistEntry, 0)
- for _, e := range entries {
- if e.DelaySeconds != 0 || e.Filename != "" {
- cleanedEntries = append(cleanedEntries, e)
- }
- }
-
- if id != 0 {
- db.UpdatePlaylist(p)
- } else {
- id = db.CreatePlaylist(p)
- }
- db.SetEntriesForPlaylist(cleanedEntries, id)
- // Notify connected radios
- playlists.NotifyChanges()
- }
- http.Redirect(w, r, "/playlists/", http.StatusFound)
-}
-
-func deletePlaylist(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- id, err := strconv.Atoi(r.Form.Get("playlistId"))
- if err != nil {
- return
- }
- db.DeletePlaylist(id)
- playlists.NotifyChanges()
- }
- http.Redirect(w, r, "/playlists/", http.StatusFound)
-}
-
-type EditRadioPageData struct {
- Radio Radio
-}
-
-func editRadioPage(w http.ResponseWriter, r *http.Request, id int) {
- var data EditRadioPageData
- if id == 0 {
- data.Radio.Name = "New Radio"
- data.Radio.Token = generateSession()
- } else {
- radio, err := db.GetRadio(id)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- data.Radio = radio
- }
- renderHeader(w, "radios")
- tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
- tmpl.Execute(w, data)
- renderFooter(w)
-}
-
-func submitRadio(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- var radio Radio
- id, err := strconv.Atoi(r.Form.Get("radioId"))
- if err != nil {
- return
- }
- radio.Id = id
- radio.Name = r.Form.Get("radioName")
- radio.Token = r.Form.Get("radioToken")
- if id != 0 {
- db.UpdateRadio(radio)
- } else {
- db.CreateRadio(radio)
- }
- }
- http.Redirect(w, r, "/radios/", http.StatusFound)
-}
-
-func deleteRadio(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- id, err := strconv.Atoi(r.Form.Get("radioId"))
- if err != nil {
- return
- }
- db.DeleteRadio(id)
- }
- http.Redirect(w, r, "/radios/", http.StatusFound)
-}
-
-type FilesPageData struct {
- Files []FileSpec
-}
-
-func filesPage(w http.ResponseWriter, _ *http.Request) {
- renderHeader(w, "files")
- data := FilesPageData{
- Files: files.Files(),
- }
- log.Println("file page data", data)
- tmpl := template.Must(template.ParseFS(content, "templates/files.html"))
- err := tmpl.Execute(w, data)
- if err != nil {
- log.Fatal(err)
- }
- renderFooter(w)
-}
-
-func deleteFile(w http.ResponseWriter, r *http.Request) {
- err := r.ParseForm()
- if err == nil {
- filename := r.Form.Get("filename")
- if filename == "" {
- return
- }
- files.Delete(filename)
- }
- http.Redirect(w, r, "/files/", http.StatusFound)
-}
-
-func uploadFile(w http.ResponseWriter, r *http.Request) {
- err := r.ParseMultipartForm(100 << 20)
- file, handler, err := r.FormFile("file")
- if err == nil {
- path := filepath.Join(files.Path(), filepath.Base(handler.Filename))
- f, _ := os.Create(path)
- defer f.Close()
- io.Copy(f, file)
- log.Println("uploaded file to", path)
- files.Refresh()
- }
- http.Redirect(w, r, "/files/", http.StatusFound)
-}
-
-func logOutPage(w http.ResponseWriter, r *http.Request) {
- clearSessionCookie(w)
- renderHeader(w, "")
- tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
- tmpl.Execute(w, nil)
- renderFooter(w)
-}
-
-func stopPage(w http.ResponseWriter, r *http.Request) {
- _, err := currentUser(w, r)
- if err != nil {
- http.Redirect(w, r, "/login", http.StatusFound)
- return
- }
- r.ParseForm()
- radioId, err := strconv.Atoi(r.Form.Get("radioId"))
- if err != nil {
- http.NotFound(w, r)
- return
- }
- commandRouter.Stop(radioId)
- http.Redirect(w, r, "/", http.StatusFound)
-}
+++ /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
-
- <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}}
+++ /dev/null
-
- <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>
+++ /dev/null
- </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</title>
- <style type="text/css">
- table.radio-status, td.outer {
- border: 1px solid;
- }
- table.inner {
- border-collapse: collapse;
- }
- td.clear {
- text-align: center;
- background-color: #eeeeee;
- }
- .time-table {
- font-size: 90%;
- }
- .playlist-field {
- text-align: right;
- padding-right: 1em;
- width: 5em;
- }
- .playlist-table {
- font-size: 90%;
- }
- .stop {
- text-align: center;
- }
- .head {
- text-align: center;
- }
- body {
- background-color: #447744;
- font-family: "sans-serif";
- }
- main {
- background-color: #dddddd;
- max-width: 80em;
- margin-left: auto;
- margin-right: auto;
- display: flex;
- flex-wrap: wrap;
- flex-direction: row;
- border: 2px solid black;
- }
- .menu {
- min-width: 12em;
- display: flex;
- flex-grow: 1;
- flex-direction: column;
- /* border: black solid; */
- /* border-width: 0px 2px 0px 0px; */
- text-align: center;
- }
- .menu-item {
- height: 2em;
- text-align: center;
- line-height: 2em;
- background-color: #aaaaaa;
- border: black solid;
- border-width: 1px 1px 0px 0px;
- }
- .menu-item a {
- text-decoration: none;
- color: black;
- }
- .menu-item.logout {
- border-width: 1px 1px 1px 0px;
- }
- .menu-item:first-of-type {
- border-width: 0px 1px 0px 0px;
- }
- .menu-item.selected {
- background-color: #aaccaa;
- }
- .logged-in {
- padding-top: 2em;
- padding-bottom: 2em;
- text-align: center;
- border: black solid;
- border-width: 0px 1px 0px 0px;
- }
- .menu-tail {
- flex-grow: 1;
- height: 2em;
- line-height: 2em;
- border: black solid;
- border-width: 0px 1px 0px 0px;
- }
- .content {
- flex-grow: 3;
- padding: 5em;
- padding-top: 1em;
- overflow-wrap: break-word;
- max-width: 50em;
- }
- </style>
- </head>
- <body>
- <main>
- {{if .SelectedMenu}}
- <div class="menu">
- <div class="menu-item {{if eq .SelectedMenu "status"}}selected{{end}}"><a href="/">Status</a></div>
- <div class="menu-item {{if eq .SelectedMenu "files"}}selected{{end}}"><a href="/files/">Files</a></div>
- <div class="menu-item {{if eq .SelectedMenu "playlists"}}selected{{end}}"><a href="/playlists/">Playlists</a></div>
- <div class="menu-item {{if eq .SelectedMenu "radios"}}selected{{end}}"><a href="/radios/">Radios</a></div>
- <div class="menu-item {{if eq .SelectedMenu "users"}}selected{{end}}"><a href="/users/">Users</a></div>
- <div class="menu-item {{if eq .SelectedMenu "change-password"}}selected{{end}}"><a href="/change-password">Change Password</a></div>
- <div class="menu-item logout"><a href="/logout">Log Out</a></div>
- {{if .Username}}
- <div class="logged-in">Logged in as:<br><i>{{.Username}}</i></div>
- {{end}}
- <div class="menu-tail"><small><i>broadcaster-server v1.0.0</i></small></div>
- </div>
- {{end}}
- <div class="content">
+++ /dev/null
- <script type="text/javascript">
- function connectWebsocket() {
- console.log("Attempting to create websocket connection for radio status sync")
- const cookieValue = document.cookie
- .split("; ")
- .find((row) => row.startsWith("broadcast_session="))
- ?.split("=")[1];
- const socket = new WebSocket("/web-ws");
- socket.addEventListener("open", (event) => {
- socket.send(cookieValue);
- });
- socket.addEventListener("message", (event) => {
- console.log("Received a status update from server")
- const connected = document.getElementById('connected-radios');
- connected.innerHTML = event.data;
- });
- socket.addEventListener("close", (event) => {
- console.log("Websocket closed. Will retry in 10 seconds.")
- setTimeout(connectWebsocket, 10000);
- });
- }
- // initial connection on page load
- connectWebsocket();
- </script>
-
- <h1>Welcome!</h1>
- {{if .LoggedIn}}
- <p>Your username is: {{.Username}}.</p>
- <p><a href="/logout">Log Out</a></p>
- {{else}}
- <p><a href="/login">Log In</a></p>
- {{end}}
- <p><a href="/files/">File Management</a></p>
- <p><a href="/playlists/">Playlist Management</a></p>
- <p><a href="/radios/">Radio Management</a></p>
- <h2>Connected Radios</h2>
- <div id="connected-radios">
- <i>Loading...</i>
- </div>
+++ /dev/null
-
- <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>
+++ /dev/null
-
- <h1>Logged Out</h1>
- <p><a href="/login">Log In again</a></p>
+++ /dev/null
-
- <script type="text/javascript">
- function deleteItem(sender) {
- sender.parentNode.remove();
- }
- function addItem() {
- const p = document.createElement('p');
- const temp = document.getElementById('item-template');
- p.innerHTML = temp.innerHTML;
- const marker = document.getElementById('add-item');
- const parent = marker.parentNode;
- parent.insertBefore(p, marker);
- }
- </script>
-
- <h1>A specific playlist</h1>
- <h2>
- {{if .Playlist.Id}}
- Edit Playlist
- {{else}}
- Create New Playlist
- {{end}}
- </h2>
- <form action="/playlists/submit" method="POST">
- <input type="hidden" name="playlistId" value="{{.Playlist.Id}}">
- <p>
- <input type="checkbox" id="playlistEnabled" name="playlistEnabled" value="1" {{if .Playlist.Enabled}} checked {{end}}>
- <label for="playlistEnabled">Playlist enabled?</label><br>
- </p>
- <p>
- <label for="playlistName">Name:</label>
- <input type="text" id="playlistName" name="playlistName" value="{{.Playlist.Name}}">
- </p>
- <p>
- <label for="playlistStartTime">Transmission Start:</label>
- <input type="datetime-local" id="playlistStartTime" name="playlistStartTime" value="{{.Playlist.StartTime}}">
- </p>
- <h3>Playlist Items</h3>
- {{range .Entries}}
- <p>
- Wait until
- <input type="text" name="delaySeconds" value="{{.DelaySeconds}}">
- seconds from
- <select name="isRelative">
- <option value="1">previous item</option>
- <option value="0" {{if not .IsRelative}} selected="selected" {{end}}>start of transmission</option>
- </select>
- then play
- <select name="filename">{{$f := .Filename}}
- <option value="">(no file selected)</option>
- {{range $.Files}}
- <option value="{{.}}" {{if eq . $f }} selected="selected" {{end}}>{{.}}</option>
- {{end}}
- </select>
- <a href="#" onclick="deleteItem(this)">(Delete Item)</a>
- </p>
- {{end}}
- <p>
- <a href="#" onclick="addItem()" id="add-item">Add Item</a>
- </p>
- <p>
- <input type="submit" value="Save Playlist">
- </p>
- </form>
- {{if .Playlist.Id}}
- <h3>Delete</h3>
- <form action="/playlists/delete" method="POST">
- <input type="hidden" name="playlistId" value="{{.Playlist.Id}}">
- <p>
- <input type="submit" value="Delete Playlist">
- </p>
- </form>
- {{end}}
- <template id="item-template">
- Wait until
- <input type="text" name="delaySeconds" value="0">
- seconds from
- <select name="isRelative">
- <option value="1">previous item</option>
- <option value="0">start of transmission</option>
- </select>
- then play
- <select name="filename">
- <option value="">(no file selected)</option>
- {{range $.Files}}
- <option value="{{.}}">{{.}}</option>
- {{end}}
- </select>
- <a href="#" onclick="deleteItem(this)">(Delete Item)</a>
- </template>
+++ /dev/null
-
- <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>
+++ /dev/null
-
- <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}}
+++ /dev/null
-
- <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>
+++ /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" colspan="2">
- <table class="playlist-table">
- <tr>
- <td class="playlist-field">
- Playlist:
- </td>
- <td>
- {{.Playlist}}
- </td>
- </tr>
- <tr>
- <td class="playlist-field">
- File:
- </td>
- <td>
- {{.File}}
- </td>
- </tr>
- <tr>
- <td class="playlist-field">
- Status:
- </td>
- <td>
- {{.Status}}
- </td>
- </tr>
- </table>
- </td>
- <tr>
- <td class="outer stop" colspan="3">
- <form action="/stop" method="post">
- <input type="hidden" name="radioId" value="{{.Id}}">
- <input type="submit" value="Cancel Playback" {{if .DisableCancel}} disabled {{end}}>
- </form>
- </td>
- </tr>
-</tr>
-</table>
-{{end}}
-{{else}}
-<p><i>There are no radios online.</i></p>
-{{end}}
+++ /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
- }
- }
-}
--- /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 content = os.DirFS("../broadcaster-server/")
+
+var config ServerConfig = NewServerConfig()
+
+func main() {
+ configFlag := flag.String("c", "", "path to configuration file")
+ addUserFlag := flag.Bool("a", false, "interactively add an admin user then exit")
+ versionFlag := flag.Bool("v", false, "print version then exit")
+ flag.Parse()
+
+ if *versionFlag {
+ fmt.Println("Broadcaster Server", version)
+ os.Exit(0)
+ }
+ if *configFlag == "" {
+ log.Fatal("must specify a configuration file with -c")
+ }
+ config.LoadFromFile(*configFlag)
+
+ InitDatabase()
+ defer db.CloseDatabase()
+
+ if *addUserFlag {
+ scanner := bufio.NewScanner(os.Stdin)
+ fmt.Println("Enter new admin username:")
+ if !scanner.Scan() {
+ os.Exit(1)
+ }
+ username := scanner.Text()
+ fmt.Println("Enter new admin password (will be printed in the clear):")
+ if !scanner.Scan() {
+ os.Exit(1)
+ }
+ password := scanner.Text()
+ if username == "" || password == "" {
+ fmt.Println("Both username and password must be specified")
+ os.Exit(1)
+ }
+ if err := users.CreateUser(username, password, true); err != nil {
+ log.Fatal(err)
+ }
+ os.Exit(0)
+ }
+
+ log.Println("Broadcaster Server", version, "starting up")
+ InitCommandRouter()
+ InitPlaylists()
+ InitAudioFiles(config.AudioFilesPath)
+ InitServerStatus()
+
+ // Public routes
+
+ http.HandleFunc("/login", logInPage)
+ http.Handle("/file-downloads/", http.StripPrefix("/file-downloads/", http.FileServer(http.Dir(config.AudioFilesPath))))
+
+ // Authenticated routes
+
+ http.HandleFunc("/", homePage)
+ http.HandleFunc("/logout", logOutPage)
+ http.HandleFunc("/change-password", changePasswordPage)
+
+ http.HandleFunc("/playlists/", playlistSection)
+ http.HandleFunc("/files/", fileSection)
+ http.HandleFunc("/radios/", radioSection)
+
+ http.Handle("/radio-ws", websocket.Handler(RadioSync))
+ http.Handle("/web-ws", websocket.Handler(WebSync))
+ http.HandleFunc("/stop", stopPage)
+
+ // Admin routes
+
+ err := http.ListenAndServe(config.BindAddress+":"+strconv.Itoa(config.Port), nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+type HeaderData struct {
+ SelectedMenu string
+ Username string
+}
+
+func renderHeader(w http.ResponseWriter, selectedMenu string) {
+ tmpl := template.Must(template.ParseFS(content, "templates/header.html"))
+ data := HeaderData{
+ SelectedMenu: selectedMenu,
+ Username: "username",
+ }
+ err := tmpl.Execute(w, data)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+func renderFooter(w http.ResponseWriter) {
+ tmpl := template.Must(template.ParseFS(content, "templates/footer.html"))
+ err := tmpl.Execute(w, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+type HomeData struct {
+ LoggedIn bool
+ Username string
+}
+
+func homePage(w http.ResponseWriter, r *http.Request) {
+ renderHeader(w, "status")
+ tmpl := template.Must(template.ParseFS(content, "templates/index.html"))
+ data := HomeData{
+ LoggedIn: true,
+ Username: "Bob",
+ }
+ tmpl.Execute(w, data)
+ renderFooter(w)
+}
+
+type LogInData struct {
+ Error string
+}
+
+func logInPage(w http.ResponseWriter, r *http.Request) {
+ log.Println("Log in page!")
+ r.ParseForm()
+ username := r.Form["username"]
+ password := r.Form["password"]
+ errText := ""
+ if username != nil {
+ user, err := users.Authenticate(username[0], password[0])
+ if err != nil {
+ errText = "Incorrect login"
+ } else {
+ createSessionCookie(w, user.Username)
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ }
+
+ data := LogInData{
+ Error: errText,
+ }
+ renderHeader(w, "")
+ tmpl := template.Must(template.ParseFS(content, "templates/login.html"))
+ tmpl.Execute(w, data)
+ renderFooter(w)
+}
+
+func playlistSection(w http.ResponseWriter, r *http.Request) {
+ path := strings.Split(r.URL.Path, "/")
+ if len(path) != 3 {
+ http.NotFound(w, r)
+ return
+ }
+ if path[2] == "new" {
+ editPlaylistPage(w, r, 0)
+ } else if path[2] == "submit" && r.Method == "POST" {
+ submitPlaylist(w, r)
+ } else if path[2] == "delete" && r.Method == "POST" {
+ deletePlaylist(w, r)
+ } else if path[2] == "" {
+ playlistsPage(w, r)
+ } else {
+ id, err := strconv.Atoi(path[2])
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ editPlaylistPage(w, r, id)
+ }
+}
+
+func fileSection(w http.ResponseWriter, r *http.Request) {
+ path := strings.Split(r.URL.Path, "/")
+ if len(path) != 3 {
+ http.NotFound(w, r)
+ return
+ }
+ if path[2] == "upload" {
+ uploadFile(w, r)
+ } else if path[2] == "delete" && r.Method == "POST" {
+ deleteFile(w, r)
+ } else if path[2] == "" {
+ filesPage(w, r)
+ } else {
+ http.NotFound(w, r)
+ return
+ }
+}
+
+func radioSection(w http.ResponseWriter, r *http.Request) {
+ path := strings.Split(r.URL.Path, "/")
+ if len(path) != 3 {
+ http.NotFound(w, r)
+ return
+ }
+ if path[2] == "new" {
+ editRadioPage(w, r, 0)
+ } else if path[2] == "submit" && r.Method == "POST" {
+ submitRadio(w, r)
+ } else if path[2] == "delete" && r.Method == "POST" {
+ deleteRadio(w, r)
+ } else if path[2] == "" {
+ radiosPage(w, r)
+ } else {
+ id, err := strconv.Atoi(path[2])
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ editRadioPage(w, r, id)
+ }
+}
+
+type ChangePasswordPageData struct {
+ Message string
+ ShowForm bool
+}
+
+func changePasswordPage(w http.ResponseWriter, r *http.Request) {
+ user, err := currentUser(w, r)
+ if err != nil {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return
+ }
+ var data ChangePasswordPageData
+ if r.Method == "POST" {
+ err := r.ParseForm()
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ oldPassword := r.Form.Get("oldPassword")
+ newPassword := r.Form.Get("newPassword")
+ err = users.UpdatePassword(user.Username, oldPassword, newPassword)
+ if err != nil {
+ data.Message = "Failed to change password: " + err.Error()
+ data.ShowForm = true
+ } else {
+ data.Message = "Successfully changed password"
+ data.ShowForm = false
+ cookie, err := r.Cookie("broadcast_session")
+ if err == nil {
+ log.Println("clearing other sessions for username", user.Username, "token", cookie.Value)
+ db.ClearOtherSessions(user.Username, cookie.Value)
+ }
+ }
+ } else {
+ data.Message = ""
+ data.ShowForm = true
+ }
+ renderHeader(w, "change-password")
+ tmpl := template.Must(template.ParseFS(content, "templates/change_password.html"))
+ err = tmpl.Execute(w, data)
+ if err != nil {
+ log.Fatal(err)
+ }
+ renderFooter(w)
+}
+
+type PlaylistsPageData struct {
+ Playlists []Playlist
+}
+
+func playlistsPage(w http.ResponseWriter, _ *http.Request) {
+ renderHeader(w, "playlists")
+ data := PlaylistsPageData{
+ Playlists: db.GetPlaylists(),
+ }
+ tmpl := template.Must(template.ParseFS(content, "templates/playlists.html"))
+ err := tmpl.Execute(w, data)
+ if err != nil {
+ log.Fatal(err)
+ }
+ renderFooter(w)
+}
+
+type RadiosPageData struct {
+ Radios []Radio
+}
+
+func radiosPage(w http.ResponseWriter, _ *http.Request) {
+ renderHeader(w, "radios")
+ data := RadiosPageData{
+ Radios: db.GetRadios(),
+ }
+ tmpl := template.Must(template.ParseFS(content, "templates/radios.html"))
+ err := tmpl.Execute(w, data)
+ if err != nil {
+ log.Fatal(err)
+ }
+ renderFooter(w)
+}
+
+type EditPlaylistPageData struct {
+ Playlist Playlist
+ Entries []PlaylistEntry
+ Files []string
+}
+
+func editPlaylistPage(w http.ResponseWriter, r *http.Request, id int) {
+ var data EditPlaylistPageData
+ for _, f := range files.Files() {
+ data.Files = append(data.Files, f.Name)
+ }
+ if id == 0 {
+ data.Playlist.Enabled = true
+ data.Playlist.Name = "New Playlist"
+ data.Playlist.StartTime = time.Now().Format(formatString)
+ data.Entries = append(data.Entries, PlaylistEntry{})
+ } else {
+ playlist, err := db.GetPlaylist(id)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ data.Playlist = playlist
+ data.Entries = db.GetEntriesForPlaylist(id)
+ }
+ renderHeader(w, "radios")
+ tmpl := template.Must(template.ParseFS(content, "templates/playlist.html"))
+ tmpl.Execute(w, data)
+ renderFooter(w)
+}
+
+func submitPlaylist(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err == nil {
+ var p Playlist
+ id, err := strconv.Atoi(r.Form.Get("playlistId"))
+ if err != nil {
+ return
+ }
+ _, err = time.Parse(formatString, r.Form.Get("playlistStartTime"))
+ if err != nil {
+ return
+ }
+ p.Id = id
+ p.Enabled = r.Form.Get("playlistEnabled") == "1"
+ p.Name = r.Form.Get("playlistName")
+ p.StartTime = r.Form.Get("playlistStartTime")
+
+ delays := r.Form["delaySeconds"]
+ filenames := r.Form["filename"]
+ isRelatives := r.Form["isRelative"]
+
+ entries := make([]PlaylistEntry, 0)
+ for i := range delays {
+ var e PlaylistEntry
+ delay, err := strconv.Atoi(delays[i])
+ if err != nil {
+ return
+ }
+ e.DelaySeconds = delay
+ e.Position = i
+ e.IsRelative = isRelatives[i] == "1"
+ e.Filename = filenames[i]
+ entries = append(entries, e)
+ }
+ cleanedEntries := make([]PlaylistEntry, 0)
+ for _, e := range entries {
+ if e.DelaySeconds != 0 || e.Filename != "" {
+ cleanedEntries = append(cleanedEntries, e)
+ }
+ }
+
+ if id != 0 {
+ db.UpdatePlaylist(p)
+ } else {
+ id = db.CreatePlaylist(p)
+ }
+ db.SetEntriesForPlaylist(cleanedEntries, id)
+ // Notify connected radios
+ playlists.NotifyChanges()
+ }
+ http.Redirect(w, r, "/playlists/", http.StatusFound)
+}
+
+func deletePlaylist(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err == nil {
+ id, err := strconv.Atoi(r.Form.Get("playlistId"))
+ if err != nil {
+ return
+ }
+ db.DeletePlaylist(id)
+ playlists.NotifyChanges()
+ }
+ http.Redirect(w, r, "/playlists/", http.StatusFound)
+}
+
+type EditRadioPageData struct {
+ Radio Radio
+}
+
+func editRadioPage(w http.ResponseWriter, r *http.Request, id int) {
+ var data EditRadioPageData
+ if id == 0 {
+ data.Radio.Name = "New Radio"
+ data.Radio.Token = generateSession()
+ } else {
+ radio, err := db.GetRadio(id)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ data.Radio = radio
+ }
+ renderHeader(w, "radios")
+ tmpl := template.Must(template.ParseFS(content, "templates/radio.html"))
+ tmpl.Execute(w, data)
+ renderFooter(w)
+}
+
+func submitRadio(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err == nil {
+ var radio Radio
+ id, err := strconv.Atoi(r.Form.Get("radioId"))
+ if err != nil {
+ return
+ }
+ radio.Id = id
+ radio.Name = r.Form.Get("radioName")
+ radio.Token = r.Form.Get("radioToken")
+ if id != 0 {
+ db.UpdateRadio(radio)
+ } else {
+ db.CreateRadio(radio)
+ }
+ }
+ http.Redirect(w, r, "/radios/", http.StatusFound)
+}
+
+func deleteRadio(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err == nil {
+ id, err := strconv.Atoi(r.Form.Get("radioId"))
+ if err != nil {
+ return
+ }
+ db.DeleteRadio(id)
+ }
+ http.Redirect(w, r, "/radios/", http.StatusFound)
+}
+
+type FilesPageData struct {
+ Files []FileSpec
+}
+
+func filesPage(w http.ResponseWriter, _ *http.Request) {
+ renderHeader(w, "files")
+ data := FilesPageData{
+ Files: files.Files(),
+ }
+ log.Println("file page data", data)
+ tmpl := template.Must(template.ParseFS(content, "templates/files.html"))
+ err := tmpl.Execute(w, data)
+ if err != nil {
+ log.Fatal(err)
+ }
+ renderFooter(w)
+}
+
+func deleteFile(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err == nil {
+ filename := r.Form.Get("filename")
+ if filename == "" {
+ return
+ }
+ files.Delete(filename)
+ }
+ http.Redirect(w, r, "/files/", http.StatusFound)
+}
+
+func uploadFile(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseMultipartForm(100 << 20)
+ file, handler, err := r.FormFile("file")
+ if err == nil {
+ path := filepath.Join(files.Path(), filepath.Base(handler.Filename))
+ f, _ := os.Create(path)
+ defer f.Close()
+ io.Copy(f, file)
+ log.Println("uploaded file to", path)
+ files.Refresh()
+ }
+ http.Redirect(w, r, "/files/", http.StatusFound)
+}
+
+func logOutPage(w http.ResponseWriter, r *http.Request) {
+ clearSessionCookie(w)
+ renderHeader(w, "")
+ tmpl := template.Must(template.ParseFS(content, "templates/logout.html"))
+ tmpl.Execute(w, nil)
+ renderFooter(w)
+}
+
+func stopPage(w http.ResponseWriter, r *http.Request) {
+ _, err := currentUser(w, r)
+ if err != nil {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return
+ }
+ r.ParseForm()
+ radioId, err := strconv.Atoi(r.Form.Get("radioId"))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ commandRouter.Stop(radioId)
+ http.Redirect(w, r, "/", http.StatusFound)
+}
--- /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
+
+ <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}}
--- /dev/null
+
+ <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>
--- /dev/null
+ </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</title>
+ <style type="text/css">
+ table.radio-status, td.outer {
+ border: 1px solid;
+ }
+ table.inner {
+ border-collapse: collapse;
+ }
+ td.clear {
+ text-align: center;
+ background-color: #eeeeee;
+ }
+ .time-table {
+ font-size: 90%;
+ }
+ .playlist-field {
+ text-align: right;
+ padding-right: 1em;
+ width: 5em;
+ }
+ .playlist-table {
+ font-size: 90%;
+ }
+ .stop {
+ text-align: center;
+ }
+ .head {
+ text-align: center;
+ }
+ body {
+ background-color: #447744;
+ font-family: "sans-serif";
+ }
+ main {
+ background-color: #dddddd;
+ max-width: 80em;
+ margin-left: auto;
+ margin-right: auto;
+ display: flex;
+ flex-wrap: wrap;
+ flex-direction: row;
+ border: 2px solid black;
+ }
+ .menu {
+ min-width: 12em;
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ text-align: center;
+ }
+ .menu-item {
+ height: 2em;
+ text-align: center;
+ line-height: 2em;
+ background-color: #aaaaaa;
+ border: black solid;
+ border-width: 1px 1px 0px 0px;
+ }
+ .menu-item a {
+ text-decoration: none;
+ color: black;
+ }
+ .menu-item.logout {
+ border-width: 1px 1px 1px 0px;
+ }
+ .menu-item:first-of-type {
+ border-width: 0px 1px 0px 0px;
+ }
+ .menu-item.selected {
+ background-color: #aaccaa;
+ }
+ .logged-in {
+ padding-top: 2em;
+ padding-bottom: 2em;
+ text-align: center;
+ border: black solid;
+ border-width: 0px 1px 0px 0px;
+ }
+ .menu-tail {
+ flex-grow: 1;
+ height: 2em;
+ line-height: 2em;
+ border: black solid;
+ border-width: 0px 1px 0px 0px;
+ }
+ .content {
+ flex-grow: 3;
+ padding: 5em;
+ padding-top: 1em;
+ overflow-wrap: break-word;
+ max-width: 50em;
+ }
+ </style>
+ </head>
+ <body>
+ <main>
+ {{if .SelectedMenu}}
+ <div class="menu">
+ <div class="menu-item {{if eq .SelectedMenu "status"}}selected{{end}}"><a href="/">Status</a></div>
+ <div class="menu-item {{if eq .SelectedMenu "files"}}selected{{end}}"><a href="/files/">Files</a></div>
+ <div class="menu-item {{if eq .SelectedMenu "playlists"}}selected{{end}}"><a href="/playlists/">Playlists</a></div>
+ <div class="menu-item {{if eq .SelectedMenu "radios"}}selected{{end}}"><a href="/radios/">Radios</a></div>
+ <div class="menu-item {{if eq .SelectedMenu "users"}}selected{{end}}"><a href="/users/">Users</a></div>
+ <div class="menu-item {{if eq .SelectedMenu "change-password"}}selected{{end}}"><a href="/change-password">Change Password</a></div>
+ <div class="menu-item logout"><a href="/logout">Log Out</a></div>
+ {{if .Username}}
+ <div class="logged-in">Logged in as:<br><i>{{.Username}}</i></div>
+ {{end}}
+ <div class="menu-tail"><small><i>broadcaster-server vTODO</i></small></div>
+ </div>
+ {{end}}
+ <div class="content">
--- /dev/null
+ <script type="text/javascript">
+ function connectWebsocket() {
+ console.log("Attempting to create websocket connection for radio status sync")
+ const cookieValue = document.cookie
+ .split("; ")
+ .find((row) => row.startsWith("broadcast_session="))
+ ?.split("=")[1];
+ const socket = new WebSocket("/web-ws");
+ socket.addEventListener("open", (event) => {
+ socket.send(cookieValue);
+ });
+ socket.addEventListener("message", (event) => {
+ console.log("Received a status update from server")
+ const connected = document.getElementById('connected-radios');
+ connected.innerHTML = event.data;
+ });
+ socket.addEventListener("close", (event) => {
+ console.log("Websocket closed. Will retry in 10 seconds.")
+ setTimeout(connectWebsocket, 10000);
+ });
+ }
+ // initial connection on page load
+ connectWebsocket();
+ </script>
+
+ <h1>Welcome!</h1>
+ {{if .LoggedIn}}
+ <p>Your username is: {{.Username}}.</p>
+ <p><a href="/logout">Log Out</a></p>
+ {{else}}
+ <p><a href="/login">Log In</a></p>
+ {{end}}
+ <p><a href="/files/">File Management</a></p>
+ <p><a href="/playlists/">Playlist Management</a></p>
+ <p><a href="/radios/">Radio Management</a></p>
+ <h2>Connected Radios</h2>
+ <div id="connected-radios">
+ <i>Loading...</i>
+ </div>
--- /dev/null
+
+ <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>
--- /dev/null
+
+ <h1>Logged Out</h1>
+ <p><a href="/login">Log In again</a></p>
--- /dev/null
+
+ <script type="text/javascript">
+ function deleteItem(sender) {
+ sender.parentNode.remove();
+ }
+ function addItem() {
+ const p = document.createElement('p');
+ const temp = document.getElementById('item-template');
+ p.innerHTML = temp.innerHTML;
+ const marker = document.getElementById('add-item');
+ const parent = marker.parentNode;
+ parent.insertBefore(p, marker);
+ }
+ </script>
+
+ <h1>A specific playlist</h1>
+ <h2>
+ {{if .Playlist.Id}}
+ Edit Playlist
+ {{else}}
+ Create New Playlist
+ {{end}}
+ </h2>
+ <form action="/playlists/submit" method="POST">
+ <input type="hidden" name="playlistId" value="{{.Playlist.Id}}">
+ <p>
+ <input type="checkbox" id="playlistEnabled" name="playlistEnabled" value="1" {{if .Playlist.Enabled}} checked {{end}}>
+ <label for="playlistEnabled">Playlist enabled?</label><br>
+ </p>
+ <p>
+ <label for="playlistName">Name:</label>
+ <input type="text" id="playlistName" name="playlistName" value="{{.Playlist.Name}}">
+ </p>
+ <p>
+ <label for="playlistStartTime">Transmission Start:</label>
+ <input type="datetime-local" id="playlistStartTime" name="playlistStartTime" value="{{.Playlist.StartTime}}">
+ </p>
+ <h3>Playlist Items</h3>
+ {{range .Entries}}
+ <p>
+ Wait until
+ <input type="text" name="delaySeconds" value="{{.DelaySeconds}}">
+ seconds from
+ <select name="isRelative">
+ <option value="1">previous item</option>
+ <option value="0" {{if not .IsRelative}} selected="selected" {{end}}>start of transmission</option>
+ </select>
+ then play
+ <select name="filename">{{$f := .Filename}}
+ <option value="">(no file selected)</option>
+ {{range $.Files}}
+ <option value="{{.}}" {{if eq . $f }} selected="selected" {{end}}>{{.}}</option>
+ {{end}}
+ </select>
+ <a href="#" onclick="deleteItem(this)">(Delete Item)</a>
+ </p>
+ {{end}}
+ <p>
+ <a href="#" onclick="addItem()" id="add-item">Add Item</a>
+ </p>
+ <p>
+ <input type="submit" value="Save Playlist">
+ </p>
+ </form>
+ {{if .Playlist.Id}}
+ <h3>Delete</h3>
+ <form action="/playlists/delete" method="POST">
+ <input type="hidden" name="playlistId" value="{{.Playlist.Id}}">
+ <p>
+ <input type="submit" value="Delete Playlist">
+ </p>
+ </form>
+ {{end}}
+ <template id="item-template">
+ Wait until
+ <input type="text" name="delaySeconds" value="0">
+ seconds from
+ <select name="isRelative">
+ <option value="1">previous item</option>
+ <option value="0">start of transmission</option>
+ </select>
+ then play
+ <select name="filename">
+ <option value="">(no file selected)</option>
+ {{range $.Files}}
+ <option value="{{.}}">{{.}}</option>
+ {{end}}
+ </select>
+ <a href="#" onclick="deleteItem(this)">(Delete Item)</a>
+ </template>
--- /dev/null
+
+ <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>
--- /dev/null
+
+ <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}}
--- /dev/null
+
+ <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>
--- /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" colspan="2">
+ <table class="playlist-table">
+ <tr>
+ <td class="playlist-field">
+ Playlist:
+ </td>
+ <td>
+ {{.Playlist}}
+ </td>
+ </tr>
+ <tr>
+ <td class="playlist-field">
+ File:
+ </td>
+ <td>
+ {{.File}}
+ </td>
+ </tr>
+ <tr>
+ <td class="playlist-field">
+ Status:
+ </td>
+ <td>
+ {{.Status}}
+ </td>
+ </tr>
+ </table>
+ </td>
+ <tr>
+ <td class="outer stop" colspan="3">
+ <form action="/stop" method="post">
+ <input type="hidden" name="radioId" value="{{.Id}}">
+ <input type="submit" value="Cancel Playback" {{if .DisableCancel}} disabled {{end}}>
+ </form>
+ </td>
+ </tr>
+</tr>
+</table>
+{{end}}
+{{else}}
+<p><i>There are no radios online.</i></p>
+{{end}}
--- /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
+ }
+ }
+}