From 99caa31f2e3ca05658558000420f366869cc83f0 Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Thu, 23 Jan 2025 22:19:33 +0100 Subject: [PATCH] feat: config --- .gitignore | 1 + config/config.go | 80 +++++++++++++++++ config/config_test.go | 85 +++++++++++++++++++ config/testdata/complete.yml | 4 + config/testdata/invalid-destination-url.yml | 4 + .../multiple-invalid-destination-urls.yml | 4 + config/testdata/no-logfile.yml | 3 + domain/types.go | 6 ++ go.mod | 2 +- main.go | 31 +++++-- terminal/actor.go | 75 ++++++++++------ terminal/command.go | 18 ++++ 12 files changed, 283 insertions(+), 30 deletions(-) create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 config/testdata/complete.yml create mode 100644 config/testdata/invalid-destination-url.yml create mode 100644 config/testdata/multiple-invalid-destination-urls.yml create mode 100644 config/testdata/no-logfile.yml create mode 100644 terminal/command.go diff --git a/.gitignore b/.gitignore index c9eb6ba..cf3f485 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ +/config.yml /termstream.log diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..29b5f82 --- /dev/null +++ b/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +const defaultLogFile = "termstream.log" + +// Destination holds the configuration for a destination. +type Destination struct { + URL string `yaml:"url"` +} + +// Config holds the configuration for the application. +type Config struct { + LogFile string `yaml:"logfile"` + Destinations []Destination `yaml:"destinations"` +} + +// FromFile returns a reader for the default configuration file. +func FromFile() io.Reader { + r, err := os.Open("config.yml") + if err != nil { + return bytes.NewReader([]byte{}) + } + + return r +} + +// Default returns a reader for the default configuration. +func Default() io.Reader { + return bytes.NewReader([]byte(nil)) +} + +// Load loads the configuration from the given reader. +// +// Passing an empty reader will load the default configuration. +func Load(r io.Reader) (cfg Config, _ error) { + filePayload, err := io.ReadAll(r) + if err != nil { + return cfg, fmt.Errorf("read file: %w", err) + } + + if err = yaml.Unmarshal(filePayload, &cfg); err != nil { + return cfg, fmt.Errorf("unmarshal: %w", err) + } + + setDefaults(&cfg) + + if err = validate(cfg); err != nil { + return cfg, err + } + + return cfg, nil +} + +func setDefaults(cfg *Config) { + if cfg.LogFile == "" { + cfg.LogFile = defaultLogFile + } +} + +func validate(cfg Config) error { + var err error + + for _, dest := range cfg.Destinations { + if !strings.HasPrefix(dest.URL, "rtmp://") { + err = errors.Join(err, fmt.Errorf("destination URL must start with rtmp://")) + } + } + + return err +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..636bdc2 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,85 @@ +package config_test + +import ( + "bytes" + _ "embed" + "io" + "testing" + + "git.netflux.io/rob/termstream/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed testdata/complete.yml +var configComplete []byte + +//go:embed testdata/no-logfile.yml +var configNoLogfile []byte + +//go:embed testdata/invalid-destination-url.yml +var configInvalidDestinationURL []byte + +//go:embed testdata/multiple-invalid-destination-urls.yml +var configMultipleInvalidDestinationURLs []byte + +func TestConfig(t *testing.T) { + testCases := []struct { + name string + r io.Reader + want func(*testing.T, config.Config) + wantErr string + }{ + { + name: "complete", + r: bytes.NewReader(configComplete), + want: func(t *testing.T, cfg config.Config) { + require.Equal( + t, + config.Config{ + LogFile: "test.log", + Destinations: []config.Destination{ + {URL: "rtmp://rtmp.example.com:1935/live"}, + }, + }, cfg) + }, + }, + { + name: "no logfile", + r: bytes.NewReader(configNoLogfile), + want: func(t *testing.T, cfg config.Config) { + assert.Equal(t, "termstream.log", cfg.LogFile) + }, + }, + { + name: "invalid destination URL", + r: bytes.NewReader(configInvalidDestinationURL), + wantErr: "destination URL must start with rtmp://", + }, + { + name: "multiple invalid destination URLs", + r: bytes.NewReader(configMultipleInvalidDestinationURLs), + wantErr: "destination URL must start with rtmp://\ndestination URL must start with rtmp://", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := config.Load(tc.r) + + if tc.wantErr == "" { + require.NoError(t, err) + tc.want(t, cfg) + } else { + assert.EqualError(t, err, tc.wantErr) + } + }) + } +} + +func TestConfigDefault(t *testing.T) { + cfg, err := config.Load(config.Default()) + require.NoError(t, err) + assert.Equal(t, "termstream.log", cfg.LogFile) + assert.Empty(t, cfg.Destinations) +} diff --git a/config/testdata/complete.yml b/config/testdata/complete.yml new file mode 100644 index 0000000..6542551 --- /dev/null +++ b/config/testdata/complete.yml @@ -0,0 +1,4 @@ +--- +logfile: test.log +destinations: +- url: rtmp://rtmp.example.com:1935/live diff --git a/config/testdata/invalid-destination-url.yml b/config/testdata/invalid-destination-url.yml new file mode 100644 index 0000000..037e169 --- /dev/null +++ b/config/testdata/invalid-destination-url.yml @@ -0,0 +1,4 @@ +--- +logfile: test.log +destinations: +- url: http://nope.example.com:443/live diff --git a/config/testdata/multiple-invalid-destination-urls.yml b/config/testdata/multiple-invalid-destination-urls.yml new file mode 100644 index 0000000..7f754c6 --- /dev/null +++ b/config/testdata/multiple-invalid-destination-urls.yml @@ -0,0 +1,4 @@ +--- +destinations: +- url: http://nope1.example.com:443/live +- url: http://nope2.example.com:443/live diff --git a/config/testdata/no-logfile.yml b/config/testdata/no-logfile.yml new file mode 100644 index 0000000..e392257 --- /dev/null +++ b/config/testdata/no-logfile.yml @@ -0,0 +1,3 @@ +--- +destinations: +- url: rtmp://rtmp.example.com:1935/live diff --git a/domain/types.go b/domain/types.go index 337f7ce..f06e485 100644 --- a/domain/types.go +++ b/domain/types.go @@ -5,4 +5,10 @@ type AppState struct { ContainerRunning bool IngressLive bool IngressURL string + Destinations []Destination +} + +// Destination is a single destination. +type Destination struct { + URL string } diff --git a/go.mod b/go.mod index 933d3d5..63de494 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/uuid v1.6.0 github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 github.com/stretchr/testify v1.10.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -43,6 +44,5 @@ require ( golang.org/x/term v0.17.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.9.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect ) diff --git a/main.go b/main.go index 513ad8f..7c1b961 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,11 @@ package main import ( "context" "fmt" + "io" "log/slog" "os" + "git.netflux.io/rob/termstream/config" "git.netflux.io/rob/termstream/container" "git.netflux.io/rob/termstream/domain" "git.netflux.io/rob/termstream/mediaserver" @@ -16,19 +18,26 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if err := run(ctx); err != nil { + if err := run(ctx, config.FromFile()); err != nil { _, _ = os.Stderr.WriteString("Error: " + err.Error() + "\n") } } -func run(ctx context.Context) error { +func run(ctx context.Context, cfgReader io.Reader) error { + cfg, err := config.Load(cfgReader) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + state := new(domain.AppState) + applyConfig(cfg, state) logFile, err := os.OpenFile("termstream.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) if err != nil { return fmt.Errorf("error opening log file: %w", err) } logger := slog.New(slog.NewTextHandler(logFile, nil)) + logger.Info("Starting termstream", slog.Any("initial_state", state)) runner, err := container.NewRunner(logger.With("component", "runner")) if err != nil { @@ -56,9 +65,12 @@ func run(ctx context.Context) error { for { select { - case <-ui.C(): - logger.Info("UI closed") - return nil + case cmd, ok := <-ui.C(): + logger.Info("Command received", "cmd", cmd) + if !ok { + logger.Info("UI closed") + return nil + } case serverState, ok := <-srv.C(): if ok { applyServerState(serverState, state) @@ -71,8 +83,17 @@ func run(ctx context.Context) error { } } +// applyServerState applies the current server state to the app state. func applyServerState(serverState mediaserver.State, appState *domain.AppState) { appState.ContainerRunning = serverState.ContainerRunning appState.IngressLive = serverState.IngressLive appState.IngressURL = serverState.IngressURL } + +// applyConfig applies the configuration to the app state. +func applyConfig(cfg config.Config, appState *domain.AppState) { + appState.Destinations = make([]domain.Destination, 0, len(cfg.Destinations)) + for _, dest := range cfg.Destinations { + appState.Destinations = append(appState.Destinations, domain.Destination{URL: dest.URL}) + } +} diff --git a/terminal/actor.go b/terminal/actor.go index 3abb26c..a2652ea 100644 --- a/terminal/actor.go +++ b/terminal/actor.go @@ -11,19 +11,20 @@ import ( "github.com/rivo/tview" ) -const defaultChanSize = 64 - -type action func() - // Actor is responsible for managing the terminal user interface. type Actor struct { app *tview.Application ch chan action - doneCh chan struct{} + commandCh chan Command logger *slog.Logger serverBox *tview.TextView + destBox *tview.Table } +const defaultChanSize = 64 + +type action func() + // StartActorParams contains the parameters for starting a new terminal user // interface. type StartActorParams struct { @@ -34,6 +35,8 @@ type StartActorParams struct { // StartActor starts the terminal user interface actor. func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) { chanSize := cmp.Or(params.ChanSize, defaultChanSize) + ch := make(chan action, chanSize) + commandCh := make(chan Command, chanSize) app := tview.NewApplication() serverBox := tview.NewTextView() @@ -42,9 +45,15 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) { serverBox.SetTitle("media server") serverBox.SetTextAlign(tview.AlignCenter) - destBox := tview.NewBox(). - SetBorder(true). - SetTitle("destinations") + destBox := tview.NewTable() + destBox.SetTitle("destinations") + destBox.SetBorder(true) + destBox.SetSelectable(true, false) + destBox.SetWrapSelection(true, false) + destBox.SetDoneFunc(func(key tcell.Key) { + row, _ := destBox.GetSelection() + commandCh <- CommandToggleDestination{URL: destBox.GetCell(row, 0).Text} + }) flex := tview.NewFlex(). SetDirection(tview.FlexRow). @@ -58,38 +67,43 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) { AddItem(nil, 0, 1, false) app.SetRoot(container, true) - app.EnableMouse(true) + app.SetFocus(destBox) + app.EnableMouse(false) + + actor := &Actor{ + ch: ch, + commandCh: commandCh, + logger: params.Logger, + app: app, + serverBox: serverBox, + destBox: destBox, + } + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyCtrlC { + switch event.Key() { + case tcell.KeyCtrlC: app.Stop() return nil } return event }) - - actor := &Actor{ - ch: make(chan action, chanSize), - doneCh: make(chan struct{}, 1), - logger: params.Logger, - app: app, - serverBox: serverBox, - } - go actor.actorLoop(ctx) return actor, nil } -// C returns a channel that is closed when the terminal user interface closes. -func (a *Actor) C() <-chan struct{} { - return a.doneCh +// C returns a channel that receives commands from the user interface. +func (a *Actor) C() <-chan Command { + return a.commandCh } func (a *Actor) actorLoop(ctx context.Context) { uiDone := make(chan struct{}) go func() { - defer close(uiDone) + defer func() { + uiDone <- struct{}{} + }() if err := a.app.Run(); err != nil { a.logger.Error("tui application error", "err", err) @@ -101,7 +115,7 @@ func (a *Actor) actorLoop(ctx context.Context) { case <-ctx.Done(): a.logger.Info("Context done") case <-uiDone: - a.doneCh <- struct{}{} + close(a.commandCh) case action, ok := <-a.ch: if !ok { return @@ -120,6 +134,19 @@ func (a *Actor) SetState(state domain.AppState) { func (a *Actor) redrawFromState(state domain.AppState) { a.serverBox.SetText(generateServerStatus(state)) + + a.destBox.Clear() + + a.destBox.SetCell(0, 0, tview.NewTableCell("[grey]URL").SetAlign(tview.AlignLeft).SetExpansion(7).SetSelectable(false)) + a.destBox.SetCell(0, 1, tview.NewTableCell("[grey]Status").SetAlign(tview.AlignLeft).SetExpansion(1).SetSelectable(false)) + a.destBox.SetCell(0, 2, tview.NewTableCell("[grey]Actions").SetAlign(tview.AlignLeft).SetExpansion(2).SetSelectable(false)) + + for i, dest := range state.Destinations { + a.destBox.SetCell(i+1, 0, tview.NewTableCell(dest.URL)) + a.destBox.SetCell(i+1, 1, tview.NewTableCell("[yellow]off-air")) + a.destBox.SetCell(i+1, 2, tview.NewTableCell("[green]Tab to go live")) + } + a.app.Draw() } diff --git a/terminal/command.go b/terminal/command.go new file mode 100644 index 0000000..64782e9 --- /dev/null +++ b/terminal/command.go @@ -0,0 +1,18 @@ +package terminal + +// CommandToggleDestination toggles a destination from on-air to off-air, or +// vice versa. +type CommandToggleDestination struct { + URL string +} + +// Name implements the Command interface. +func (c CommandToggleDestination) Name() string { + return "toggle_destination" +} + +// Command is an interface for commands that can be triggered by the terminal +// user interface. +type Command interface { + Name() string +}