feat(ui): add and remove destinations

This commit is contained in:
Rob Watson 2025-03-28 06:31:54 +01:00 committed by Rob Watson
parent 3f25458b03
commit 7edb975b8e
9 changed files with 555 additions and 110 deletions

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"slices"
"time" "time"
"git.netflux.io/rob/octoplex/internal/config" "git.netflux.io/rob/octoplex/internal/config"
@ -17,7 +18,7 @@ import (
// RunParams holds the parameters for running the application. // RunParams holds the parameters for running the application.
type RunParams struct { type RunParams struct {
Config config.Config ConfigService *config.Service
DockerClient container.DockerClient DockerClient container.DockerClient
Screen *terminal.Screen // Screen may be nil. Screen *terminal.Screen // Screen may be nil.
ClipboardAvailable bool ClipboardAvailable bool
@ -28,9 +29,15 @@ type RunParams struct {
// Run starts the application, and blocks until it exits. // Run starts the application, and blocks until it exits.
func Run(ctx context.Context, params RunParams) error { func Run(ctx context.Context, params RunParams) error {
state := newStateFromRunParams(params) // cfg is the current configuration of the application, as reflected in the
logger := params.Logger // config file.
cfg := params.ConfigService.Current()
// state is the current state of the application, as reflected in the UI.
state := new(domain.AppState)
applyConfig(cfg, state)
logger := params.Logger
ui, err := terminal.StartUI(ctx, terminal.StartParams{ ui, err := terminal.StartUI(ctx, terminal.StartParams{
Screen: params.Screen, Screen: params.Screen,
ClipboardAvailable: params.ClipboardAvailable, ClipboardAvailable: params.ClipboardAvailable,
@ -52,7 +59,6 @@ func Run(ctx context.Context, params RunParams) error {
updateUI := func() { ui.SetState(*state) } updateUI := func() { ui.SetState(*state) }
updateUI() updateUI()
// TODO: check for unused networks.
var exists bool var exists bool
if exists, err = containerClient.ContainerRunning(ctx, container.AllContainers()); err != nil { if exists, err = containerClient.ContainerRunning(ctx, container.AllContainers()); err != nil {
return fmt.Errorf("check existing containers: %w", err) return fmt.Errorf("check existing containers: %w", err)
@ -71,12 +77,12 @@ func Run(ctx context.Context, params RunParams) error {
ui.AllowQuit() ui.AllowQuit()
// While RTMP is the only source, it doesn't make sense to disable it. // While RTMP is the only source, it doesn't make sense to disable it.
if !params.Config.Sources.RTMP.Enabled { if !cfg.Sources.RTMP.Enabled {
return errors.New("config: sources.rtmp.enabled must be set to true") return errors.New("config: sources.rtmp.enabled must be set to true")
} }
srv, err := mediaserver.StartActor(ctx, mediaserver.StartActorParams{ srv, err := mediaserver.StartActor(ctx, mediaserver.StartActorParams{
StreamKey: mediaserver.StreamKey(params.Config.Sources.RTMP.StreamKey), StreamKey: mediaserver.StreamKey(cfg.Sources.RTMP.StreamKey),
ContainerClient: containerClient, ContainerClient: containerClient,
Logger: logger.With("component", "mediaserver"), Logger: logger.With("component", "mediaserver"),
}) })
@ -98,6 +104,9 @@ func Run(ctx context.Context, params RunParams) error {
for { for {
select { select {
case cfg = <-params.ConfigService.C():
applyConfig(cfg, state)
updateUI()
case cmd, ok := <-ui.C(): case cmd, ok := <-ui.C():
if !ok { if !ok {
// TODO: keep UI open until all containers have closed // TODO: keep UI open until all containers have closed
@ -107,6 +116,24 @@ func Run(ctx context.Context, params RunParams) error {
logger.Debug("Command received", "cmd", cmd.Name()) logger.Debug("Command received", "cmd", cmd.Name())
switch c := cmd.(type) { switch c := cmd.(type) {
case terminal.CommandAddDestination:
cfg.Destinations = append(cfg.Destinations, config.Destination{
Name: c.DestinationName,
URL: c.URL,
})
if err := params.ConfigService.SetConfig(cfg); err != nil {
// TODO: error handling
logger.Error("Failed to set config", "err", err)
}
case terminal.CommandRemoveDestination:
mp.StopDestination(c.URL) // no-op if not live
cfg.Destinations = slices.DeleteFunc(cfg.Destinations, func(dest config.Destination) bool {
return dest.URL == c.URL
})
if err := params.ConfigService.SetConfig(cfg); err != nil {
// TODO: error handling
logger.Error("Failed to set config", "err", err)
}
case terminal.CommandStartDestination: case terminal.CommandStartDestination:
mp.StartDestination(c.URL) mp.StartDestination(c.URL)
case terminal.CommandStopDestination: case terminal.CommandStopDestination:
@ -183,17 +210,31 @@ func handleDestError(destError destinationError, mp *multiplexer.Actor, ui *term
mp.StopDestination(destError.url) mp.StopDestination(destError.url)
} }
// newStateFromRunParams creates a new app state from the run parameters. // applyConfig applies the config to the app state. For now we only set the
func newStateFromRunParams(params RunParams) *domain.AppState { // destinations.
var state domain.AppState func applyConfig(cfg config.Config, appState *domain.AppState) {
appState.Destinations = resolveDestinations(appState.Destinations, cfg.Destinations)
}
state.Destinations = make([]domain.Destination, 0, len(params.Config.Destinations)) // resolveDestinations merges the current destinations with newly configured
for _, dest := range params.Config.Destinations { // destinations.
state.Destinations = append(state.Destinations, domain.Destination{ func resolveDestinations(destinations []domain.Destination, inDestinations []config.Destination) []domain.Destination {
Name: dest.Name, destinations = slices.DeleteFunc(destinations, func(dest domain.Destination) bool {
URL: dest.URL, return !slices.ContainsFunc(inDestinations, func(inDest config.Destination) bool {
return inDest.URL == dest.URL
})
})
for i, inDest := range inDestinations {
if i < len(destinations) && destinations[i].URL == inDest.URL {
continue
}
destinations = slices.Insert(destinations, i, domain.Destination{
Name: inDest.Name,
URL: inDest.URL,
}) })
} }
return &state return destinations[:len(inDestinations)]
} }

71
internal/app/app_test.go Normal file
View File

@ -0,0 +1,71 @@
package app
import (
"testing"
"git.netflux.io/rob/octoplex/internal/config"
"git.netflux.io/rob/octoplex/internal/domain"
"github.com/stretchr/testify/assert"
)
func TestResolveDestinations(t *testing.T) {
testCases := []struct {
name string
in []config.Destination
existing []domain.Destination
want []domain.Destination
}{
{
name: "nil slices",
existing: nil,
want: nil,
},
{
name: "empty slices",
existing: []domain.Destination{},
want: []domain.Destination{},
},
{
name: "identical slices",
in: []config.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
want: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
},
{
name: "adding a new destination",
in: []config.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}},
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}},
want: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}},
},
{
name: "removing a destination",
in: []config.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}},
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}},
want: []domain.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}},
},
{
name: "switching order, two items",
in: []config.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.youtube.com/live"}},
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}},
want: []domain.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.youtube.com/live"}},
},
{
name: "switching order, several items",
in: []config.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}, {URL: "rtmp://rtmp.facebook.com/live"}},
want: []domain.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
},
{
name: "removing all destinations",
in: []config.Destination{},
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
want: []domain.Destination{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, resolveDestinations(tc.existing, tc.in))
})
}
}

View File

@ -5,6 +5,8 @@ package app_test
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"os"
"runtime" "runtime"
"sync" "sync"
"testing" "testing"
@ -24,9 +26,12 @@ import (
) )
func TestIntegration(t *testing.T) { func TestIntegration(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute)
defer cancel() defer cancel()
configService, err := config.NewDefaultService()
require.NoError(t, err)
destServer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ destServer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{
Image: "bluenviron/mediamtx:latest", Image: "bluenviron/mediamtx:latest",
@ -42,6 +47,7 @@ func TestIntegration(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
logger := testhelpers.NewTestLogger().With("component", "integration") logger := testhelpers.NewTestLogger().With("component", "integration")
logger.Info("Initialised logger", "debug_level", logger.Enabled(ctx, slog.LevelDebug), "runner_debug", os.Getenv("RUNNER_DEBUG"))
dockerClient, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv) dockerClient, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv)
require.NoError(t, err) require.NoError(t, err)
@ -78,6 +84,12 @@ func TestIntegration(t *testing.T) {
return lines return lines
} }
t.Cleanup(func() {
if t.Failed() {
printScreen(getContents, "After failing")
}
})
screen := tcell.NewSimulationScreen("") screen := tcell.NewSimulationScreen("")
screenCaptureC := make(chan terminal.ScreenCapture, 1) screenCaptureC := make(chan terminal.ScreenCapture, 1)
go func() { go func() {
@ -94,33 +106,31 @@ func TestIntegration(t *testing.T) {
} }
}() }()
done := make(chan struct{})
go func() {
// https://stackoverflow.com/a/60740997/62871 // https://stackoverflow.com/a/60740997/62871
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
panic("TODO: try host.docker.internal or Mac equivalent here") panic("TODO: try host.docker.internal or Mac equivalent here")
} }
const destHost = "172.17.0.1" const destHost = "172.17.0.1"
destURL1 := fmt.Sprintf("rtmp://%s:%d/live/dest1", destHost, destServerPort.Int())
destURL2 := fmt.Sprintf("rtmp://%s:%d/live/dest2", destHost, destServerPort.Int())
cfg := config.Config{
Sources: config.Sources{RTMP: config.RTMPSource{Enabled: true, StreamKey: "live"}},
// Load one destination from config, add the other in-app.
Destinations: []config.Destination{{Name: "Local server 1", URL: destURL1}},
}
tmpDir, err := os.MkdirTemp("", "octoplex-app-test-integration")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(tmpDir) })
configService, err = config.NewService(func() (string, error) { return tmpDir, nil }, 1)
require.NoError(t, err)
require.NoError(t, configService.SetConfig(cfg))
done := make(chan struct{})
go func() {
err := app.Run(ctx, app.RunParams{ err := app.Run(ctx, app.RunParams{
Config: config.Config{ ConfigService: configService,
Sources: config.Sources{
RTMP: config.RTMPSource{
Enabled: true,
StreamKey: "live",
},
},
Destinations: []config.Destination{
{
Name: "Local server 1",
URL: fmt.Sprintf("rtmp://%s:%d/live/dest1", destHost, destServerPort.Int()),
},
{
Name: "Local server 2",
URL: fmt.Sprintf("rtmp://%s:%d/live/dest2", destHost, destServerPort.Int()),
},
},
},
DockerClient: dockerClient, DockerClient: dockerClient,
Screen: &terminal.Screen{ Screen: &terminal.Screen{
Screen: screen, Screen: screen,
@ -137,17 +147,79 @@ func TestIntegration(t *testing.T) {
done <- struct{}{} done <- struct{}{}
}() }()
// Wait for mediaserver container to start:
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
require.EventuallyWithT(
t,
func(t *assert.CollectT) {
contents := getContents()
require.True(t, len(contents) > 2, "expected at least 3 lines of output")
assert.Contains(t, contents[2], "Status ready", "expected mediaserver status to be ready")
},
2*time.Minute,
time.Second,
"expected the mediaserver to start",
)
printScreen(getContents, "After starting the mediaserver")
// Start streaming a test video to the app: // Start streaming a test video to the app:
testhelpers.StreamFLV(t, "rtmp://localhost:1935/live") testhelpers.StreamFLV(t, "rtmp://localhost:1935/live")
time.Sleep(10 * time.Second)
require.EventuallyWithT(
t,
func(t *assert.CollectT) {
contents := getContents()
require.True(t, len(contents) > 2, "expected at least 3 lines of output")
assert.Contains(t, contents[2], "Status receiving", "expected mediaserver status to be receiving")
assert.Contains(t, contents[3], "Tracks H264", "expected mediaserver tracks to be H264")
assert.Contains(t, contents[4], "Health healthy", "expected mediaserver to be healthy")
},
time.Minute,
time.Second,
"expected to receive an ingress stream",
)
printScreen(getContents, "After receiving the ingress stream")
// Add a second destination in-app:
sendKey(screen, tcell.KeyRune, 'a')
sendBackspaces(screen, 30)
sendKeys(screen, "Local server 2")
sendKey(screen, tcell.KeyTab, ' ')
sendBackspaces(screen, 30)
sendKeys(screen, destURL2)
sendKey(screen, tcell.KeyTab, ' ')
sendKey(screen, tcell.KeyEnter, ' ')
require.EventuallyWithT(
t,
func(t *assert.CollectT) {
contents := getContents()
require.True(t, len(contents) > 2, "expected at least 3 lines of output")
assert.Contains(t, contents[2], "Status receiving", "expected mediaserver status to be receiving")
assert.Contains(t, contents[3], "Tracks H264", "expected mediaserver tracks to be H264")
assert.Contains(t, contents[4], "Health healthy", "expected mediaserver to be healthy")
require.Contains(t, contents[2], "Local server 1", "expected local server 1 to be present")
assert.Contains(t, contents[2], "off-air", "expected local server 1 to be off-air")
require.Contains(t, contents[3], "Local server 2", "expected local server 2 to be present")
assert.Contains(t, contents[3], "off-air", "expected local server 2 to be off-air")
},
2*time.Minute,
time.Second,
"expected to add the destinations",
)
printScreen(getContents, "After adding the destinations")
// Start destinations: // Start destinations:
screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModNone)) sendKey(screen, tcell.KeyRune, ' ')
screen.PostEvent(tcell.NewEventKey(tcell.KeyDown, ' ', tcell.ModNone)) sendKey(screen, tcell.KeyDown, ' ')
screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModNone)) sendKey(screen, tcell.KeyRune, ' ')
require.EventuallyWithT( require.EventuallyWithT(
t, t,
@ -166,18 +238,43 @@ func TestIntegration(t *testing.T) {
require.Contains(t, contents[3], "Local server 2", "expected local server 2 to be present") require.Contains(t, contents[3], "Local server 2", "expected local server 2 to be present")
assert.Contains(t, contents[3], "sending", "expected local server 2 to be sending") assert.Contains(t, contents[3], "sending", "expected local server 2 to be sending")
assert.Contains(t, contents[3], "healthy", "expected local server 2 to be healthy") assert.Contains(t, contents[3], "healthy", "expected local server 2 to be healthy")
},
2*time.Minute,
time.Second,
"expected to start the destination streams",
)
printScreen(getContents, "After starting the destination streams")
sendKey(screen, tcell.KeyRune, 'r')
sendKey(screen, tcell.KeyEnter, ' ')
require.EventuallyWithT(
t,
func(t *assert.CollectT) {
contents := getContents()
require.True(t, len(contents) > 2, "expected at least 3 lines of output")
assert.Contains(t, contents[2], "Status receiving", "expected mediaserver status to be receiving")
assert.Contains(t, contents[3], "Tracks H264", "expected mediaserver tracks to be H264")
assert.Contains(t, contents[4], "Health healthy", "expected mediaserver to be healthy")
require.Contains(t, contents[2], "Local server 1", "expected local server 1 to be present")
assert.Contains(t, contents[2], "sending", "expected local server 1 to be sending")
assert.Contains(t, contents[2], "healthy", "expected local server 1 to be healthy")
require.NotContains(t, contents[3], "Local server 2", "expected local server 2 to not be present")
}, },
2*time.Minute, 2*time.Minute,
time.Second, time.Second,
"expected to remove the second destination",
) )
printScreen(getContents, "After removing the second destination")
// Stop destinations: // Stop remaining destination.
screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModNone)) // It is currently necessary to press down to re-focus the destination:
screen.PostEvent(tcell.NewEventKey(tcell.KeyUp, ' ', tcell.ModNone)) sendKey(screen, tcell.KeyDown, ' ')
screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModNone)) sendKey(screen, tcell.KeyRune, ' ')
time.Sleep(10 * time.Second)
require.EventuallyWithT( require.EventuallyWithT(
t, t,
@ -188,13 +285,15 @@ func TestIntegration(t *testing.T) {
require.Contains(t, contents[2], "Local server 1", "expected local server 1 to be present") require.Contains(t, contents[2], "Local server 1", "expected local server 1 to be present")
assert.Contains(t, contents[2], "exited", "expected local server 1 to have exited") assert.Contains(t, contents[2], "exited", "expected local server 1 to have exited")
require.Contains(t, contents[3], "Local server 2", "expected local server 2 to be present") require.NotContains(t, contents[3], "Local server 2", "expected local server 2 to not be present")
assert.Contains(t, contents[3], "exited", "expected local server 2 to have exited")
}, },
2*time.Minute, time.Minute,
time.Second, time.Second,
"expected to stop the first destination stream",
) )
printScreen(getContents, "After stopping the first destination")
// TODO: // TODO:
// - Source error // - Source error
// - Destination error // - Destination error
@ -204,3 +303,27 @@ func TestIntegration(t *testing.T) {
<-done <-done
} }
func printScreen(getContents func() []string, label string) {
fmt.Println(label + ":")
for _, line := range getContents() {
fmt.Println(line)
}
}
func sendKey(screen tcell.SimulationScreen, key tcell.Key, ch rune) {
screen.InjectKey(key, ch, tcell.ModNone)
time.Sleep(50 * time.Millisecond)
}
func sendKeys(screen tcell.SimulationScreen, keys string) {
screen.InjectKeyBytes([]byte(keys))
time.Sleep(500 * time.Millisecond)
}
func sendBackspaces(screen tcell.SimulationScreen, n int) {
for range n {
screen.InjectKey(tcell.KeyBackspace, ' ', tcell.ModNone)
time.Sleep(50 * time.Millisecond)
}
time.Sleep(500 * time.Millisecond)
}

