feat: config
This commit is contained in:
parent
8c12683a3c
commit
99caa31f2e
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
|
/config.yml
|
||||||
/termstream.log
|
/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
|
ContainerRunning bool
|
||||||
IngressLive bool
|
IngressLive bool
|
||||||
IngressURL string
|
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/google/uuid v1.6.0
|
||||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
|
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@ -43,6 +44,5 @@ require (
|
|||||||
golang.org/x/term v0.17.0 // indirect
|
golang.org/x/term v0.17.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
golang.org/x/time v0.9.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
|
gotest.tools/v3 v3.5.1 // indirect
|
||||||
)
|
)
|
||||||
|
27
main.go
27
main.go
@ -3,9 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"git.netflux.io/rob/termstream/config"
|
||||||
"git.netflux.io/rob/termstream/container"
|
"git.netflux.io/rob/termstream/container"
|
||||||
"git.netflux.io/rob/termstream/domain"
|
"git.netflux.io/rob/termstream/domain"
|
||||||
"git.netflux.io/rob/termstream/mediaserver"
|
"git.netflux.io/rob/termstream/mediaserver"
|
||||||
@ -16,19 +18,26 @@ func main() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := run(ctx); err != nil {
|
if err := run(ctx, config.FromFile()); err != nil {
|
||||||
_, _ = os.Stderr.WriteString("Error: " + err.Error() + "\n")
|
_, _ = 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)
|
state := new(domain.AppState)
|
||||||
|
applyConfig(cfg, state)
|
||||||
|
|
||||||
logFile, err := os.OpenFile("termstream.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
logFile, err := os.OpenFile("termstream.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error opening log file: %w", err)
|
return fmt.Errorf("error opening log file: %w", err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(logFile, nil))
|
logger := slog.New(slog.NewTextHandler(logFile, nil))
|
||||||
|
logger.Info("Starting termstream", slog.Any("initial_state", state))
|
||||||
|
|
||||||
runner, err := container.NewRunner(logger.With("component", "runner"))
|
runner, err := container.NewRunner(logger.With("component", "runner"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -56,9 +65,12 @@ func run(ctx context.Context) error {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ui.C():
|
case cmd, ok := <-ui.C():
|
||||||
|
logger.Info("Command received", "cmd", cmd)
|
||||||
|
if !ok {
|
||||||
logger.Info("UI closed")
|
logger.Info("UI closed")
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
case serverState, ok := <-srv.C():
|
case serverState, ok := <-srv.C():
|
||||||
if ok {
|
if ok {
|
||||||
applyServerState(serverState, state)
|
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) {
|
func applyServerState(serverState mediaserver.State, appState *domain.AppState) {
|
||||||
appState.ContainerRunning = serverState.ContainerRunning
|
appState.ContainerRunning = serverState.ContainerRunning
|
||||||
appState.IngressLive = serverState.IngressLive
|
appState.IngressLive = serverState.IngressLive
|
||||||
appState.IngressURL = serverState.IngressURL
|
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"
|
"github.com/rivo/tview"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultChanSize = 64
|
|
||||||
|
|
||||||
type action func()
|
|
||||||
|
|
||||||
// Actor is responsible for managing the terminal user interface.
|
// Actor is responsible for managing the terminal user interface.
|
||||||
type Actor struct {
|
type Actor struct {
|
||||||
app *tview.Application
|
app *tview.Application
|
||||||
ch chan action
|
ch chan action
|
||||||
doneCh chan struct{}
|
commandCh chan Command
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
serverBox *tview.TextView
|
serverBox *tview.TextView
|
||||||
|
destBox *tview.Table
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultChanSize = 64
|
||||||
|
|
||||||
|
type action func()
|
||||||
|
|
||||||
// StartActorParams contains the parameters for starting a new terminal user
|
// StartActorParams contains the parameters for starting a new terminal user
|
||||||
// interface.
|
// interface.
|
||||||
type StartActorParams struct {
|
type StartActorParams struct {
|
||||||
@ -34,6 +35,8 @@ type StartActorParams struct {
|
|||||||
// StartActor starts the terminal user interface actor.
|
// StartActor starts the terminal user interface actor.
|
||||||
func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) {
|
func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) {
|
||||||
chanSize := cmp.Or(params.ChanSize, defaultChanSize)
|
chanSize := cmp.Or(params.ChanSize, defaultChanSize)
|
||||||
|
ch := make(chan action, chanSize)
|
||||||
|
commandCh := make(chan Command, chanSize)
|
||||||
|
|
||||||
app := tview.NewApplication()
|
app := tview.NewApplication()
|
||||||
serverBox := tview.NewTextView()
|
serverBox := tview.NewTextView()
|
||||||
@ -42,9 +45,15 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) {
|
|||||||
serverBox.SetTitle("media server")
|
serverBox.SetTitle("media server")
|
||||||
serverBox.SetTextAlign(tview.AlignCenter)
|
serverBox.SetTextAlign(tview.AlignCenter)
|
||||||
|
|
||||||
destBox := tview.NewBox().
|
destBox := tview.NewTable()
|
||||||
SetBorder(true).
|
destBox.SetTitle("destinations")
|
||||||
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().
|
flex := tview.NewFlex().
|
||||||
SetDirection(tview.FlexRow).
|
SetDirection(tview.FlexRow).
|
||||||
@ -58,38 +67,43 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) {
|
|||||||
AddItem(nil, 0, 1, false)
|
AddItem(nil, 0, 1, false)
|
||||||
|
|
||||||
app.SetRoot(container, true)
|
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 {
|
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
if event.Key() == tcell.KeyCtrlC {
|
switch event.Key() {
|
||||||
|
case tcell.KeyCtrlC:
|
||||||
app.Stop()
|
app.Stop()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return event
|
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)
|
go actor.actorLoop(ctx)
|
||||||
|
|
||||||
return actor, nil
|
return actor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// C returns a channel that is closed when the terminal user interface closes.
|
// C returns a channel that receives commands from the user interface.
|
||||||
func (a *Actor) C() <-chan struct{} {
|
func (a *Actor) C() <-chan Command {
|
||||||
return a.doneCh
|
return a.commandCh
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Actor) actorLoop(ctx context.Context) {
|
func (a *Actor) actorLoop(ctx context.Context) {
|
||||||
uiDone := make(chan struct{})
|
uiDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
defer close(uiDone)
|
defer func() {
|
||||||
|
uiDone <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
if err := a.app.Run(); err != nil {
|
if err := a.app.Run(); err != nil {
|
||||||
a.logger.Error("tui application error", "err", err)
|
a.logger.Error("tui application error", "err", err)
|
||||||
@ -101,7 +115,7 @@ func (a *Actor) actorLoop(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
a.logger.Info("Context done")
|
a.logger.Info("Context done")
|
||||||
case <-uiDone:
|
case <-uiDone:
|
||||||
a.doneCh <- struct{}{}
|
close(a.commandCh)
|
||||||
case action, ok := <-a.ch:
|
case action, ok := <-a.ch:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@ -120,6 +134,19 @@ func (a *Actor) SetState(state domain.AppState) {
|
|||||||
|
|
||||||
func (a *Actor) redrawFromState(state domain.AppState) {
|
func (a *Actor) redrawFromState(state domain.AppState) {
|
||||||
a.serverBox.SetText(generateServerStatus(state))
|
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()
|
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