diff --git a/Makefile b/Makefile index 72ced11..3cc54ea 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ streampanel-ios: builddir cd cmd/streampanel && fyne package -release -os ios && mv streampanel.ipa ../../build/ streampanel-windows: builddir - CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows go build -o build/streampanel.exe ./cmd/streampanel/ + CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows go build -ldflags "-H windowsgui" -o build/streampanel.exe ./cmd/streampanel/ builddir: mkdir -p build diff --git a/go.mod b/go.mod index cd68a6a..6056a6c 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( fyne.io/systray v1.10.1-0.20231115130155-104f5ef7839e // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -37,9 +38,14 @@ require ( github.com/go-text/typesetting v0.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/gorilla/websocket v1.5.2 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mmcloughlin/profile v0.1.1 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect @@ -65,6 +71,7 @@ require ( cloud.google.com/go/compute/metadata v0.2.3 // indirect fyne.io/fyne/v2 v2.4.5 github.com/DataDog/gostackparse v0.6.0 // indirect + github.com/andreykaipov/goobs v1.4.1 github.com/fatih/color v1.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-git/go-git/v5 v5.12.0 @@ -98,7 +105,7 @@ require ( go.uber.org/zap v1.24.0 // indirect golang.org/x/crypto v0.21.0 golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect - golang.org/x/net v0.22.0 // indirect + golang.org/x/net v0.23.0 // indirect golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect diff --git a/go.sum b/go.sum index 3474083..176fb83 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/andreykaipov/goobs v1.4.1 h1:IpvSMVFzwsrN2d+h8pwMMHIAYiR4sVS2jSTYKNvNmA4= +github.com/andreykaipov/goobs v1.4.1/go.mod h1:rjGZl9Y/O2axzrGwq9kL0l+ykKtRUYcNgPVh23YVwgc= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -68,6 +70,8 @@ github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLj github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -251,6 +255,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.5.2 h1:qoW6V1GT3aZxybsbC6oLnailWnB+qTMVwMreOso9XUw= +github.com/gorilla/websocket v1.5.2/go.mod h1:0n9H61RBAcf5/38py2MCYbxzPIY9rOkpvvMT24Rqs30= github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY= github.com/goxjs/glfw v0.0.0-20191126052801-d2efb5f20838/go.mod h1:oS8P8gVOT4ywTcjV6wZlOU4GuVFQ8F5328KY3MJ79CY= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= @@ -273,6 +279,7 @@ github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= @@ -320,6 +327,10 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mmcloughlin/profile v0.1.1 h1:jhDmAqPyebOsVDOCICJoINoLb/AnLBaUw58nFzxWS2w= +github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -327,6 +338,8 @@ github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJE github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/nicklaw5/helix/v2 v2.26.0 h1:Qkc/R0eCDdWtUmnczk2g03+mObPUfc49Kz2Bt4B5d0g= github.com/nicklaw5/helix/v2 v2.26.0/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -541,6 +554,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/pkg/streamcontrol/obs/config.go b/pkg/streamcontrol/obs/config.go new file mode 100644 index 0000000..b9cdd38 --- /dev/null +++ b/pkg/streamcontrol/obs/config.go @@ -0,0 +1,25 @@ +package obs + +import ( + streamctl "github.com/xaionaro-go/streamctl/pkg/streamcontrol" +) + +const ID = streamctl.PlatformName("obs") + +type PlatformSpecificConfig struct { + Host string + Port uint16 + Password string `yaml:"pass" json:"pass"` +} + +type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile] + +func InitConfig(cfg streamctl.Config) { + streamctl.InitConfig(cfg, ID, Config{}) +} + +type StreamProfile struct { + streamctl.StreamProfileBase `yaml:",omitempty,inline,alias"` + + EnableRecording bool `yaml:"enable_recording" json:"enable_recording"` +} diff --git a/pkg/streamcontrol/obs/obs.go b/pkg/streamcontrol/obs/obs.go new file mode 100644 index 0000000..46ee183 --- /dev/null +++ b/pkg/streamcontrol/obs/obs.go @@ -0,0 +1,187 @@ +package obs + +import ( + "context" + "fmt" + "time" + + "github.com/andreykaipov/goobs" + "github.com/facebookincubator/go-belt/tool/logger" + "github.com/hashicorp/go-multierror" + "github.com/xaionaro-go/streamctl/pkg/streamcontrol" +) + +type OBS struct { + Config Config + CurrentStream struct { + EnableRecording bool + } +} + +var _ streamcontrol.StreamController[StreamProfile] = (*OBS)(nil) + +func New( + ctx context.Context, + cfg Config, +) (*OBS, error) { + if cfg.Config.Host == "" { + return nil, fmt.Errorf("'host' is not set") + } + if cfg.Config.Port == 0 { + return nil, fmt.Errorf("'port' is not set") + } + + return &OBS{ + Config: cfg, + }, nil +} + +func (obs *OBS) getClient() (*goobs.Client, error) { + var opts []goobs.Option + if obs.Config.Config.Password != "" { + opts = append(opts, goobs.WithPassword(obs.Config.Config.Password)) + } + return goobs.New( + fmt.Sprintf("%s:%d", obs.Config.Config.Host, obs.Config.Config.Port), + opts..., + ) +} + +func (obs *OBS) Close() error { + return nil +} + +func (obs *OBS) ApplyProfile( + ctx context.Context, + profile StreamProfile, + customArgs ...any, +) error { + return fmt.Errorf("not supported") +} + +func (obs *OBS) SetTitle( + ctx context.Context, + title string, +) error { + // So nothing to do here: + return nil +} + +func (obs *OBS) SetDescription( + ctx context.Context, + description string, +) error { + // So nothing to do here: + return nil +} + +func (obs *OBS) InsertAdsCuePoint( + ctx context.Context, + ts time.Time, + duration time.Duration, +) error { + // So nothing to do here: + return nil +} + +func (obs *OBS) Flush( + ctx context.Context, +) error { + // So nothing to do here: + return nil +} + +func (obs *OBS) StartStream( + ctx context.Context, + title string, + description string, + profile StreamProfile, + customArgs ...any, +) error { + client, err := obs.getClient() + if err != nil { + return fmt.Errorf("unable to initialize client to OBS: %w", err) + } + defer client.Disconnect() + + streamStatus, err := client.Stream.GetStreamStatus() + if err != nil { + return fmt.Errorf("unable to get current stream status: %w", err) + } + + recordingStarted := false + if profile.EnableRecording { + recordStatus, err := client.Record.GetRecordStatus() + if err != nil { + return fmt.Errorf("unable to get current recording status: %w", err) + } + + if !recordStatus.OutputActive { + _, recordStartErr := client.Record.StartRecord() + if recordStartErr == nil { + recordingStarted = true + } else { + err = multierror.Append(err, recordStartErr) + } + } + } + + if !streamStatus.OutputActive { + _, streamStartErr := client.Stream.StartStream() + if streamStartErr != nil { + err = multierror.Append(err, streamStartErr) + } + } + + if err != nil { + if recordingStarted { + _, e0 := client.Record.StopRecord() + logger.Debugf(ctx, "StopRecord result: %v", e0) + } + _, e1 := client.Stream.StopStream() + logger.Debugf(ctx, "StopStream result: %v", e1) + + return err + } + + obs.CurrentStream.EnableRecording = profile.EnableRecording + return nil +} + +func (obs *OBS) EndStream( + ctx context.Context, +) error { + client, err := obs.getClient() + if err != nil { + return fmt.Errorf("unable to initialize client to OBS: %w", err) + } + defer client.Disconnect() + + streamStatus, err := client.Stream.GetStreamStatus() + if err != nil { + return fmt.Errorf("unable to get current stream status: %w", err) + } + + if obs.CurrentStream.EnableRecording { + recordStatus, err := client.Record.GetRecordStatus() + if err != nil { + return fmt.Errorf("unable to get current recording status: %w", err) + } + + if recordStatus.OutputActive { + _, recordStopErr := client.Record.StopRecord() + if recordStopErr != nil { + err = multierror.Append(err, recordStopErr) + } + } + } + + if streamStatus.OutputActive { + _, streamStopErr := client.Stream.StopStream() + if streamStopErr != nil { + err = multierror.Append(err, streamStopErr) + } + } + + return err +} diff --git a/pkg/streamcontrol/stream_control.go b/pkg/streamcontrol/stream_control.go index cbb4989..4c6faaa 100644 --- a/pkg/streamcontrol/stream_control.go +++ b/pkg/streamcontrol/stream_control.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "reflect" "sync" "time" @@ -50,11 +51,12 @@ func GetStreamProfile[T StreamProfile]( if err != nil { return nil, fmt.Errorf("unable to serialize: %w: %#+v", err, v) } + logger.Debugf(ctx, "JSON representation: <%s>", b) err = json.Unmarshal(b, &profile) if err != nil { return nil, fmt.Errorf("unable to deserialize: %w: <%s>", err, b) } - logger.Debugf(ctx, "converted %#+v to %#+v", v, profile) + logger.Debugf(ctx, "converted %#+v (%s) to %#+v", v, v, profile) return &profile, nil } @@ -68,12 +70,14 @@ func ConvertStreamProfiles[T StreamProfile]( return err } m[k] = *profile - logger.Debugf(ctx, "converted %#+v to %#+v", v, profile) + logger.Debugf(ctx, "converted %#+v (%s) to %#+v", v, v, profile) } return nil } type StreamControllerCommons interface { + io.Closer + SetTitle(ctx context.Context, title string) error SetDescription(ctx context.Context, description string) error InsertAdsCuePoint(ctx context.Context, ts time.Time, duration time.Duration) error @@ -101,6 +105,10 @@ type abstractStreamController struct { StreamProfileTypeValue reflect.Type } +func (c *abstractStreamController) Close() error { + return c.StreamController.Close() +} + func (c *abstractStreamController) GetImplementation() StreamControllerCommons { return c.StreamController } diff --git a/pkg/streamcontrol/twitch/twitch.go b/pkg/streamcontrol/twitch/twitch.go index b26ff0b..e3653da 100644 --- a/pkg/streamcontrol/twitch/twitch.go +++ b/pkg/streamcontrol/twitch/twitch.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" "time" + "unicode" + "unicode/utf8" "github.com/facebookincubator/go-belt/tool/experimental/errmon" "github.com/facebookincubator/go-belt/tool/logger" @@ -65,6 +67,10 @@ func getUserID( return resp.Data.Users[0].ID, nil } +func (t *Twitch) Close() error { + return nil +} + func (t *Twitch) editChannelInfo( ctx context.Context, params *helix.EditChannelInformationParams, @@ -88,6 +94,32 @@ type SaveProfileHandler interface { SaveProfile(context.Context, StreamProfile) error } +func removeNonAlphanumeric(input string) string { + var builder strings.Builder + for _, r := range input { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + builder.WriteRune(r) + } + } + return builder.String() +} + +// truncateStringByByteLength +func truncateStringByByteLength(input string, byteLength int) string { + byteSlice := []byte(input) + + if len(byteSlice) <= byteLength { + return input + } + + truncationPoint := byteLength + for !utf8.Valid(byteSlice[:truncationPoint]) { + truncationPoint-- + } + + return string(byteSlice[:truncationPoint]) +} + func (t *Twitch) ApplyProfile( ctx context.Context, profile StreamProfile, @@ -109,11 +141,11 @@ func (t *Twitch) ApplyProfile( tags := make([]string, 0, len(profile.Tags)) for _, tag := range profile.Tags { - tag = strings.ReplaceAll(tag, " ", "") - tag = strings.ReplaceAll(tag, "-", "") + tag = removeNonAlphanumeric(tag) if tag == "" { continue } + tag = truncateStringByByteLength(tag, 25) // see also: https://github.com/twitchdev/issues/issues/789 tags = append(tags, tag) } diff --git a/pkg/streamcontrol/youtube/youtube.go b/pkg/streamcontrol/youtube/youtube.go index 7f0975f..b826bb4 100644 --- a/pkg/streamcontrol/youtube/youtube.go +++ b/pkg/streamcontrol/youtube/youtube.go @@ -27,6 +27,7 @@ const copyThumbnail = false type YouTube struct { YouTubeService *youtube.Service + CancelFunc context.CancelFunc } var _ streamcontrol.StreamController[StreamProfile] = (*YouTube)(nil) @@ -40,6 +41,8 @@ func New( return nil, fmt.Errorf("'clientid' or/and 'clientsecret' is/are not set; go to https://console.cloud.google.com/apis/credentials and create an app if it not created, yet") } + ctx, cancelFn := context.WithCancel(ctx) + isNewToken := false getNewToken := func() error { t, err := getToken(ctx, cfg) @@ -100,6 +103,7 @@ func New( yt := &YouTube{ YouTubeService: youtubeService, + CancelFunc: cancelFn, } go func() { @@ -159,6 +163,11 @@ func getToken(ctx context.Context, cfg Config) (*oauth2.Token, error) { return tok, nil } +func (yt *YouTube) Close() error { + yt.CancelFunc() + return nil +} + func (yt *YouTube) iterateActiveBroadcasts( ctx context.Context, callback func(broadcast *youtube.LiveBroadcast) error, diff --git a/pkg/streampanel/panel.go b/pkg/streampanel/panel.go index bb0b989..a84431b 100644 --- a/pkg/streampanel/panel.go +++ b/pkg/streampanel/panel.go @@ -12,9 +12,11 @@ import ( "runtime" "runtime/debug" "sort" + "strconv" "strings" "sync" "time" + "unicode" "fyne.io/fyne/v2" fyneapp "fyne.io/fyne/v2/app" @@ -26,6 +28,7 @@ import ( "github.com/go-ng/xmath" "github.com/xaionaro-go/streamctl/pkg/oauthhandler" "github.com/xaionaro-go/streamctl/pkg/streamcontrol" + "github.com/xaionaro-go/streamctl/pkg/streamcontrol/obs" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube" ) @@ -45,6 +48,7 @@ type Panel struct { startStopMutex sync.Mutex updateTimerHandler *updateTimerHandler streamControllers struct { + OBS *obs.OBS Twitch *twitch.Twitch YouTube *youtube.YouTube } @@ -62,6 +66,7 @@ type Panel struct { dataPath string filterValue string + obsCheck *widget.Check youtubeCheck *widget.Check twitchCheck *widget.Check @@ -297,6 +302,94 @@ func (p *Panel) savePlatformConfig( return p.saveData(ctx) } +func removeNonDigits(input string) string { + var result []rune + for _, r := range input { + if unicode.IsDigit(r) { + result = append(result, r) + } + } + return string(result) +} + +func (p *Panel) inputOBSConnectInfo( + ctx context.Context, + cfg *streamcontrol.PlatformConfig[obs.PlatformSpecificConfig, obs.StreamProfile], +) (bool, error) { + w := p.app.NewWindow("Input Twitch user info") + resizeWindow(w, fyne.NewSize(600, 200)) + + hostField := widget.NewEntry() + hostField.SetPlaceHolder("OBS hostname, e.g. 192.168.0.134") + portField := widget.NewEntry() + portField.OnChanged = func(s string) { + filtered := removeNonDigits(s) + if s != filtered { + portField.SetText(filtered) + } + } + portField.SetPlaceHolder("OBS port, usually it is 4455") + passField := widget.NewEntry() + passField.SetPlaceHolder("OBS password") + instructionText := widget.NewRichText( + &widget.ListSegment{Items: []widget.RichTextSegment{ + &widget.TextSegment{Text: `Open OBS`}, + &widget.TextSegment{Text: `Click "Tools" on the top menu`}, + &widget.TextSegment{Text: `Select "WebSocket Server Settings"`}, + &widget.TextSegment{Text: `Check the "Enable WebSocket server" checkbox`}, + &widget.TextSegment{Text: `In the window click "Show Connect Info"`}, + &widget.TextSegment{Text: `Copy the data from the connect info to the fields above`}, + }}, + ) + instructionText.Wrapping = fyne.TextWrapWord + + waitCh := make(chan struct{}) + skip := false + skipButton := widget.NewButtonWithIcon("Skip", theme.ConfirmIcon(), func() { + skip = true + close(waitCh) + }) + + var port uint64 + okButton := widget.NewButtonWithIcon("OK", theme.ConfirmIcon(), func() { + var err error + port, err = strconv.ParseUint(portField.Text, 10, 16) + if err != nil { + p.displayError(fmt.Errorf("unable to parse port '%s': %w", portField.Text, err)) + return + } + + close(waitCh) + }) + + w.SetContent(container.NewBorder( + widget.NewRichTextWithText("Enter OBS user info:"), + container.NewHBox(skipButton, okButton), + nil, + nil, + container.NewVBox( + hostField, + portField, + passField, + instructionText, + ), + )) + w.Show() + <-waitCh + w.Hide() + + if skip { + cfg.Enable = ptr(false) + return false, nil + } + + cfg.Config.Host = hostField.Text + cfg.Config.Port = uint16(port) + cfg.Config.Password = passField.Text + + return true, nil +} + func (p *Panel) oauthHandlerTwitch(ctx context.Context, arg oauthhandler.OAuthHandlerArgument) error { logger.Infof(ctx, "oauthHandlerTwitch: %#+v", arg) defer logger.Infof(ctx, "/oauthHandlerTwitch") @@ -506,6 +599,8 @@ func (p *Panel) initStreamControllers(ctx context.Context) error { for _, platName := range platNames { var err error switch strings.ToLower(string(platName)) { + case strings.ToLower(string(obs.ID)): + err = p.initOBSBackend(ctx) case strings.ToLower(string(twitch.ID)): err = p.initTwitchBackend(ctx) case strings.ToLower(string(youtube.ID)): @@ -793,6 +888,13 @@ func (p *Panel) openSettingsWindow(ctx context.Context) { templateInstruction := widget.NewRichTextFromMarkdown("Commands support [Go templates](https://pkg.go.dev/text/template) with two custom functions predefined:\n* `devnull` nullifies any inputs\n* `httpGET` makes an HTTP GET request and inserts the response body") templateInstruction.Wrapping = fyne.TextWrapWord + obsAlreadyLoggedIn := widget.NewLabel("") + if p.streamControllers.OBS == nil { + obsAlreadyLoggedIn.SetText("(not logged in)") + } else { + obsAlreadyLoggedIn.SetText("(already logged in)") + } + twitchAlreadyLoggedIn := widget.NewLabel("") if p.streamControllers.Twitch == nil { twitchAlreadyLoggedIn.SetText("(not logged in)") @@ -819,8 +921,30 @@ func (p *Panel) openSettingsWindow(ctx context.Context) { widget.NewSeparator(), widget.NewSeparator(), widget.NewRichTextFromMarkdown(`# Streaming platforms`), + container.NewHBox( + widget.NewButtonWithIcon("(Re-)login in OBS", theme.LoginIcon(), func() { + if p.data.Backends[obs.ID] == nil { + obs.InitConfig(p.data.Backends) + } + oldEnable := p.data.Backends[obs.ID].Enable + oldCfg := p.data.Backends[obs.ID].Config + p.data.Backends[obs.ID].Enable = nil + p.data.Backends[obs.ID].Config = obs.PlatformSpecificConfig{} + err := p.initOBSBackend(ctx) + if err != nil { + p.displayError(err) + p.data.Backends[obs.ID].Enable = oldEnable + p.data.Backends[obs.ID].Config = oldCfg + return + } + }), + obsAlreadyLoggedIn, + ), container.NewHBox( widget.NewButtonWithIcon("(Re-)login in Twitch", theme.LoginIcon(), func() { + if p.data.Backends[twitch.ID] == nil { + twitch.InitConfig(p.data.Backends) + } oldEnable := p.data.Backends[twitch.ID].Enable oldCfg := p.data.Backends[twitch.ID].Config p.data.Backends[twitch.ID].Enable = nil @@ -837,6 +961,9 @@ func (p *Panel) openSettingsWindow(ctx context.Context) { ), container.NewHBox( widget.NewButtonWithIcon("(Re-)login in YouTube", theme.LoginIcon(), func() { + if p.data.Backends[youtube.ID] == nil { + youtube.InitConfig(p.data.Backends) + } oldEnable := p.data.Backends[youtube.ID].Enable oldCfg := p.data.Backends[youtube.ID].Config p.data.Backends[youtube.ID].Config = youtube.PlatformSpecificConfig{} @@ -890,39 +1017,6 @@ func (p *Panel) openSettingsWindow(ctx context.Context) { w.Show() } -func (p *Panel) initTwitchBackend(ctx context.Context) error { - twitch, err := newTwitch( - ctx, - p.data.Backends[twitch.ID], - p.inputTwitchUserInfo, - func(cfg *streamcontrol.AbstractPlatformConfig) error { - return p.savePlatformConfig(ctx, twitch.ID, cfg) - }, - p.oauthHandlerTwitch) - if err != nil { - return err - } - p.streamControllers.Twitch = twitch - return nil -} - -func (p *Panel) initYouTubeBackend(ctx context.Context) error { - youTube, err := newYouTube( - ctx, - p.data.Backends[youtube.ID], - p.inputYouTubeUserInfo, - func(cfg *streamcontrol.AbstractPlatformConfig) error { - return p.savePlatformConfig(ctx, youtube.ID, cfg) - }, - p.oauthHandlerYouTube, - ) - if err != nil { - return err - } - p.streamControllers.YouTube = youTube - return nil -} - func (p *Panel) resetCache(ctx context.Context) { var wg sync.WaitGroup @@ -1062,6 +1156,12 @@ func (p *Panel) initMainWindow(ctx context.Context) { p.startStopButton.OnTapped() } + p.obsCheck = widget.NewCheck("OBS", nil) + p.obsCheck.SetChecked(true) + if p.streamControllers.OBS == nil { + p.obsCheck.SetChecked(false) + p.obsCheck.Disable() + } p.twitchCheck = widget.NewCheck("Twitch", nil) p.twitchCheck.SetChecked(true) if p.streamControllers.Twitch == nil { @@ -1081,7 +1181,7 @@ func (p *Panel) initMainWindow(ctx context.Context) { container.NewBorder( nil, nil, - container.NewHBox(p.twitchCheck, p.youtubeCheck), + container.NewHBox(p.obsCheck, p.twitchCheck, p.youtubeCheck), nil, p.startStopButton, ), @@ -1152,6 +1252,7 @@ func (p *Panel) startStream(ctx context.Context) { return } + p.obsCheck.Disable() p.twitchCheck.Disable() p.youtubeCheck.Disable() @@ -1164,10 +1265,20 @@ func (p *Panel) startStream(ctx context.Context) { p.updateTimerHandler = newUpdateTimerHandler(p.startStopButton) profile := p.getSelectedProfile() + var obsProfile *obs.StreamProfile + if p.twitchCheck.Checked && p.streamControllers.Twitch != nil { + var err error + obsProfile, err = streamcontrol.GetStreamProfile[obs.StreamProfile](ctx, profile.PerPlatform[obs.ID]) + if err != nil { + p.displayError(fmt.Errorf("unable to get the streaming profile for OBS: %w", err)) + return + } + } + var twitchProfile *twitch.StreamProfile if p.twitchCheck.Checked && p.streamControllers.Twitch != nil { var err error - twitchProfile, err = streamcontrol.GetStreamProfile[twitch.StreamProfile](p.defaultContext, profile.PerPlatform[twitch.ID]) + twitchProfile, err = streamcontrol.GetStreamProfile[twitch.StreamProfile](ctx, profile.PerPlatform[twitch.ID]) if err != nil { p.displayError(fmt.Errorf("unable to get the streaming profile for Twitch: %w", err)) return @@ -1177,7 +1288,7 @@ func (p *Panel) startStream(ctx context.Context) { var youtubeProfile *youtube.StreamProfile if p.youtubeCheck.Checked && p.streamControllers.YouTube != nil { var err error - youtubeProfile, err = streamcontrol.GetStreamProfile[youtube.StreamProfile](p.defaultContext, profile.PerPlatform[youtube.ID]) + youtubeProfile, err = streamcontrol.GetStreamProfile[youtube.StreamProfile](ctx, profile.PerPlatform[youtube.ID]) if err != nil { p.displayError(fmt.Errorf("unable to get the streaming profile for YouTube: %w", err)) return @@ -1186,7 +1297,7 @@ func (p *Panel) startStream(ctx context.Context) { if p.twitchCheck.Checked && p.streamControllers.Twitch != nil { err := p.streamControllers.Twitch.StartStream( - p.defaultContext, + ctx, p.streamTitleField.Text, p.streamDescriptionField.Text, *twitchProfile, @@ -1198,7 +1309,7 @@ func (p *Panel) startStream(ctx context.Context) { if p.youtubeCheck.Checked && p.streamControllers.YouTube != nil { err := p.streamControllers.YouTube.StartStream( - p.defaultContext, + ctx, p.streamTitleField.Text, p.streamDescriptionField.Text, *youtubeProfile, @@ -1208,6 +1319,18 @@ func (p *Panel) startStream(ctx context.Context) { } } + if p.obsCheck.Checked && p.streamControllers.OBS != nil { + err := p.streamControllers.OBS.StartStream( + ctx, + p.streamTitleField.Text, + p.streamDescriptionField.Text, + *obsProfile, + ) + if err != nil { + p.displayError(fmt.Errorf("unable to start the stream on OBS: %w", err)) + } + } + p.execCommand(ctx, p.data.Commands.OnStartStream) p.startStopButton.Refresh() @@ -1217,6 +1340,9 @@ func (p *Panel) stopStream(ctx context.Context) { p.startStopMutex.Lock() defer p.startStopMutex.Unlock() + if p.streamControllers.OBS != nil { + p.obsCheck.Enable() + } if p.streamControllers.Twitch != nil { p.twitchCheck.Enable() } @@ -1232,9 +1358,17 @@ func (p *Panel) stopStream(ctx context.Context) { } p.updateTimerHandler = nil + if p.streamControllers.OBS != nil { + p.startStopButton.SetText("Stopping OBS...") + err := p.streamControllers.OBS.EndStream(ctx) + if err != nil { + p.displayError(fmt.Errorf("unable to stop the stream on OBS: %w", err)) + } + } + if p.streamControllers.Twitch != nil { p.startStopButton.SetText("Stopping Twitch...") - err := p.streamControllers.Twitch.EndStream(p.defaultContext) + err := p.streamControllers.Twitch.EndStream(ctx) if err != nil { p.displayError(fmt.Errorf("unable to stop the stream on Twitch: %w", err)) } @@ -1242,7 +1376,7 @@ func (p *Panel) stopStream(ctx context.Context) { if p.streamControllers.YouTube != nil { p.startStopButton.SetText("Stopping YouTube...") - err := p.streamControllers.YouTube.EndStream(p.defaultContext) + err := p.streamControllers.YouTube.EndStream(ctx) if err != nil { p.displayError(fmt.Errorf("unable to stop the stream on YouTube: %w", err)) } @@ -1385,6 +1519,7 @@ func (p *Panel) profileWindow( commitFn func(context.Context, Profile) error, ) fyne.Window { var ( + obsProfile *obs.StreamProfile twitchProfile *twitch.StreamProfile youtubeProfile *youtube.StreamProfile ) @@ -1517,6 +1652,22 @@ func (p *Panel) profileWindow( var bottomContent []fyne.CanvasObject + bottomContent = append(bottomContent, widget.NewSeparator()) + bottomContent = append(bottomContent, widget.NewRichTextFromMarkdown("# OBS:")) + if p.streamControllers.OBS != nil { + if platProfile := values.PerPlatform[obs.ID]; platProfile != nil { + obsProfile = ptr(streamcontrol.GetPlatformSpecificConfig[obs.StreamProfile](ctx, platProfile)) + } else { + obsProfile = &obs.StreamProfile{} + } + + enableRecordingCheck := widget.NewCheck("Enable recording", func(b bool) { + obsProfile.EnableRecording = b + }) + enableRecordingCheck.SetChecked(obsProfile.EnableRecording) + bottomContent = append(bottomContent, enableRecordingCheck) + } + bottomContent = append(bottomContent, widget.NewSeparator()) bottomContent = append(bottomContent, widget.NewRichTextFromMarkdown("# Twitch:")) if p.streamControllers.Twitch != nil { @@ -1622,7 +1773,6 @@ func (p *Panel) profileWindow( autoNumerateCheck := widget.NewCheck("Auto-numerate", func(b bool) { youtubeProfile.AutoNumerate = b }) - autoNumerateCheck.MouseOut() autoNumerateCheck.SetChecked(youtubeProfile.AutoNumerate) autoNumerateHint := NewHintWidget(w, "When enabled, it adds the number of the stream to the stream's title.\n\nFor example 'Watching presidential debate' -> 'Watching presidential debate [#52]'.") bottomContent = append(bottomContent, container.NewHBox(autoNumerateCheck, autoNumerateHint)) @@ -1729,6 +1879,9 @@ func (p *Panel) profileWindow( MaxOrder: 0, }, } + if obsProfile != nil { + profile.PerPlatform[obs.ID] = obsProfile + } if twitchProfile != nil { for i := 0; i < len(twitchProfile.Tags); i++ { var v string diff --git a/pkg/streampanel/panel_data.go b/pkg/streampanel/panel_data.go index d82b9b9..d6b2cfc 100644 --- a/pkg/streampanel/panel_data.go +++ b/pkg/streampanel/panel_data.go @@ -12,6 +12,7 @@ import ( "github.com/goccy/go-yaml" "github.com/nicklaw5/helix/v2" "github.com/xaionaro-go/streamctl/pkg/streamcontrol" + "github.com/xaionaro-go/streamctl/pkg/streamcontrol/obs" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube" ) @@ -53,6 +54,7 @@ type panelData struct { func newPanelData() panelData { cfg := streamcontrol.Config{} + obs.InitConfig(cfg) twitch.InitConfig(cfg) youtube.InitConfig(cfg) return panelData{ @@ -63,6 +65,7 @@ func newPanelData() panelData { func newSamplePanelData() panelData { cfg := newPanelData() + cfg.Backends[obs.ID].StreamProfiles = map[streamcontrol.ProfileName]streamcontrol.AbstractStreamProfile{"some_profile": obs.StreamProfile{}} cfg.Backends[twitch.ID].StreamProfiles = map[streamcontrol.ProfileName]streamcontrol.AbstractStreamProfile{"some_profile": twitch.StreamProfile{}} cfg.Backends[youtube.ID].StreamProfiles = map[streamcontrol.ProfileName]streamcontrol.AbstractStreamProfile{"some_profile": youtube.StreamProfile{}} return cfg @@ -97,6 +100,14 @@ func readPanelData( cfg.Backends = streamcontrol.Config{} } + if cfg.Backends[obs.ID] != nil { + err = streamcontrol.ConvertStreamProfiles[obs.StreamProfile](ctx, cfg.Backends[obs.ID].StreamProfiles) + if err != nil { + return fmt.Errorf("unable to convert stream profiles of OBS: %w: <%s>", err, b) + } + logger.Debugf(ctx, "final stream profiles of OBS: %#+v", cfg.Backends[obs.ID].StreamProfiles) + } + if cfg.Backends[twitch.ID] != nil { err = streamcontrol.ConvertStreamProfiles[twitch.StreamProfile](ctx, cfg.Backends[twitch.ID].StreamProfiles) if err != nil { diff --git a/pkg/streampanel/stream_controller.go b/pkg/streampanel/stream_controller.go index 353a870..a994fc7 100644 --- a/pkg/streampanel/stream_controller.go +++ b/pkg/streampanel/stream_controller.go @@ -7,12 +7,69 @@ import ( "github.com/facebookincubator/go-belt/tool/logger" "github.com/xaionaro-go/streamctl/pkg/streamcontrol" + "github.com/xaionaro-go/streamctl/pkg/streamcontrol/obs" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch" "github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube" ) var ErrSkipBackend = errors.New("backend was skipped") +func newOBS( + ctx context.Context, + cfg *streamcontrol.AbstractPlatformConfig, + setConnectionInfo func(context.Context, *streamcontrol.PlatformConfig[obs.PlatformSpecificConfig, obs.StreamProfile]) (bool, error), + saveCfgFunc func(*streamcontrol.AbstractPlatformConfig) error, +) ( + *obs.OBS, + error, +) { + platCfg := streamcontrol.ConvertPlatformConfig[obs.PlatformSpecificConfig, obs.StreamProfile]( + ctx, cfg, + ) + if platCfg == nil { + return nil, fmt.Errorf("OBS config was not found") + } + + if cfg.Enable != nil && !*cfg.Enable { + return nil, ErrSkipBackend + } + + hadSetNewConnectionInfo := false + if platCfg.Config.Host == "" || platCfg.Config.Port == 0 { + ok, err := setConnectionInfo(ctx, platCfg) + if !ok { + err := saveCfgFunc(&streamcontrol.AbstractPlatformConfig{ + Enable: platCfg.Enable, + Config: platCfg.Config, + StreamProfiles: streamcontrol.ToAbstractStreamProfiles(platCfg.StreamProfiles), + }) + if err != nil { + logger.Error(ctx, err) + } + return nil, ErrSkipBackend + } + if err != nil { + return nil, fmt.Errorf("unable to set connection info: %w", err) + } + hadSetNewConnectionInfo = true + } + + logger.Debugf(ctx, "OBS config: %#+v", platCfg) + cfg = streamcontrol.ToAbstractPlatformConfig(ctx, platCfg) + obs, err := obs.New(ctx, *platCfg) + if err != nil { + return nil, fmt.Errorf("unable to initialize OBS client: %w", err) + } + if hadSetNewConnectionInfo { + logger.Debugf(ctx, "confirmed new OBS connection info, saving it") + if err := saveCfgFunc(cfg); err != nil { + return nil, fmt.Errorf("unable to save the configuration: %w", err) + } + } + return obs, nil + +} + func newTwitch( ctx context.Context, cfg *streamcontrol.AbstractPlatformConfig, @@ -142,3 +199,52 @@ func newYouTube( } return yt, nil } + +func (p *Panel) initOBSBackend(ctx context.Context) error { + obs, err := newOBS( + ctx, + p.data.Backends[obs.ID], + p.inputOBSConnectInfo, + func(cfg *streamcontrol.AbstractPlatformConfig) error { + return p.savePlatformConfig(ctx, obs.ID, cfg) + }, + ) + if err != nil { + return err + } + p.streamControllers.OBS = obs + return nil +} + +func (p *Panel) initTwitchBackend(ctx context.Context) error { + twitch, err := newTwitch( + ctx, + p.data.Backends[twitch.ID], + p.inputTwitchUserInfo, + func(cfg *streamcontrol.AbstractPlatformConfig) error { + return p.savePlatformConfig(ctx, twitch.ID, cfg) + }, + p.oauthHandlerTwitch) + if err != nil { + return err + } + p.streamControllers.Twitch = twitch + return nil +} + +func (p *Panel) initYouTubeBackend(ctx context.Context) error { + youTube, err := newYouTube( + ctx, + p.data.Backends[youtube.ID], + p.inputYouTubeUserInfo, + func(cfg *streamcontrol.AbstractPlatformConfig) error { + return p.savePlatformConfig(ctx, youtube.ID, cfg) + }, + p.oauthHandlerYouTube, + ) + if err != nil { + return err + } + p.streamControllers.YouTube = youTube + return nil +}