diff --git a/go.mod b/go.mod index 37260d81..fb807d30 100644 --- a/go.mod +++ b/go.mod @@ -475,6 +475,7 @@ require ( github.com/ultraware/funlen v0.2.0 // indirect github.com/ultraware/whitespace v0.2.0 // indirect github.com/urfave/cli/v2 v2.27.7 // indirect + github.com/urfave/cli/v3 v3.6.2 // indirect github.com/uudashr/gocognit v1.2.0 // indirect github.com/uudashr/iface v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index 001794f9..a851833c 100644 --- a/go.sum +++ b/go.sum @@ -1389,6 +1389,8 @@ github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givT github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= +github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= +github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA= github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U= diff --git a/pkg/cmd/combine.go b/pkg/cmd/combine.go index 960e23ba..a6500646 100644 --- a/pkg/cmd/combine.go +++ b/pkg/cmd/combine.go @@ -17,20 +17,26 @@ import ( func Combine(ctx context.Context, build *config.BuildFlags, allArgs []string) error { gstinit.InitGST() cli := &config.CLI{Build: build} - fs := cli.NewFlagSet("streamplace combine") - debugDir := fs.String("debug-dir", "", "directory to write debug files to") - err := cli.Parse(fs, allArgs) - if err != nil { - return err + var debugDir string + // Simple flag parsing for debug-dir + args := allArgs + for i, arg := range allArgs { + if arg == "--debug-dir" && i+1 < len(allArgs) { + debugDir = allArgs[i+1] + // Remove the flag from args + args = append(allArgs[:i], allArgs[i+2:]...) + break + } } - if *debugDir != "" { - err := os.MkdirAll(*debugDir, 0755) + + if debugDir != "" { + err := os.MkdirAll(debugDir, 0755) if err != nil { return fmt.Errorf("failed to create debug directory: %w", err) } } - log.Debug(context.Background(), "combine command: starting", "args", fs.Args()) + log.Debug(context.Background(), "combine command: starting", "args", args) ctx = log.WithDebugValue(ctx, cli.Debug) cryptoSigner, err := createSigner(ctx, cli) if err != nil { @@ -40,7 +46,10 @@ func Combine(ctx context.Context, build *config.BuildFlags, allArgs []string) er if err != nil { return err } - args := fs.Args() + + if len(args) < 2 { + return fmt.Errorf("usage: streamplace combine [--debug-dir dir] [input2...]") + } outFile := args[0] inputs := args[1:] log.Log(ctx, "combining segments", "outFile", outFile, "inputs", inputs) @@ -62,7 +71,7 @@ func Combine(ctx context.Context, build *config.BuildFlags, allArgs []string) er if err != nil { return err } - err = CheckCombined(ctx, cli, outFd, *debugDir) + err = CheckCombined(ctx, cli, outFd, debugDir) if err != nil { return err } diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index c916df2e..996bd010 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -21,8 +21,8 @@ import ( "github.com/bluesky-social/indigo/carstore" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/livepeer/go-livepeer/cmd/livepeer/starter" - "github.com/peterbourgon/ff/v3" "github.com/streamplace/oatproxy/pkg/oatproxy" + urfavecli "github.com/urfave/cli/v3" "stream.place/streamplace/pkg/aqhttp" "stream.place/streamplace/pkg/atproto" "stream.place/streamplace/pkg/bus" @@ -54,134 +54,67 @@ type jobFunc func(ctx context.Context, cli *config.CLI) error // parse the CLI and fire up an streamplace node! func start(build *config.BuildFlags, platformJobs []jobFunc) error { iroh_streamplace.InitLogging() - selfTest := len(os.Args) > 1 && os.Args[1] == "self-test" - err := media.RunSelfTest(context.Background()) - if err != nil { - if selfTest { - fmt.Println(err.Error()) - os.Exit(1) - } else { - retryCount, _ := strconv.Atoi(os.Getenv("STREAMPLACE_SELFTEST_RETRY")) - if retryCount >= 3 { - log.Error(context.Background(), "gstreamer self-test failed 3 times, giving up", "error", err) - return err - } - log.Log(context.Background(), "error in gstreamer self-test, attempting recovery", "error", err, "retry", retryCount+1) - os.Setenv("STREAMPLACE_SELFTEST_RETRY", strconv.Itoa(retryCount+1)) - err := syscall.Exec(os.Args[0], os.Args[1:], os.Environ()) - if err != nil { - log.Error(context.Background(), "error in gstreamer self-test, could not restart", "error", err) - return err - } - panic("invalid code path: exec succeeded but we're still here???") - } - } - if selfTest { - runtime.GC() - if err := pprof.Lookup("goroutine").WriteTo(os.Stderr, 2); err != nil { - log.Error(context.Background(), "error creating pprof", "error", err) - } - fmt.Println("self-test successful!") - os.Exit(0) - } - - if len(os.Args) > 1 && os.Args[1] == "stream" { - if len(os.Args) != 3 { - fmt.Println("usage: streamplace stream [user]") - os.Exit(1) - } - return Stream(os.Args[2]) - } - if len(os.Args) > 1 && os.Args[1] == "live" { - cli := config.CLI{Build: build} - fs := cli.NewFlagSet("streamplace live") - - err := cli.Parse(fs, os.Args[2:]) - if err != nil { - return err - } - - args := fs.Args() - if len(args) != 1 { - fmt.Println("usage: streamplace live [flags] [stream-key]") - os.Exit(1) - } - - return Live(args[0], cli.HTTPInternalAddr) - } - - if len(os.Args) > 1 && os.Args[1] == "sign" { - return Sign(context.Background()) - } - - if len(os.Args) > 1 && os.Args[1] == "whep" { - return WHEP(os.Args[2:]) - } - if len(os.Args) > 1 && os.Args[1] == "whip" { - return WHIP(os.Args[2:]) - } - - if len(os.Args) > 1 && os.Args[1] == "combine" { - return Combine(context.Background(), build, os.Args[2:]) - } - - if len(os.Args) > 1 && os.Args[1] == "split" { - cli := config.CLI{Build: build} - fs := cli.NewFlagSet("streamplace split") - - err := cli.Parse(fs, os.Args[2:]) + cli := config.CLI{Build: build} + app := cli.NewCommand("streamplace") + app.Usage = "decentralized live streaming platform" + app.Version = build.Version + app.Commands = []*urfavecli.Command{ + makeSelfTestCommand(build), + makeStreamCommand(build), + makeLiveCommand(build), + makeSignCommand(build), + makeWhepCommand(build), + makeWhipCommand(build), + makeCombineCommand(build), + makeSplitCommand(build), + makeLivepeerCommand(build), + makeMigrateCommand(build), + } + // Add the verbosity flag + app.Flags = append(app.Flags, &urfavecli.StringFlag{ + Name: "v", + Usage: "log verbosity level", + Value: "3", + }) + app.Before = func(ctx context.Context, cmd *urfavecli.Command) (context.Context, error) { + // Run self-test before starting + selfTest := cmd.Name == "self-test" + err := media.RunSelfTest(ctx) if err != nil { - return err - } - ctx := context.Background() - ctx = log.WithDebugValue(ctx, cli.Debug) - if len(fs.Args()) != 2 { - fmt.Println("usage: streamplace split [flags] [input file] [output directory]") - os.Exit(1) + if selfTest { + fmt.Println(err.Error()) + os.Exit(1) + } else { + retryCount, _ := strconv.Atoi(os.Getenv("STREAMPLACE_SELFTEST_RETRY")) + if retryCount >= 3 { + log.Error(ctx, "gstreamer self-test failed 3 times, giving up", "error", err) + return ctx, err + } + log.Log(ctx, "error in gstreamer self-test, attempting recovery", "error", err, "retry", retryCount+1) + os.Setenv("STREAMPLACE_SELFTEST_RETRY", strconv.Itoa(retryCount+1)) + err := syscall.Exec(os.Args[0], os.Args[1:], os.Environ()) + if err != nil { + log.Error(ctx, "error in gstreamer self-test, could not restart", "error", err) + return ctx, err + } + panic("invalid code path: exec succeeded but we're still here???") + } } - gstinit.InitGST() - return Split(ctx, fs.Args()[0], fs.Args()[1]) + return ctx, nil } - - if len(os.Args) > 1 && os.Args[1] == "self-test" { - err := media.RunSelfTest(context.Background()) - if err != nil { - fmt.Println(err.Error()) - os.Exit(1) - } - fmt.Println("self-test successful!") - os.Exit(0) + app.Action = func(ctx context.Context, cmd *urfavecli.Command) error { + return runMain(ctx, build, platformJobs, cmd, &cli) } - if len(os.Args) > 1 && os.Args[1] == "livepeer" { - lpfs := flag.NewFlagSet("livepeer", flag.ExitOnError) - _ = starter.NewLivepeerConfig(lpfs) - err = ff.Parse(lpfs, os.Args[2:], - ff.WithConfigFileFlag("config"), - ff.WithEnvVarPrefix("LP"), - ) - if err != nil { - return err - } - err = GoLivepeer(context.Background(), lpfs) - if err != nil { - log.Error(context.Background(), "error in livepeer", "error", err) - os.Exit(1) - } - os.Exit(0) - } + return app.Run(context.Background(), os.Args) +} +func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFunc, cmd *urfavecli.Command, cli *config.CLI) error { _ = flag.Set("logtostderr", "true") vFlag := flag.Lookup("v") - cli := config.CLI{Build: build} - fs := cli.NewFlagSet("streamplace") - verbosity := fs.String("v", "3", "log verbosity level") - version := fs.Bool("version", false, "print version and exit") - err = cli.Parse( - fs, os.Args[1:], - ) + err := cli.Validate(cmd) if err != nil { return err } @@ -190,9 +123,9 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { if err != nil { return err } - _ = vFlag.Value.Set(*verbosity) + verbosity := cmd.String("v") + _ = vFlag.Value.Set(verbosity) log.SetColorLogger(cli.Color) - ctx := context.Background() ctx = log.WithDebugValue(ctx, cli.Debug) log.Log(ctx, @@ -203,16 +136,14 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { "runtime.GOOS", runtime.GOOS, "runtime.GOARCH", runtime.GOARCH, "runtime.Version", runtime.Version()) - if *version { - return nil - } - signer, err := createSigner(ctx, &cli) + + signer, err := createSigner(ctx, cli) if err != nil { return err } if len(os.Args) > 1 && os.Args[1] == "migrate" { - return statedb.Migrate(&cli) + return statedb.Migrate(cli) } spmetrics.Version.WithLabelValues(build.Version).Inc() @@ -262,11 +193,11 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { if err != nil { return err } - state, err := statedb.MakeDB(ctx, &cli, noter, mod) + state, err := statedb.MakeDB(ctx, cli, noter, mod) if err != nil { return err } - handle, err := atproto.MakeLexiconRepo(ctx, &cli, mod, state) + handle, err := atproto.MakeLexiconRepo(ctx, cli, mod, state) if err != nil { return err } @@ -286,7 +217,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { b := bus.NewBus() atsync := &atproto.ATProtoSynchronizer{ - CLI: &cli, + CLI: cli, Model: mod, StatefulDB: state, Noter: noter, @@ -297,12 +228,12 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { return fmt.Errorf("failed to migrate: %w", err) } - mm, err := media.MakeMediaManager(ctx, &cli, signer, mod, b, atsync, ldb) + mm, err := media.MakeMediaManager(ctx, cli, signer, mod, b, atsync, ldb) if err != nil { return err } - ms, err := media.MakeMediaSigner(ctx, &cli, cli.StreamerName, signer, mod) + ms, err := media.MakeMediaSigner(ctx, cli, cli.StreamerName, signer, mod) if err != nil { return err } @@ -365,7 +296,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { return err } } - replicator, err = iroh_replicator.NewSwarm(ctx, &cli, secret, topic, mm, b, mod) + replicator, err = iroh_replicator.NewSwarm(ctx, cli, secret, topic, mm, b, mod) if err != nil { return err } @@ -387,8 +318,8 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { Public: cli.PublicOAuth, HTTPClient: &aqhttp.Client, }) - d := director.NewDirector(mm, mod, &cli, b, op, state, replicator, ldb) - a, err := api.MakeStreamplaceAPI(&cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb) + d := director.NewDirector(mm, mod, cli, b, op, state, replicator, ldb) + a, err := api.MakeStreamplaceAPI(cli, mod, state, noter, mm, ms, b, atsync, d, op, ldb) if err != nil { return err } @@ -418,11 +349,11 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { }) if cli.RTMPServerAddon != "" { group.Go(func() error { - return rtmps.ServeRTMPSAddon(ctx, &cli) + return rtmps.ServeRTMPSAddon(ctx, cli) }) } group.Go(func() error { - return a.ServeRTMPS(ctx, &cli) + return a.ServeRTMPS(ctx, cli) }) } else { group.Go(func() error { @@ -453,7 +384,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { }) group.Go(func() error { - return storage.StartSegmentCleaner(ctx, ldb, &cli) + return storage.StartSegmentCleaner(ctx, ldb, cli) }) group.Go(func() error { @@ -461,7 +392,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { }) group.Go(func() error { - return replicator.Start(ctx, &cli) + return replicator.Start(ctx, cli) }) if cli.LivepeerGateway { @@ -475,7 +406,14 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { return err } group.Go(func() error { - err := GoLivepeer(ctx, fs) + lpfs := flag.NewFlagSet("livepeer", flag.ExitOnError) + _ = starter.NewLivepeerConfig(lpfs) + // Parse livepeer flags from mainCmd + err := lpfs.Parse([]string{}) + if err != nil { + return err + } + err = GoLivepeer(ctx, lpfs) if err != nil { return err } @@ -497,7 +435,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { return err } did := atkey.DIDKey() - testMediaSigner, err := media.MakeMediaSigner(ctx, &cli, did, signer, mod) + testMediaSigner, err := media.MakeMediaSigner(ctx, cli, did, signer, mod) if err != nil { return err } @@ -524,7 +462,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { return err } did2 := atkey2.DIDKey() - intermittentMediaSigner, err := media.MakeMediaSigner(ctx, &cli, did2, signer, mod) + intermittentMediaSigner, err := media.MakeMediaSigner(ctx, cli, did2, signer, mod) if err != nil { return err } @@ -561,7 +499,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { for _, job := range platformJobs { group.Go(func() error { - return job(ctx, &cli) + return job(ctx, cli) }) } @@ -599,3 +537,139 @@ func handleSignals(ctx context.Context) error { } } } + +func makeSelfTestCommand(build *config.BuildFlags) *urfavecli.Command { + return &urfavecli.Command{ + Name: "self-test", + Usage: "run gstreamer self-test", + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + err := media.RunSelfTest(ctx) + if err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + runtime.GC() + if err := pprof.Lookup("goroutine").WriteTo(os.Stderr, 2); err != nil { + log.Error(ctx, "error creating pprof", "error", err) + } + fmt.Println("self-test successful!") + return nil + }, + } +} + +func makeStreamCommand(build *config.BuildFlags) *urfavecli.Command { + return &urfavecli.Command{ + Name: "stream", + Usage: "stream command", + ArgsUsage: "[user]", + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + args := cmd.Args() + if args.Len() != 1 { + return fmt.Errorf("usage: streamplace stream [user]") + } + return Stream(args.First()) + }, + } +} + +func makeLiveCommand(build *config.BuildFlags) *urfavecli.Command { + cli := config.CLI{Build: build} + liveCmd := cli.NewCommand("live") + liveCmd.Usage = "start live stream" + liveCmd.ArgsUsage = "[stream-key]" + liveCmd.Action = func(ctx context.Context, cmd *urfavecli.Command) error { + args := cmd.Args() + if args.Len() != 1 { + return fmt.Errorf("usage: streamplace live [flags] [stream-key]") + } + return Live(args.First(), cli.HTTPInternalAddr) + } + return liveCmd +} + +func makeSignCommand(build *config.BuildFlags) *urfavecli.Command { + return &urfavecli.Command{ + Name: "sign", + Usage: "sign command", + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + return Sign(ctx) + }, + } +} + +func makeWhepCommand(build *config.BuildFlags) *urfavecli.Command { + return &urfavecli.Command{ + Name: "whep", + Usage: "WHEP client", + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + return WHEP(cmd.Args().Slice()) + }, + } +} + +func makeWhipCommand(build *config.BuildFlags) *urfavecli.Command { + return &urfavecli.Command{ + Name: "whip", + Usage: "WHIP client", + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + return WHIP(cmd.Args().Slice()) + }, + } +} + +func makeCombineCommand(build *config.BuildFlags) *urfavecli.Command { + return &urfavecli.Command{ + Name: "combine", + Usage: "combine segments", + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + return Combine(ctx, build, cmd.Args().Slice()) + }, + } +} + +func makeSplitCommand(build *config.BuildFlags) *urfavecli.Command { + cli := config.CLI{Build: build} + splitCmd := cli.NewCommand("split") + splitCmd.Usage = "split video file" + splitCmd.ArgsUsage = "[input file] [output directory]" + splitCmd.Action = func(ctx context.Context, cmd *urfavecli.Command) error { + args := cmd.Args() + if args.Len() != 2 { + return fmt.Errorf("usage: streamplace split [flags] [input file] [output directory]") + } + ctx = log.WithDebugValue(ctx, cli.Debug) + gstinit.InitGST() + return Split(ctx, args.Get(0), args.Get(1)) + } + return splitCmd +} + +func makeLivepeerCommand(build *config.BuildFlags) *urfavecli.Command { + return &urfavecli.Command{ + Name: "livepeer", + Usage: "run livepeer gateway", + SkipFlagParsing: true, + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + args := cmd.Args().Slice() + lpfs := flag.NewFlagSet("livepeer", flag.ExitOnError) + _ = starter.NewLivepeerConfig(lpfs) + err := lpfs.Parse(args) + if err != nil { + return err + } + return GoLivepeer(ctx, lpfs) + }, + } +} + +func makeMigrateCommand(build *config.BuildFlags) *urfavecli.Command { + cli := config.CLI{Build: build} + return &urfavecli.Command{ + Name: "migrate", + Usage: "run database migrations", + Action: func(ctx context.Context, cmd *urfavecli.Command) error { + return statedb.Migrate(&cli) + }, + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index b7ece9b6..7cc35ea6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,13 +7,13 @@ import ( "encoding/json" "encoding/pem" "errors" - "flag" "fmt" "io" "net" "os" "path/filepath" "runtime" + "slices" "strconv" "strings" "time" @@ -21,10 +21,9 @@ import ( "math/rand/v2" "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/livepeer/go-livepeer/cmd/livepeer/starter" "github.com/lmittmann/tint" slogGorm "github.com/orandin/slog-gorm" - "github.com/peterbourgon/ff/v3" + urfavecli "github.com/urfave/cli/v3" "stream.place/streamplace/pkg/aqtime" "stream.place/streamplace/pkg/constants" "stream.place/streamplace/pkg/crypto/aqpub" @@ -160,109 +159,686 @@ const ( ReplicatorIroh string = "iroh" ) -func (cli *CLI) NewFlagSet(name string) *flag.FlagSet { - fs := flag.NewFlagSet("streamplace", flag.ExitOnError) - fs.StringVar(&cli.DataDir, "data-dir", DefaultDataDir(), "directory for keeping all streamplace data") - fs.StringVar(&cli.HTTPAddr, "http-addr", ":38080", "Public HTTP address") - fs.StringVar(&cli.HTTPInternalAddr, "http-internal-addr", "127.0.0.1:39090", "Private, admin-only HTTP address") - fs.StringVar(&cli.HTTPSAddr, "https-addr", ":38443", "Public HTTPS address") - fs.BoolVar(&cli.Secure, "secure", false, "Run with HTTPS. Required for WebRTC output") - cli.DataDirFlag(fs, &cli.TLSCertPath, "tls-cert", filepath.Join("tls", "tls.crt"), "Path to TLS certificate") - cli.DataDirFlag(fs, &cli.TLSKeyPath, "tls-key", filepath.Join("tls", "tls.key"), "Path to TLS key") - fs.StringVar(&cli.SigningKeyPath, "signing-key", "", "Path to signing key for pushing OTA updates to the app") - fs.StringVar(&cli.DBURL, "db-url", "sqlite://$SP_DATA_DIR/state.sqlite", "URL of the database to use for storing private streamplace state") +func (cli *CLI) NewCommand(name string) *urfavecli.Command { + cmd := &urfavecli.Command{ + Name: name, + Usage: "streamplace server", + Flags: []urfavecli.Flag{ + &urfavecli.StringFlag{ + Name: "data-dir", + Usage: "directory for keeping all streamplace data", + Value: DefaultDataDir(), + Destination: &cli.DataDir, + Sources: urfavecli.EnvVars("SP_DATA_DIR"), + }, + &urfavecli.StringFlag{ + Name: "http-addr", + Usage: "Public HTTP address", + Value: ":38080", + Destination: &cli.HTTPAddr, + Sources: urfavecli.EnvVars("SP_HTTP_ADDR"), + }, + &urfavecli.StringFlag{ + Name: "http-internal-addr", + Usage: "Private, admin-only HTTP address", + Value: "127.0.0.1:39090", + Destination: &cli.HTTPInternalAddr, + Sources: urfavecli.EnvVars("SP_HTTP_INTERNAL_ADDR"), + }, + &urfavecli.StringFlag{ + Name: "https-addr", + Usage: "Public HTTPS address", + Value: ":38443", + Destination: &cli.HTTPSAddr, + Sources: urfavecli.EnvVars("SP_HTTPS_ADDR"), + }, + &urfavecli.BoolFlag{ + Name: "secure", + Usage: "Run with HTTPS. Required for WebRTC output", + Value: false, + Destination: &cli.Secure, + Sources: urfavecli.EnvVars("SP_SECURE"), + }, + &urfavecli.StringFlag{ + Name: "tls-cert", + Usage: fmt.Sprintf(`Path to TLS certificate (default: "%s")`, filepath.Join(SPDataDir, "tls", "tls.crt")), + Destination: &cli.TLSCertPath, + Value: filepath.Join(SPDataDir, "tls", "tls.crt"), + Sources: urfavecli.EnvVars("SP_TLS_CERT"), + }, + &urfavecli.StringFlag{ + Name: "tls-key", + Usage: fmt.Sprintf(`Path to TLS key (default: "%s")`, filepath.Join(SPDataDir, "tls", "tls.key")), + Destination: &cli.TLSKeyPath, + Value: filepath.Join(SPDataDir, "tls", "tls.key"), + Sources: urfavecli.EnvVars("SP_TLS_KEY"), + }, + &urfavecli.StringFlag{ + Name: "signing-key", + Usage: "Path to signing key for pushing OTA updates to the app", + Destination: &cli.SigningKeyPath, + Sources: urfavecli.EnvVars("SP_SIGNING_KEY"), + }, + &urfavecli.StringFlag{ + Name: "db-url", + Usage: "URL of the database to use for storing private streamplace state", + Value: "sqlite://$SP_DATA_DIR/state.sqlite", + Destination: &cli.DBURL, + Sources: urfavecli.EnvVars("SP_DB_URL"), + }, + &urfavecli.StringFlag{ + Name: "admin-account", + Usage: "ethereum account that administrates this streamplace node", + Destination: &cli.AdminAccount, + Sources: urfavecli.EnvVars("SP_ADMIN_ACCOUNT"), + }, + &urfavecli.StringFlag{ + Name: "firebase-service-account", + Usage: "Base64-encoded JSON string of a firebase service account key", + Destination: &cli.FirebaseServiceAccount, + Sources: urfavecli.EnvVars("SP_FIREBASE_SERVICE_ACCOUNT"), + }, + &urfavecli.StringFlag{ + Name: "firebase-service-account-file", + Usage: "Path to a JSON file containing a firebase service account key", + Destination: &cli.FirebaseServiceAccountFile, + Sources: urfavecli.EnvVars("SP_FIREBASE_SERVICE_ACCOUNT_FILE"), + }, + &urfavecli.StringFlag{ + Name: "gitlab-url", + Usage: "gitlab url for generating download links", + Value: "https://git.stream.place/api/v4/projects/1", + Destination: &cli.GitLabURL, + Sources: urfavecli.EnvVars("SP_GITLAB_URL"), + }, + &urfavecli.StringFlag{ + Name: "eth-keystore-path", + Usage: fmt.Sprintf(`path to ethereum keystore (default: "%s")`, filepath.Join(SPDataDir, "keystore")), + Destination: &cli.EthKeystorePath, + Value: filepath.Join(SPDataDir, "keystore"), + Sources: urfavecli.EnvVars("SP_ETH_KEYSTORE_PATH"), + }, + &urfavecli.StringFlag{ + Name: "eth-account-addr", + Usage: "ethereum account address to use (if keystore contains more than one)", + Destination: &cli.EthAccountAddr, + Sources: urfavecli.EnvVars("SP_ETH_ACCOUNT_ADDR"), + }, + &urfavecli.StringFlag{ + Name: "eth-password", + Usage: "password for encrypting keystore", + Destination: &cli.EthPassword, + Sources: urfavecli.EnvVars("SP_ETH_PASSWORD"), + }, + &urfavecli.StringFlag{ + Name: "ta-url", + Usage: "timestamp authority server for signing", + Value: "http://timestamp.digicert.com", + Destination: &cli.TAURL, + Sources: urfavecli.EnvVars("SP_TA_URL"), + }, + &urfavecli.StringFlag{ + Name: "pkcs11-module-path", + Usage: "path to a PKCS11 module for HSM signing, for example /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so", + Destination: &cli.PKCS11ModulePath, + Sources: urfavecli.EnvVars("SP_PKCS11_MODULE_PATH"), + }, + &urfavecli.StringFlag{ + Name: "pkcs11-pin", + Usage: "PIN for logging into PKCS11 token. if not provided, will be prompted interactively", + Destination: &cli.PKCS11Pin, + Sources: urfavecli.EnvVars("SP_PKCS11_PIN"), + }, + &urfavecli.StringFlag{ + Name: "pkcs11-token-slot", + Usage: "slot number of PKCS11 token (only use one of slot, label, or serial)", + Destination: &cli.PKCS11TokenSlot, + Sources: urfavecli.EnvVars("SP_PKCS11_TOKEN_SLOT"), + }, + &urfavecli.StringFlag{ + Name: "pkcs11-token-label", + Usage: "label of PKCS11 token (only use one of slot, label, or serial)", + Destination: &cli.PKCS11TokenLabel, + Sources: urfavecli.EnvVars("SP_PKCS11_TOKEN_LABEL"), + }, + &urfavecli.StringFlag{ + Name: "pkcs11-token-serial", + Usage: "serial number of PKCS11 token (only use one of slot, label, or serial)", + Destination: &cli.PKCS11TokenSerial, + Sources: urfavecli.EnvVars("SP_PKCS11_TOKEN_SERIAL"), + }, + &urfavecli.StringFlag{ + Name: "pkcs11-keypair-label", + Usage: "label of signing keypair on PKCS11 token", + Destination: &cli.PKCS11KeypairLabel, + Sources: urfavecli.EnvVars("SP_PKCS11_KEYPAIR_LABEL"), + }, + &urfavecli.StringFlag{ + Name: "pkcs11-keypair-id", + Usage: "id of signing keypair on PKCS11 token", + Destination: &cli.PKCS11KeypairID, + Sources: urfavecli.EnvVars("SP_PKCS11_KEYPAIR_ID"), + }, + &urfavecli.StringFlag{ + Name: "app-bundle-id", + Usage: "bundle id of an app that we facilitate oauth login for", + Destination: &cli.AppBundleID, + Sources: urfavecli.EnvVars("SP_APP_BUNDLE_ID"), + }, + &urfavecli.StringFlag{ + Name: "streamer-name", + Usage: "name of the person streaming from this streamplace node", + Destination: &cli.StreamerName, + Sources: urfavecli.EnvVars("SP_STREAMER_NAME"), + }, + &urfavecli.StringFlag{ + Name: "dev-frontend-proxy", + Usage: "(FOR DEVELOPMENT ONLY) proxy frontend requests to this address instead of using the bundled frontend", + Destination: &cli.FrontendProxy, + Sources: urfavecli.EnvVars("SP_DEV_FRONTEND_PROXY"), + }, + &urfavecli.BoolFlag{ + Name: "dev-public-oauth", + Usage: "(FOR DEVELOPMENT ONLY) enable public oauth login for http://127.0.0.1 development", + Value: false, + Destination: &cli.PublicOAuth, + Sources: urfavecli.EnvVars("SP_DEV_PUBLIC_OAUTH"), + }, + &urfavecli.StringFlag{ + Name: "livepeer-gateway-url", + Usage: "URL of the Livepeer Gateway to use for transcoding", + Destination: &cli.LivepeerGatewayURL, + Sources: urfavecli.EnvVars("SP_LIVEPEER_GATEWAY_URL"), + }, + &urfavecli.BoolFlag{ + Name: "livepeer-gateway", + Usage: "enable embedded Livepeer Gateway", + Value: false, + Destination: &cli.LivepeerGateway, + Sources: urfavecli.EnvVars("SP_LIVEPEER_GATEWAY"), + }, + &urfavecli.BoolFlag{ + Name: "wide-open", + Usage: "allow ALL streams to be uploaded to this node (not recommended for production)", + Value: false, + Destination: &cli.WideOpen, + Sources: urfavecli.EnvVars("SP_WIDE_OPEN"), + }, + &urfavecli.StringFlag{ + Name: "allowed-streams", + Usage: `if set, only allow these addresses or atproto DIDs to upload to this node (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.AllowedStreams = strings.Split(s, ",") + return nil + }, + Sources: urfavecli.EnvVars("SP_ALLOWED_STREAMS"), + }, + &urfavecli.StringFlag{ + Name: "peers", + Usage: `other streamplace nodes to replicate to (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.Peers = strings.Split(s, ",") + return nil + }, + Sources: urfavecli.EnvVars("SP_PEERS"), + }, + &urfavecli.StringFlag{ + Name: "redirects", + Usage: `http 302s /path/one:/path/two,/path/three:/path/four (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.Redirects = strings.Split(s, ",") + return nil + }, + Sources: urfavecli.EnvVars("SP_REDIRECTS"), + }, + &urfavecli.StringFlag{ + Name: "debug", + Usage: "modified log verbosity for specific functions or files in form func=ToHLS:3,file=gstreamer.go:4", + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.Debug = map[string]map[string]int{} + pairs := strings.SplitSeq(s, ",") + for pair := range pairs { + scoreSplit := strings.Split(pair, ":") + if len(scoreSplit) != 2 { + return fmt.Errorf("invalid debug flag: %s", pair) + } + score, err := strconv.Atoi(scoreSplit[1]) + if err != nil { + return fmt.Errorf("invalid debug flag: %s", pair) + } + selectorSplit := strings.Split(scoreSplit[0], "=") + if len(selectorSplit) != 2 { + return fmt.Errorf("invalid debug flag: %s", pair) + } + _, ok := cli.Debug[selectorSplit[0]] + if !ok { + cli.Debug[selectorSplit[0]] = map[string]int{} + } + cli.Debug[selectorSplit[0]][selectorSplit[1]] = score + } + return nil + }, + Sources: urfavecli.EnvVars("SP_DEBUG"), + }, + &urfavecli.BoolFlag{ + Name: "test-stream", + Usage: "run a built-in test stream on boot", + Value: false, + Destination: &cli.TestStream, + Sources: urfavecli.EnvVars("SP_TEST_STREAM"), + }, + &urfavecli.BoolFlag{ + Name: "no-firehose", + Usage: "disable the bluesky firehose", + Value: false, + Destination: &cli.NoFirehose, + Sources: urfavecli.EnvVars("SP_NO_FIREHOSE"), + }, + &urfavecli.BoolFlag{ + Name: "print-chat", + Usage: "print chat messages to stdout", + Value: false, + Destination: &cli.PrintChat, + Sources: urfavecli.EnvVars("SP_PRINT_CHAT"), + }, + &urfavecli.StringFlag{ + Name: "whip-test", + Usage: "run a WHIP self-test with the given parameters", + Destination: &cli.WHIPTest, + Sources: urfavecli.EnvVars("SP_WHIP_TEST"), + }, + &urfavecli.StringFlag{ + Name: "relay-host", + Usage: "websocket url for relay firehose", + Value: "wss://bsky.network", + Destination: &cli.RelayHost, + Sources: urfavecli.EnvVars("SP_RELAY_HOST"), + }, + &urfavecli.StringFlag{ + Name: "color", + Usage: "'true' to enable colorized logging, 'false' to disable", + Destination: &cli.Color, + Sources: urfavecli.EnvVars("SP_COLOR"), + }, + &urfavecli.StringFlag{ + Name: "broadcaster-host", + Usage: "public host for the broadcaster group that this node is a part of (excluding https:// e.g. stream.place)", + Destination: &cli.BroadcasterHost, + Sources: urfavecli.EnvVars("SP_BROADCASTER_HOST"), + }, + &urfavecli.StringFlag{ + Name: "public-host", + Usage: "deprecated, use broadcaster-host or server-host instead as appropriate", + Destination: &cli.XXDeprecatedPublicHost, + Sources: urfavecli.EnvVars("SP_PUBLIC_HOST"), + }, + &urfavecli.StringFlag{ + Name: "server-host", + Usage: "public host for this particular physical streamplace node. defaults to broadcaster-host and only must be set for multi-node broadcasters", + Destination: &cli.ServerHost, + Sources: urfavecli.EnvVars("SP_SERVER_HOST"), + }, + &urfavecli.BoolFlag{ + Name: "thumbnail", + Usage: "enable thumbnail generation", + Value: true, + Destination: &cli.Thumbnail, + Sources: urfavecli.EnvVars("SP_THUMBNAIL"), + }, + &urfavecli.BoolFlag{ + Name: "smear-audio", + Usage: "enable audio smearing to create 'perfect' segment timestamps", + Value: false, + Destination: &cli.SmearAudio, + Sources: urfavecli.EnvVars("SP_SMEAR_AUDIO"), + }, + &urfavecli.StringFlag{ + Name: "tracing-endpoint", + Usage: "gRPC endpoint to send traces to", + Destination: &cli.TracingEndpoint, + Sources: urfavecli.EnvVars("SP_TRACING_ENDPOINT"), + }, + &urfavecli.IntFlag{ + Name: "rate-limit-per-second", + Usage: "rate limit for requests per second per ip", + Value: 0, + Destination: &cli.RateLimitPerSecond, + Sources: urfavecli.EnvVars("SP_RATE_LIMIT_PER_SECOND"), + }, + &urfavecli.IntFlag{ + Name: "rate-limit-burst", + Usage: "rate limit burst for requests per ip", + Value: 0, + Destination: &cli.RateLimitBurst, + Sources: urfavecli.EnvVars("SP_RATE_LIMIT_BURST"), + }, + &urfavecli.IntFlag{ + Name: "rate-limit-websocket", + Usage: "number of concurrent websocket connections allowed per ip", + Value: 10, + Destination: &cli.RateLimitWebsocket, + Sources: urfavecli.EnvVars("SP_RATE_LIMIT_WEBSOCKET"), + }, + &urfavecli.StringFlag{ + Name: "rtmp-server-addon", + Usage: "address of external RTMP server to forward streams to", + Destination: &cli.RTMPServerAddon, + Sources: urfavecli.EnvVars("SP_RTMP_SERVER_ADDON"), + }, + &urfavecli.StringFlag{ + Name: "rtmps-addon-addr", + Usage: "address to listen for RTMPS on the addon server", + Value: ":1936", + Destination: &cli.RTMPSAddonAddr, + Sources: urfavecli.EnvVars("SP_RTMPS_ADDON_ADDR"), + }, + &urfavecli.StringFlag{ + Name: "rtmps-addr", + Usage: "address to listen for RTMPS connections (when --secure=true)", + Value: ":1935", + Destination: &cli.RTMPSAddr, + Sources: urfavecli.EnvVars("SP_RTMPS_ADDR"), + }, + &urfavecli.StringFlag{ + Name: "rtmp-addr", + Usage: "address to listen for RTMP connections (when --secure=false)", + Value: ":1935", + Destination: &cli.RTMPAddr, + Sources: urfavecli.EnvVars("SP_RTMP_ADDR"), + }, + &urfavecli.StringFlag{ + Name: "discord-webhooks", + Usage: `JSON array of Discord webhooks to send notifications to (default: "[]")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + return json.Unmarshal([]byte(s), &cli.DiscordWebhooks) + }, + Sources: urfavecli.EnvVars("SP_DISCORD_WEBHOOKS"), + }, + &urfavecli.BoolFlag{ + Name: "new-webrtc-playback", + Usage: "enable new webrtc playback", + Value: true, + Destination: &cli.NewWebRTCPlayback, + Sources: urfavecli.EnvVars("SP_NEW_WEBRTC_PLAYBACK"), + }, + &urfavecli.StringFlag{ + Name: "apple-team-id", + Usage: "apple team id for deep linking", + Destination: &cli.AppleTeamID, + Sources: urfavecli.EnvVars("SP_APPLE_TEAM_ID"), + }, + &urfavecli.StringFlag{ + Name: "android-cert-fingerprint", + Usage: "android cert fingerprint for deep linking", + Destination: &cli.AndroidCertFingerprint, + Sources: urfavecli.EnvVars("SP_ANDROID_CERT_FINGERPRINT"), + }, + &urfavecli.StringFlag{ + Name: "labelers", + Usage: `did of labelers that this instance should subscribe to (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.Labelers = strings.Split(s, ",") + return nil + }, + Sources: urfavecli.EnvVars("SP_LABELERS"), + }, + &urfavecli.StringFlag{ + Name: "atproto-did", + Usage: "atproto did to respond to on /.well-known/atproto-did (default did:web:PUBLIC_HOST)", + Destination: &cli.AtprotoDID, + Sources: urfavecli.EnvVars("SP_ATPROTO_DID"), + }, + &urfavecli.StringFlag{ + Name: "content-filters", + Usage: `JSON content filtering rules (default: "{}")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + return json.Unmarshal([]byte(s), &cli.ContentFilters) + }, + Sources: urfavecli.EnvVars("SP_CONTENT_FILTERS"), + }, + &urfavecli.StringFlag{ + Name: "default-recommended-streamers", + Usage: `comma-separated list of streamer DIDs to recommend by default when no other recommendations are available (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.DefaultRecommendedStreamers = strings.Split(s, ",") + return nil + }, + Sources: urfavecli.EnvVars("SP_DEFAULT_RECOMMENDED_STREAMERS"), + }, + &urfavecli.BoolFlag{ + Name: "livepeer-help", + Usage: "print help for livepeer flags and exit", + Value: false, + Destination: &cli.LivepeerHelp, + Sources: urfavecli.EnvVars("SP_LIVEPEER_HELP"), + }, + &urfavecli.StringFlag{ + Name: "plc-url", + Usage: "url of the plc directory", + Value: "https://plc.directory", + Destination: &cli.PLCURL, + Sources: urfavecli.EnvVars("SP_PLC_URL"), + }, + &urfavecli.BoolFlag{ + Name: "sql-logging", + Usage: "enable sql logging", + Value: false, + Destination: &cli.SQLLogging, + Sources: urfavecli.EnvVars("SP_SQL_LOGGING"), + }, + &urfavecli.StringFlag{ + Name: "sentry-dsn", + Usage: "sentry dsn for error reporting", + Destination: &cli.SentryDSN, + Sources: urfavecli.EnvVars("SP_SENTRY_DSN"), + }, + &urfavecli.BoolFlag{ + Name: "livepeer-debug", + Usage: "log livepeer segments to $SP_DATA_DIR/livepeer-debug", + Value: false, + Destination: &cli.LivepeerDebug, + Sources: urfavecli.EnvVars("SP_LIVEPEER_DEBUG"), + }, + &urfavecli.StringFlag{ + Name: "segment-debug-dir", + Usage: "directory to log segment validation to", + Destination: &cli.SegmentDebugDir, + Sources: urfavecli.EnvVars("SP_SEGMENT_DEBUG_DIR"), + }, + &urfavecli.StringFlag{ + Name: "tickets", + Usage: `tickets to join the swarm with (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.Tickets = strings.Split(s, ",") + return nil + }, + Sources: urfavecli.EnvVars("SP_TICKETS"), + }, + &urfavecli.StringFlag{ + Name: "iroh-topic", + Usage: "topic to use for the iroh swarm (must be 32 bytes in hex)", + Destination: &cli.IrohTopic, + Sources: urfavecli.EnvVars("SP_IROH_TOPIC"), + }, + &urfavecli.BoolFlag{ + Name: "disable-iroh-relay", + Usage: "disable the iroh relay", + Value: false, + Destination: &cli.DisableIrohRelay, + Sources: urfavecli.EnvVars("SP_DISABLE_IROH_RELAY"), + }, + &urfavecli.StringFlag{ + Name: "dev-account-creds", + Usage: `(FOR DEVELOPMENT ONLY) did=password pairs for logging into test accounts without oauth (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.DevAccountCreds = map[string]string{} + pairs := strings.Split(s, ",") + for _, pair := range pairs { + parts := strings.Split(pair, "=") + if len(parts) != 2 { + return fmt.Errorf("invalid kv flag: %s", pair) + } + cli.DevAccountCreds[parts[0]] = parts[1] + } + return nil + }, + Sources: urfavecli.EnvVars("SP_DEV_ACCOUNT_CREDS"), + }, + &urfavecli.DurationFlag{ + Name: "stream-session-timeout", + Usage: "how long to wait before considering a stream inactive on this node?", + Value: 60 * time.Second, + Destination: &cli.StreamSessionTimeout, + Sources: urfavecli.EnvVars("SP_STREAM_SESSION_TIMEOUT"), + }, + &urfavecli.StringFlag{ + Name: "replicators", + Usage: "comma-separated list of replication protocols to use (websocket, iroh)", + Value: ReplicatorWebsocket, + Sources: urfavecli.EnvVars("SP_REPLICATORS"), + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s != "" { + cli.Replicators = strings.Split(s, ",") + } + return nil + }, + }, + &urfavecli.StringFlag{ + Name: "websocket-url", + Usage: "override the websocket (ws:// or wss://) url to use for replication (normally not necessary, used for testing)", + Destination: &cli.WebsocketURL, + Sources: urfavecli.EnvVars("SP_WEBSOCKET_URL"), + }, + &urfavecli.BoolFlag{ + Name: "behind-https-proxy", + Usage: "set to true if this node is behind an https proxy and we should report https URLs even though the node isn't serving HTTPS", + Value: false, + Destination: &cli.BehindHTTPSProxy, + Sources: urfavecli.EnvVars("SP_BEHIND_HTTPS_PROXY"), + }, + &urfavecli.StringFlag{ + Name: "admin-dids", + Usage: `comma-separated list of DIDs that are authorized to modify branding and other admin operations (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.AdminDIDs = strings.Split(s, ",") + return nil + }, + Sources: urfavecli.EnvVars("SP_ADMIN_DIDS"), + }, + &urfavecli.StringFlag{ + Name: "syndicate", + Usage: `list of DIDs that we should rebroadcast ('*' for everybody) (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.Syndicate = strings.Split(s, ",") + return nil + }, + Sources: urfavecli.EnvVars("SP_SYNDICATE"), + }, + &urfavecli.BoolFlag{ + Name: "player-telemetry", + Usage: "enable player telemetry", + Value: true, + Destination: &cli.PlayerTelemetry, + Sources: urfavecli.EnvVars("SP_PLAYER_TELEMETRY"), + }, + &urfavecli.StringFlag{ + Name: "local-db-url", + Usage: "URL of the local database to use for storing local data", + Value: "sqlite://$SP_DATA_DIR/localdb.sqlite", + Destination: &cli.LocalDBURL, + Sources: urfavecli.EnvVars("SP_LOCAL_DB_URL"), + }, + &urfavecli.BoolFlag{ + Name: "external-signing", + Usage: "DEPRECATED, does nothing.", + Value: true, + }, + &urfavecli.BoolFlag{ + Name: "insecure", + Usage: "DEPRECATED, does nothing.", + Value: false, + }, + }, + Before: func(ctx context.Context, cmd *urfavecli.Command) (context.Context, error) { + return ctx, cli.Validate(cmd) + }, + } + + // Add data dir flags cli.dataDirFlags = append(cli.dataDirFlags, &cli.DBURL) - fs.StringVar(&cli.AdminAccount, "admin-account", "", "ethereum account that administrates this streamplace node") - fs.StringVar(&cli.FirebaseServiceAccount, "firebase-service-account", "", "Base64-encoded JSON string of a firebase service account key") - fs.StringVar(&cli.FirebaseServiceAccountFile, "firebase-service-account-file", "", "Path to a JSON file containing a firebase service account key") - fs.StringVar(&cli.GitLabURL, "gitlab-url", "https://git.stream.place/api/v4/projects/1", "gitlab url for generating download links") - cli.DataDirFlag(fs, &cli.EthKeystorePath, "eth-keystore-path", "keystore", "path to ethereum keystore") - fs.StringVar(&cli.EthAccountAddr, "eth-account-addr", "", "ethereum account address to use (if keystore contains more than one)") - fs.StringVar(&cli.EthPassword, "eth-password", "", "password for encrypting keystore") - fs.StringVar(&cli.TAURL, "ta-url", "http://timestamp.digicert.com", "timestamp authority server for signing") - fs.StringVar(&cli.PKCS11ModulePath, "pkcs11-module-path", "", "path to a PKCS11 module for HSM signing, for example /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so") - fs.StringVar(&cli.PKCS11Pin, "pkcs11-pin", "", "PIN for logging into PKCS11 token. if not provided, will be prompted interactively") - fs.StringVar(&cli.PKCS11TokenSlot, "pkcs11-token-slot", "", "slot number of PKCS11 token (only use one of slot, label, or serial)") - fs.StringVar(&cli.PKCS11TokenLabel, "pkcs11-token-label", "", "label of PKCS11 token (only use one of slot, label, or serial)") - fs.StringVar(&cli.PKCS11TokenSerial, "pkcs11-token-serial", "", "serial number of PKCS11 token (only use one of slot, label, or serial)") - fs.StringVar(&cli.PKCS11KeypairLabel, "pkcs11-keypair-label", "", "label of signing keypair on PKCS11 token") - fs.StringVar(&cli.PKCS11KeypairID, "pkcs11-keypair-id", "", "id of signing keypair on PKCS11 token") - fs.StringVar(&cli.AppBundleID, "app-bundle-id", "", "bundle id of an app that we facilitate oauth login for") - fs.StringVar(&cli.StreamerName, "streamer-name", "", "name of the person streaming from this streamplace node") - fs.StringVar(&cli.FrontendProxy, "dev-frontend-proxy", "", "(FOR DEVELOPMENT ONLY) proxy frontend requests to this address instead of using the bundled frontend") - fs.BoolVar(&cli.PublicOAuth, "dev-public-oauth", false, "(FOR DEVELOPMENT ONLY) enable public oauth login for http://127.0.0.1 development") - fs.StringVar(&cli.LivepeerGatewayURL, "livepeer-gateway-url", "", "URL of the Livepeer Gateway to use for transcoding") - fs.BoolVar(&cli.LivepeerGateway, "livepeer-gateway", false, "enable embedded Livepeer Gateway") - fs.BoolVar(&cli.WideOpen, "wide-open", false, "allow ALL streams to be uploaded to this node (not recommended for production)") - cli.StringSliceFlag(fs, &cli.AllowedStreams, "allowed-streams", []string{}, "if set, only allow these addresses or atproto DIDs to upload to this node") - cli.StringSliceFlag(fs, &cli.Peers, "peers", []string{}, "other streamplace nodes to replicate to") - cli.StringSliceFlag(fs, &cli.Redirects, "redirects", []string{}, "http 302s /path/one:/path/two,/path/three:/path/four") - cli.DebugFlag(fs, &cli.Debug, "debug", "", "modified log verbosity for specific functions or files in form func=ToHLS:3,file=gstreamer.go:4") - fs.BoolVar(&cli.TestStream, "test-stream", false, "run a built-in test stream on boot") - fs.BoolVar(&cli.NoFirehose, "no-firehose", false, "disable the bluesky firehose") - fs.BoolVar(&cli.PrintChat, "print-chat", false, "print chat messages to stdout") - fs.StringVar(&cli.WHIPTest, "whip-test", "", "run a WHIP self-test with the given parameters") - fs.StringVar(&cli.RelayHost, "relay-host", "wss://bsky.network", "websocket url for relay firehose") - fs.StringVar(&cli.Color, "color", "", "'true' to enable colorized logging, 'false' to disable") - fs.StringVar(&cli.BroadcasterHost, "broadcaster-host", "", "public host for the broadcaster group that this node is a part of (excluding https:// e.g. stream.place)") - fs.StringVar(&cli.XXDeprecatedPublicHost, "public-host", "", "deprecated, use broadcaster-host or server-host instead as appropriate") - fs.StringVar(&cli.ServerHost, "server-host", "", "public host for this particular physical streamplace node. defaults to broadcaster-host and only must be set for multi-node broadcasters") - fs.BoolVar(&cli.Thumbnail, "thumbnail", true, "enable thumbnail generation") - fs.BoolVar(&cli.SmearAudio, "smear-audio", false, "enable audio smearing to create 'perfect' segment timestamps") - - fs.StringVar(&cli.TracingEndpoint, "tracing-endpoint", "", "gRPC endpoint to send traces to") - fs.IntVar(&cli.RateLimitPerSecond, "rate-limit-per-second", 0, "rate limit for requests per second per ip") - fs.IntVar(&cli.RateLimitBurst, "rate-limit-burst", 0, "rate limit burst for requests per ip") - fs.IntVar(&cli.RateLimitWebsocket, "rate-limit-websocket", 10, "number of concurrent websocket connections allowed per ip") - fs.StringVar(&cli.RTMPServerAddon, "rtmp-server-addon", "", "address of external RTMP server to forward streams to") - fs.StringVar(&cli.RTMPSAddonAddr, "rtmps-addon-addr", ":1936", "address to listen for RTMPS on the addon server") - fs.StringVar(&cli.RTMPSAddr, "rtmps-addr", ":1935", "address to listen for RTMPS connections (when --secure=true)") - fs.StringVar(&cli.RTMPAddr, "rtmp-addr", ":1935", "address to listen for RTMP connections (when --secure=false)") - cli.JSONFlag(fs, &cli.DiscordWebhooks, "discord-webhooks", "[]", "JSON array of Discord webhooks to send notifications to") - fs.BoolVar(&cli.NewWebRTCPlayback, "new-webrtc-playback", true, "enable new webrtc playback") - fs.StringVar(&cli.AppleTeamID, "apple-team-id", "", "apple team id for deep linking") - fs.StringVar(&cli.AndroidCertFingerprint, "android-cert-fingerprint", "", "android cert fingerprint for deep linking") - cli.StringSliceFlag(fs, &cli.Labelers, "labelers", []string{}, "did of labelers that this instance should subscribe to") - fs.StringVar(&cli.AtprotoDID, "atproto-did", "", "atproto did to respond to on /.well-known/atproto-did (default did:web:PUBLIC_HOST)") - cli.JSONFlag(fs, &cli.ContentFilters, "content-filters", "{}", "JSON content filtering rules") - cli.StringSliceFlag(fs, &cli.DefaultRecommendedStreamers, "default-recommended-streamers", []string{}, "comma-separated list of streamer DIDs to recommend by default when no other recommendations are available") - fs.BoolVar(&cli.LivepeerHelp, "livepeer-help", false, "print help for livepeer flags and exit") - fs.StringVar(&cli.PLCURL, "plc-url", "https://plc.directory", "url of the plc directory") - fs.BoolVar(&cli.SQLLogging, "sql-logging", false, "enable sql logging") - fs.StringVar(&cli.SentryDSN, "sentry-dsn", "", "sentry dsn for error reporting") - fs.BoolVar(&cli.LivepeerDebug, "livepeer-debug", false, "log livepeer segments to $SP_DATA_DIR/livepeer-debug") - fs.StringVar(&cli.SegmentDebugDir, "segment-debug-dir", "", "directory to log segment validation to") - cli.StringSliceFlag(fs, &cli.Tickets, "tickets", []string{}, "tickets to join the swarm with") - fs.StringVar(&cli.IrohTopic, "iroh-topic", "", "topic to use for the iroh swarm (must be 32 bytes in hex)") - fs.BoolVar(&cli.DisableIrohRelay, "disable-iroh-relay", false, "disable the iroh relay") - cli.KVSliceFlag(fs, &cli.DevAccountCreds, "dev-account-creds", "", "(FOR DEVELOPMENT ONLY) did=password pairs for logging into test accounts without oauth") - fs.DurationVar(&cli.StreamSessionTimeout, "stream-session-timeout", 60*time.Second, "how long to wait before considering a stream inactive on this node?") - cli.StringSliceFlag(fs, &cli.Replicators, "replicators", []string{ReplicatorWebsocket}, "list of replication protocols to use (http, iroh)") - fs.StringVar(&cli.WebsocketURL, "websocket-url", "", "override the websocket (ws:// or wss://) url to use for replication (normally not necessary, used for testing)") - fs.BoolVar(&cli.BehindHTTPSProxy, "behind-https-proxy", false, "set to true if this node is behind an https proxy and we should report https URLs even though the node isn't serving HTTPS") - cli.StringSliceFlag(fs, &cli.AdminDIDs, "admin-dids", []string{}, "comma-separated list of DIDs that are authorized to modify branding and other admin operations") - cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)") - fs.BoolVar(&cli.PlayerTelemetry, "player-telemetry", true, "enable player telemetry") - fs.StringVar(&cli.LocalDBURL, "local-db-url", "sqlite://$SP_DATA_DIR/localdb.sqlite", "URL of the local database to use for storing local data") cli.dataDirFlags = append(cli.dataDirFlags, &cli.LocalDBURL) - - fs.Bool("external-signing", true, "DEPRECATED, does nothing.") - fs.Bool("insecure", false, "DEPRECATED, does nothing.") - - lpFlags := flag.NewFlagSet("livepeer", flag.ContinueOnError) - _ = starter.NewLivepeerConfig(lpFlags) - lpFlags.VisitAll(func(f *flag.Flag) { - adapted := LivepeerFlags.CamelToSnake[f.Name] - fs.Var(f.Value, fmt.Sprintf("livepeer.%s", adapted), f.Usage) - }) + cli.dataDirFlags = append(cli.dataDirFlags, &cli.TLSCertPath) + cli.dataDirFlags = append(cli.dataDirFlags, &cli.TLSKeyPath) + cli.dataDirFlags = append(cli.dataDirFlags, &cli.EthKeystorePath) if runtime.GOOS == "linux" { - fs.BoolVar(&cli.NoMist, "no-mist", true, "Disable MistServer") - fs.IntVar(&cli.MistAdminPort, "mist-admin-port", 14242, "MistServer admin port (internal use only)") - fs.IntVar(&cli.MistRTMPPort, "mist-rtmp-port", 11935, "MistServer RTMP port (internal use only)") - fs.IntVar(&cli.MistHTTPPort, "mist-http-port", 18080, "MistServer HTTP port (internal use only)") + cmd.Flags = append(cmd.Flags, &urfavecli.BoolFlag{ + Name: "no-mist", + Usage: "Disable MistServer", + Value: true, + Destination: &cli.NoMist, + Sources: urfavecli.EnvVars("SP_NO_MIST"), + }) + cmd.Flags = append(cmd.Flags, &urfavecli.IntFlag{ + Name: "mist-admin-port", + Usage: "MistServer admin port (internal use only)", + Value: 14242, + Destination: &cli.MistAdminPort, + Sources: urfavecli.EnvVars("SP_MIST_ADMIN_PORT"), + }) + cmd.Flags = append(cmd.Flags, &urfavecli.IntFlag{ + Name: "mist-rtmp-port", + Usage: "MistServer RTMP port (internal use only)", + Value: 11935, + Destination: &cli.MistRTMPPort, + Sources: urfavecli.EnvVars("SP_MIST_RTMP_PORT"), + }) + cmd.Flags = append(cmd.Flags, &urfavecli.IntFlag{ + Name: "mist-http-port", + Usage: "MistServer HTTP port (internal use only)", + Value: 18080, + Destination: &cli.MistHTTPPort, + Sources: urfavecli.EnvVars("SP_MIST_HTTP_PORT"), + }) } - return fs + + return cmd } var StreamplaceSchemePrefix = "streamplace://" @@ -350,14 +926,7 @@ func EnableSQLLogging() { ) } -func (cli *CLI) Parse(fs *flag.FlagSet, args []string) error { - err := ff.Parse( - fs, args, - ff.WithEnvVarPrefix("SP"), - ) - if err != nil { - return err - } +func (cli *CLI) Validate(cmd *urfavecli.Command) error { if cli.DataDir == "" { return fmt.Errorf("could not determine default data dir (no $HOME) and none provided, please set --data-dir") } @@ -366,32 +935,8 @@ func (cli *CLI) Parse(fs *flag.FlagSet, args []string) error { } if cli.LivepeerGateway { log.MonkeypatchStderr() - gatewayPath := cli.DataFilePath([]string{"livepeer", "gateway"}) - err = fs.Set("livepeer.rtmp-addr", "127.0.0.1:0") - if err != nil { - return err - } - err = fs.Set("livepeer.data-dir", gatewayPath) - if err != nil { - return err - } - err = fs.Set("livepeer.gateway", "true") - if err != nil { - return err - } - httpAddrFlag := fs.Lookup("livepeer.http-addr") - if httpAddrFlag == nil { - return fmt.Errorf("livepeer.http-addr not found") - } - httpAddr := httpAddrFlag.Value.String() - if httpAddr == "" { - httpAddr = "127.0.0.1:8935" - err = fs.Set("livepeer.http-addr", httpAddr) - if err != nil { - return err - } - } - cli.LivepeerGatewayURL = fmt.Sprintf("http://%s", httpAddr) + // Livepeer gateway configuration will be handled in the caller + cli.LivepeerGatewayURL = "http://127.0.0.1:8935" } for _, dest := range cli.dataDirFlags { *dest = strings.Replace(*dest, SPDataDir, cli.DataDir, 1) @@ -421,6 +966,10 @@ func (cli *CLI) Parse(fs *flag.FlagSet, args []string) error { } cli.FirebaseServiceAccount = string(bs) } + // Set default replicator if none specified + if len(cli.Replicators) == 0 { + cli.Replicators = []string{ReplicatorWebsocket} + } return nil } @@ -529,112 +1078,34 @@ func (cli *CLI) DataFileRead(fpath []string, w io.Writer) error { return nil } -func (cli *CLI) DataDirFlag(fs *flag.FlagSet, dest *string, name, defaultValue, usage string) { - cli.dataDirFlags = append(cli.dataDirFlags, dest) - *dest = filepath.Join(SPDataDir, defaultValue) - usage = fmt.Sprintf(`%s (default: "%s")`, usage, *dest) - fs.Func(name, usage, func(s string) error { - *dest = s - return nil - }) -} - func (cli *CLI) HasMist() bool { return runtime.GOOS == "linux" } // type for comma-separated ethereum addresses -func (cli *CLI) AddressSliceFlag(fs *flag.FlagSet, dest *[]aqpub.Pub, name, defaultValue, usage string) { +func (cli *CLI) AddressSliceFlag(name, defaultValue, usage string, dest *[]aqpub.Pub) urfavecli.Flag { *dest = []aqpub.Pub{} - usage = fmt.Sprintf(`%s (default: "%s")`, usage, *dest) - fs.Func(name, usage, func(s string) error { - if s == "" { - return nil - } - strs := strings.Split(s, ",") - for _, str := range strs { - pub, err := aqpub.FromHexString(str) - if err != nil { - return err - } - *dest = append(*dest, pub) - } - return nil - }) -} - -func (cli *CLI) StringSliceFlag(fs *flag.FlagSet, dest *[]string, name string, defaultValue []string, usage string) { - *dest = defaultValue - usage = fmt.Sprintf(`%s (default: "%s")`, usage, *dest) - fs.Func(name, usage, func(s string) error { - if s == "" { - return nil - } - strs := strings.Split(s, ",") - *dest = append([]string{}, strs...) - return nil - }) -} - -func (cli *CLI) KVSliceFlag(fs *flag.FlagSet, dest *map[string]string, name, defaultValue, usage string) { - *dest = map[string]string{} - usage = fmt.Sprintf(`%s (default: "%s")`, usage, *dest) - fs.Func(name, usage, func(s string) error { - if s == "" { - return nil - } - pairs := strings.Split(s, ",") - for _, pair := range pairs { - parts := strings.Split(pair, "=") - if len(parts) != 2 { - return fmt.Errorf("invalid kv flag: %s", pair) - } - (*dest)[parts[0]] = parts[1] - } - return nil - }) -} - -func (cli *CLI) JSONFlag(fs *flag.FlagSet, dest any, name, defaultValue, usage string) { usage = fmt.Sprintf(`%s (default: "%s")`, usage, defaultValue) - fs.Func(name, usage, func(s string) error { - if s == "" { - return nil - } - return json.Unmarshal([]byte(s), dest) - }) -} -// debug flag for turning func=ToHLS:3,file=gstreamer.go:4 into {"func": {"ToHLS": 3}, "file": {"gstreamer.go": 4}} -func (cli *CLI) DebugFlag(fs *flag.FlagSet, dest *map[string]map[string]int, name, defaultValue, usage string) { - *dest = map[string]map[string]int{} - fs.Func(name, usage, func(s string) error { - if s == "" { - return nil - } - pairs := strings.Split(s, ",") - for _, pair := range pairs { - scoreSplit := strings.Split(pair, ":") - if len(scoreSplit) != 2 { - return fmt.Errorf("invalid debug flag: %s", pair) + return &urfavecli.StringFlag{ + Name: name, + Usage: usage, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil } - score, err := strconv.Atoi(scoreSplit[1]) - if err != nil { - return fmt.Errorf("invalid debug flag: %s", pair) + strs := strings.Split(s, ",") + for _, str := range strs { + pub, err := aqpub.FromHexString(str) + if err != nil { + return err + } + *dest = append(*dest, pub) } - selectorSplit := strings.Split(scoreSplit[0], "=") - if len(selectorSplit) != 2 { - return fmt.Errorf("invalid debug flag: %s", pair) - } - _, ok := (*dest)[selectorSplit[0]] - if !ok { - (*dest)[selectorSplit[0]] = map[string]int{} - } - (*dest)[selectorSplit[0]][selectorSplit[1]] = score - } - - return nil - }) + return nil + }, + Sources: urfavecli.EnvVars(fmt.Sprintf("SP_%s", strings.ToUpper(strings.ReplaceAll(name, "-", "_")))), + } } func (cli *CLI) StreamIsAllowed(did string) error { @@ -648,10 +1119,8 @@ func (cli *CLI) StreamIsAllowed(did string) error { if openServer && !isDIDKey { return nil } - for _, a := range cli.AllowedStreams { - if a == did { - return nil - } + if slices.Contains(cli.AllowedStreams, did) { + return nil } return fmt.Errorf("user is not allowed to stream") } diff --git a/pkg/media/segment_roundtrip_test.go b/pkg/media/segment_roundtrip_test.go index c3820631..7b52af61 100644 --- a/pkg/media/segment_roundtrip_test.go +++ b/pkg/media/segment_roundtrip_test.go @@ -87,9 +87,11 @@ func TestSegmentRoundtrip(t *testing.T) { require.NoError(t, err) signedSplitSegDir := makeTestSubdir(t, tempDir, "signed-split-segments") - cli := &config.CLI{} - fs := cli.NewFlagSet("rtcrec-test") - err = cli.Parse(fs, []string{}) + cli := &config.CLI{ + DataDir: tempDir, // Set data dir for test + } + cmd := cli.NewCommand("rtcrec-test") + err = cli.Validate(cmd) require.NoError(t, err) err = SplitSegments(context.Background(), cli, rws, func(fname string) ReadWriteSeekCloser { fd, err := os.Create(filepath.Join(signedSplitSegDir, fname)) -- 2.51.2 From 520f4f6f32a63b4634a955ac1f1b31c37e065e47 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 10 Feb 2026 14:42:36 -0800 Subject: [PATCH 02/19] config: fix --dev-frontend-proxy (needs false now) --- .../guides/start-contributing/streamplace-dev-setup.md | 2 +- pkg/config/config.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/js/docs/src/content/docs/guides/start-contributing/streamplace-dev-setup.md b/js/docs/src/content/docs/guides/start-contributing/streamplace-dev-setup.md index b1d5b25b..a44aa32e 100644 --- a/js/docs/src/content/docs/guides/start-contributing/streamplace-dev-setup.md +++ b/js/docs/src/content/docs/guides/start-contributing/streamplace-dev-setup.md @@ -57,7 +57,7 @@ exclusively backend changes — and you want to launch the node with the embedde frontend, you can override the pertinent command line argument: ```shell -make dev && ./build-darwin-arm64/streamplace --dev-frontend-proxy="" +make dev && ./build-darwin-arm64/streamplace --dev-frontend-proxy=false ``` If you're using a proxy server, you may want to set your tunnel URL as the diff --git a/pkg/config/config.go b/pkg/config/config.go index 7cc35ea6..024d888b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -336,6 +336,14 @@ func (cli *CLI) NewCommand(name string) *urfavecli.Command { Usage: "(FOR DEVELOPMENT ONLY) proxy frontend requests to this address instead of using the bundled frontend", Destination: &cli.FrontendProxy, Sources: urfavecli.EnvVars("SP_DEV_FRONTEND_PROXY"), + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "false" { + cli.FrontendProxy = "" + return nil + } + cli.FrontendProxy = s + return nil + }, }, &urfavecli.BoolFlag{ Name: "dev-public-oauth", -- 2.51.2 From 018ed419c838b3afe9bc144fd2b7c6783a05fdcd Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 10 Feb 2026 15:51:35 -0800 Subject: [PATCH 03/19] config: fix livepeer stuff --- go.mod | 5 +++-- go.sum | 6 ------ pkg/cmd/go_livepeer.go | 33 ++------------------------------- pkg/cmd/streamplace.go | 33 +++++++++------------------------ pkg/config/config.go | 19 +++++++++++++++++++ 5 files changed, 33 insertions(+), 63 deletions(-) diff --git a/go.mod b/go.mod index fb807d30..7b3f4ca0 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ replace github.com/gocql/gocql => github.com/scylladb/gocql v1.14.4 replace github.com/AxisCommunications/go-dpop => github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 +replace github.com/livepeer/go-livepeer => ../go-livepeer + tool github.com/bluesky-social/indigo/cmd/lexgen require ( @@ -48,7 +50,6 @@ require ( github.com/multiformats/go-multihash v0.2.3 github.com/orandin/slog-gorm v1.4.0 github.com/patrickmn/go-cache v2.1.0+incompatible - github.com/peterbourgon/ff/v3 v3.4.0 github.com/pion/interceptor v0.1.37 github.com/pion/rtcp v1.2.16 github.com/pion/webrtc/v4 v4.0.11 @@ -64,6 +65,7 @@ require ( github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b github.com/stretchr/testify v1.11.1 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 + github.com/urfave/cli/v3 v3.6.2 github.com/whyrusleeping/cbor-gen v0.3.1 github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 gitlab.com/gitlab-org/release-cli v0.18.0 @@ -475,7 +477,6 @@ require ( github.com/ultraware/funlen v0.2.0 // indirect github.com/ultraware/whitespace v0.2.0 // indirect github.com/urfave/cli/v2 v2.27.7 // indirect - github.com/urfave/cli/v3 v3.6.2 // indirect github.com/uudashr/gocognit v1.2.0 // indirect github.com/uudashr/iface v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index a851833c..32b084e2 100644 --- a/go.sum +++ b/go.sum @@ -923,8 +923,6 @@ github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0 github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= github.com/libp2p/go-netroute v0.2.2 h1:Dejd8cQ47Qx2kRABg6lPwknU7+nBnFRpko45/fFPuZ8= github.com/libp2p/go-netroute v0.2.2/go.mod h1:Rntq6jUAH0l9Gg17w5bFGhcC9a+vk4KNXs6s7IljKYE= -github.com/livepeer/go-livepeer v0.8.7-0.20250811105915-31d2e1f89f81 h1:ObaUiy1Jl5n7yTG96sVnU+SDqntYRZi45qdebl6RoXk= -github.com/livepeer/go-livepeer v0.8.7-0.20250811105915-31d2e1f89f81/go.mod h1:3sDsXYtDJLKcwCE8TmYjjj6yz2mPQXBHa+24u2PfWh8= github.com/livepeer/go-tools v0.3.6-0.20240130205227-92479de8531b h1:VQcnrqtCA2UROp7q8ljkh2XA/u0KRgVv0S1xoUvOweE= github.com/livepeer/go-tools v0.3.6-0.20240130205227-92479de8531b/go.mod h1:hwJ5DKhl+pTanFWl+EUpw1H7ukPO/H+MFpgA7jjshzw= github.com/livepeer/joy4 v0.1.2-0.20191121080656-b2fea45cbded h1:ZQlvR5RB4nfT+cOQee+WqmaDOgGtP2oDMhcVvR4L0yA= @@ -1080,8 +1078,6 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk= github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw= -github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= -github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= @@ -1317,8 +1313,6 @@ github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac h github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac/go.mod h1:9LlKkqciiO5lRfbX0n4Wn5KNY9nvFb4R3by8FdW2TWc= github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAyNnkwfuZubgfeZy8rkWmA0cMtH5Z0HqNc= github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= -github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f h1:hhbQ8CtcAZVlLit/r7b9QDK7qEgOth4hgE13xV6ViBI= -github.com/streamplace/oatproxy v0.0.0-20260112011721-d74b4913c93f/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b h1:BB/R1egvkEqZhGeKL3tqAlTn0mkoOaaMY6r6s18XJYA= github.com/streamplace/oatproxy v0.0.0-20260130124113-420429019d3b/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/pkg/cmd/go_livepeer.go b/pkg/cmd/go_livepeer.go index d3bebfde..50eea7ab 100644 --- a/pkg/cmd/go_livepeer.go +++ b/pkg/cmd/go_livepeer.go @@ -3,32 +3,12 @@ package cmd import ( "context" "flag" - "strings" - "github.com/golang/glog" "github.com/livepeer/go-livepeer/cmd/livepeer/starter" "stream.place/streamplace/pkg/config" ) func GoLivepeer(ctx context.Context, fs *flag.FlagSet) error { - lpfs := flag.NewFlagSet("livepeer", flag.ExitOnError) - cfg := starter.NewLivepeerConfig(lpfs) - fs.VisitAll(func(f *flag.Flag) { - if !strings.HasPrefix(f.Name, "livepeer.") { - return - } - name := strings.TrimPrefix(f.Name, "livepeer.") - adapted := config.LivepeerFlags.SnakeToCamel[name] - - if adapted == "" { - panic("unknown livepeer flag: " + name) - } - err := lpfs.Set(adapted, f.Value.String()) - if err != nil { - panic(err) - } - }) - err := flag.Set("logtostderr", "true") if err != nil { return err @@ -39,18 +19,9 @@ func GoLivepeer(ctx context.Context, fs *flag.FlagSet) error { return err } - // Config file - // err = ff.Parse(fs, args, - // ff.WithConfigFileFlag("config"), - // ff.WithEnvVarPrefix("SP_LIVEPEER"), - // ) - if err != nil { - glog.Exit("Error parsing config: ", err) - } - - cfg = starter.UpdateNilsForUnsetFlags(cfg) + config.LivepeerConfig = starter.UpdateNilsForUnsetFlags(config.LivepeerConfig) - starter.StartLivepeer(ctx, cfg) + starter.StartLivepeer(ctx, config.LivepeerConfig) return nil } diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index 996bd010..53fe5d2a 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -72,11 +72,11 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { makeMigrateCommand(build), } // Add the verbosity flag - app.Flags = append(app.Flags, &urfavecli.StringFlag{ - Name: "v", - Usage: "log verbosity level", - Value: "3", - }) + // app.Flags = append(app.Flags, &urfavecli.StringFlag{ + // Name: "v", + // Usage: "log verbosity level", + // Value: "3", + // }) app.Before = func(ctx context.Context, cmd *urfavecli.Command) (context.Context, error) { // Run self-test before starting selfTest := cmd.Name == "self-test" @@ -406,14 +406,7 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu return err } group.Go(func() error { - lpfs := flag.NewFlagSet("livepeer", flag.ExitOnError) - _ = starter.NewLivepeerConfig(lpfs) - // Parse livepeer flags from mainCmd - err := lpfs.Parse([]string{}) - if err != nil { - return err - } - err = GoLivepeer(ctx, lpfs) + err = GoLivepeer(ctx, config.LivepeerFlagSet) if err != nil { return err } @@ -647,18 +640,10 @@ func makeSplitCommand(build *config.BuildFlags) *urfavecli.Command { func makeLivepeerCommand(build *config.BuildFlags) *urfavecli.Command { return &urfavecli.Command{ - Name: "livepeer", - Usage: "run livepeer gateway", - SkipFlagParsing: true, + Name: "livepeer", + Usage: "run livepeer gateway", Action: func(ctx context.Context, cmd *urfavecli.Command) error { - args := cmd.Args().Slice() - lpfs := flag.NewFlagSet("livepeer", flag.ExitOnError) - _ = starter.NewLivepeerConfig(lpfs) - err := lpfs.Parse(args) - if err != nil { - return err - } - return GoLivepeer(ctx, lpfs) + return GoLivepeer(ctx, config.LivepeerFlagSet) }, } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 024d888b..b9e268be 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,6 +7,7 @@ import ( "encoding/json" "encoding/pem" "errors" + "flag" "fmt" "io" "net" @@ -21,6 +22,7 @@ import ( "math/rand/v2" "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/livepeer/go-livepeer/cmd/livepeer/starter" "github.com/lmittmann/tint" slogGorm "github.com/orandin/slog-gorm" urfavecli "github.com/urfave/cli/v3" @@ -159,6 +161,9 @@ const ( ReplicatorIroh string = "iroh" ) +var LivepeerFlagSet *flag.FlagSet +var LivepeerConfig starter.LivepeerConfig + func (cli *CLI) NewCommand(name string) *urfavecli.Command { cmd := &urfavecli.Command{ Name: name, @@ -846,6 +851,20 @@ func (cli *CLI) NewCommand(name string) *urfavecli.Command { }) } + LivepeerFlagSet = flag.NewFlagSet("livepeer", flag.ContinueOnError) + LivepeerConfig = starter.NewLivepeerConfig(LivepeerFlagSet) + LivepeerFlagSet.VisitAll(func(f *flag.Flag) { + adapted := LivepeerFlags.CamelToSnake[f.Name] + cmd.Flags = append(cmd.Flags, &urfavecli.StringFlag{ + Name: fmt.Sprintf("livepeer.%s", adapted), + Usage: f.Usage, + Sources: urfavecli.EnvVars(fmt.Sprintf("SP_LIVEPEER_%s", adapted)), + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + return LivepeerFlagSet.Set(f.Name, s) + }, + }) + }) + return cmd } -- 2.51.2 From b3af6adea928cdea488242bf3aaad9bedf7f4f89 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:08:59 -0600 Subject: [PATCH 04/19] parse whip/whep in the subcommand flags --- pkg/cmd/streamplace.go | 71 ++++++++++++++++++++++++++++++++++++++++-- pkg/cmd/whep.go | 22 +++---------- pkg/cmd/whip.go | 34 ++++++-------------- 3 files changed, 83 insertions(+), 44 deletions(-) diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index 53fe5d2a..8e82e4bb 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -498,7 +498,10 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu if cli.WHIPTest != "" { group.Go(func() error { - err := WHIP(strings.Split(cli.WHIPTest, " ")) + // Parse WHIPTest string using the whip command's flag parser + whipCmd := makeWhipCommand(build) + args := strings.Split(cli.WHIPTest, " ") + err := whipCmd.Run(ctx, append([]string{"streamplace", "whip"}, args...)) log.Warn(ctx, "WHIP test complete, sleeping for 3 seconds and shutting down gstreamer") time.Sleep(time.Second * 3) // gst.Deinit() @@ -595,8 +598,28 @@ func makeWhepCommand(build *config.BuildFlags) *urfavecli.Command { return &urfavecli.Command{ Name: "whep", Usage: "WHEP client", + Flags: []urfavecli.Flag{ + &urfavecli.IntFlag{ + Name: "count", + Usage: "number of concurrent streams (for load testing)", + Value: 1, + }, + &urfavecli.DurationFlag{ + Name: "duration", + Usage: "stop after this long", + }, + &urfavecli.StringFlag{ + Name: "endpoint", + Usage: "endpoint to send the WHEP request to", + }, + }, Action: func(ctx context.Context, cmd *urfavecli.Command) error { - return WHEP(cmd.Args().Slice()) + return WHEP( + ctx, + cmd.Int("count"), + cmd.Duration("duration"), + cmd.String("endpoint"), + ) }, } } @@ -605,8 +628,50 @@ func makeWhipCommand(build *config.BuildFlags) *urfavecli.Command { return &urfavecli.Command{ Name: "whip", Usage: "WHIP client", + Flags: []urfavecli.Flag{ + &urfavecli.StringFlag{ + Name: "stream-key", + Usage: "stream key", + }, + &urfavecli.IntFlag{ + Name: "count", + Usage: "number of concurrent streams (for load testing)", + Value: 1, + }, + &urfavecli.IntFlag{ + Name: "viewers", + Usage: "number of viewers to simulate per stream", + }, + &urfavecli.DurationFlag{ + Name: "duration", + Usage: "duration of the stream", + }, + &urfavecli.StringFlag{ + Name: "file", + Usage: "file to stream (needs to be an MP4 containing H264 video and Opus audio)", + Required: true, + }, + &urfavecli.StringFlag{ + Name: "endpoint", + Usage: "endpoint to send the WHIP request to", + Value: "http://127.0.0.1:38080", + }, + &urfavecli.DurationFlag{ + Name: "freeze-after", + Usage: "freeze the stream after the given duration", + }, + }, Action: func(ctx context.Context, cmd *urfavecli.Command) error { - return WHIP(cmd.Args().Slice()) + return WHIP( + ctx, + cmd.String("stream-key"), + cmd.Int("count"), + cmd.Int("viewers"), + cmd.Duration("duration"), + cmd.String("file"), + cmd.String("endpoint"), + cmd.Duration("freeze-after"), + ) }, } } diff --git a/pkg/cmd/whep.go b/pkg/cmd/whep.go index bfc40a11..a30ca0b2 100644 --- a/pkg/cmd/whep.go +++ b/pkg/cmd/whep.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "flag" "fmt" "io" "net/http" @@ -15,27 +14,16 @@ import ( "stream.place/streamplace/pkg/log" ) -func WHEP(args []string) error { - fs := flag.NewFlagSet("whep", flag.ExitOnError) - count := fs.Int("count", 1, "number of concurrent streams (for load testing)") - duration := fs.Duration("duration", 0, "stop after this long") - endpoint := fs.String("endpoint", "", "endpoint to send the WHEP request to") - err := fs.Parse(args) - - if err != nil { - return err - } - - ctx := context.Background() - if *duration > 0 { +func WHEP(ctx context.Context, count int, duration time.Duration, endpoint string) error { + if duration > 0 { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *duration) + ctx, cancel = context.WithTimeout(ctx, duration) defer cancel() } w := &WHEPClient{ - Endpoint: *endpoint, - Count: *count, + Endpoint: endpoint, + Count: count, } return w.WHEP(ctx) diff --git a/pkg/cmd/whip.go b/pkg/cmd/whip.go index 9a377a48..2f67e8b6 100644 --- a/pkg/cmd/whip.go +++ b/pkg/cmd/whip.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "flag" "fmt" "io" "net/http" @@ -20,38 +19,25 @@ import ( "stream.place/streamplace/pkg/media" ) -func WHIP(args []string) error { - fs := flag.NewFlagSet("whip", flag.ExitOnError) - streamKey := fs.String("stream-key", "", "stream key") - count := fs.Int("count", 1, "number of concurrent streams (for load testing)") - viewers := fs.Int("viewers", 0, "number of viewers to simulate per stream") - duration := fs.Duration("duration", 0, "duration of the stream") - file := fs.String("file", "", "file to stream (needs to be an MP4 containing H264 video and Opus audio)") - endpoint := fs.String("endpoint", "http://127.0.0.1:38080", "endpoint to send the WHIP request to") - freezeAfter := fs.Duration("freeze-after", 0, "freeze the stream after the given duration") - err := fs.Parse(args) - if *file == "" { +func WHIP(ctx context.Context, streamKey string, count int, viewers int, duration time.Duration, file string, endpoint string, freezeAfter time.Duration) error { + if file == "" { return fmt.Errorf("file is required") } - if err != nil { - return err - } gstinit.InitGST() - ctx := context.Background() - if *duration > 0 { + if duration > 0 { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *duration) + ctx, cancel = context.WithTimeout(ctx, duration) defer cancel() } w := &WHIPClient{ - StreamKey: *streamKey, - File: *file, - Endpoint: *endpoint, - Count: *count, - FreezeAfter: *freezeAfter, - Viewers: *viewers, + StreamKey: streamKey, + File: file, + Endpoint: endpoint, + Count: count, + FreezeAfter: freezeAfter, + Viewers: viewers, } return w.WHIP(ctx) -- 2.51.2 From b48009b3e7e11b76797400a8fa55aaa347728465 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:12:54 -0600 Subject: [PATCH 05/19] convert sign over too --- pkg/cmd/sign.go | 38 +++++++++++++------------------------- pkg/cmd/streamplace.go | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/pkg/cmd/sign.go b/pkg/cmd/sign.go index 6f5c1f05..7e5967c1 100644 --- a/pkg/cmd/sign.go +++ b/pkg/cmd/sign.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/ecdsa" - "flag" "fmt" "io" "os" @@ -16,29 +15,18 @@ import ( "stream.place/streamplace/pkg/media" ) -func Sign(ctx context.Context) error { - fs := flag.NewFlagSet("streamplace", flag.ExitOnError) - certPath := fs.String("cert", "", "path to the certificate file") - key := fs.String("key", "", "base58-encoded secp256k1 private key") - streamerName := fs.String("streamer", "", "streamer name") - taURL := fs.String("ta-url", "http://timestamp.digicert.com", "timestamp authority server for signing") - startTime := fs.Int64("start-time", 0, "start time of the stream") - manifestJSON := fs.String("manifest", "", "JSON manifest to use for signing") - if err := fs.Parse(os.Args[2:]); err != nil { - return err - } - +func Sign(ctx context.Context, certPath string, key string, streamerName string, taURL string, startTime int64, manifestJSON string) error { log.Debug(ctx, "Sign command: starting", - "streamer", *streamerName, - "startTime", *startTime, - "hasManifest", len(*manifestJSON) > 0) + "streamer", streamerName, + "startTime", startTime, + "hasManifest", len(manifestJSON) > 0) - keyBs, err := base58.Decode(*key) + keyBs, err := base58.Decode(key) if err != nil { return err } - if *streamerName == "" { + if streamerName == "" { return fmt.Errorf("streamer name is required") } @@ -48,7 +36,7 @@ func Sign(ctx context.Context) error { } signer := secpSigner.ToECDSA() - certBs, err := os.ReadFile(*certPath) + certBs, err := os.ReadFile(certPath) if err != nil { return err } @@ -61,14 +49,14 @@ func Sign(ctx context.Context) error { ms := &media.MediaSignerLocal{ Signer: signer, Cert: certBs, - StreamerName: *streamerName, - TAURL: *taURL, + StreamerName: streamerName, + TAURL: taURL, AQPub: pub, - PrebuiltManifest: []byte(*manifestJSON), // Pass the manifest from parent process + PrebuiltManifest: []byte(manifestJSON), // Pass the manifest from parent process } - if len(*manifestJSON) > 0 { - log.Debug(ctx, "Sign command: using provided manifest", "manifestLength", len(*manifestJSON)) + if len(manifestJSON) > 0 { + log.Debug(ctx, "Sign command: using provided manifest", "manifestLength", len(manifestJSON)) } inputBs, err := io.ReadAll(os.Stdin) @@ -76,7 +64,7 @@ func Sign(ctx context.Context) error { return err } - mp4, err := ms.SignMP4(ctx, bytes.NewReader(inputBs), *startTime) + mp4, err := ms.SignMP4(ctx, bytes.NewReader(inputBs), startTime) if err != nil { return err } diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index 8e82e4bb..bfac2d92 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -588,8 +588,43 @@ func makeSignCommand(build *config.BuildFlags) *urfavecli.Command { return &urfavecli.Command{ Name: "sign", Usage: "sign command", + Flags: []urfavecli.Flag{ + &urfavecli.StringFlag{ + Name: "cert", + Usage: "path to the certificate file", + }, + &urfavecli.StringFlag{ + Name: "key", + Usage: "base58-encoded secp256k1 private key", + }, + &urfavecli.StringFlag{ + Name: "streamer", + Usage: "streamer name", + }, + &urfavecli.StringFlag{ + Name: "ta-url", + Usage: "timestamp authority server for signing", + Value: "http://timestamp.digicert.com", + }, + &urfavecli.IntFlag{ + Name: "start-time", + Usage: "start time of the stream", + }, + &urfavecli.StringFlag{ + Name: "manifest", + Usage: "JSON manifest to use for signing", + }, + }, Action: func(ctx context.Context, cmd *urfavecli.Command) error { - return Sign(ctx) + return Sign( + ctx, + cmd.String("cert"), + cmd.String("key"), + cmd.String("streamer"), + cmd.String("ta-url"), + int64(cmd.Int("start-time")), + cmd.String("manifest"), + ) }, } } -- 2.51.2 From d94a5ab85b10f9f4c32f5b0014b57f3cf0390e24 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:40:15 -0600 Subject: [PATCH 06/19] unalias livepeer package --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 7b3f4ca0..82ac35b1 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ replace github.com/gocql/gocql => github.com/scylladb/gocql v1.14.4 replace github.com/AxisCommunications/go-dpop => github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 -replace github.com/livepeer/go-livepeer => ../go-livepeer +//replace github.com/livepeer/go-livepeer => ../go-livepeer tool github.com/bluesky-social/indigo/cmd/lexgen diff --git a/go.sum b/go.sum index 32b084e2..a2f54da9 100644 --- a/go.sum +++ b/go.sum @@ -923,6 +923,8 @@ github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0 github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= github.com/libp2p/go-netroute v0.2.2 h1:Dejd8cQ47Qx2kRABg6lPwknU7+nBnFRpko45/fFPuZ8= github.com/libp2p/go-netroute v0.2.2/go.mod h1:Rntq6jUAH0l9Gg17w5bFGhcC9a+vk4KNXs6s7IljKYE= +github.com/livepeer/go-livepeer v0.8.7-0.20250811105915-31d2e1f89f81 h1:ObaUiy1Jl5n7yTG96sVnU+SDqntYRZi45qdebl6RoXk= +github.com/livepeer/go-livepeer v0.8.7-0.20250811105915-31d2e1f89f81/go.mod h1:3sDsXYtDJLKcwCE8TmYjjj6yz2mPQXBHa+24u2PfWh8= github.com/livepeer/go-tools v0.3.6-0.20240130205227-92479de8531b h1:VQcnrqtCA2UROp7q8ljkh2XA/u0KRgVv0S1xoUvOweE= github.com/livepeer/go-tools v0.3.6-0.20240130205227-92479de8531b/go.mod h1:hwJ5DKhl+pTanFWl+EUpw1H7ukPO/H+MFpgA7jjshzw= github.com/livepeer/joy4 v0.1.2-0.20191121080656-b2fea45cbded h1:ZQlvR5RB4nfT+cOQee+WqmaDOgGtP2oDMhcVvR4L0yA= -- 2.51.2 From 7dda81f9ec0fc170050de23b6cc86884ced49328 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:32:45 -0600 Subject: [PATCH 07/19] Propagate errors in chat --- js/components/src/components/chat/chat-box.tsx | 4 +++- .../src/components/chat/system-message.tsx | 14 ++++++++++++-- js/components/src/livestream-store/chat.tsx | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/js/components/src/components/chat/chat-box.tsx b/js/components/src/components/chat/chat-box.tsx index ea554617..a84a317e 100644 --- a/js/components/src/components/chat/chat-box.tsx +++ b/js/components/src/components/chat/chat-box.tsx @@ -24,6 +24,7 @@ import { w, } from "../../lib/theme/atoms"; import { + useAddSystemMessage, useChat, useCreateChatMessage, useLivestream, @@ -76,6 +77,7 @@ export function ChatBox({ const chat = useChat(); const createChatMessage = useCreateChatMessage(); + const addSystemMessage = useAddSystemMessage(); const replyTo = useReplyToMessage(); const setReplyToMessage = useSetReplyToMessage(); const textAreaRef = useRef(null); @@ -280,7 +282,7 @@ export function ChatBox({ if (result.handled) { if (result.error) { console.error("Slash command error:", result.error); - SystemMessages.commandError(result.error); + addSystemMessage(SystemMessages.commandError(result.error)); } return; } diff --git a/js/components/src/components/chat/system-message.tsx b/js/components/src/components/chat/system-message.tsx index 760c5abb..a29d2ad2 100644 --- a/js/components/src/components/chat/system-message.tsx +++ b/js/components/src/components/chat/system-message.tsx @@ -1,7 +1,7 @@ import { View } from "react-native"; import { Main } from "streamplace/src/lexicons/types/place/stream/richtext/facet"; import { SystemMessageType } from "../../lib/system-messages"; -import { colors, flex, gap, layout, ml, pb, pl, px, w } from "../../ui"; +import { bg, colors, flex, gap, layout, ml, pb, pl, px, r, w } from "../../ui"; import { Code, Text } from "../ui/text"; import { RichTextMessage } from "./chat-message"; @@ -18,8 +18,18 @@ export function SystemMessage({ timestamp, facets, }: SystemMessageProps) { + const isError = variant === SystemMessageType.command_error; + return ( - + SYSTEM MESSAGE diff --git a/js/components/src/livestream-store/chat.tsx b/js/components/src/livestream-store/chat.tsx index 4c9e1978..de064166 100644 --- a/js/components/src/livestream-store/chat.tsx +++ b/js/components/src/livestream-store/chat.tsx @@ -155,6 +155,18 @@ export const useDeleteChatMessage = () => { }; }; +export const useAddSystemMessage = () => { + const store = getStoreFromContext(); + return useCallback( + (message: ChatMessageViewHydrated) => { + const state = store.getState(); + const newState = reduceChat(state, [message], []); + store.setState(newState); + }, + [store], + ); +}; + const buildSortedChatList = ( chatIndex: { [key: string]: ChatMessageViewHydrated }, existingChatList: ChatMessageViewHydrated[], -- 2.51.2 From c9f03a16cb155351e0ea6a990b64afb16b9e3a5f Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:59:35 -0600 Subject: [PATCH 08/19] teleport dialog v0 --- .../src/components/chat/chat-box.tsx | 42 +++- .../src/components/chat/teleport-modal.tsx | 200 ++++++++++++++++++ js/components/src/components/ui/dialog.tsx | 8 + .../src/lib/slash-commands/teleport.ts | 68 ++++++ 4 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 js/components/src/components/chat/teleport-modal.tsx diff --git a/js/components/src/components/chat/chat-box.tsx b/js/components/src/components/chat/chat-box.tsx index ea554617..ddf0a159 100644 --- a/js/components/src/components/chat/chat-box.tsx +++ b/js/components/src/components/chat/chat-box.tsx @@ -7,7 +7,10 @@ import { Platform, Pressable, TextInput } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; import { Button, Loader, Text, toast, useTheme, View } from "../../"; import { handleSlashCommand } from "../../lib/slash-commands"; -import { registerTeleportCommand } from "../../lib/slash-commands/teleport"; +import { + createTeleport, + registerTeleportCommand, +} from "../../lib/slash-commands/teleport"; import { StreamNotifications } from "../../lib/stream-notifications"; import { SystemMessages } from "../../lib/system-messages"; import { @@ -36,6 +39,7 @@ import { Textarea } from "../ui/textarea"; import { RenderChatMessage } from "./chat-message"; import { EmojiData, EmojiSuggestions } from "./emoji-suggestions"; import { MentionSuggestions } from "./mention-suggestions"; +import { TeleportModal } from "./teleport-modal"; const COOL_EMOJI_LIST = [ // @ts-ignore we can iterate through this just fine it seems @@ -68,6 +72,7 @@ export function ChatBox({ new Map(), ); const [filteredEmojis, setFilteredEmojis] = useState([]); + const [showTeleportModal, setShowTeleportModal] = useState(false); const isOverLimit = graphemer.countGraphemes(message) > 300; let linfo = useLivestream(); @@ -88,7 +93,9 @@ export function ChatBox({ useEffect(() => { if (pdsAgent && userDID) { - registerTeleportCommand(pdsAgent, userDID, setActiveTeleportUri); + registerTeleportCommand(pdsAgent, userDID, setActiveTeleportUri, () => + setShowTeleportModal(true), + ); } }, [pdsAgent, userDID, setActiveTeleportUri]); @@ -105,7 +112,12 @@ export function ChatBox({ useEffect(() => { if (pdsAgent && linfo?.author?.did && pdsAgent.did === linfo.author.did) { - registerTeleportCommand(pdsAgent, pdsAgent.did, setActiveTeleportUri); + registerTeleportCommand( + pdsAgent, + pdsAgent.did, + setActiveTeleportUri, + () => setShowTeleportModal(true), + ); } }, [pdsAgent, linfo?.author?.did, setActiveTeleportUri]); @@ -121,6 +133,25 @@ export function ChatBox({ setShowEmojiSuggestions(false); }; + const handleTeleportSubmit = async ( + targetHandle: string, + countdownSeconds: number, + ) => { + if (!pdsAgent || !userDID) return; + + const result = await createTeleport( + pdsAgent, + userDID, + targetHandle, + countdownSeconds, + setActiveTeleportUri, + ); + + if (!result.success && result.error) { + SystemMessages.commandError(result.error); + } + }; + const updateSuggestions = (text: string) => { // Handle mentions const atIndex = text.lastIndexOf("@"); @@ -321,6 +352,11 @@ export function ChatBox({ return ( + {replyTo && ( void; + onSubmit: (targetHandle: string, countdownSeconds: number) => void; +} + +export const TeleportModal: React.FC = ({ + open, + onOpenChange, + onSubmit, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedStream, setSelectedStream] = + useState(null); + const [countdownSeconds, setCountdownSeconds] = useState("10"); + + const { theme } = useTheme(); + + const liveUsersCache = useStreamplaceStore((state) => state.liveUsers); + const liveUsersLoading = useStreamplaceStore( + (state) => state.liveUsersLoading, + ); + + const [liveUsers, setLiveUsers] = useState(liveUsersCache); + + useEffect(() => { + setLiveUsers(liveUsersCache); + }, [liveUsersCache]); + + const profiles = useAvatars(liveUsers?.map((u) => u.author?.did || "") || []); + + const filteredStreams = useMemo(() => { + if (!liveUsers) return []; + if (!searchQuery.trim()) return liveUsers; + + const query = searchQuery.toLowerCase(); + return liveUsers.filter( + (stream) => + stream.author?.handle?.toLowerCase().includes(query) || + stream.author?.displayName?.toLowerCase().includes(query), + ); + }, [liveUsers, searchQuery]); + + const handleCancel = () => { + setSearchQuery(""); + setSelectedStream(null); + setCountdownSeconds("10"); + onOpenChange(false); + }; + + const handleSubmit = () => { + if (!selectedStream?.author?.handle) return; + + const countdown = parseInt(countdownSeconds, 10); + if (isNaN(countdown) || countdown < 5 || countdown > 300) { + return; + } + + onSubmit(selectedStream.author.handle, countdown); + handleCancel(); + }; + + return ( + + + + + + + {liveUsersLoading && !liveUsers ? ( + + + Loading live users... + + + ) : filteredStreams.length === 0 ? ( + + + {searchQuery + ? "No matching live users found" + : "No live users found"} + + + ) : ( + + + {filteredStreams.map((stream) => ( + setSelectedStream(stream)} + > + + + + + + {stream.author?.handle} + + {stream.record.title ? ( + + {(stream.record.title as any) || ""} + + ) : null} + {stream.viewerCount && ( + + {stream.viewerCount.count} viewer + {stream.viewerCount.count !== 1 ? "s" : ""} + + )} + + + + + ))} + + + )} + + + + + + + ); +}; diff --git a/js/components/src/components/ui/dialog.tsx b/js/components/src/components/ui/dialog.tsx index 984f83f1..d62b2ee1 100644 --- a/js/components/src/components/ui/dialog.tsx +++ b/js/components/src/components/ui/dialog.tsx @@ -477,22 +477,30 @@ function createStyles(theme: any) { // Size styles smContent: { + width: 400, minWidth: 300, + maxWidth: 500, minHeight: 200, }, mdContent: { + width: 500, minWidth: 400, + maxWidth: 600, minHeight: 300, }, lgContent: { + width: 600, minWidth: 500, + maxWidth: 800, minHeight: 400, }, xlContent: { + width: 800, minWidth: 600, + maxWidth: 1000, minHeight: 500, }, diff --git a/js/components/src/lib/slash-commands/teleport.ts b/js/components/src/lib/slash-commands/teleport.ts index 2f4db551..603f359e 100644 --- a/js/components/src/lib/slash-commands/teleport.ts +++ b/js/components/src/lib/slash-commands/teleport.ts @@ -21,16 +21,84 @@ export async function deleteTeleport( }); } +export async function createTeleport( + pdsAgent: StreamplaceAgent, + userDID: string, + targetHandle: string, + countdownSeconds: number, + setActiveTeleportUri?: (uri: string | null) => void, +): Promise<{ success: boolean; error?: string }> { + if (countdownSeconds < 5 || countdownSeconds > 300) { + return { + success: false, + error: "Countdown must be between 5 seconds and 5 minutes", + }; + } + + let targetDID: string; + try { + const resolution = await pdsAgent.resolveHandle({ + handle: targetHandle, + }); + targetDID = resolution.data.did; + } catch (err) { + return { + success: false, + error: `Could not resolve handle: ${targetHandle}`, + }; + } + + if (targetDID === userDID) { + return { + success: false, + error: "You cannot teleport to yourself", + }; + } + + const startsAt = new Date(Date.now() + countdownSeconds * 1000).toISOString(); + + const record: PlaceStreamLiveTeleport.Record = { + $type: "place.stream.live.teleport", + streamer: targetDID, + startsAt, + countdownSeconds, + }; + + try { + const result = await pdsAgent.com.atproto.repo.createRecord({ + repo: userDID, + collection: "place.stream.live.teleport", + record, + }); + + if (setActiveTeleportUri) { + setActiveTeleportUri(result.data.uri); + } + + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed to create teleport", + }; + } +} + export function registerTeleportCommand( pdsAgent: StreamplaceAgent, userDID: string, setActiveTeleportUri?: (uri: string | null) => void, + onOpenModal?: () => void, ) { const teleportHandler: SlashCommandHandler = async ( args, rawInput, ): Promise => { if (args.length === 0) { + if (onOpenModal) { + onOpenModal(); + return { handled: true }; + } return { handled: true, error: "Usage: /teleport @handle.bsky.social [duration_seconds]", -- 2.51.2 From 51384b0ee65714039391d722e13f8571f2827199 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:12:00 -0600 Subject: [PATCH 09/19] two column card grid? --- .../src/components/chat/teleport-modal.tsx | 213 +++++++++++++----- 1 file changed, 159 insertions(+), 54 deletions(-) diff --git a/js/components/src/components/chat/teleport-modal.tsx b/js/components/src/components/chat/teleport-modal.tsx index 390337dd..ec038169 100644 --- a/js/components/src/components/chat/teleport-modal.tsx +++ b/js/components/src/components/chat/teleport-modal.tsx @@ -1,3 +1,4 @@ +import { Check } from "lucide-react-native"; import React, { useEffect, useMemo, useState } from "react"; import { Image, Pressable, ScrollView, View } from "react-native"; import { PlaceStreamLivestream } from "streamplace"; @@ -7,8 +8,6 @@ import { Button, DialogFooter, Input, - MenuGroup, - MenuItem, ResponsiveDialog, Text, useTheme, @@ -83,7 +82,7 @@ export const TeleportModal: React.FC = ({ title="Teleport to Streamer" showCloseButton variant="default" - size="md" + size="xl" dismissible={false} > @@ -113,72 +112,178 @@ export const TeleportModal: React.FC = ({ ) : ( - - {filteredStreams.map((stream) => ( - setSelectedStream(stream)} - > - + {filteredStreams.map((stream) => { + const isSelected = selectedStream?.uri === stream.uri; + const profile = profiles[stream.author?.did]; + + return ( + setSelectedStream(stream)} + style={[ + { + width: "48%", + minWidth: 200, + }, + ]} > - - - {stream.author?.handle} - - {stream.record.title ? ( + + {isSelected && ( + + + + )} + {stream.viewerCount && ( + + + {stream.viewerCount.count} viewer + {stream.viewerCount.count !== 1 ? "s" : ""} + + + )} + + + + {profile?.avatar ? ( + + ) : ( + + )} + + + {/* Text */} + - {(stream.record.title as any) || ""} - - ) : null} - {stream.viewerCount && ( - - {stream.viewerCount.count} viewer - {stream.viewerCount.count !== 1 ? "s" : ""} + {stream.author?.handle} - )} + {stream.record.title ? ( + + {stream.record.title as any} + + ) : null} + - - - ))} - + + ); + })} + )} -- 2.51.2 From ad7959bcb282f26888f2ae7f36f331611a275122 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:37:36 -0600 Subject: [PATCH 10/19] update combine too --- pkg/cmd/combine.go | 22 ++-------------------- pkg/cmd/streamplace.go | 28 +++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/pkg/cmd/combine.go b/pkg/cmd/combine.go index a6500646..3b7e82f2 100644 --- a/pkg/cmd/combine.go +++ b/pkg/cmd/combine.go @@ -14,21 +14,8 @@ import ( "stream.place/streamplace/pkg/media" ) -func Combine(ctx context.Context, build *config.BuildFlags, allArgs []string) error { +func Combine(ctx context.Context, cli *config.CLI, debugDir string, outFile string, inputs []string) error { gstinit.InitGST() - cli := &config.CLI{Build: build} - - var debugDir string - // Simple flag parsing for debug-dir - args := allArgs - for i, arg := range allArgs { - if arg == "--debug-dir" && i+1 < len(allArgs) { - debugDir = allArgs[i+1] - // Remove the flag from args - args = append(allArgs[:i], allArgs[i+2:]...) - break - } - } if debugDir != "" { err := os.MkdirAll(debugDir, 0755) @@ -36,7 +23,7 @@ func Combine(ctx context.Context, build *config.BuildFlags, allArgs []string) er return fmt.Errorf("failed to create debug directory: %w", err) } } - log.Debug(context.Background(), "combine command: starting", "args", args) + log.Debug(context.Background(), "combine command: starting", "outFile", outFile, "inputs", inputs) ctx = log.WithDebugValue(ctx, cli.Debug) cryptoSigner, err := createSigner(ctx, cli) if err != nil { @@ -47,11 +34,6 @@ func Combine(ctx context.Context, build *config.BuildFlags, allArgs []string) er return err } - if len(args) < 2 { - return fmt.Errorf("usage: streamplace combine [--debug-dir dir] [input2...]") - } - outFile := args[0] - inputs := args[1:] log.Log(ctx, "combining segments", "outFile", outFile, "inputs", inputs) outFd, err := os.Create(outFile) if err != nil { diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index bfac2d92..c82df90c 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -712,13 +712,31 @@ func makeWhipCommand(build *config.BuildFlags) *urfavecli.Command { } func makeCombineCommand(build *config.BuildFlags) *urfavecli.Command { - return &urfavecli.Command{ - Name: "combine", - Usage: "combine segments", - Action: func(ctx context.Context, cmd *urfavecli.Command) error { - return Combine(ctx, build, cmd.Args().Slice()) + cli := config.CLI{Build: build} + combineCmd := cli.NewCommand("combine") + combineCmd.Usage = "combine segments" + combineCmd.ArgsUsage = "[output] [input1] [input2...]" + combineCmd.Flags = []urfavecli.Flag{ + &urfavecli.StringFlag{ + Name: "debug-dir", + Usage: "directory to write debug output", }, } + combineCmd.Action = func(ctx context.Context, cmd *urfavecli.Command) error { + args := cmd.Args() + if args.Len() < 2 { + return fmt.Errorf("usage: streamplace combine [--debug-dir dir] [output] [input1] [input2...]") + } + ctx = log.WithDebugValue(ctx, cli.Debug) + return Combine( + ctx, + &cli, + cmd.String("debug-dir"), + args.Get(0), + args.Slice()[1:], + ) + } + return combineCmd } func makeSplitCommand(build *config.BuildFlags) *urfavecli.Command { -- 2.51.2 From f919968560422a0c625b0c3300c1b5a39a455865 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:20:27 -0600 Subject: [PATCH 11/19] clean up styles a taaad --- .../src/components/chat/teleport-modal.tsx | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/js/components/src/components/chat/teleport-modal.tsx b/js/components/src/components/chat/teleport-modal.tsx index ec038169..3f203148 100644 --- a/js/components/src/components/chat/teleport-modal.tsx +++ b/js/components/src/components/chat/teleport-modal.tsx @@ -1,17 +1,10 @@ -import { Check } from "lucide-react-native"; +import { Check, X } from "lucide-react-native"; import React, { useEffect, useMemo, useState } from "react"; import { Image, Pressable, ScrollView, View } from "react-native"; import { PlaceStreamLivestream } from "streamplace"; import { useAvatars, zero } from "../.."; import { useStreamplaceStore } from "../../streamplace-store"; -import { - Button, - DialogFooter, - Input, - ResponsiveDialog, - Text, - useTheme, -} from "../ui"; +import { Button, Input, ResponsiveDialog, Text, useTheme } from "../ui"; interface TeleportModalProps { open: boolean; @@ -49,10 +42,11 @@ export const TeleportModal: React.FC = ({ if (!searchQuery.trim()) return liveUsers; const query = searchQuery.toLowerCase(); + // filter by handle or stream title return liveUsers.filter( (stream) => stream.author?.handle?.toLowerCase().includes(query) || - stream.author?.displayName?.toLowerCase().includes(query), + stream.record.title?.toString().toLowerCase().includes(query), ); }, [liveUsers, searchQuery]); @@ -79,13 +73,23 @@ export const TeleportModal: React.FC = ({ + + + Teleport to another live streamer + + Select a streamer to teleport your viewers to their stream. + + + + + + = ({ {liveUsersLoading && !liveUsers ? ( - - Loading live users... - + Loading live users... ) : filteredStreams.length === 0 ? ( - + {searchQuery ? "No matching live users found" : "No live users found"} @@ -131,7 +133,7 @@ export const TeleportModal: React.FC = ({ onPress={() => setSelectedStream(stream)} style={[ { - width: "48%", + width: "49.2%", minWidth: 200, }, ]} @@ -256,11 +258,7 @@ export const TeleportModal: React.FC = ({ {/* Text */} - + {stream.author?.handle} {stream.record.title ? ( @@ -287,7 +285,14 @@ export const TeleportModal: React.FC = ({ )} - + @@ -299,7 +304,7 @@ export const TeleportModal: React.FC = ({ > Teleport - + ); }; -- 2.51.2 From 2dd151efdb01d9f39d50fed0b5108b417eaba0e0 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:10:55 -0600 Subject: [PATCH 12/19] resolution display for source --- .../mobile-player/ui/viewer-context-menu.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx b/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx index fe903325..b3b70c36 100644 --- a/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx +++ b/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx @@ -58,6 +58,32 @@ export function ContextMenu({ const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); const setReportSubject = usePlayerStore((x) => x.setReportSubject); + const latestSegment = useLivestreamStore((x) => x.segment); + // get highest height x width rendition for video + const videoRendition = latestSegment?.video?.reduce((prev, current) => { + const prevPixels = prev.width * prev.height; + const currentPixels = current.width * current.height; + return currentPixels > prevPixels ? current : prev; + }, latestSegment?.video?.[0]); + const highestLength = videoRendition + ? videoRendition.height < videoRendition.width + ? videoRendition.height + : videoRendition?.width + : 0; + + // ugh i hate this + const frames = videoRendition?.framerate as + | { num: number; den: number } + | undefined; + const fps = + frames?.num && frames?.den + ? Math.round((frames.num / frames.den) * 100) / 100 + : 0; + + const resolutionDisplay = highestLength + ? `(${highestLength}p${fps > 0 ? fps : ""})` + : "(Original Quality)"; + const { profile } = useLivestreamInfo(); const avatars = useAvatars(profile?.did ? [profile?.did] : []); @@ -215,7 +241,9 @@ export function ContextMenu({ > Quality - {quality === "source" ? "Source" : quality},{" "} + {quality === "source" + ? `Source${resolutionDisplay ? " " + resolutionDisplay + "\n" : ", "}` + : quality} {lowLatency ? "Low Latency" : ""} @@ -227,7 +255,7 @@ export function ContextMenu({ onValueChange={setQuality} > - Source (Original Quality) + Source {resolutionDisplay} {qualities.map((r) => ( -- 2.51.2 From 5672e4c7039a0a607e3c0ca3e760b532b4738770 Mon Sep 17 00:00:00 2001 From: Jordan Weatherby Date: Fri, 13 Feb 2026 23:09:22 +0000 Subject: [PATCH 13/19] move hover ref up to chat so there is never multiple hovers --- js/components/src/components/chat/chat.tsx | 74 ++++++++++++---------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/js/components/src/components/chat/chat.tsx b/js/components/src/components/chat/chat.tsx index a9015950..82dde078 100644 --- a/js/components/src/components/chat/chat.tsx +++ b/js/components/src/components/chat/chat.tsx @@ -141,34 +141,22 @@ const ActionsBar = memo( }, ); -const ChatLine = memo(({ item }: { item: ChatMessageViewHydrated }) => { +const ChatLine = memo(({ + item, + isHovered, + onHoverIn, + onHoverOut, + hoverTimeoutRef, +}: { + item: ChatMessageViewHydrated; + isHovered?: boolean; + onHoverIn?: () => void; + onHoverOut?: () => void; + hoverTimeoutRef?: React.MutableRefObject; +}) => { const setReply = useSetReplyToMessage(); const setModMsg = usePlayerStore((state) => state.setModMessage); const swipeableRef = useRef(null); - const [isHovered, setIsHovered] = useState(false); - const hoverTimeoutRef = useRef(null); - - const handleHoverIn = () => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; - } - setIsHovered(true); - }; - - const handleHoverOut = () => { - hoverTimeoutRef.current = setTimeout(() => { - setIsHovered(false); - }, 50); - }; - - useEffect(() => { - return () => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - } - }; - }, []); if (item.author.did === "did:sys:system") { return ( @@ -195,23 +183,22 @@ const ChatLine = memo(({ item }: { item: ChatMessageViewHydrated }) => { }, isHovered && bg.gray[950], ]} - onPointerEnter={handleHoverIn} - onPointerLeave={handleHoverOut} + onPointerEnter={onHoverIn} + onPointerLeave={onHoverOut} > ); } return ( - <> { > - ); }); @@ -254,6 +240,22 @@ export function Chat({ const chat = useChat(); const [isScrolledUp, setIsScrolledUp] = useState(false); const flatListRef = useRef(null); + const [hoveredMessageUri, setHoveredMessageUri] = useState(null); + const hoverTimeoutRef = useRef(null); + + const handleHoverIn = (uri: string) => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + setHoveredMessageUri(uri); + }; + + const handleHoverOut = () => { + hoverTimeoutRef.current = setTimeout(() => { + setHoveredMessageUri(null); + }, 50); + }; // Animation for scroll-to-bottom button const buttonOpacity = useSharedValue(0); @@ -319,7 +321,15 @@ export function Chat({ data={chat.slice(0, shownMessages)} inverted={true} keyExtractor={keyExtractor} - renderItem={({ item, index }) => } + renderItem={({ item }) => ( + handleHoverIn(item.uri)} + onHoverOut={handleHoverOut} + hoverTimeoutRef={hoverTimeoutRef} + /> + )} removeClippedSubviews={true} maxToRenderPerBatch={10} initialNumToRender={10} -- 2.51.2 From be1b73de8cbeaf0a058bf9d649d75ea065efde62 Mon Sep 17 00:00:00 2001 From: Lim Chunwei Date: Sun, 15 Feb 2026 02:08:45 +0800 Subject: [PATCH 14/19] Simplified Chinese translations --- js/components/locales/manifest.json | 8 +- js/components/locales/zh-Hans/common.ftl | 57 ++++++ js/components/locales/zh-Hans/settings.ftl | 222 +++++++++++++++++++++ 3 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 js/components/locales/zh-Hans/common.ftl create mode 100644 js/components/locales/zh-Hans/settings.ftl diff --git a/js/components/locales/manifest.json b/js/components/locales/manifest.json index d21a64ce..feb84f87 100644 --- a/js/components/locales/manifest.json +++ b/js/components/locales/manifest.json @@ -1,5 +1,5 @@ { - "supportedLocales": ["en-US", "pt-BR", "es-ES", "zh-Hant", "fr-FR"], + "supportedLocales": ["en-US", "pt-BR", "es-ES", "zh-Hant", "fr-FR", "zh-Hans"], "fallbackChain": ["en-US"], "languages": { "en-US": { @@ -31,6 +31,12 @@ "name": "French", "nativeName": "Français", "flag": "🇫🇷" + }, + "zh-Hans": { + "code": "zh-Hans", + "name": "Chinese (Simplified)", + "nativeName": "简体中文", + "flag": "汉" } } } diff --git a/js/components/locales/zh-Hans/common.ftl b/js/components/locales/zh-Hans/common.ftl new file mode 100644 index 00000000..e69e7f87 --- /dev/null +++ b/js/components/locales/zh-Hans/common.ftl @@ -0,0 +1,57 @@ +# Common UI Translations - Chinese (Simplified) + +## General UI +loading = 正在加载... +error = 错误 +cancel = 取消 +confirm = 确认 +close = 关闭 +open = 开启 +ok = 确定 +yes = 是 +no = 否 +continue = 继续 +back = 返回 +next = 下一步 +finish = 完成 + +## Actions +save = 保存 +delete = 删除 +edit = 编辑 +create = 创建 +update = 更新 +refresh = 刷新 + +## Status Messages +success = 成功 +warning = 警告 +info = 信息 + +## Input Placeholders +search-placeholder = 搜索... +message-input = 输入您的消息... + +## Authentication & Access +please-log-in-to-access-this-page = 请登录以访问此页面 +go-to-settings = 前往设置 +go-back = 返回 + +## Demo and Testing +welcome-user = 欢迎,{ $username }! +notification-count = { $count -> + [0] 无通知 + *[other] { $count } 则通知 +} + +## Offline User +user-offline = 用户离线 +user-offline-message = { $source -> + [streamer] 看起来 <1>@{ $handle } 离线了,但他们推荐观看: + *[default] 看起来 <1>@{ $handle } 离线了,但我们推荐观看: +} +user-offline-no-recommendations = + 看起来 <1>@{ $handle } 离线了。 + 请稍后再来看看。 +streaming-title = 正在直播 { $title } +viewer-count = { $count } 位观众 diff --git a/js/components/locales/zh-Hans/settings.ftl b/js/components/locales/zh-Hans/settings.ftl new file mode 100644 index 00000000..988a674c --- /dev/null +++ b/js/components/locales/zh-Hans/settings.ftl @@ -0,0 +1,222 @@ +# Settings Page Translations - Chinese (Simplified) + +## App Version +app-version = Streamplace v{ $version } +download-new-update = 下载新更新 +check-for-updates = 检查更新 + +bundled-runtype = 捆绑版 +ota-runtype = 空中下载 (OTA) +recovery-runtype = 复原模式 + +modal-latest-version = 您正在使用最新版本。 +modal-no-update-available = 您已经在使用最新版本的 Streamplace,太棒了! +modal-update-available-title = 有可用更新 +modal-update-available-description = 新版本的 Streamplace 已准备好下载 +modal-update-failed = 更新检查失败。您可能需要透过 { $store } 更新应用程序。 +modal-update-failed-title = 更新失败 +modal-update-failed-description = 更新检查失败。您可能需要透过 { $store } 更新应用程序。 +button-reload-app-on-update = 套用更新 (将重新加载应用程序) + +## Custom Node Settings +use-custom-node = 使用自定义节点 +default-url = 默认:{ $url } +enter-custom-node-url = 输入自定义节点网址 +save-button = 保存 + +## Language Settings +language-selection = 语言 +language-selection-description = 选择您偏好的语言 +input-search-languages = 搜索语言... +help-translate = 帮助我们翻译 Streamplace +help-translate-description = 我们正在寻找志愿者协助将 Streamplace 翻译成更多语言。如果您有兴趣,请在 Discord 或 GitHub 上与我们联系! +currently-translating = 翻译正在进行中 +currently-translating-description = 应用程序的某些部分可能看起来不完整。感谢您的耐心等待! + +## Debug Recording +debug-recording-title = 允许 { $host } 录制您的直播串流以进行除错和服务改善 +debug-recording-description = 可选项目 + +## Key Management +manage-keys = 管理密钥 + +## Settings Page Specific +settings-title = 设置 + +## Navigation Categories +about = 关于 +account = 帐户 +advanced = 高端 +danmu = 弹幕 +developer = 开发者 +languages = 语言 +privacy-security = 隐私与安全 +streaming = 串流 + +## Common Actions +cancel = 取消 +create = 创建 +delete = 删除 +refresh = 刷新 +save-button = 保存 +sign-in = 登录 +update = 更新 +log-out = 注销 +optional = 选填 + +## Account Settings +account-greeting = 嗨,@{ $handle }。 +edit-profile-bluesky = 在 Bluesky 编辑个人数据 +change-name-color = 变更名称颜色 + +## Key Management +key-management = 密钥管理 +key-manager = 密钥管理器 +manage-keys = 管理密钥 +your-stream-pubkeys = 您的串流公开密钥 +no-keys = 尚未设置密钥 +pubkey-description = 公开密钥与串流密钥 (用于串流软件) 配对以签署和验证您的串流 +keys-count = { $count } 个密钥 + +## Recommendations +recommendations = 推荐主播 +manage-recommendations = 管理推荐主播 +recommendations-to-others = 向他人推荐主播 +recommendations-description = 向您的观众推荐最多 8 位主播 +no-recommendations-yet = 尚未配置推荐 +add-recommendation = 添加推荐 +streamer-did = 主播 DID +recommendations-count = { $count } 位推荐主播 + +## Webhook Management +webhooks = Webhooks +webhook-integrations = Webhook 集成 +webhook-integrations-description = 连接外部服务以即时接收有关您串流的更新 +create-webhook = 创建 Webhook +edit-webhook = 编辑 Webhook +delete-webhook = 删除 Webhook +no-webhooks-yet = 尚未设置 Webhook +failed-load-webhooks = 加载 Webhook 失败 +webhook-will-no-longer-receive-events = 此 Webhook 将不再接收事件 +create-first-webhook-description = 创建您的第一个 Webhook 以开始接收串流事件 +example-captain-hook = Hook 船长 +webhooks-count = { $count } 个 Webhook + +## Webhook Events +activates-on = 触发于: +events = 事件 +events-livestream = 直播串流事件 +events-chat = 聊天事件 +untitled-webhook = 未命名的 Webhook +inactive = 停用 +active = 活动 + +## Multistreaming +multistream = 多重串流 +multistream-targets = 多重串流目标 +multistream-description = 自动将您的 Streamplace 直播推送到 Twitch 或 YouTube 等其他直播平台。 +create-multistream-target = 创建多重串流目标 +untitled-multistream-target = 未命名目标 +failed-load-multistream-targets = 加载多重串流目标失败。请重试。 +failed-toggle-multistream-target = 切换多重串流目标失败。请重试。 +failed-delete-multistream-target = 删除多重串流目标失败。请重试。 +no-multistream-targets-yet = 还没有目标! +multistream-targets-count = { $count } 个目标 +multistream-delete-target-confirmation = 您确定要删除“{ $target }”吗? +this-action-cannot-be-undone = 此操作无法撤销。 +rtmp-target-name = RTMP 目标 +rtmp-target-url = RTMP 网址 +rtmp-target-name-placeholder = 我的多重串流目标 +multistream-create-target = 创建目标 +multistream-edit-target = 编辑目标 +created = 创建于 +status = 状态 + +## Debug Recording +debug-recording = 调试录制 + +## Danmu Settings +danmu = 弹幕 +danmu-enabled = 启用弹幕 +danmu-enabled-description = 将即时聊天消息以浮动评论的形式显示在您的屏幕上 +danmu-opacity = 不透明度 +danmu-speed = 速度 +danmu-lane-count = 轨道数量 +danmu-max-messages = 最大消息数 + +## General +app-version-description = 当前没有可用的更新 +confirm-delete = 您确定要删除吗? +action-cannot-be-undone = 此操作无法撤销 +name-optional = 名称 (选填) +deleting = 正在删除... +saving = 正在保存... +go-to-dashboard = 前往仪表板 +need-setup-live-dashboard = 需要先设置串流吗?请访问直播仪表板 +no-languages-found = 找不到语言 + +## Branding Administration +branding = 品牌 +branding-admin = 品牌管理 +branding-admin-description = 自定义您的 Streamplace 实例。请注意,设置可能需要几小时才能生效。 +branding-login-required = 请登录以管理品牌 +branding-configuration = 配置 +branding-text-settings = 文字设置 +branding-colors = 颜色 +branding-legal-links = 法律链接 +branding-images = 图像 + +## Branding Fields +branding-broadcaster-did = 主播 DID +branding-broadcaster-did-description = 留空以使用服务器默认值 +branding-site-title = 网站标题 +branding-site-title-placeholder = 输入新网站标题 +branding-site-description = 网站描述 +branding-site-description-placeholder = 输入网站描述 +branding-default-streamer = 默认主播 +branding-default-streamer-none = 无 +branding-default-streamer-placeholder = did:plc:... +branding-clear-default-streamer = 清除默认主播 +branding-primary-color = 首要颜色 +branding-primary-color-placeholder = #6366f1 +branding-accent-color = 强调色 +branding-accent-color-placeholder = #8b5cf6 +branding-main-logo = 主标志 +branding-main-logo-description = SVG、PNG 或 JPEG (最大 500KB) +branding-favicon = 网站图标 +branding-favicon-description = SVG、PNG 或 ICO (最大 100KB) +branding-sidebar-bg = 侧边栏背景图片 +branding-sidebar-bg-description = SVG、PNG 或 JPEG (最大 500KB) - 显示在侧边栏底部,全宽显示。为了获得最佳效果,请上传一张带有透明度的图片,因为目前没有单独的透明度选项。 +branding-current = 当前:{ $value } +branding-dimensions = { $height } x { $width } + +## Branding Actions +branding-upload-logo = 上传标志 +branding-delete-logo = 删除标志 +branding-upload-favicon = 上传网站图标 +branding-delete-favicon = 删除网站图标 +branding-upload-background = 上传背景 +branding-delete-background = 删除背景 +branding-web-only = 图像上传仅在网页端可用。 + +## Branding Legal Links +refresh-branding = 更新品牌资源 +branding-add-legal-link = 添加法律链接 +branding-edit-legal-link = 编辑法律链接 +branding-legal-link-text-placeholder = 链接文本 (例如:隐私政策) +branding-legal-link-url-placeholder = 网址 (例如:https://example.com/privacy) +add = 添加 +edit = 编辑 + +## Branding Toast Messages +branding-not-authenticated = 请先登录 +branding-empty-value = 请输入一个数值 +branding-update-success = { $key } 更新成功 +branding-upload-success = { $key } 上传成功 +branding-delete-success = { $key } 删除成功 +branding-upload-failed = 上传失败 +branding-delete-failed = 删除失败 +branding-not-available = 文件上传仅在网页版上可用 + +## Navigation Categories (About Page) +node-legal-documents = 主播专属文档 -- 2.51.2 From 77b57943ca47052d52cb76aa423e363e4825e650 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:10:11 -0600 Subject: [PATCH 15/19] round unless dev mode is unlocked --- .../components/mobile-player/ui/viewer-context-menu.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx b/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx index b3b70c36..171425ab 100644 --- a/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx +++ b/js/components/src/components/mobile-player/ui/viewer-context-menu.tsx @@ -14,6 +14,7 @@ import { formatHandleWithAt, useAvatars, useLivestreamInfo, + useStreamplaceStore, zero, } from "../../.."; import { useLivestreamStore } from "../../../livestream-store"; @@ -58,6 +59,8 @@ export function ContextMenu({ const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); const setReportSubject = usePlayerStore((x) => x.setReportSubject); + const isDevModeOn = useStreamplaceStore((x) => x.danmuUnlocked); + const latestSegment = useLivestreamStore((x) => x.segment); // get highest height x width rendition for video const videoRendition = latestSegment?.video?.reduce((prev, current) => { @@ -75,11 +78,15 @@ export function ContextMenu({ const frames = videoRendition?.framerate as | { num: number; den: number } | undefined; - const fps = + let fps = frames?.num && frames?.den ? Math.round((frames.num / frames.den) * 100) / 100 : 0; + if (!isDevModeOn && latestSegment?.video?.length) { + fps = Math.round(fps); + } + const resolutionDisplay = highestLength ? `(${highestLength}p${fps > 0 ? fps : ""})` : "(Original Quality)"; -- 2.51.2 From 67d032e09d72768cb76aa2cc22a1da5883cc62ce Mon Sep 17 00:00:00 2001 From: Jordan Weatherby Date: Mon, 16 Feb 2026 19:31:32 +0000 Subject: [PATCH 16/19] run prettier --- js/components/src/components/chat/chat.tsx | 120 +++++++++++---------- 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/js/components/src/components/chat/chat.tsx b/js/components/src/components/chat/chat.tsx index 82dde078..d42069ec 100644 --- a/js/components/src/components/chat/chat.tsx +++ b/js/components/src/components/chat/chat.tsx @@ -141,64 +141,65 @@ const ActionsBar = memo( }, ); -const ChatLine = memo(({ - item, - isHovered, - onHoverIn, - onHoverOut, - hoverTimeoutRef, -}: { - item: ChatMessageViewHydrated; - isHovered?: boolean; - onHoverIn?: () => void; - onHoverOut?: () => void; - hoverTimeoutRef?: React.MutableRefObject; -}) => { - const setReply = useSetReplyToMessage(); - const setModMsg = usePlayerStore((state) => state.setModMessage); - const swipeableRef = useRef(null); - - if (item.author.did === "did:sys:system") { - return ( - - ); - } +const ChatLine = memo( + ({ + item, + isHovered, + onHoverIn, + onHoverOut, + hoverTimeoutRef, + }: { + item: ChatMessageViewHydrated; + isHovered?: boolean; + onHoverIn?: () => void; + onHoverOut?: () => void; + hoverTimeoutRef?: React.MutableRefObject; + }) => { + const setReply = useSetReplyToMessage(); + const setModMsg = usePlayerStore((state) => state.setModMessage); + const swipeableRef = useRef(null); - if (Platform.OS === "web") { - return ( - - - - - - - ); - } + ); + } - return ( + if (Platform.OS === "web") { + return ( + + + + + + + ); + } + + return ( - ); -}); + ); + }, +); export function Chat({ shownMessages = SHOWN_MSGS, @@ -240,7 +242,9 @@ export function Chat({ const chat = useChat(); const [isScrolledUp, setIsScrolledUp] = useState(false); const flatListRef = useRef(null); - const [hoveredMessageUri, setHoveredMessageUri] = useState(null); + const [hoveredMessageUri, setHoveredMessageUri] = useState( + null, + ); const hoverTimeoutRef = useRef(null); const handleHoverIn = (uri: string) => { -- 2.51.2 From ffc30ac6dbc63855449b6c9f8df663aec0de4efa Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:13:15 -0600 Subject: [PATCH 17/19] fix: add hans imports to native --- js/components/src/i18n/i18n-loader.native.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/components/src/i18n/i18n-loader.native.ts b/js/components/src/i18n/i18n-loader.native.ts index 754435a0..2bca13fc 100644 --- a/js/components/src/i18n/i18n-loader.native.ts +++ b/js/components/src/i18n/i18n-loader.native.ts @@ -10,6 +10,8 @@ import frFRCommon from "../../public/locales/fr-FR/common.json"; import frFRSettings from "../../public/locales/fr-FR/settings.json"; import ptBRCommon from "../../public/locales/pt-BR/common.json"; import ptBRSettings from "../../public/locales/pt-BR/settings.json"; +import zhHansCommon from "../../public/locales/zh-Hans/common.json"; +import zhHansSettings from "../../public/locales/zh-Hans/settings.json"; import zhHantCommon from "../../public/locales/zh-Hant/common.json"; import zhHantSettings from "../../public/locales/zh-Hant/settings.json"; @@ -20,6 +22,8 @@ const translationMap: Record = { "pt-BR/settings": ptBRSettings, "es-ES/common": esESCommon, "es-ES/settings": esESSettings, + "zh-Hans/common": zhHansCommon, + "zh-Hans/settings": zhHansSettings, "zh-Hant/common": zhHantCommon, "zh-Hant/settings": zhHantSettings, "fr-FR/common": frFRCommon, -- 2.51.2 From adb409b3b1009a5ce137dab53681332643eca5e2 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:42:18 -0600 Subject: [PATCH 18/19] prettier fmt --- js/components/locales/manifest.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/js/components/locales/manifest.json b/js/components/locales/manifest.json index feb84f87..ad8a20e3 100644 --- a/js/components/locales/manifest.json +++ b/js/components/locales/manifest.json @@ -1,5 +1,12 @@ { - "supportedLocales": ["en-US", "pt-BR", "es-ES", "zh-Hant", "fr-FR", "zh-Hans"], + "supportedLocales": [ + "en-US", + "pt-BR", + "es-ES", + "zh-Hant", + "fr-FR", + "zh-Hans" + ], "fallbackChain": ["en-US"], "languages": { "en-US": { -- 2.51.2 From 8e2f4eff457c4dbb7a44196c03d7c0cbe6e4b8bf Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:37:16 -0600 Subject: [PATCH 19/19] fix: remount volume slider on sidebar toggle to fix drag offset --- js/app/components/mobile/desktop-ui/bottom-controls.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/app/components/mobile/desktop-ui/bottom-controls.tsx b/js/app/components/mobile/desktop-ui/bottom-controls.tsx index 2f33bd0d..1983f0a9 100644 --- a/js/app/components/mobile/desktop-ui/bottom-controls.tsx +++ b/js/app/components/mobile/desktop-ui/bottom-controls.tsx @@ -17,6 +17,7 @@ import { PictureInPicture2, } from "lucide-react-native"; import { Platform, Pressable } from "react-native"; +import { useIsSidebarCollapsed } from "store/hooks"; import { VolumeSlider } from "./volume-slider"; import { Mu } from "./mu"; @@ -48,6 +49,7 @@ export function BottomControlBar({ const danmuUnlocked = useDanmuUnlocked(); const danmuEnabled = useDanmuEnabled(); const setDanmuEnabled = useSetDanmuEnabled(); + const sidebarCollapsed = useIsSidebarCollapsed(); return ( - +