View File

@ -17,25 +17,29 @@ var exampleConfig []byte
// Service provides configuration services. // Service provides configuration services.
type Service struct { type Service struct {
userConfigDir string current Config
appConfigDir string appConfigDir string
appStateDir string appStateDir string
configC chan Config
} }
// ConfigDirFunc is a function that returns the user configuration directory. // ConfigDirFunc is a function that returns the user configuration directory.
type ConfigDirFunc func() (string, error) type ConfigDirFunc func() (string, error)
// defaultChanSize is the default size of the configuration channel.
const defaultChanSize = 64
// NewDefaultService creates a new service with the default configuration file // NewDefaultService creates a new service with the default configuration file
// location. // location.
func NewDefaultService() (*Service, error) { func NewDefaultService() (*Service, error) {
return NewService(os.UserConfigDir) return NewService(os.UserConfigDir, defaultChanSize)
} }
// NewService creates a new service with provided ConfigDirFunc. // NewService creates a new service with provided ConfigDirFunc.
// //
// The app data directories (config and state) are created if they do not // The app data directories (config and state) are created if they do not
// exist. // exist.
func NewService(configDirFunc ConfigDirFunc) (*Service, error) { func NewService(configDirFunc ConfigDirFunc, chanSize int) (*Service, error) {
configDir, err := configDirFunc() configDir, err := configDirFunc()
if err != nil { if err != nil {
return nil, fmt.Errorf("user config dir: %w", err) return nil, fmt.Errorf("user config dir: %w", err)
@ -51,18 +55,37 @@ func NewService(configDirFunc ConfigDirFunc) (*Service, error) {
return nil, fmt.Errorf("app state dir: %w", err) return nil, fmt.Errorf("app state dir: %w", err)
} }
return &Service{ svc := &Service{
userConfigDir: configDir,
appConfigDir: appConfigDir, appConfigDir: appConfigDir,
appStateDir: appStateDir, appStateDir: appStateDir,
}, nil configC: make(chan Config, chanSize),
} }
// ReadOrCreateConfig reads the configuration from the file at the given path or svc.setDefaults(&svc.current)
// creates it with default values.
return svc, nil
}
// Current returns the current configuration.
//
// This will be the last-loaded or last-updated configuration, or a default
// configuration if nothing else is available.
func (s *Service) Current() Config {
return s.current
}
// C returns a channel that receives configuration updates.
//
// The channel is never closed.
func (s *Service) C() <-chan Config {
return s.configC
}
// ReadOrCreateConfig reads the configuration from the file or creates it with
// default values.
func (s *Service) ReadOrCreateConfig() (cfg Config, _ error) { func (s *Service) ReadOrCreateConfig() (cfg Config, _ error) {
if _, err := os.Stat(s.Path()); os.IsNotExist(err) { if _, err := os.Stat(s.Path()); os.IsNotExist(err) {
return s.createConfig() return s.writeDefaultConfig()
} else if err != nil { } else if err != nil {
return cfg, fmt.Errorf("stat: %w", err) return cfg, fmt.Errorf("stat: %w", err)
} }
@ -70,6 +93,29 @@ func (s *Service) ReadOrCreateConfig() (cfg Config, _ error) {
return s.readConfig() return s.readConfig()
} }
// SetConfig sets the configuration to the given value and writes it to the
// file.
func (s *Service) SetConfig(cfg Config) error {
cfgBytes, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
if err = s.writeConfig(cfgBytes); err != nil {
return fmt.Errorf("write config: %w", err)
}
s.current = cfg
s.configC <- cfg
return nil
}
// Path returns the path to the configuration file.
func (s *Service) Path() string {
return filepath.Join(s.appConfigDir, "config.yaml")
}
func (s *Service) readConfig() (cfg Config, _ error) { func (s *Service) readConfig() (cfg Config, _ error) {
contents, err := os.ReadFile(s.Path()) contents, err := os.ReadFile(s.Path())
if err != nil { if err != nil {
@ -86,23 +132,34 @@ func (s *Service) readConfig() (cfg Config, _ error) {
return cfg, err return cfg, err
} }
s.current = cfg
return s.current, nil
}
func (s *Service) writeDefaultConfig() (Config, error) {
var cfg Config
if err := yaml.Unmarshal(exampleConfig, &cfg); err != nil {
return cfg, fmt.Errorf("unmarshal: %w", err)
}
if err := s.writeConfig(exampleConfig); err != nil {
return Config{}, fmt.Errorf("write config: %w", err)
}
return cfg, nil return cfg, nil
} }
func (s *Service) createConfig() (Config, error) { func (s *Service) writeConfig(cfgBytes []byte) error {
if err := os.MkdirAll(s.appConfigDir, 0744); err != nil { if err := os.MkdirAll(s.appConfigDir, 0744); err != nil {
return Config{}, fmt.Errorf("mkdir: %w", err) return fmt.Errorf("mkdir: %w", err)
} }
if err := os.WriteFile(s.Path(), exampleConfig, 0644); err != nil { if err := os.WriteFile(s.Path(), cfgBytes, 0644); err != nil {
return Config{}, fmt.Errorf("write file: %w", err) return fmt.Errorf("write file: %w", err)
} }
return Config{}, nil return nil
}
func (s *Service) Path() string {
return filepath.Join(s.appConfigDir, "config.yaml")
} }
func (s *Service) setDefaults(cfg *Config) { func (s *Service) setDefaults(cfg *Config) {
@ -110,6 +167,8 @@ func (s *Service) setDefaults(cfg *Config) {
cfg.LogFile.Path = filepath.Join(s.appStateDir, domain.AppName+".log") cfg.LogFile.Path = filepath.Join(s.appStateDir, domain.AppName+".log")
} }
cfg.Sources.RTMP.Enabled = true
for i := range cfg.Destinations { for i := range cfg.Destinations {
if strings.TrimSpace(cfg.Destinations[i].Name) == "" { if strings.TrimSpace(cfg.Destinations[i].Name) == "" {
cfg.Destinations[i].Name = fmt.Sprintf("Stream %d", i+1) cfg.Destinations[i].Name = fmt.Sprintf("Stream %d", i+1)

View File

@ -31,14 +31,26 @@ var configInvalidDestinationURL []byte
//go:embed testdata/multiple-invalid-destination-urls.yml //go:embed testdata/multiple-invalid-destination-urls.yml
var configMultipleInvalidDestinationURLs []byte var configMultipleInvalidDestinationURLs []byte
func TestConfigServiceCurrent(t *testing.T) {
suffix := "current_" + shortid.New().String()
systemConfigDirFunc := buildSystemConfigDirFunc(suffix)
systemConfigDir, _ := systemConfigDirFunc()
service, err := config.NewService(systemConfigDirFunc, 1)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, os.RemoveAll(systemConfigDir)) })
// Ensure defaults are set:
assert.True(t, service.Current().Sources.RTMP.Enabled)
}
func TestConfigServiceCreateConfig(t *testing.T) { func TestConfigServiceCreateConfig(t *testing.T) {
suffix := "read_or_create_" + shortid.New().String() suffix := "read_or_create_" + shortid.New().String()
systemConfigDirFunc := buildSystemConfigDirFunc(suffix) systemConfigDirFunc := buildSystemConfigDirFunc(suffix)
systemConfigDir, _ := systemConfigDirFunc() systemConfigDir, _ := systemConfigDirFunc()
service, err := config.NewService(systemConfigDirFunc) service, err := config.NewService(systemConfigDirFunc, 1)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, os.RemoveAll(systemConfigDir)) }) t.Cleanup(func() { require.NoError(t, os.RemoveAll(systemConfigDir)) })
cfg, err := service.ReadOrCreateConfig() cfg, err := service.ReadOrCreateConfig()
@ -131,7 +143,7 @@ func TestConfigServiceReadConfig(t *testing.T) {
configPath := filepath.Join(appConfigDir, "config.yaml") configPath := filepath.Join(appConfigDir, "config.yaml")
require.NoError(t, os.WriteFile(configPath, tc.configBytes, 0644)) require.NoError(t, os.WriteFile(configPath, tc.configBytes, 0644))
service, err := config.NewService(buildSystemConfigDirFunc(suffix)) service, err := config.NewService(buildSystemConfigDirFunc(suffix), 1)
require.NoError(t, err) require.NoError(t, err)
cfg, err := service.ReadOrCreateConfig() cfg, err := service.ReadOrCreateConfig()
@ -146,6 +158,25 @@ func TestConfigServiceReadConfig(t *testing.T) {
} }
} }
func TestConfigServiceSetConfig(t *testing.T) {
suffix := "set_config_" + shortid.New().String()
systemConfigDirFunc := buildSystemConfigDirFunc(suffix)
systemConfigDir, _ := systemConfigDirFunc()
service, err := config.NewService(systemConfigDirFunc, 1)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, os.RemoveAll(systemConfigDir)) })
cfg := config.Config{LogFile: config.LogFile{Enabled: true, Path: "test.log"}}
require.NoError(t, service.SetConfig(cfg))
cfg, err = service.ReadOrCreateConfig()
require.NoError(t, err)
assert.Equal(t, "test.log", cfg.LogFile.Path)
assert.True(t, cfg.LogFile.Enabled)
}
// buildAppConfigDir returns a temporary directory which mimics // buildAppConfigDir returns a temporary directory which mimics
// $XDG_CONFIG_HOME/octoplex. // $XDG_CONFIG_HOME/octoplex.
func buildAppConfigDir(suffix string) string { func buildAppConfigDir(suffix string) string {

View File

@ -28,7 +28,7 @@ func createAppStateDir() (string, error) {
var dir string var dir string
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
dir = filepath.Join(userHomeDir, "/Library", "Caches", domain.AppName) dir = filepath.Join(userHomeDir, "Library", "Caches", domain.AppName)
case "windows": case "windows":
// TODO: Windows support // TODO: Windows support
return "", errors.New("not implemented") return "", errors.New("not implemented")

View File

@ -1,5 +1,26 @@
package terminal package terminal
// CommandAddDestination adds a destination.
type CommandAddDestination struct {
DestinationName string
URL string
}
// Name implements the Command interface.
func (c CommandAddDestination) Name() string {
return "add_destination"
}
// CommandRemoveDestination removes a destination.
type CommandRemoveDestination struct {
URL string
}
// Name implements the Command interface.
func (c CommandRemoveDestination) Name() string {
return "remove_destination"
}
// CommandStartDestination starts a destination. // CommandStartDestination starts a destination.
type CommandStartDestination struct { type CommandStartDestination struct {
URL string URL string

View File

@ -163,6 +163,8 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
aboutView.SetDirection(tview.FlexRow) aboutView.SetDirection(tview.FlexRow)
aboutView.SetBorder(true) aboutView.SetBorder(true)
aboutView.SetTitle("Actions") aboutView.SetTitle("Actions")
aboutView.AddItem(tview.NewTextView().SetText("[a] Add new destination"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[r] Remove destination"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[Space] Toggle destination"), 1, 0, false) aboutView.AddItem(tview.NewTextView().SetText("[Space] Toggle destination"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[u] Copy ingress RTMP URL"), 1, 0, false) aboutView.AddItem(tview.NewTextView().SetText("[u] Copy ingress RTMP URL"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[c] Copy config file path"), 1, 0, false) aboutView.AddItem(tview.NewTextView().SetText("[c] Copy config file path"), 1, 0, false)
@ -189,7 +191,7 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
AddItem(destView, 0, 6, false) AddItem(destView, 0, 6, false)
pages := tview.NewPages() pages := tview.NewPages()
pages.AddPage("main", flex, true, true) pages.AddPage(pageNameMain, flex, true, true)
app.SetRoot(pages, true) app.SetRoot(pages, true)
app.SetFocus(destView) app.SetFocus(destView)
@ -218,9 +220,26 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
} }
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
// Special case: allow all keys except Escape to be passed to the add
// destination modal.
if pageName, _ := pages.GetFrontPage(); pageName == pageNameAddDestination {
if event.Key() == tcell.KeyEscape {
ui.closeDestinationForm()
return nil
}
return event
}
switch event.Key() { switch event.Key() {
case tcell.KeyRune: case tcell.KeyRune:
switch event.Rune() { switch event.Rune() {
case 'a', 'A':
ui.addDestination()
return nil
case 'r', 'R':
ui.removeDestination()
return nil
case ' ': case ' ':
ui.toggleDestination() ui.toggleDestination()
case 'u', 'U': case 'u', 'U':
@ -287,7 +306,7 @@ func (ui *UI) ShowStartupCheckModal() bool {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.showModal( ui.showModal(
modalGroupStartupCheck, pageNameModalStartupCheck,
"Another instance of Octoplex may already be running. Pressing continue will close that instance. Continue?", "Another instance of Octoplex may already be running. Pressing continue will close that instance. Continue?",
[]string{"Continue", "Exit"}, []string{"Continue", "Exit"},
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
@ -309,7 +328,7 @@ func (ui *UI) ShowDestinationErrorModal(name string, err error) {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.showModal( ui.showModal(
modalGroupStartupCheck, pageNameModalStartupCheck,
fmt.Sprintf( fmt.Sprintf(
"Streaming to %s failed:\n\n%s", "Streaming to %s failed:\n\n%s",
cmp.Or(name, "this destination"), cmp.Or(name, "this destination"),
@ -395,7 +414,9 @@ func (ui *UI) updatePullProgress(state domain.AppState) {
} }
if len(pullingContainers) == 0 { if len(pullingContainers) == 0 {
ui.hideModal(modalGroupPullProgress) ui.app.QueueUpdateDraw(func() {
ui.hideModal(pageNameModalPullProgress)
})
return return
} }
@ -410,7 +431,7 @@ func (ui *UI) updatePullProgress(state domain.AppState) {
func (ui *UI) updateProgressModal(container domain.Container) { func (ui *UI) updateProgressModal(container domain.Container) {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
modalName := "modal-" + string(modalGroupPullProgress) modalName := string(pageNameModalPullProgress)
var status string var status string
// Avoid showing the long Docker pull status in the modal content. // Avoid showing the long Docker pull status in the modal content.
@ -434,21 +455,23 @@ func (ui *UI) updateProgressModal(container domain.Container) {
}) })
} }
// modalGroup represents a specific modal of which only one may be shown // page names represent a specific page in the terminal user interface.
// simultaneously. //
type modalGroup string // Modals should generally have a unique name, which allows them to be stacked
// on top of other modals.
const ( const (
modalGroupAbout modalGroup = "about" pageNameMain = "main"
modalGroupQuit modalGroup = "quit" pageNameAddDestination = "add-destination"
modalGroupStartupCheck modalGroup = "startup-check" pageNameModalAbout = "modal-about"
modalGroupClipboard modalGroup = "clipboard" pageNameModalQuit = "modal-quit"
modalGroupPullProgress modalGroup = "pull-progress" pageNameModalStartupCheck = "modal-startup-check"
pageNameModalClipboard = "modal-clipboard"
pageNameModalPullProgress = "modal-pull-progress"
pageNameModalRemoveDestination = "modal-remove-destination"
) )
func (ui *UI) showModal(group modalGroup, text string, buttons []string, doneFunc func(int, string)) { func (ui *UI) showModal(pageName string, text string, buttons []string, doneFunc func(int, string)) {
modalName := "modal-" + string(group) if ui.pages.HasPage(pageName) {
if ui.pages.HasPage(modalName) {
return return
} }
@ -458,9 +481,9 @@ func (ui *UI) showModal(group modalGroup, text string, buttons []string, doneFun
SetBackgroundColor(tcell.ColorBlack). SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite). SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(buttonIndex int, buttonLabel string) { SetDoneFunc(func(buttonIndex int, buttonLabel string) {
ui.pages.RemovePage(modalName) ui.pages.RemovePage(pageName)
if ui.pages.GetPageCount() == 1 { if name, _ := ui.pages.GetFrontPage(); name == pageNameMain {
ui.app.SetFocus(ui.destView) ui.app.SetFocus(ui.destView)
} }
@ -470,16 +493,15 @@ func (ui *UI) showModal(group modalGroup, text string, buttons []string, doneFun
}). }).
SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.pages.AddPage(modalName, modal, true, true) ui.pages.AddPage(pageName, modal, true, true)
} }
func (ui *UI) hideModal(group modalGroup) { func (ui *UI) hideModal(pageName string) {
modalName := "modal-" + string(group) if !ui.pages.HasPage(pageName) {
if !ui.pages.HasPage(modalName) {
return return
} }
ui.pages.RemovePage(modalName) ui.pages.RemovePage(pageName)
ui.app.SetFocus(ui.destView) ui.app.SetFocus(ui.destView)
} }
@ -637,6 +659,83 @@ func (ui *UI) Close() {
ui.app.Stop() ui.app.Stop()
} }
func (ui *UI) addDestination() {
// TODO: check for existing
const (
inputLen = 60
inputLabelName = "Name"
inputLabelURL = "RTMP URL"
formInnerWidth = inputLen + 8 + 1 // inputLen + length of longest label + one space
formInnerHeight = 7 // line count from first input field to last button
formWidth = formInnerWidth + 4
formHeight = formInnerHeight + 2
)
var currWidth, currHeight int
if name, frontPage := ui.pages.GetFrontPage(); name == pageNameMain {
_, _, currWidth, currHeight = frontPage.GetRect()
} else {
return
}
form := tview.NewForm()
form.
AddInputField(inputLabelName, "My stream", inputLen, nil, nil).
AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil).
AddButton("Add", func() {
ui.commandCh <- CommandAddDestination{
DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(),
URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(),
}
ui.closeDestinationForm()
}).
AddButton("Cancel", func() {
ui.closeDestinationForm()
}).
SetFieldBackgroundColor(tcell.ColorDarkSlateGrey).
SetBorder(true).
SetTitle("Add a new destination").
SetTitleAlign(tview.AlignLeft).
SetRect((currWidth-formWidth)/2, (currHeight-formHeight)/2, formWidth, formHeight)
ui.pages.AddPage(pageNameAddDestination, form, false, true)
}
func (ui *UI) removeDestination() {
const urlCol = 1
row, _ := ui.destView.GetSelection()
url, ok := ui.destView.GetCell(row, urlCol).GetReference().(string)
if !ok {
return
}
var started bool
ui.mu.Lock()
started = ui.urlsToStartState[url] != startStateNotStarted
ui.mu.Unlock()
text := "Are you sure you want to remove the destination?"
if started {
text += "\n\nThis will stop the current live stream for this destination."
}
ui.showModal(
pageNameModalRemoveDestination,
text,
[]string{"Remove", "Cancel"},
func(buttonIndex int, _ string) {
if buttonIndex == 0 {
ui.commandCh <- CommandRemoveDestination{URL: url}
}
},
)
}
func (ui *UI) closeDestinationForm() {
ui.pages.RemovePage(pageNameAddDestination)
ui.app.SetFocus(ui.destView)
}
func (ui *UI) toggleDestination() { func (ui *UI) toggleDestination() {
const urlCol = 1 const urlCol = 1
row, _ := ui.destView.GetSelection() row, _ := ui.destView.GetSelection()
@ -685,7 +784,7 @@ func (ui *UI) copySourceURLToClipboard(clipboardAvailable bool) {
} }
ui.showModal( ui.showModal(
modalGroupClipboard, pageNameModalClipboard,
text, text,
[]string{"Ok"}, []string{"Ok"},
nil, nil,
@ -706,7 +805,7 @@ func (ui *UI) copyConfigFilePathToClipboard(clipboardAvailable bool, configFileP
} }
ui.showModal( ui.showModal(
modalGroupClipboard, pageNameModalClipboard,
text, text,
[]string{"Ok"}, []string{"Ok"},
nil, nil,
@ -724,7 +823,7 @@ func (ui *UI) confirmQuit() {
} }
ui.showModal( ui.showModal(
modalGroupQuit, pageNameModalQuit,
"Are you sure you want to quit?", "Are you sure you want to quit?",
[]string{"Quit", "Cancel"}, []string{"Quit", "Cancel"},
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
@ -743,7 +842,7 @@ func (ui *UI) showAbout() {
} }
ui.showModal( ui.showModal(
modalGroupAbout, pageNameModalAbout,
fmt.Sprintf( fmt.Sprintf(
"%s: live stream multiplexer\n(c) Rob Watson\nhttps://git.netflux.io/rob/octoplex\n\nReleased under AGPL3.\n\nv%s (%s)\nBuilt on %s (%s).", "%s: live stream multiplexer\n(c) Rob Watson\nhttps://git.netflux.io/rob/octoplex\n\nReleased under AGPL3.\n\nv%s (%s)\nBuilt on %s (%s).",
domain.AppName, domain.AppName,

View File

@ -89,7 +89,7 @@ func run(ctx context.Context) error {
return app.Run( return app.Run(
ctx, ctx,
app.RunParams{ app.RunParams{
Config: cfg, ConfigService: configService,
DockerClient: dockerClient, DockerClient: dockerClient,
ClipboardAvailable: clipboardAvailable, ClipboardAvailable: clipboardAvailable,
ConfigFilePath: configService.Path(), ConfigFilePath: configService.Path(),