feat: config
This commit is contained in:
parent
8c12683a3c
commit
99caa31f2e
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
/config.yml
|
||||
/termstream.log
|
||||
|
80
config/config.go
Normal file
80
config/config.go
Normal file
@ -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
|
||||
}
|
85
config/config_test.go
Normal file
85
config/config_test.go
Normal file
@ -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)
|
||||
}
|
4
config/testdata/complete.yml
vendored
Normal file
4
config/testdata/complete.yml
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
logfile: test.log
|
||||
destinations:
|
||||
- url: rtmp://rtmp.example.com:1935/live
|
4
config/testdata/invalid-destination-url.yml
vendored
Normal file
4
config/testdata/invalid-destination-url.yml
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
logfile: test.log
|
||||
destinations:
|
||||
- url: http://nope.example.com:443/live
|
4
config/testdata/multiple-invalid-destination-urls.yml
vendored
Normal file
4
config/testdata/multiple-invalid-destination-urls.yml
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
destinations:
|
||||
- url: http://nope1.example.com:443/live
|
||||
- url: http://nope2.example.com:443/live
|
3
config/testdata/no-logfile.yml
vendored
Normal file
3
config/testdata/no-logfile.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
---
|
||||
destinations:
|
||||
- url: rtmp://rtmp.example.com:1935/live
|
@ -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
|
||||
}
|
||||
|
2
go.mod
2
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
|
||||
)
|
||||
|
27
main.go
27
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():
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
18
terminal/command.go
Normal file
18
terminal/command.go
Normal file
@ -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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user