Initial commit, pt. 22

This commit is contained in:
Dmitrii Okunev
2024-06-23 19:31:00 +01:00
parent 5da165f9f5
commit bbe098fdfb
11 changed files with 600 additions and 47 deletions

View File

@@ -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

9
go.mod
View File

@@ -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

15
go.sum
View File

@@ -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=

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}