Initial commit, pt. 42

This commit is contained in:
Dmitrii Okunev
2024-07-14 22:40:24 +01:00
parent 8476cbc7f1
commit 60013622d4
45 changed files with 6593 additions and 613 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ qamel-*
rcc*.go
rcc*.cpp
*.qrc
.vscode

View File

@@ -22,11 +22,17 @@ streampanel-ios: builddir
streampanel-windows: builddir
CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows go build -ldflags "-H windowsgui" -o build/streampanel.exe ./cmd/streampanel/
streampanel-windows-debug: builddir
CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows go build -o build/streampanel-debug.exe ./cmd/streampanel/
streamd-linux-amd64: builddir
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o build/streamd-linux-amd64 ./cmd/streamd
streamcli-linux-amd64: builddir
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o build/streamcli-linux-amd64 ./cmd/streamcli
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/streamcli-linux-amd64 ./cmd/streamcli
streamcli-linux-arm64: builddir
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/streamcli-linux-arm64 ./cmd/streamcli
builddir:
mkdir -p build

View File

@@ -3,6 +3,7 @@ package commands
import (
"bytes"
"context"
"crypto"
"encoding/json"
"fmt"
"io"
@@ -62,6 +63,12 @@ var (
Run: variablesGet,
}
VariablesGetHash = &cobra.Command{
Use: "get_hash",
Args: cobra.ExactArgs(1),
Run: variablesGetHash,
}
VariablesSet = &cobra.Command{
Use: "set",
Args: cobra.ExactArgs(1),
@@ -78,6 +85,7 @@ func init() {
Root.AddCommand(Variables)
Variables.AddCommand(VariablesGet)
Variables.AddCommand(VariablesGetHash)
Variables.AddCommand(VariablesSet)
Root.PersistentFlags().Var(&LoggerLevel, "log-level", "")
@@ -176,6 +184,20 @@ func variablesGet(cmd *cobra.Command, args []string) {
assertNoError(ctx, err)
}
func variablesGetHash(cmd *cobra.Command, args []string) {
variableKey := args[0]
ctx := cmd.Context()
remoteAddr, err := cmd.Flags().GetString("remote-addr")
assertNoError(ctx, err)
streamD := client.New(remoteAddr)
b, err := streamD.GetVariableHash(ctx, consts.VarKey(variableKey), crypto.SHA1)
assertNoError(ctx, err)
fmt.Printf("%X\n", b)
}
func variablesSet(cmd *cobra.Command, args []string) {
variableKey := args[0]
ctx := cmd.Context()

View File

@@ -20,6 +20,7 @@ import (
"github.com/spf13/pflag"
"github.com/xaionaro-go/streamctl/cmd/streamd/ui"
"github.com/xaionaro-go/streamctl/pkg/observability"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
"github.com/xaionaro-go/streamctl/pkg/streamd"
"github.com/xaionaro-go/streamctl/pkg/streamd/config"
"github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc"
@@ -154,9 +155,11 @@ func main() {
}
}
go func() {
if err = streamD.Run(ctx); err != nil {
l.Errorf("streamd returned an error: %v", err)
}
}()
listener, err := net.Listen("tcp", *listenAddr)
if err != nil {
@@ -172,7 +175,10 @@ func main() {
streamdGRPC = server.NewGRPCServer(streamD)
streamd_grpc.RegisterStreamDServer(grpcServer, streamdGRPC)
l.Infof("started server at %s", *listenAddr)
streamdGRPCLocker.Unlock()
err = grpcServer.Serve(listener)
streamdGRPCLocker.Lock()
if err != nil {
log.Fatal(err)
}
@@ -180,10 +186,18 @@ func main() {
_ui = ui.NewUI(
ctx,
func(authURL string) {
func(listenPort uint16, platID streamcontrol.PlatformName, authURL string) bool {
logger.Tracef(ctx, "streamd.UI.OpenOAuthURL(%d, %s, '%s')", listenPort, platID, authURL)
defer logger.Tracef(ctx, "/streamd.UI.OpenOAuthURL(%d, %s, '%s')", listenPort, platID, authURL)
streamdGRPCLocker.Lock()
logger.Tracef(ctx, "streamdGRPCLocker.Lock()-ed")
defer logger.Tracef(ctx, "streamdGRPCLocker.Lock()-ed")
defer streamdGRPCLocker.Unlock()
streamdGRPC.OpenOAuthURL(authURL)
err := streamdGRPC.OpenOAuthURL(ctx, listenPort, platID, authURL)
errmon.ObserveErrorCtx(ctx, err)
return err == nil
},
func(ctx context.Context, s string) {
restart()

View File

@@ -2,6 +2,9 @@ package ui
import (
"context"
"fmt"
"sync"
"time"
"github.com/facebookincubator/go-belt"
"github.com/facebookincubator/go-belt/tool/logger"
@@ -15,22 +18,25 @@ import (
)
type UI struct {
OAuthURLOpenFn func(authURL string)
OAuthURLOpenFn func(listenPort uint16, platID streamcontrol.PlatformName, authURL string) bool
Belt *belt.Belt
RestartFn func(context.Context, string)
CodeChMap map[streamcontrol.PlatformName]chan string
CodeChMapLocker sync.Mutex
}
var _ ui.UI = (*UI)(nil)
func NewUI(
ctx context.Context,
oauthURLOpener func(authURL string),
oauthURLOpener func(listenPort uint16, platID streamcontrol.PlatformName, authURL string) bool,
restartFn func(context.Context, string),
) *UI {
return &UI{
OAuthURLOpenFn: oauthURLOpener,
Belt: belt.CtxBelt(ctx),
RestartFn: restartFn,
CodeChMap: map[streamcontrol.PlatformName]chan string{},
}
}
@@ -52,33 +58,99 @@ func (*UI) InputGitUserData(
return false, "", nil, nil
}
func (ui *UI) oauth2Handler(
func (ui *UI) newOAuthCodeReceiver(
_ context.Context,
arg oauthhandler.OAuthHandlerArgument,
) error {
codeCh, err := oauthhandler.NewCodeReceiver(arg.RedirectURL)
if err != nil {
return err
platID streamcontrol.PlatformName,
) (<-chan string, context.CancelFunc) {
ui.CodeChMapLocker.Lock()
defer ui.CodeChMapLocker.Unlock()
if oldCh, ok := ui.CodeChMap[platID]; ok {
return oldCh, nil
}
ui.OAuthURLOpenFn(arg.AuthURL)
ch := make(chan string)
ui.CodeChMap[platID] = ch
code := <-codeCh
return ch, func() {
ui.CodeChMapLocker.Lock()
defer ui.CodeChMapLocker.Unlock()
delete(ui.CodeChMap, platID)
}
}
func (ui *UI) getOAuthCodeReceiver(
_ context.Context,
platID streamcontrol.PlatformName,
) chan<- string {
ui.CodeChMapLocker.Lock()
defer ui.CodeChMapLocker.Unlock()
return ui.CodeChMap[platID]
}
func (ui *UI) oauth2Handler(
ctx context.Context,
platID streamcontrol.PlatformName,
arg oauthhandler.OAuthHandlerArgument,
) error {
ctx, cancelFn := context.WithCancel(ctx)
defer cancelFn()
codeCh, removeReceiver := ui.newOAuthCodeReceiver(ctx, platID)
if codeCh == nil {
return fmt.Errorf("there is already another oauth handler for this platform running")
}
if removeReceiver != nil {
defer removeReceiver()
}
logger.Debugf(ctx, "asking to open the URL: %s", arg.AuthURL)
ui.OAuthURLOpenFn(arg.ListenPort, platID, arg.AuthURL)
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
logger.Debugf(ctx, "waiting for an auth code")
select {
case <-ctx.Done():
return ctx.Err()
case code := <-codeCh:
return arg.ExchangeFn(code)
case <-t.C:
logger.Debugf(ctx, "re-asking to open the URL: %s", arg.AuthURL)
ui.OAuthURLOpenFn(arg.ListenPort, platID, arg.AuthURL)
}
}
}
func (ui *UI) OnSubmittedOAuthCode(
ctx context.Context,
platID streamcontrol.PlatformName,
code string,
) error {
codeCh := ui.getOAuthCodeReceiver(ctx, platID)
if codeCh == nil {
logger.Debugf(ctx, "no code receiver for '%s'", platID)
return nil
}
codeCh <- code
return nil
}
func (ui *UI) OAuthHandlerTwitch(
ctx context.Context,
arg oauthhandler.OAuthHandlerArgument,
) error {
return ui.oauth2Handler(ctx, arg)
return ui.oauth2Handler(ctx, twitch.ID, arg)
}
func (ui *UI) OAuthHandlerYouTube(
ctx context.Context,
arg oauthhandler.OAuthHandlerArgument,
) error {
return ui.oauth2Handler(ctx, arg)
return ui.oauth2Handler(ctx, youtube.ID, arg)
}
func (*UI) InputTwitchUserInfo(

View File

@@ -5,4 +5,4 @@ Website = "https://github.com/xaionaro/streamctl"
Name = "streampanel"
ID = "center.dx.streampanel"
Version = "0.1.0"
Build = 31
Build = 37

View File

@@ -17,10 +17,12 @@ import (
"github.com/getsentry/sentry-go"
"github.com/spf13/pflag"
"github.com/xaionaro-go/streamctl/pkg/observability"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
"github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc"
"github.com/xaionaro-go/streamctl/pkg/streamd/server"
"github.com/xaionaro-go/streamctl/pkg/streampanel"
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
_ "github.com/xaionaro-go/streamctl/pkg/streamserver"
"google.golang.org/grpc"
)
@@ -125,10 +127,25 @@ func main() {
}
if *listenAddr != "" {
go func() {
grpcServer := grpc.NewServer()
streamdGRPC := server.NewGRPCServer(panel.StreamD)
streamd_grpc.RegisterStreamDServer(grpcServer, streamdGRPC)
// to erase an oauth request answered locally from "UnansweredOAuthRequests" in the GRPC server:
panel.OnInternallySubmittedOAuthCode = func(
ctx context.Context,
platID streamcontrol.PlatformName,
code string,
) error {
_, err := streamdGRPC.SubmitOAuthCode(ctx, &streamd_grpc.SubmitOAuthCodeRequest{
PlatID: string(platID),
Code: code,
})
return err
}
// start the server:
go func() {
l.Infof("started server at %s", *listenAddr)
err = grpcServer.Serve(listener)
if err != nil {

23
go.mod
View File

@@ -72,7 +72,6 @@ require (
github.com/google/gopacket v1.1.19 // indirect
github.com/google/pprof v0.0.0-20240207164012-fb44976bdcd5 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.2 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
@@ -117,7 +116,7 @@ require (
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.58 // indirect
github.com/miekg/dns v1.1.59 // indirect
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
@@ -139,6 +138,10 @@ require (
github.com/opencontainers/runtime-spec v1.2.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.14 // indirect
github.com/pion/rtp v1.8.6 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -153,6 +156,8 @@ require (
github.com/rymdport/portal v0.2.2 // indirect
github.com/samber/lo v1.36.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 // indirect
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect
@@ -178,13 +183,13 @@ require (
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
golang.org/x/tools v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
@@ -200,15 +205,16 @@ require (
require (
fyne.io/fyne/v2 v2.4.6-0.20240630191754-79694263c06a
github.com/AlexxIT/go2rtc v1.9.4
github.com/DataDog/gostackparse v0.6.0
github.com/andreykaipov/goobs v1.4.1
github.com/anthonynsimon/bild v0.14.0
github.com/chai2010/webp v1.1.1
github.com/dustin/go-broadcast v0.0.0-20211018055107-71439988bd91
github.com/getsentry/sentry-go v0.28.1
github.com/go-git/go-git/v5 v5.12.0
github.com/go-ng/xmath v0.0.0-20230704233441-028f5ea62335
github.com/go-yaml/yaml v2.1.0+incompatible
github.com/google/uuid v1.6.0
github.com/hyprspace/hyprspace v0.10.1
github.com/immune-gmbh/attestation-sdk v0.0.0-20230711173209-f44e4502aeca
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237
@@ -217,11 +223,12 @@ require (
github.com/multiformats/go-multiaddr v0.12.3
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/prometheus/client_golang v1.18.0
github.com/rs/zerolog v1.33.0
github.com/spf13/pflag v1.0.5
github.com/xaionaro-go/datacounter v1.0.4
github.com/xaionaro-go/unsafetools v0.0.0-20210722164218-75ba48cf7b3c
github.com/yl2chen/cidranger v1.0.2
golang.org/x/crypto v0.23.0
golang.org/x/crypto v0.24.0
google.golang.org/grpc v1.63.2
google.golang.org/protobuf v1.33.1-0.20240408130810-98873a205002
)

50
go.sum
View File

@@ -54,6 +54,8 @@ fyne.io/fyne/v2 v2.4.6-0.20240630191754-79694263c06a/go.mod h1:9D4oT3NWeG+MLi/lP
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
github.com/AlexxIT/go2rtc v1.9.4 h1:GC25fWz9S0XwZn/RV5Y4cV7UEGbtIWktYy8Aq96RROg=
github.com/AlexxIT/go2rtc v1.9.4/go.mod h1:3nYj8jnqS0O38cCxa96fbifX1RF6GxAjlzhfEl32zeY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
@@ -136,8 +138,6 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-broadcast v0.0.0-20211018055107-71439988bd91 h1:jAUM3D1KIrJmwx60DKB+a/qqM69yHnu6otDGVa2t0vs=
github.com/dustin/go-broadcast v0.0.0-20211018055107-71439988bd91/go.mod h1:8rK6Kbo1Jd6sK22b24aPVgAm3jlNy1q1ft+lBALdIqA=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/elastic/gosigar v0.12.0/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
github.com/elastic/gosigar v0.14.2 h1:Dg80n8cr90OZ7x+bAax/QjoW/XqTI11RmA79ZwIm9/4=
@@ -499,6 +499,7 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
@@ -506,8 +507,8 @@ github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00v
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/dns v1.1.42/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk=
github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8=
github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c/go.mod h1:0SQS9kMwD2VsyFEB++InYyBJroV/FRmBgcydeSUcJms=
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc=
@@ -588,6 +589,14 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhM
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.6 h1:MTmn/b0aWWsAzux2AmP8WGllusBVw4NPYPVFFd7jUPw=
github.com/pion/rtp v1.8.6/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -626,6 +635,9 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -664,6 +676,10 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4=
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw=
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I=
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA=
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@@ -709,6 +725,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
@@ -810,8 +827,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -858,8 +875,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -907,8 +924,8 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -1007,15 +1024,16 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1097,8 +1115,8 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

17
pkg/loggedsync/mutex.go Normal file
View File

@@ -0,0 +1,17 @@
package loggedsync
import (
"context"
"sync"
"github.com/facebookincubator/go-belt/tool/logger"
)
type Mutex struct {
sync.Mutex
}
func (m *Mutex) LockCtx(ctx context.Context) {
logger.Tracef(ctx, "")
m.Mutex.Lock()
}

View File

@@ -6,31 +6,16 @@ import (
"log"
"net"
"net/http"
"net/url"
"os/exec"
"runtime"
"github.com/facebookincubator/go-belt/tool/logger"
)
type OAuthHandlerArgument struct {
AuthURL string
RedirectURL string
ListenPort uint16
ExchangeFn func(code string) error
}
// it is guaranteed exchangeFn was called if error is nil.
func OAuth2Handler(ctx context.Context, arg OAuthHandlerArgument) error {
if arg.RedirectURL != "" {
err := OAuth2HandlerViaBrowser(ctx, arg)
if err == nil {
return nil
}
logger.Errorf(ctx, "unable to authenticate automatically: %v", err)
}
return OAuth2HandlerViaCLI(ctx, arg)
}
func OAuth2HandlerViaCLI(ctx context.Context, arg OAuthHandlerArgument) error {
fmt.Printf(
"It is required to get an oauth2 token. "+
@@ -47,7 +32,9 @@ func OAuth2HandlerViaCLI(ctx context.Context, arg OAuthHandlerArgument) error {
}
func OAuth2HandlerViaBrowser(ctx context.Context, arg OAuthHandlerArgument) error {
codeCh, err := NewCodeReceiver(arg.RedirectURL)
ctx, cancelFn := context.WithCancel(ctx)
defer cancelFn()
codeCh, _, err := NewCodeReceiver(ctx, arg.ListenPort)
if err != nil {
return err
}
@@ -64,32 +51,39 @@ func OAuth2HandlerViaBrowser(ctx context.Context, arg OAuthHandlerArgument) erro
return arg.ExchangeFn(code)
}
func NewCodeReceiver(redirectURL string) (codeCh chan string, err error) {
urlParsed, err := url.Parse(redirectURL)
func NewCodeReceiver(
ctx context.Context,
listenPort uint16,
) (chan string, uint16, error) {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", listenPort))
if err != nil {
return nil, fmt.Errorf("unable to parse URL '%s': %w", redirectURL, err)
return nil, 0, err
}
codeCh := make(chan string)
// this function was mostly borrowed from https://developers.google.com/youtube/v3/code_samples/go#authorize_a_request
listener, err := net.Listen("tcp", urlParsed.Host)
if err != nil {
return nil, err
}
codeCh = make(chan string)
go http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code")
codeCh <- code
listener.Close()
w.Header().Set("Content-Type", "text/plain")
if code == "" {
fmt.Fprintf(w, "No code received :(\r\n\r\nYou can close this browser window.")
return
}
fmt.Fprintf(w, "Received code: %v\r\n\r\nYou can now safely close this browser window.", code)
}))
}),
}
return codeCh, nil
go func() {
<-ctx.Done()
listener.Close()
srv.Close()
close(codeCh)
}()
go srv.Serve(listener)
return codeCh, uint16(listener.Addr().(*net.TCPAddr).Port), nil
}
func LaunchBrowser(url string) error {

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
@@ -18,7 +19,11 @@ import (
type Twitch struct {
client *helix.Client
config Config
broadcasterID string
lazyInitOnce sync.Once
saveCfgFn func(Config) error
tokenLocker sync.Mutex
}
var _ streamcontrol.StreamController[StreamProfile] = (*Twitch)(nil)
@@ -26,7 +31,7 @@ var _ streamcontrol.StreamController[StreamProfile] = (*Twitch)(nil)
func New(
ctx context.Context,
cfg Config,
safeCfgFn func(Config) error,
saveCfgFn func(Config) error,
) (*Twitch, error) {
if cfg.Config.Channel == "" {
return nil, fmt.Errorf("'channel' is not set")
@@ -34,20 +39,17 @@ func New(
if cfg.Config.ClientID == "" || cfg.Config.ClientSecret == "" {
return nil, fmt.Errorf("'clientid' or/and 'clientsecret' is/are not set; go to https://dev.twitch.tv/console/apps/create and create an app if it not created, yet")
}
client, err := getClient(ctx, cfg, safeCfgFn)
client, err := getClient(ctx, cfg, saveCfgFn)
if err != nil {
return nil, err
}
logger.Debugf(ctx, "initialized a client")
broadcasterID, err := getUserID(ctx, client, cfg.Config.Channel)
if err != nil {
return nil, fmt.Errorf("unable to get the user ID: %w", err)
}
logger.Debugf(ctx, "broadcaster_id: %s (login: %s)", broadcasterID, cfg.Config.Channel)
return &Twitch{
t := &Twitch{
client: client,
broadcasterID: broadcasterID,
}, nil
config: cfg,
saveCfgFn: saveCfgFn,
}
logger.Debugf(ctx, "initialized a client")
return t, nil
}
func getUserID(
@@ -67,6 +69,25 @@ func getUserID(
return resp.Data.Users[0].ID, nil
}
func (t *Twitch) prepare(ctx context.Context) error {
err := t.getTokenIfNeeded(ctx)
if err != nil {
return err
}
t.lazyInitOnce.Do(func() {
if t.broadcasterID != "" {
return
}
t.broadcasterID, err = getUserID(ctx, t.client, t.config.Config.Channel)
if err != nil {
return
}
logger.Debugf(ctx, "broadcaster_id: %s (login: %s)", t.broadcasterID, t.config.Config.Channel)
})
return err
}
func (t *Twitch) Close() error {
return nil
}
@@ -125,6 +146,8 @@ func (t *Twitch) ApplyProfile(
profile StreamProfile,
customArgs ...any,
) error {
t.prepare(ctx)
if profile.CategoryName != nil {
if profile.CategoryID != nil {
logger.Warnf(ctx, "both category name and ID are set; these are contradicting stream profile settings; prioritizing the name")
@@ -195,6 +218,7 @@ func (t *Twitch) SetTitle(
ctx context.Context,
title string,
) error {
t.prepare(ctx)
return t.editChannelInfo(ctx, &helix.EditChannelInformationParams{
Title: title,
})
@@ -233,6 +257,7 @@ func (t *Twitch) StartStream(
profile StreamProfile,
customArgs ...any,
) error {
t.prepare(ctx)
var result error
if err := t.SetTitle(ctx, title); err != nil {
result = multierror.Append(result, fmt.Errorf("unable to set title: %w", err))
@@ -256,6 +281,7 @@ func (t *Twitch) EndStream(
func (t *Twitch) GetStreamStatus(
ctx context.Context,
) (*streamcontrol.StreamStatus, error) {
t.prepare(ctx)
// Twitch ends a stream automatically, nothing to do:
reply, err := t.client.GetStreams(&helix.StreamsParams{
UserIDs: []string{t.broadcasterID},
@@ -281,15 +307,185 @@ func (t *Twitch) GetStreamStatus(
}, nil
}
func (t *Twitch) getTokenIfNeeded(
ctx context.Context,
) error {
switch t.config.Config.AuthType {
case "user":
t.tokenLocker.Lock()
t.client.SetUserAccessToken(t.config.Config.UserAccessToken)
t.client.SetRefreshToken(t.config.Config.RefreshToken)
t.tokenLocker.Unlock()
if t.config.Config.UserAccessToken != "" {
return nil
}
case "app":
t.tokenLocker.Lock()
t.client.SetUserAccessToken(t.config.Config.AppAccessToken) // shouldn't it be "SetAppAccessToken"?
t.tokenLocker.Unlock()
if t.config.Config.AppAccessToken != "" {
logger.Debugf(ctx, "already have an app access token")
return nil
}
logger.Debugf(ctx, "do not have an app access token")
}
return t.getNewToken(ctx)
}
func (t *Twitch) getNewToken(
ctx context.Context,
) error {
t.tokenLocker.Lock()
defer t.tokenLocker.Unlock()
switch t.config.Config.AuthType {
case "user":
if t.config.Config.ClientCode == "" {
getPortsFn := t.config.Config.GetOAuthListenPorts
if getPortsFn == nil {
return fmt.Errorf("the function GetOAuthListenPorts is not set")
}
oauthHandler := t.config.Config.CustomOAuthHandler
if oauthHandler == nil {
oauthHandler = oauthhandler.OAuth2HandlerViaCLI
}
ctx, cancelFunc := context.WithCancel(ctx)
var errWg sync.WaitGroup
var resultErr error
errCh := make(chan error)
errWg.Add(1)
go func() {
errWg.Done()
for err := range errCh {
errmon.ObserveErrorCtx(ctx, err)
resultErr = multierror.Append(resultErr, err)
}
}()
alreadyListening := map[uint16]struct{}{}
var wg sync.WaitGroup
success := false
startHandlerForPort := func(listenPort uint16) {
if _, ok := alreadyListening[listenPort]; ok {
return
}
logger.Tracef(ctx, "starting the oauth handler at port %d", listenPort)
wg.Add(1)
go func(listenPort uint16) {
defer wg.Done()
authURL := GetAuthorizationURL(
&helix.AuthorizationURLParams{
ResponseType: "code", // or "token"
Scopes: []string{"channel:manage:broadcast"},
},
t.config.Config.ClientID,
fmt.Sprintf("127.0.0.1:%d", listenPort),
)
arg := oauthhandler.OAuthHandlerArgument{
AuthURL: authURL,
ExchangeFn: func(code string) error {
t.config.Config.ClientCode = code
err := t.saveCfgFn(t.config)
errmon.ObserveErrorCtx(ctx, err)
return nil
},
}
err := oauthHandler(ctx, arg)
if err != nil {
errCh <- fmt.Errorf("unable to get or exchange the oauth code to a token: %w", err)
return
}
cancelFunc()
success = true
}(listenPort)
}
for _, listenPort := range getPortsFn() {
startHandlerForPort(listenPort)
}
wg.Add(1)
go func() {
defer wg.Done()
t := time.NewTicker(time.Second)
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
ports := getPortsFn()
logger.Tracef(ctx, "oauth listener ports: %#+v", ports)
alreadyListeningNext := map[uint16]struct{}{}
for _, listenPort := range ports {
startHandlerForPort(listenPort)
alreadyListeningNext[listenPort] = struct{}{}
}
alreadyListening = alreadyListeningNext
}
}()
go func() {
wg.Wait()
close(errCh)
}()
<-ctx.Done()
if !success {
errWg.Wait()
return resultErr
}
}
resp, err := t.client.RequestUserAccessToken(t.config.Config.ClientCode)
if err != nil {
return fmt.Errorf("unable to get user access token: %w", err)
}
if resp.ErrorStatus != 0 {
return fmt.Errorf("unable to query: %d %v: %v", resp.ErrorStatus, resp.Error, resp.ErrorMessage)
}
t.client.SetUserAccessToken(resp.Data.AccessToken)
t.client.SetRefreshToken(resp.Data.RefreshToken)
t.config.Config.ClientCode = ""
t.config.Config.UserAccessToken = resp.Data.AccessToken
t.config.Config.RefreshToken = resp.Data.RefreshToken
err = t.saveCfgFn(t.config)
errmon.ObserveErrorCtx(ctx, err)
case "app":
resp, err := t.client.RequestAppAccessToken(nil)
if err != nil {
return fmt.Errorf("unable to get app access token: %w", err)
}
if resp.ErrorStatus != 0 {
return fmt.Errorf("unable to get app access token (the response contains an error): %d %v: %v", resp.ErrorStatus, resp.Error, resp.ErrorMessage)
}
logger.Debugf(ctx, "setting the app access token")
t.client.SetAppAccessToken(resp.Data.AccessToken)
t.config.Config.AppAccessToken = resp.Data.AccessToken
err = t.saveCfgFn(t.config)
errmon.ObserveErrorCtx(ctx, err)
default:
return fmt.Errorf("invalid AuthType: <%s>", t.config.Config.AuthType)
}
return nil
}
func getClient(
ctx context.Context,
cfg Config,
safeCfgFn func(Config) error,
saveCfgFn func(Config) error,
) (*helix.Client, error) {
options := &helix.Options{
ClientID: cfg.Config.ClientID,
ClientSecret: cfg.Config.ClientSecret,
RedirectURI: "http://0.0.0.0:8091/", // TODO: make this secure and also random
}
client, err := helix.NewClient(options)
if err != nil {
@@ -299,89 +495,41 @@ func getClient(
logger.Debugf(ctx, "updated tokens")
cfg.Config.UserAccessToken = newAccessToken
cfg.Config.RefreshToken = newRefreshToken
err := safeCfgFn(cfg)
err := saveCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err)
})
switch cfg.Config.AuthType {
case "user":
client.SetUserAccessToken(cfg.Config.UserAccessToken)
client.SetRefreshToken(cfg.Config.RefreshToken)
if cfg.Config.UserAccessToken == "" {
if cfg.Config.ClientCode == "" {
authURL := client.GetAuthorizationURL(&helix.AuthorizationURLParams{
ResponseType: "code", // or "token"
Scopes: []string{"channel:manage:broadcast"},
})
arg := oauthhandler.OAuthHandlerArgument{
AuthURL: authURL,
RedirectURL: options.RedirectURI,
ExchangeFn: func(code string) error {
cfg.Config.ClientCode = code
err = safeCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err)
return nil
},
}
oauthHandler := cfg.Config.CustomOAuthHandler
if oauthHandler == nil {
oauthHandler = oauthhandler.OAuth2HandlerViaCLI
}
err := oauthHandler(ctx, arg)
if err != nil {
return nil, fmt.Errorf("unable to get or exchange the oauth code to a token: %w", err)
}
}
resp, err := client.RequestUserAccessToken(cfg.Config.ClientCode)
if err != nil {
return nil, fmt.Errorf("unable to get user access token: %w", err)
}
if resp.ErrorStatus != 0 {
return nil, fmt.Errorf("unable to query: %d %v: %v", resp.ErrorStatus, resp.Error, resp.ErrorMessage)
}
client.SetUserAccessToken(resp.Data.AccessToken)
client.SetRefreshToken(resp.Data.RefreshToken)
cfg.Config.ClientCode = ""
cfg.Config.UserAccessToken = resp.Data.AccessToken
cfg.Config.RefreshToken = resp.Data.RefreshToken
err = safeCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err)
}
case "app":
if cfg.Config.AppAccessToken != "" {
logger.Debugf(ctx, "already have an app access token")
client.SetUserAccessToken(cfg.Config.AppAccessToken) // shouldn't it be "SetAppAccessToken"?
break
}
logger.Debugf(ctx, "do not have an app access token")
resp, err := client.RequestAppAccessToken(nil)
if err != nil {
return nil, fmt.Errorf("unable to get app access token: %w", err)
}
if resp.ErrorStatus != 0 {
return nil, fmt.Errorf("unable to get app access token (the response contains an error): %d %v: %v", resp.ErrorStatus, resp.Error, resp.ErrorMessage)
}
logger.Debugf(ctx, "setting the app access token")
client.SetAppAccessToken(resp.Data.AccessToken)
cfg.Config.AppAccessToken = resp.Data.AccessToken
err = safeCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err)
default:
return nil, fmt.Errorf("invalid AuthType: <%s>", cfg.Config.AuthType)
}
return client, nil
}
func GetAuthorizationURL(
params *helix.AuthorizationURLParams,
clientID string,
redirectURI string,
) string {
url := helix.AuthBaseURL + "/authorize"
url += "?response_type=" + params.ResponseType
url += "&client_id=" + clientID
url += "&redirect_uri=" + redirectURI
if params.State != "" {
url += "&state=" + params.State
}
if params.ForceVerify {
url += "&force_verify=true"
}
if len(params.Scopes) != 0 {
url += "&scope=" + strings.Join(params.Scopes, "%20")
}
return url
}
func (t *Twitch) GetAllCategories(
ctx context.Context,
) ([]helix.Game, error) {
t.prepare(ctx)
categoriesMap := map[string]helix.Game{}
var pagination *helix.Pagination
for {

View File

@@ -21,6 +21,7 @@ type PlatformSpecificConfig struct {
UserAccessToken string
RefreshToken string
CustomOAuthHandler OAuthHandler `yaml:"-"`
GetOAuthListenPorts func() []uint16 `yaml:"-"`
}
type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile]

View File

@@ -17,6 +17,7 @@ type PlatformSpecificConfig struct {
ClientSecret string
Token *oauth2.Token
CustomOAuthHandler OAuthHandler `yaml:"-"`
GetOAuthListenPorts func() []uint16 `yaml:"-"`
}
type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile]

View File

@@ -11,12 +11,14 @@ import (
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/go-yaml/yaml"
"github.com/hashicorp/go-multierror"
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
"golang.org/x/oauth2"
@@ -74,6 +76,10 @@ func New(
case <-ticker.C:
err := yt.checkToken(ctx)
errmon.ObserveErrorCtx(ctx, err)
if err != nil && strings.Contains(err.Error(), "expired or revoked") {
_, err := yt.getNewToken(ctx)
errmon.ObserveErrorCtx(ctx, err)
}
}
}
}()
@@ -82,8 +88,8 @@ func New(
}
func (yt *YouTube) checkToken(ctx context.Context) (_err error) {
logger.Debugf(ctx, "YouTube.checkToken")
defer func() { logger.Debugf(ctx, "/YouTube.checkToken: %v", _err) }()
logger.Tracef(ctx, "YouTube.checkToken")
defer func() { logger.Tracef(ctx, "/YouTube.checkToken: %v", _err) }()
yt.locker.Lock()
defer yt.locker.Unlock()
@@ -91,24 +97,29 @@ func (yt *YouTube) checkToken(ctx context.Context) (_err error) {
}
func (yt *YouTube) checkTokenNoLock(ctx context.Context) (_err error) {
logger.Debugf(ctx, "YouTube.checkTokenNoLock")
defer func() { logger.Debugf(ctx, "/YouTube.checkTokenNoLock: %v", _err) }()
logger.Tracef(ctx, "YouTube.checkTokenNoLock")
defer func() { logger.Tracef(ctx, "/YouTube.checkTokenNoLock: %v", _err) }()
tokenSource := getAuthCfg(yt.Config).TokenSource(ctx, yt.Config.Config.Token)
logger.Debugf(ctx, "checking if the token changed")
tokenSource := getAuthCfgBase(yt.Config).TokenSource(ctx, yt.Config.Config.Token)
counter := 0
for {
logger.Tracef(ctx, "checking if the token changed")
token, err := tokenSource.Token()
if err != nil {
logger.Errorf(ctx, "unable to get a token: %v", err)
return err
if yt.fixError(ctx, err, &counter) {
continue
}
return fmt.Errorf("unable to get a token: %v", err)
}
if token.AccessToken == yt.Config.Config.Token.AccessToken {
logger.Debugf(ctx, "the token have not change")
return err
logger.Tracef(ctx, "the token have not changed")
return nil
}
logger.Debugf(ctx, "the token have changed")
logger.Tracef(ctx, "the token have changed")
yt.Config.Config.Token = token
return yt.SaveConfigFunc(yt.Config)
}
}
func (yt *YouTube) getNewToken(ctx context.Context) (_ret *oauth2.Token, _err error) {
logger.Debugf(ctx, "YouTube.getNewToken")
@@ -144,7 +155,9 @@ func (yt *YouTube) init(ctx context.Context) (_err error) {
isNewToken = true
}
tokenSource := getAuthCfg(yt.Config).TokenSource(ctx, yt.Config.Config.Token)
authCfg := getAuthCfgBase(yt.Config)
tokenSource := authCfg.TokenSource(ctx, yt.Config.Config.Token)
if !isNewToken {
if err := yt.checkTokenNoLock(ctx); err != nil {
@@ -155,7 +168,7 @@ func (yt *YouTube) init(ctx context.Context) (_err error) {
return err
}
isNewToken = true
tokenSource = getAuthCfg(yt.Config).TokenSource(ctx, yt.Config.Config.Token)
tokenSource = authCfg.TokenSource(ctx, yt.Config.Config.Token)
}
}
@@ -175,12 +188,11 @@ func (yt *YouTube) init(ctx context.Context) (_err error) {
return nil
}
func getAuthCfg(cfg Config) *oauth2.Config {
func getAuthCfgBase(cfg Config) *oauth2.Config {
return &oauth2.Config{
ClientID: cfg.Config.ClientID,
ClientSecret: cfg.Config.ClientSecret,
Endpoint: google.Endpoint,
RedirectURL: "http://0.0.0.0:8090", // TODO: make this secure and also random
Scopes: []string{
"https://www.googleapis.com/auth/youtube",
},
@@ -188,18 +200,51 @@ func getAuthCfg(cfg Config) *oauth2.Config {
}
func getToken(ctx context.Context, cfg Config) (*oauth2.Token, error) {
googleAuthCfg := getAuthCfg(cfg)
if cfg.Config.GetOAuthListenPorts == nil {
return nil, fmt.Errorf("function GetOAuthListenPorts is not set")
}
ctx, cancelFn := context.WithCancel(ctx)
defer cancelFn()
var errWg sync.WaitGroup
var resultErr error
errCh := make(chan error)
errWg.Add(1)
go func() {
errWg.Done()
for err := range errCh {
errmon.ObserveErrorCtx(ctx, err)
resultErr = multierror.Append(resultErr, err)
}
}()
alreadyListening := map[uint16]struct{}{}
var wg sync.WaitGroup
var tok *oauth2.Token
startHandlerForPort := func(listenPort uint16) {
if _, ok := alreadyListening[listenPort]; ok {
return
}
logger.Tracef(ctx, "starting the oauth handler at port %d", listenPort)
alreadyListening[listenPort] = struct{}{}
oauthCfg := getAuthCfgBase(cfg)
oauthCfg.RedirectURL = fmt.Sprintf("http://127.0.0.1:%d", listenPort)
wg.Add(1)
go func(oauthCfg *oauth2.Config) {
defer wg.Done()
oauthHandlerArg := oauthhandler.OAuthHandlerArgument{
AuthURL: googleAuthCfg.AuthCodeURL("state-token", oauth2.AccessTypeOffline),
RedirectURL: googleAuthCfg.RedirectURL,
AuthURL: oauthCfg.AuthCodeURL("state-token", oauth2.AccessTypeOffline),
ListenPort: listenPort,
ExchangeFn: func(code string) error {
_tok, err := googleAuthCfg.Exchange(ctx, code)
_tok, err := oauthCfg.Exchange(ctx, code)
if err != nil {
return fmt.Errorf("unable to get a token: %w", err)
}
tok = _tok
cancelFn()
return nil
},
}
@@ -208,9 +253,51 @@ func getToken(ctx context.Context, cfg Config) (*oauth2.Token, error) {
if oauthHandler == nil {
oauthHandler = oauthhandler.OAuth2HandlerViaCLI
}
logger.Tracef(ctx, "calling oauthHandler for %d", listenPort)
err := oauthHandler(ctx, oauthHandlerArg)
logger.Tracef(ctx, "called oauthHandler for %d: %v", listenPort, err)
if err != nil {
return nil, err
errCh <- err
return
}
}(oauthCfg)
}
for _, listenPort := range cfg.Config.GetOAuthListenPorts() {
startHandlerForPort(listenPort)
}
wg.Add(1)
go func() {
defer wg.Done()
t := time.NewTicker(time.Second)
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
ports := cfg.Config.GetOAuthListenPorts()
logger.Tracef(ctx, "oauth listener ports: %#+v", ports)
alreadyListeningNext := map[uint16]struct{}{}
for _, listenPort := range ports {
startHandlerForPort(listenPort)
alreadyListeningNext[listenPort] = struct{}{}
}
alreadyListening = alreadyListeningNext
}
}()
go func() {
wg.Wait()
close(errCh)
}()
<-ctx.Done()
if tok == nil {
errWg.Wait()
return nil, resultErr
}
return tok, nil
@@ -575,9 +662,11 @@ func (yt *YouTube) StartStream(
now := time.Now().UTC()
broadcast.Id = ""
broadcast.Etag = ""
broadcast.ContentDetails.EnableAutoStop = false
broadcast.ContentDetails.BoundStreamLastUpdateTimeMs = ""
broadcast.ContentDetails.BoundStreamId = ""
broadcast.ContentDetails.MonitorStream = nil
broadcast.ContentDetails.ForceSendFields = []string{"EnableAutoStop"}
broadcast.Snippet.ScheduledStartTime = now.Format("2006-01-02T15:04:05") + ".00Z"
broadcast.Snippet.ScheduledEndTime = now.Add(time.Hour*12).Format("2006-01-02T15:04:05") + ".00Z"
broadcast.Snippet.LiveChatId = ""
@@ -696,6 +785,7 @@ func (yt *YouTube) EndStream(
) error {
return yt.updateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
broadcast.ContentDetails.EnableAutoStop = true
broadcast.ContentDetails.MonitorStream.ForceSendFields = []string{"BroadcastStreamDelayMs"}
return nil
}, "contentDetails")
}
@@ -842,17 +932,16 @@ func (yt *YouTube) ListBroadcasts(
}
func (yt *YouTube) fixError(ctx context.Context, err error, counterPtr *int) bool {
if err == nil {
return false
}
if *counterPtr > 2 {
return false
}
*counterPtr++
gErr := &googleapi.Error{}
if !errors.As(err, &gErr) {
return false
}
if gErr.Code == 401 {
tryGetNewToken := func() bool {
logger.Debugf(ctx, "trying to get a new token")
_, tErr := yt.getNewToken(ctx)
if tErr != nil {
logger.Errorf(ctx, "unable to get a new token: %w", err)
@@ -866,5 +955,20 @@ func (yt *YouTube) fixError(ctx context.Context, err error, counterPtr *int) boo
return true
}
if strings.Contains(err.Error(), "token expired") {
logger.Tracef(ctx, "token expired")
return tryGetNewToken()
}
gErr := &googleapi.Error{}
if !errors.As(err, &gErr) {
return false
}
if gErr.Code == 401 {
logger.Tracef(ctx, "error 401")
return tryGetNewToken()
}
return false
}

View File

@@ -0,0 +1,32 @@
package grpcconv
import (
"fmt"
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
"github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc"
)
func StreamServerTypeGo2GRPC(t api.StreamServerType) (streamd_grpc.StreamServerType, error) {
switch t {
case api.StreamServerTypeUndefined:
return streamd_grpc.StreamServerType_Undefined, nil
case api.StreamServerTypeRTMP:
return streamd_grpc.StreamServerType_RTMP, nil
case api.StreamServerTypeRTSP:
return streamd_grpc.StreamServerType_RTSP, nil
}
return streamd_grpc.StreamServerType_Undefined, fmt.Errorf("unexpected value: %v", t)
}
func StreamServerTypeGRPC2Go(t streamd_grpc.StreamServerType) (api.StreamServerType, error) {
switch t {
case streamd_grpc.StreamServerType_Undefined:
return api.StreamServerTypeUndefined, nil
case streamd_grpc.StreamServerType_RTMP:
return api.StreamServerTypeRTMP, nil
case streamd_grpc.StreamServerType_RTSP:
return api.StreamServerTypeRTSP, nil
}
return api.StreamServerTypeUndefined, fmt.Errorf("unexpected value: %v", t)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
"github.com/xaionaro-go/streamctl/pkg/streamd/cache"
"github.com/xaionaro-go/streamctl/pkg/streamd/config"
"github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc"
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
)
@@ -58,6 +59,52 @@ type StreamD interface {
ctx context.Context,
req *scenes.SetCurrentProgramSceneParams,
) error
SubmitOAuthCode(
context.Context,
*streamd_grpc.SubmitOAuthCodeRequest,
) (*streamd_grpc.SubmitOAuthCodeReply, error)
ListStreamServers(
ctx context.Context,
) ([]StreamServer, error)
StartStreamServer(
ctx context.Context,
serverType StreamServerType,
listenAddr string,
) error
StopStreamServer(
ctx context.Context,
listenAddr string,
) error
ListIncomingStreams(
ctx context.Context,
) ([]IncomingStream, error)
ListStreamDestinations(
ctx context.Context,
) ([]StreamDestination, error)
AddStreamDestination(
ctx context.Context,
streamID StreamID,
url string,
) error
RemoveStreamDestination(
ctx context.Context,
streamID StreamID,
) error
ListStreamForwards(
ctx context.Context,
) ([]StreamForward, error)
AddStreamForward(
ctx context.Context,
streamIDSrc StreamID,
streamIDDst StreamID,
) error
RemoveStreamForward(
ctx context.Context,
streamIDSrc StreamID,
streamIDDst StreamID,
) error
}
type BackendDataOBS struct{}
@@ -69,3 +116,32 @@ type BackendDataTwitch struct {
type BackendDataYouTube struct {
Cache cache.YouTube
}
type StreamServerType int
const (
StreamServerTypeUndefined = StreamServerType(iota)
StreamServerTypeRTSP
StreamServerTypeRTMP
)
type StreamServer struct {
Type StreamServerType
ListenAddr string
}
type StreamDestination struct {
StreamID StreamID
URL string
}
type StreamForward struct {
StreamIDSrc StreamID
StreamIDDst StreamID
}
type IncomingStream struct {
StreamID StreamID
}
type StreamID string

View File

@@ -18,6 +18,7 @@ import (
twitch "github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch/types"
youtube "github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube/types"
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
"github.com/xaionaro-go/streamctl/pkg/streamd/api/grpcconv"
"github.com/xaionaro-go/streamctl/pkg/streamd/config"
"github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc"
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
@@ -438,15 +439,18 @@ func (c *Client) UpdateStream(
func (c *Client) SubscriberToOAuthURLs(
ctx context.Context,
) (chan string, error) {
listenPort uint16,
) (chan *streamd_grpc.OAuthRequest, error) {
client, conn, err := c.grpcClient()
if err != nil {
return nil, err
}
result := make(chan string)
result := make(chan *streamd_grpc.OAuthRequest)
subClient, err := client.SubscribeToOAuthRequests(ctx, &streamd_grpc.SubscribeToOAuthRequestsRequest{})
subClient, err := client.SubscribeToOAuthRequests(ctx, &streamd_grpc.SubscribeToOAuthRequestsRequest{
ListenPort: int32(listenPort),
})
if err != nil {
return nil, fmt.Errorf("unable to subscribe to oauth URLs: %w", err)
}
@@ -460,10 +464,15 @@ func (c *Client) SubscriberToOAuthURLs(
for {
res, err := subClient.Recv()
if err == io.EOF {
logger.Debugf(ctx, "the receiver is closed: %v", err)
return
}
if err != nil {
logger.Errorf(ctx, "unable to read data: %v", err)
return
}
result <- res.GetAuthURL()
result <- res
}
}()
@@ -605,3 +614,250 @@ func (c *Client) OBSSetCurrentProgramScene(
func ptr[T any](in T) *T {
return &in
}
func (c *Client) SubmitOAuthCode(
ctx context.Context,
req *streamd_grpc.SubmitOAuthCodeRequest,
) (*streamd_grpc.SubmitOAuthCodeReply, error) {
client, conn, err := c.grpcClient()
if err != nil {
return nil, err
}
defer conn.Close()
return client.SubmitOAuthCode(ctx, req)
}
func (c *Client) ListStreamServers(
ctx context.Context,
) ([]api.StreamServer, error) {
client, conn, err := c.grpcClient()
if err != nil {
return nil, err
}
defer conn.Close()
reply, err := client.ListStreamServers(
ctx,
&streamd_grpc.ListStreamServersRequest{},
)
if err != nil {
return nil, fmt.Errorf("unable to request to list of the stream servers: %w", err)
}
var result []api.StreamServer
for _, server := range reply.GetStreamServers() {
t, err := grpcconv.StreamServerTypeGRPC2Go(server.GetServerType())
if err != nil {
return nil, fmt.Errorf("unable to convert the server type value: %w", err)
}
result = append(result, api.StreamServer{
Type: t,
ListenAddr: server.GetListenAddr(),
})
}
return result, nil
}
func (c *Client) StartStreamServer(
ctx context.Context,
serverType api.StreamServerType,
listenAddr string,
) error {
client, conn, err := c.grpcClient()
if err != nil {
return err
}
defer conn.Close()
t, err := grpcconv.StreamServerTypeGo2GRPC(serverType)
if err != nil {
return fmt.Errorf("unable to convert the server type: %w", err)
}
_, err = client.StartStreamServer(ctx, &streamd_grpc.StartStreamServerRequest{
Config: &streamd_grpc.StreamServer{
ServerType: t,
ListenAddr: listenAddr,
},
})
if err != nil {
return fmt.Errorf("unable to request to start the stream server: %w", err)
}
return nil
}
func (c *Client) StopStreamServer(
ctx context.Context,
listenAddr string,
) error {
client, conn, err := c.grpcClient()
if err != nil {
return err
}
defer conn.Close()
_, err = client.StopStreamServer(ctx, &streamd_grpc.StopStreamServerRequest{
ListenAddr: listenAddr,
})
if err != nil {
return fmt.Errorf("unable to request to stop the stream server: %w", err)
}
return nil
}
func (c *Client) ListIncomingStreams(
ctx context.Context,
) ([]api.IncomingStream, error) {
client, conn, err := c.grpcClient()
if err != nil {
return nil, err
}
defer conn.Close()
reply, err := client.ListIncomingStreams(ctx, &streamd_grpc.ListIncomingStreamsRequest{})
if err != nil {
return nil, fmt.Errorf("unable to request to list the incoming streams: %w", err)
}
var result []api.IncomingStream
for _, stream := range reply.GetIncomingStreams() {
result = append(result, api.IncomingStream{
StreamID: api.StreamID(stream.GetStreamID()),
})
}
return result, nil
}
func (c *Client) ListStreamDestinations(
ctx context.Context,
) ([]api.StreamDestination, error) {
client, conn, err := c.grpcClient()
if err != nil {
return nil, err
}
defer conn.Close()
reply, err := client.ListStreamDestinations(ctx, &streamd_grpc.ListStreamDestinationsRequest{})
if err != nil {
return nil, fmt.Errorf("unable to request to list the stream destinations: %w", err)
}
var result []api.StreamDestination
for _, dst := range reply.GetStreamDestinations() {
result = append(result, api.StreamDestination{
StreamID: api.StreamID(dst.GetStreamID()),
URL: dst.GetUrl(),
})
}
return result, nil
}
func (c *Client) AddStreamDestination(
ctx context.Context,
streamID api.StreamID,
url string,
) error {
client, conn, err := c.grpcClient()
if err != nil {
return err
}
defer conn.Close()
_, err = client.AddStreamDestination(ctx, &streamd_grpc.AddStreamDestinationRequest{
Config: &streamd_grpc.StreamDestination{
StreamID: string(streamID),
Url: url,
},
})
if err != nil {
return fmt.Errorf("unable to request to add the stream destination: %w", err)
}
return nil
}
func (c *Client) RemoveStreamDestination(
ctx context.Context,
streamID api.StreamID,
) error {
client, conn, err := c.grpcClient()
if err != nil {
return err
}
defer conn.Close()
_, err = client.RemoveStreamDestination(ctx, &streamd_grpc.RemoveStreamDestinationRequest{
StreamID: string(streamID),
})
if err != nil {
return fmt.Errorf("unable to request to remove the stream destination: %w", err)
}
return nil
}
func (c *Client) ListStreamForwards(
ctx context.Context,
) ([]api.StreamForward, error) {
client, conn, err := c.grpcClient()
if err != nil {
return nil, err
}
defer conn.Close()
reply, err := client.ListStreamForwards(ctx, &streamd_grpc.ListStreamForwardsRequest{})
if err != nil {
return nil, fmt.Errorf("unable to request to list the stream forwards: %w", err)
}
var result []api.StreamForward
for _, forward := range reply.GetStreamForwards() {
result = append(result, api.StreamForward{
StreamIDSrc: api.StreamID(forward.GetStreamIDSrc()),
StreamIDDst: api.StreamID(forward.GetStreamIDDst()),
})
}
return result, nil
}
func (c *Client) AddStreamForward(
ctx context.Context,
streamIDSrc api.StreamID,
streamIDDst api.StreamID,
) error {
client, conn, err := c.grpcClient()
if err != nil {
return err
}
defer conn.Close()
_, err = client.AddStreamForward(ctx, &streamd_grpc.AddStreamForwardRequest{
Config: &streamd_grpc.StreamForward{
StreamIDSrc: string(streamIDSrc),
StreamIDDst: string(streamIDDst),
},
})
if err != nil {
return fmt.Errorf("unable to request to add the stream forward: %w", err)
}
return nil
}
func (c *Client) RemoveStreamForward(
ctx context.Context,
streamIDSrc api.StreamID,
streamIDDst api.StreamID,
) error {
client, conn, err := c.grpcClient()
if err != nil {
return err
}
defer conn.Close()
_, err = client.RemoveStreamForward(ctx, &streamd_grpc.RemoveStreamForwardRequest{
Config: &streamd_grpc.StreamForward{
StreamIDSrc: string(streamIDSrc),
StreamIDDst: string(streamIDDst),
},
})
if err != nil {
return fmt.Errorf("unable to request to remove the stream forward: %w", err)
}
return nil
}

View File

@@ -102,7 +102,7 @@ func (d *StreamD) onConfigUpdateViaGIT(ctx context.Context, cfg *config.Config)
}
func (d *StreamD) initGitIfNeeded(ctx context.Context) {
logger.Debugf(ctx, "initGitIfNeeded")
logger.Debugf(ctx, "initGitIfNeeded: %#+v", d.Config.GitRepo)
if d.Config.GitRepo.Enable != nil && !*d.Config.GitRepo.Enable {
logger.Debugf(ctx, "git is disabled in the configuration")
return

File diff suppressed because it is too large Load Diff

View File

@@ -44,8 +44,19 @@ type StreamDClient interface {
OBSOLETE_GitInfo(ctx context.Context, in *OBSOLETE_GetGitInfoRequest, opts ...grpc.CallOption) (*OBSOLETE_GetGitInfoReply, error)
OBSOLETE_GitRelogin(ctx context.Context, in *OBSOLETE_GitReloginRequest, opts ...grpc.CallOption) (*OBSOLETE_GitReloginReply, error)
SubscribeToOAuthRequests(ctx context.Context, in *SubscribeToOAuthRequestsRequest, opts ...grpc.CallOption) (StreamD_SubscribeToOAuthRequestsClient, error)
SubmitOAuthCode(ctx context.Context, in *SubmitOAuthCodeRequest, opts ...grpc.CallOption) (*SubmitOAuthCodeReply, error)
OBSGetSceneList(ctx context.Context, in *OBSGetSceneListRequest, opts ...grpc.CallOption) (*OBSGetSceneListReply, error)
OBSSetCurrentProgramScene(ctx context.Context, in *OBSSetCurrentProgramSceneRequest, opts ...grpc.CallOption) (*OBSSetCurrentProgramSceneReply, error)
ListStreamServers(ctx context.Context, in *ListStreamServersRequest, opts ...grpc.CallOption) (*ListStreamServersReply, error)
StartStreamServer(ctx context.Context, in *StartStreamServerRequest, opts ...grpc.CallOption) (*StartStreamServerReply, error)
StopStreamServer(ctx context.Context, in *StopStreamServerRequest, opts ...grpc.CallOption) (*StopStreamServerReply, error)
ListStreamDestinations(ctx context.Context, in *ListStreamDestinationsRequest, opts ...grpc.CallOption) (*ListStreamDestinationsReply, error)
AddStreamDestination(ctx context.Context, in *AddStreamDestinationRequest, opts ...grpc.CallOption) (*AddStreamDestinationReply, error)
RemoveStreamDestination(ctx context.Context, in *RemoveStreamDestinationRequest, opts ...grpc.CallOption) (*RemoveStreamDestinationReply, error)
ListIncomingStreams(ctx context.Context, in *ListIncomingStreamsRequest, opts ...grpc.CallOption) (*ListIncomingStreamsReply, error)
ListStreamForwards(ctx context.Context, in *ListStreamForwardsRequest, opts ...grpc.CallOption) (*ListStreamForwardsReply, error)
AddStreamForward(ctx context.Context, in *AddStreamForwardRequest, opts ...grpc.CallOption) (*AddStreamForwardReply, error)
RemoveStreamForward(ctx context.Context, in *RemoveStreamForwardRequest, opts ...grpc.CallOption) (*RemoveStreamForwardReply, error)
}
type streamDClient struct {
@@ -277,6 +288,15 @@ func (x *streamDSubscribeToOAuthRequestsClient) Recv() (*OAuthRequest, error) {
return m, nil
}
func (c *streamDClient) SubmitOAuthCode(ctx context.Context, in *SubmitOAuthCodeRequest, opts ...grpc.CallOption) (*SubmitOAuthCodeReply, error) {
out := new(SubmitOAuthCodeReply)
err := c.cc.Invoke(ctx, "/StreamD/SubmitOAuthCode", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) OBSGetSceneList(ctx context.Context, in *OBSGetSceneListRequest, opts ...grpc.CallOption) (*OBSGetSceneListReply, error) {
out := new(OBSGetSceneListReply)
err := c.cc.Invoke(ctx, "/StreamD/OBSGetSceneList", in, out, opts...)
@@ -295,6 +315,96 @@ func (c *streamDClient) OBSSetCurrentProgramScene(ctx context.Context, in *OBSSe
return out, nil
}
func (c *streamDClient) ListStreamServers(ctx context.Context, in *ListStreamServersRequest, opts ...grpc.CallOption) (*ListStreamServersReply, error) {
out := new(ListStreamServersReply)
err := c.cc.Invoke(ctx, "/StreamD/ListStreamServers", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) StartStreamServer(ctx context.Context, in *StartStreamServerRequest, opts ...grpc.CallOption) (*StartStreamServerReply, error) {
out := new(StartStreamServerReply)
err := c.cc.Invoke(ctx, "/StreamD/StartStreamServer", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) StopStreamServer(ctx context.Context, in *StopStreamServerRequest, opts ...grpc.CallOption) (*StopStreamServerReply, error) {
out := new(StopStreamServerReply)
err := c.cc.Invoke(ctx, "/StreamD/StopStreamServer", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) ListStreamDestinations(ctx context.Context, in *ListStreamDestinationsRequest, opts ...grpc.CallOption) (*ListStreamDestinationsReply, error) {
out := new(ListStreamDestinationsReply)
err := c.cc.Invoke(ctx, "/StreamD/ListStreamDestinations", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) AddStreamDestination(ctx context.Context, in *AddStreamDestinationRequest, opts ...grpc.CallOption) (*AddStreamDestinationReply, error) {
out := new(AddStreamDestinationReply)
err := c.cc.Invoke(ctx, "/StreamD/AddStreamDestination", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) RemoveStreamDestination(ctx context.Context, in *RemoveStreamDestinationRequest, opts ...grpc.CallOption) (*RemoveStreamDestinationReply, error) {
out := new(RemoveStreamDestinationReply)
err := c.cc.Invoke(ctx, "/StreamD/RemoveStreamDestination", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) ListIncomingStreams(ctx context.Context, in *ListIncomingStreamsRequest, opts ...grpc.CallOption) (*ListIncomingStreamsReply, error) {
out := new(ListIncomingStreamsReply)
err := c.cc.Invoke(ctx, "/StreamD/ListIncomingStreams", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) ListStreamForwards(ctx context.Context, in *ListStreamForwardsRequest, opts ...grpc.CallOption) (*ListStreamForwardsReply, error) {
out := new(ListStreamForwardsReply)
err := c.cc.Invoke(ctx, "/StreamD/ListStreamForwards", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) AddStreamForward(ctx context.Context, in *AddStreamForwardRequest, opts ...grpc.CallOption) (*AddStreamForwardReply, error) {
out := new(AddStreamForwardReply)
err := c.cc.Invoke(ctx, "/StreamD/AddStreamForward", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *streamDClient) RemoveStreamForward(ctx context.Context, in *RemoveStreamForwardRequest, opts ...grpc.CallOption) (*RemoveStreamForwardReply, error) {
out := new(RemoveStreamForwardReply)
err := c.cc.Invoke(ctx, "/StreamD/RemoveStreamForward", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// StreamDServer is the server API for StreamD service.
// All implementations must embed UnimplementedStreamDServer
// for forward compatibility
@@ -321,8 +431,19 @@ type StreamDServer interface {
OBSOLETE_GitInfo(context.Context, *OBSOLETE_GetGitInfoRequest) (*OBSOLETE_GetGitInfoReply, error)
OBSOLETE_GitRelogin(context.Context, *OBSOLETE_GitReloginRequest) (*OBSOLETE_GitReloginReply, error)
SubscribeToOAuthRequests(*SubscribeToOAuthRequestsRequest, StreamD_SubscribeToOAuthRequestsServer) error
SubmitOAuthCode(context.Context, *SubmitOAuthCodeRequest) (*SubmitOAuthCodeReply, error)
OBSGetSceneList(context.Context, *OBSGetSceneListRequest) (*OBSGetSceneListReply, error)
OBSSetCurrentProgramScene(context.Context, *OBSSetCurrentProgramSceneRequest) (*OBSSetCurrentProgramSceneReply, error)
ListStreamServers(context.Context, *ListStreamServersRequest) (*ListStreamServersReply, error)
StartStreamServer(context.Context, *StartStreamServerRequest) (*StartStreamServerReply, error)
StopStreamServer(context.Context, *StopStreamServerRequest) (*StopStreamServerReply, error)
ListStreamDestinations(context.Context, *ListStreamDestinationsRequest) (*ListStreamDestinationsReply, error)
AddStreamDestination(context.Context, *AddStreamDestinationRequest) (*AddStreamDestinationReply, error)
RemoveStreamDestination(context.Context, *RemoveStreamDestinationRequest) (*RemoveStreamDestinationReply, error)
ListIncomingStreams(context.Context, *ListIncomingStreamsRequest) (*ListIncomingStreamsReply, error)
ListStreamForwards(context.Context, *ListStreamForwardsRequest) (*ListStreamForwardsReply, error)
AddStreamForward(context.Context, *AddStreamForwardRequest) (*AddStreamForwardReply, error)
RemoveStreamForward(context.Context, *RemoveStreamForwardRequest) (*RemoveStreamForwardReply, error)
mustEmbedUnimplementedStreamDServer()
}
@@ -396,12 +517,45 @@ func (UnimplementedStreamDServer) OBSOLETE_GitRelogin(context.Context, *OBSOLETE
func (UnimplementedStreamDServer) SubscribeToOAuthRequests(*SubscribeToOAuthRequestsRequest, StreamD_SubscribeToOAuthRequestsServer) error {
return status.Errorf(codes.Unimplemented, "method SubscribeToOAuthRequests not implemented")
}
func (UnimplementedStreamDServer) SubmitOAuthCode(context.Context, *SubmitOAuthCodeRequest) (*SubmitOAuthCodeReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method SubmitOAuthCode not implemented")
}
func (UnimplementedStreamDServer) OBSGetSceneList(context.Context, *OBSGetSceneListRequest) (*OBSGetSceneListReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method OBSGetSceneList not implemented")
}
func (UnimplementedStreamDServer) OBSSetCurrentProgramScene(context.Context, *OBSSetCurrentProgramSceneRequest) (*OBSSetCurrentProgramSceneReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method OBSSetCurrentProgramScene not implemented")
}
func (UnimplementedStreamDServer) ListStreamServers(context.Context, *ListStreamServersRequest) (*ListStreamServersReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListStreamServers not implemented")
}
func (UnimplementedStreamDServer) StartStreamServer(context.Context, *StartStreamServerRequest) (*StartStreamServerReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartStreamServer not implemented")
}
func (UnimplementedStreamDServer) StopStreamServer(context.Context, *StopStreamServerRequest) (*StopStreamServerReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method StopStreamServer not implemented")
}
func (UnimplementedStreamDServer) ListStreamDestinations(context.Context, *ListStreamDestinationsRequest) (*ListStreamDestinationsReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListStreamDestinations not implemented")
}
func (UnimplementedStreamDServer) AddStreamDestination(context.Context, *AddStreamDestinationRequest) (*AddStreamDestinationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddStreamDestination not implemented")
}
func (UnimplementedStreamDServer) RemoveStreamDestination(context.Context, *RemoveStreamDestinationRequest) (*RemoveStreamDestinationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveStreamDestination not implemented")
}
func (UnimplementedStreamDServer) ListIncomingStreams(context.Context, *ListIncomingStreamsRequest) (*ListIncomingStreamsReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListIncomingStreams not implemented")
}
func (UnimplementedStreamDServer) ListStreamForwards(context.Context, *ListStreamForwardsRequest) (*ListStreamForwardsReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListStreamForwards not implemented")
}
func (UnimplementedStreamDServer) AddStreamForward(context.Context, *AddStreamForwardRequest) (*AddStreamForwardReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddStreamForward not implemented")
}
func (UnimplementedStreamDServer) RemoveStreamForward(context.Context, *RemoveStreamForwardRequest) (*RemoveStreamForwardReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveStreamForward not implemented")
}
func (UnimplementedStreamDServer) mustEmbedUnimplementedStreamDServer() {}
// UnsafeStreamDServer may be embedded to opt out of forward compatibility for this service.
@@ -814,6 +968,24 @@ func (x *streamDSubscribeToOAuthRequestsServer) Send(m *OAuthRequest) error {
return x.ServerStream.SendMsg(m)
}
func _StreamD_SubmitOAuthCode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubmitOAuthCodeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).SubmitOAuthCode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/SubmitOAuthCode",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).SubmitOAuthCode(ctx, req.(*SubmitOAuthCodeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_OBSGetSceneList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(OBSGetSceneListRequest)
if err := dec(in); err != nil {
@@ -850,6 +1022,186 @@ func _StreamD_OBSSetCurrentProgramScene_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _StreamD_ListStreamServers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListStreamServersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).ListStreamServers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/ListStreamServers",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).ListStreamServers(ctx, req.(*ListStreamServersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_StartStreamServer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartStreamServerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).StartStreamServer(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/StartStreamServer",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).StartStreamServer(ctx, req.(*StartStreamServerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_StopStreamServer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StopStreamServerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).StopStreamServer(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/StopStreamServer",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).StopStreamServer(ctx, req.(*StopStreamServerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_ListStreamDestinations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListStreamDestinationsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).ListStreamDestinations(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/ListStreamDestinations",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).ListStreamDestinations(ctx, req.(*ListStreamDestinationsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_AddStreamDestination_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddStreamDestinationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).AddStreamDestination(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/AddStreamDestination",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).AddStreamDestination(ctx, req.(*AddStreamDestinationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_RemoveStreamDestination_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveStreamDestinationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).RemoveStreamDestination(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/RemoveStreamDestination",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).RemoveStreamDestination(ctx, req.(*RemoveStreamDestinationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_ListIncomingStreams_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListIncomingStreamsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).ListIncomingStreams(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/ListIncomingStreams",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).ListIncomingStreams(ctx, req.(*ListIncomingStreamsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_ListStreamForwards_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListStreamForwardsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).ListStreamForwards(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/ListStreamForwards",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).ListStreamForwards(ctx, req.(*ListStreamForwardsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_AddStreamForward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddStreamForwardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).AddStreamForward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/AddStreamForward",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).AddStreamForward(ctx, req.(*AddStreamForwardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StreamD_RemoveStreamForward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveStreamForwardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StreamDServer).RemoveStreamForward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/StreamD/RemoveStreamForward",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StreamDServer).RemoveStreamForward(ctx, req.(*RemoveStreamForwardRequest))
}
return interceptor(ctx, in, info, handler)
}
// StreamD_ServiceDesc is the grpc.ServiceDesc for StreamD service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -941,6 +1293,10 @@ var StreamD_ServiceDesc = grpc.ServiceDesc{
MethodName: "OBSOLETE_GitRelogin",
Handler: _StreamD_OBSOLETE_GitRelogin_Handler,
},
{
MethodName: "SubmitOAuthCode",
Handler: _StreamD_SubmitOAuthCode_Handler,
},
{
MethodName: "OBSGetSceneList",
Handler: _StreamD_OBSGetSceneList_Handler,
@@ -949,6 +1305,46 @@ var StreamD_ServiceDesc = grpc.ServiceDesc{
MethodName: "OBSSetCurrentProgramScene",
Handler: _StreamD_OBSSetCurrentProgramScene_Handler,
},
{
MethodName: "ListStreamServers",
Handler: _StreamD_ListStreamServers_Handler,
},
{
MethodName: "StartStreamServer",
Handler: _StreamD_StartStreamServer_Handler,
},
{
MethodName: "StopStreamServer",
Handler: _StreamD_StopStreamServer_Handler,
},
{
MethodName: "ListStreamDestinations",
Handler: _StreamD_ListStreamDestinations_Handler,
},
{
MethodName: "AddStreamDestination",
Handler: _StreamD_AddStreamDestination_Handler,
},
{
MethodName: "RemoveStreamDestination",
Handler: _StreamD_RemoveStreamDestination_Handler,
},
{
MethodName: "ListIncomingStreams",
Handler: _StreamD_ListIncomingStreams_Handler,
},
{
MethodName: "ListStreamForwards",
Handler: _StreamD_ListStreamForwards_Handler,
},
{
MethodName: "AddStreamForward",
Handler: _StreamD_AddStreamForward_Handler,
},
{
MethodName: "RemoveStreamForward",
Handler: _StreamD_RemoveStreamForward_Handler,
},
},
Streams: []grpc.StreamDesc{
{

View File

@@ -27,9 +27,21 @@ service StreamD {
rpc OBSOLETE_GitRelogin(OBSOLETE_GitReloginRequest) returns (OBSOLETE_GitReloginReply) {}
rpc SubscribeToOAuthRequests(SubscribeToOAuthRequestsRequest) returns (stream OAuthRequest) {}
rpc SubmitOAuthCode(SubmitOAuthCodeRequest) returns (SubmitOAuthCodeReply) {}
rpc OBSGetSceneList(OBSGetSceneListRequest) returns (OBSGetSceneListReply) {}
rpc OBSSetCurrentProgramScene(OBSSetCurrentProgramSceneRequest) returns (OBSSetCurrentProgramSceneReply) {}
rpc ListStreamServers(ListStreamServersRequest) returns (ListStreamServersReply) {}
rpc StartStreamServer(StartStreamServerRequest) returns (StartStreamServerReply) {}
rpc StopStreamServer(StopStreamServerRequest) returns (StopStreamServerReply) {}
rpc ListStreamDestinations(ListStreamDestinationsRequest) returns (ListStreamDestinationsReply) {}
rpc AddStreamDestination(AddStreamDestinationRequest) returns (AddStreamDestinationReply) {}
rpc RemoveStreamDestination(RemoveStreamDestinationRequest) returns (RemoveStreamDestinationReply) {}
rpc ListIncomingStreams(ListIncomingStreamsRequest) returns (ListIncomingStreamsReply) {}
rpc ListStreamForwards(ListStreamForwardsRequest) returns (ListStreamForwardsReply) {}
rpc AddStreamForward(AddStreamForwardRequest) returns (AddStreamForwardReply) {}
rpc RemoveStreamForward(RemoveStreamForwardRequest) returns (RemoveStreamForwardReply) {}
}
message GetConfigRequest {}
@@ -113,9 +125,12 @@ message OBSOLETE_GitReloginRequest {}
message OBSOLETE_GitReloginReply {}
message SubscribeToOAuthRequestsRequest{}
message SubscribeToOAuthRequestsRequest{
int32 listenPort = 1;
}
message OAuthRequest{
string authURL = 1;
string platID = 1;
string authURL = 2;
}
message GetVariableRequest {
@@ -164,3 +179,85 @@ message OBSSetCurrentProgramSceneRequest {
}
}
message OBSSetCurrentProgramSceneReply {}
message SubmitOAuthCodeRequest {
string platID = 1;
string code = 2;
}
message SubmitOAuthCodeReply {}
enum StreamServerType {
Undefined = 0;
RTSP = 1;
RTMP = 2;
}
message StreamServer {
StreamServerType serverType = 1;
string listenAddr = 2;
}
message ListStreamServersRequest {}
message ListStreamServersReply {
repeated StreamServer streamServers = 1;
}
message StartStreamServerRequest {
StreamServer config = 1;
}
message StartStreamServerReply {
}
message StopStreamServerRequest {
string listenAddr = 1;
}
message StopStreamServerReply {}
message StreamDestination {
string streamID = 1;
string url = 2;
}
message ListStreamDestinationsRequest {}
message ListStreamDestinationsReply {
repeated StreamDestination streamDestinations = 1;
}
message AddStreamDestinationRequest {
StreamDestination config = 1;
}
message AddStreamDestinationReply {}
message RemoveStreamDestinationRequest {
string streamID = 1;
}
message RemoveStreamDestinationReply {}
message IncomingStream {
string streamID = 1;
}
message ListIncomingStreamsRequest {}
message ListIncomingStreamsReply {
repeated IncomingStream incomingStreams = 1;
}
message StreamForward {
string streamIDSrc = 1;
string streamIDDst = 2;
}
message ListStreamForwardsRequest {}
message ListStreamForwardsReply {
repeated StreamForward streamForwards = 1;
}
message AddStreamForwardRequest {
StreamForward config = 1;
}
message AddStreamForwardReply {}
message RemoveStreamForwardRequest {
StreamForward config = 1;
}
message RemoveStreamForwardReply {}

View File

@@ -3,17 +3,17 @@ package server
import (
"bytes"
"context"
"crypto/sha1"
"crypto"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/andreykaipov/goobs/api/requests/scenes"
"github.com/dustin/go-broadcast"
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/goccy/go-yaml"
"github.com/google/uuid"
"github.com/hashicorp/go-multierror"
"github.com/immune-gmbh/attestation-sdk/pkg/lockmap"
"github.com/immune-gmbh/attestation-sdk/pkg/objhash"
@@ -21,20 +21,31 @@ import (
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/obs"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube"
"github.com/xaionaro-go/streamctl/pkg/streamd"
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
"github.com/xaionaro-go/streamctl/pkg/streamd/api/grpcconv"
"github.com/xaionaro-go/streamctl/pkg/streamd/config"
"github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc"
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
)
type GRPCServer struct {
streamd_grpc.StreamDServer
streamd_grpc.UnimplementedStreamDServer
StreamD api.StreamD
Cache map[objhash.ObjHash]any
CacheMetaLock sync.Mutex
CacheLockMap *lockmap.LockMap
OAuthURLBroadcaster broadcast.Broadcaster
OAuthURLHandlerLocker sync.Mutex
OAuthURLHandlers map[uint16]map[uuid.UUID]*OAuthURLHandler
YouTubeStreamStartedAt time.Time
UnansweredOAuthRequestsLocker sync.Mutex
UnansweredOAuthRequests map[streamcontrol.PlatformName]map[uint16]*streamd_grpc.OAuthRequest
}
type OAuthURLHandler struct {
Sender streamd_grpc.StreamD_SubscribeToOAuthRequestsServer
CancelFn context.CancelFunc
}
var _ streamd_grpc.StreamDServer = (*GRPCServer)(nil)
@@ -45,7 +56,8 @@ func NewGRPCServer(streamd api.StreamD) *GRPCServer {
Cache: map[objhash.ObjHash]any{},
CacheLockMap: lockmap.NewLockMap(),
OAuthURLBroadcaster: broadcast.NewBroadcaster(10),
OAuthURLHandlers: map[uint16]map[uuid.UUID]*OAuthURLHandler{},
UnansweredOAuthRequests: map[streamcontrol.PlatformName]map[uint16]*streamd_grpc.OAuthRequest{},
}
}
@@ -100,13 +112,13 @@ func memoize[REQ any, REPLY any, T func(context.Context, *REQ) (*REPLY, error)](
cachedResult, ok := cache[key]
grpc.CacheMetaLock.Unlock()
logger.Tracef(ctx, "grpc.CacheMetaLock.Unlock()-ed")
if ok {
if v, ok := cachedResult.(cacheItem); ok {
cutoffTS := time.Now().Add(-cacheDuration)
if cacheDuration > 0 && !v.SavedAt.Before(cutoffTS) {
h.UserData = v
grpc.CacheMetaLock.Unlock()
logger.Tracef(ctx, "grpc.CacheMetaLock.Unlock()-ed")
h.UserData = nil
h.Unlock()
logger.Tracef(ctx, "grpc.CacheLockMap.Unlock(%X)-ed", key[:])
logger.Debugf(ctx, "using the cached value")
@@ -118,6 +130,8 @@ func memoize[REQ any, REPLY any, T func(context.Context, *REQ) (*REPLY, error)](
logger.Errorf(ctx, "cache-failure: expected type %T, but got %T", (*cacheItem)(nil), cachedResult)
}
}
grpc.CacheMetaLock.Unlock()
logger.Tracef(ctx, "grpc.CacheMetaLock.Unlock()-ed")
var ts time.Time
defer func() {
@@ -144,7 +158,12 @@ func memoize[REQ any, REPLY any, T func(context.Context, *REQ) (*REPLY, error)](
func (grpc *GRPCServer) Close() error {
err := &multierror.Error{}
err = multierror.Append(err, grpc.OAuthURLBroadcaster.Close())
grpc.OAuthURLHandlerLocker.Lock()
grpc.OAuthURLHandlerLocker.Unlock()
for listenPort, sender := range grpc.OAuthURLHandlers {
_ = sender // TODO: invent sender.Close()
delete(grpc.OAuthURLHandlers, listenPort)
}
return err.ErrorOrNil()
}
@@ -450,32 +469,116 @@ func (grpc *GRPCServer) getStreamStatus(
func (grpc *GRPCServer) SubscribeToOAuthRequests(
req *streamd_grpc.SubscribeToOAuthRequestsRequest,
sender streamd_grpc.StreamD_SubscribeToOAuthRequestsServer,
) error {
logger.Tracef(sender.Context(), "SubscribeToOAuthRequests()")
defer logger.Tracef(sender.Context(), "/SubscribeToOAuthRequests()")
oauthURLChan := make(chan any)
grpc.OAuthURLBroadcaster.Register(oauthURLChan)
defer grpc.OAuthURLBroadcaster.Unregister(oauthURLChan)
for {
select {
case _oauthURL := <-oauthURLChan:
oauthURL := _oauthURL.(string)
err := sender.Send(&streamd_grpc.OAuthRequest{
AuthURL: oauthURL,
})
) (_ret error) {
ctx, cancelFn := context.WithCancel(sender.Context())
_uuid, err := uuid.NewRandom()
if err != nil {
return fmt.Errorf("unable to send the OAuth URL: %w", err)
logger.Errorf(ctx, "unable to generate an UUID: %v", err)
}
case <-sender.Context().Done():
return sender.Context().Err()
logger.Tracef(sender.Context(), "SubscribeToOAuthRequests(): UUID:%s", _uuid)
defer func() { logger.Tracef(sender.Context(), "/SubscribeToOAuthRequests(): UUID:%s: %v", _uuid, _ret) }()
listenPort := uint16(req.ListenPort)
streamD, _ := grpc.StreamD.(*streamd.StreamD)
grpc.OAuthURLHandlerLocker.Lock()
logger.Tracef(sender.Context(), "grpc.OAuthURLHandlerLocker.Lock()-ed")
m := grpc.OAuthURLHandlers[listenPort]
if m == nil {
m = map[uuid.UUID]*OAuthURLHandler{}
}
m[_uuid] = &OAuthURLHandler{
Sender: sender,
CancelFn: cancelFn,
}
grpc.OAuthURLHandlers[listenPort] = m // unnecessary, but feels safer
grpc.OAuthURLHandlerLocker.Unlock()
logger.Tracef(sender.Context(), "grpc.OAuthURLHandlerLocker.Unlock()-ed")
var unansweredRequests []*streamd_grpc.OAuthRequest
grpc.UnansweredOAuthRequestsLocker.Lock()
for _, m := range grpc.UnansweredOAuthRequests {
req := m[listenPort]
if req == nil {
continue
}
unansweredRequests = append(unansweredRequests, req)
}
grpc.UnansweredOAuthRequestsLocker.Unlock()
for _, req := range unansweredRequests {
logger.Tracef(ctx, "re-sending an unanswered request to a new client: %#+v", *req)
err := sender.Send(req)
errmon.ObserveErrorCtx(ctx, err)
}
if streamD != nil {
streamD.AddOAuthListenPort(listenPort)
}
logger.Tracef(ctx, "waiting for the subscription to be cancelled")
<-ctx.Done()
grpc.OAuthURLHandlerLocker.Lock()
defer grpc.OAuthURLHandlerLocker.Unlock()
delete(grpc.OAuthURLHandlers[listenPort], _uuid)
if len(grpc.OAuthURLHandlers[listenPort]) == 0 {
delete(grpc.OAuthURLHandlers, listenPort)
if streamD != nil {
streamD.RemoveOAuthListenPort(listenPort)
}
}
func (grpc *GRPCServer) OpenOAuthURL(authURL string) {
grpc.OAuthURLBroadcaster.Submit(authURL)
return nil
}
type ErrNoOAuthHandlerForPort struct {
Port uint16
}
func (err ErrNoOAuthHandlerForPort) Error() string {
return fmt.Sprintf("no handler for port %d", err.Port)
}
func (grpc *GRPCServer) OpenOAuthURL(
ctx context.Context,
listenPort uint16,
platID streamcontrol.PlatformName,
authURL string,
) (_ret error) {
logger.Tracef(ctx, "OpenOAuthURL()")
defer func() { logger.Tracef(ctx, "/OpenOAuthURL(): %v", _ret) }()
grpc.OAuthURLHandlerLocker.Lock()
logger.Tracef(ctx, "grpc.OAuthURLHandlerLocker.Lock()-ed")
defer logger.Tracef(ctx, "grpc.OAuthURLHandlerLocker.Unlock()-ed")
defer grpc.OAuthURLHandlerLocker.Unlock()
handlers := grpc.OAuthURLHandlers[listenPort]
if handlers == nil {
return ErrNoOAuthHandlerForPort{
Port: listenPort,
}
}
req := streamd_grpc.OAuthRequest{
PlatID: string(platID),
AuthURL: authURL,
}
grpc.UnansweredOAuthRequestsLocker.Lock()
if grpc.UnansweredOAuthRequests[platID] == nil {
grpc.UnansweredOAuthRequests[platID] = map[uint16]*streamd_grpc.OAuthRequest{}
}
grpc.UnansweredOAuthRequests[platID][listenPort] = &req
grpc.UnansweredOAuthRequestsLocker.Unlock()
logger.Tracef(ctx, "OpenOAuthURL() sending %#+v", req)
var resultErr *multierror.Error
for _, handler := range handlers {
err := handler.Sender.Send(&req)
if err != nil {
err = multierror.Append(resultErr, fmt.Errorf("unable to send oauth request: %w", err))
}
}
return resultErr.ErrorOrNil()
}
func (grpc *GRPCServer) GetVariable(
@@ -498,26 +601,25 @@ func (grpc *GRPCServer) GetVariableHash(
ctx context.Context,
req *streamd_grpc.GetVariableHashRequest,
) (*streamd_grpc.GetVariableHashReply, error) {
var hashType crypto.Hash
hashTypeIn := req.GetHashType()
switch hashTypeIn {
case streamd_grpc.HashType_HASH_SHA1:
hashType = crypto.SHA1
default:
return nil, fmt.Errorf("unexpected hash type: %v", hashTypeIn)
}
key := consts.VarKey(req.GetKey())
b, err := grpc.StreamD.GetVariable(ctx, key)
b, err := grpc.StreamD.GetVariableHash(ctx, key, hashType)
if err != nil {
return nil, fmt.Errorf("unable to get variable '%s': %w", key, err)
}
var hash []byte
hashType := req.GetHashType()
switch hashType {
case streamd_grpc.HashType_HASH_SHA1:
_hash := sha1.Sum(b)
hash = _hash[:]
default:
return nil, fmt.Errorf("unexpected hash type: %v", hashType)
}
return &streamd_grpc.GetVariableHashReply{
Key: string(key),
HashType: hashType,
Hash: hash,
HashType: hashTypeIn,
Hash: b,
}, nil
}
@@ -579,3 +681,203 @@ func (grpc *GRPCServer) OBSSetCurrentProgramScene(
}
return &streamd_grpc.OBSSetCurrentProgramSceneReply{}, nil
}
func (grpc *GRPCServer) SubmitOAuthCode(
ctx context.Context,
req *streamd_grpc.SubmitOAuthCodeRequest,
) (*streamd_grpc.SubmitOAuthCodeReply, error) {
grpc.UnansweredOAuthRequestsLocker.Lock()
delete(grpc.UnansweredOAuthRequests, streamcontrol.PlatformName(req.PlatID))
grpc.UnansweredOAuthRequestsLocker.Unlock()
_, err := grpc.StreamD.SubmitOAuthCode(ctx, req)
if err != nil {
return nil, err
}
return &streamd_grpc.SubmitOAuthCodeReply{}, nil
}
func (grpc *GRPCServer) ListStreamServers(
ctx context.Context,
_ *streamd_grpc.ListStreamServersRequest,
) (*streamd_grpc.ListStreamServersReply, error) {
servers, err := grpc.StreamD.ListStreamServers(ctx)
if err != nil {
return nil, err
}
var result []*streamd_grpc.StreamServer
for _, srv := range servers {
t, err := grpcconv.StreamServerTypeGo2GRPC(srv.Type)
if err != nil {
return nil, fmt.Errorf("unable to convert the server type value: %w", err)
}
result = append(result, &streamd_grpc.StreamServer{
ServerType: t,
ListenAddr: srv.ListenAddr,
})
}
return &streamd_grpc.ListStreamServersReply{
StreamServers: result,
}, nil
}
func (grpc *GRPCServer) StartStreamServer(
ctx context.Context,
req *streamd_grpc.StartStreamServerRequest,
) (*streamd_grpc.StartStreamServerReply, error) {
t, err := grpcconv.StreamServerTypeGRPC2Go(req.GetConfig().GetServerType())
if err != nil {
return nil, fmt.Errorf("unable to convert the server type value: %w", err)
}
err = grpc.StreamD.StartStreamServer(
ctx,
t,
req.GetConfig().GetListenAddr(),
)
if err != nil {
return nil, err
}
return &streamd_grpc.StartStreamServerReply{}, nil
}
func (grpc *GRPCServer) StopStreamServer(
ctx context.Context,
req *streamd_grpc.StopStreamServerRequest,
) (*streamd_grpc.StopStreamServerReply, error) {
err := grpc.StreamD.StopStreamServer(
ctx,
req.GetListenAddr(),
)
if err != nil {
return nil, err
}
return &streamd_grpc.StopStreamServerReply{}, nil
}
func (grpc *GRPCServer) ListStreamDestinations(
ctx context.Context,
req *streamd_grpc.ListStreamDestinationsRequest,
) (*streamd_grpc.ListStreamDestinationsReply, error) {
dsts, err := grpc.StreamD.ListStreamDestinations(
ctx,
)
if err != nil {
return nil, err
}
var result []*streamd_grpc.StreamDestination
for _, dst := range dsts {
result = append(result, &streamd_grpc.StreamDestination{
StreamID: string(dst.StreamID),
Url: dst.URL,
})
}
return &streamd_grpc.ListStreamDestinationsReply{
StreamDestinations: result,
}, nil
}
func (grpc *GRPCServer) AddStreamDestination(
ctx context.Context,
req *streamd_grpc.AddStreamDestinationRequest,
) (*streamd_grpc.AddStreamDestinationReply, error) {
err := grpc.StreamD.AddStreamDestination(
ctx,
api.StreamID(req.GetConfig().GetStreamID()),
req.GetConfig().GetUrl(),
)
if err != nil {
return nil, err
}
return &streamd_grpc.AddStreamDestinationReply{}, nil
}
func (grpc *GRPCServer) RemoveStreamDestination(
ctx context.Context,
req *streamd_grpc.RemoveStreamDestinationRequest,
) (*streamd_grpc.RemoveStreamDestinationReply, error) {
err := grpc.StreamD.RemoveStreamDestination(
ctx,
api.StreamID(req.GetStreamID()),
)
if err != nil {
return nil, err
}
return &streamd_grpc.RemoveStreamDestinationReply{}, nil
}
func (grpc *GRPCServer) ListIncomingStreams(
ctx context.Context,
req *streamd_grpc.ListIncomingStreamsRequest,
) (*streamd_grpc.ListIncomingStreamsReply, error) {
inStreams, err := grpc.StreamD.ListIncomingStreams(
ctx,
)
if err != nil {
return nil, err
}
var result []*streamd_grpc.IncomingStream
for _, s := range inStreams {
result = append(result, &streamd_grpc.IncomingStream{
StreamID: string(s.StreamID),
})
}
return &streamd_grpc.ListIncomingStreamsReply{
IncomingStreams: result,
}, nil
}
func (grpc *GRPCServer) ListStreamForwards(
ctx context.Context,
req *streamd_grpc.ListStreamForwardsRequest,
) (*streamd_grpc.ListStreamForwardsReply, error) {
streamFwds, err := grpc.StreamD.ListStreamForwards(ctx)
if err != nil {
return nil, err
}
var result []*streamd_grpc.StreamForward
for _, s := range streamFwds {
result = append(result, &streamd_grpc.StreamForward{
StreamIDSrc: string(s.StreamIDSrc),
StreamIDDst: string(s.StreamIDDst),
})
}
return &streamd_grpc.ListStreamForwardsReply{
StreamForwards: result,
}, nil
}
func (grpc *GRPCServer) AddStreamForward(
ctx context.Context,
req *streamd_grpc.AddStreamForwardRequest,
) (*streamd_grpc.AddStreamForwardReply, error) {
err := grpc.StreamD.AddStreamForward(
ctx,
api.StreamID(req.GetConfig().GetStreamIDSrc()),
api.StreamID(req.GetConfig().GetStreamIDDst()),
)
if err != nil {
return nil, err
}
return &streamd_grpc.AddStreamForwardReply{}, nil
}
func (grpc *GRPCServer) RemoveStreamForward(
ctx context.Context,
req *streamd_grpc.RemoveStreamForwardRequest,
) (*streamd_grpc.RemoveStreamForwardReply, error) {
err := grpc.StreamD.RemoveStreamForward(
ctx,
api.StreamID(req.GetConfig().GetStreamIDSrc()),
api.StreamID(req.GetConfig().GetStreamIDDst()),
)
if err != nil {
return nil, err
}
return &streamd_grpc.RemoveStreamForwardReply{}, nil
}

View File

@@ -108,6 +108,7 @@ func newTwitch(
setUserData func(context.Context, *streamcontrol.PlatformConfig[twitch.PlatformSpecificConfig, twitch.StreamProfile]) (bool, error),
saveCfgFunc func(*streamcontrol.AbstractPlatformConfig) error,
customOAuthHandler twitch.OAuthHandler,
getOAuthListenPorts func() []uint16,
) (
*twitch.Twitch,
error,
@@ -146,6 +147,7 @@ func newTwitch(
logger.Debugf(ctx, "twitch config: %#+v", platCfg)
platCfg.Config.CustomOAuthHandler = customOAuthHandler
platCfg.Config.GetOAuthListenPorts = getOAuthListenPorts
cfg = streamcontrol.ToAbstractPlatformConfig(ctx, platCfg)
twitch, err := twitch.New(ctx, *platCfg,
func(c twitch.Config) error {
@@ -175,6 +177,7 @@ func newYouTube(
setUserData func(context.Context, *streamcontrol.PlatformConfig[youtube.PlatformSpecificConfig, youtube.StreamProfile]) (bool, error),
saveCfgFunc func(*streamcontrol.AbstractPlatformConfig) error,
customOAuthHandler youtube.OAuthHandler,
getOAuthListenPorts func() []uint16,
) (
*youtube.YouTube,
error,
@@ -213,6 +216,7 @@ func newYouTube(
logger.Debugf(ctx, "youtube config: %#+v", platCfg)
platCfg.Config.CustomOAuthHandler = customOAuthHandler
platCfg.Config.GetOAuthListenPorts = getOAuthListenPorts
cfg = streamcontrol.ToAbstractPlatformConfig(ctx, platCfg)
yt, err := youtube.New(ctx, *platCfg,
func(c youtube.Config) error {
@@ -260,7 +264,9 @@ func (d *StreamD) initTwitchBackend(ctx context.Context) error {
func(cfg *streamcontrol.AbstractPlatformConfig) error {
return d.setPlatformConfig(ctx, twitch.ID, cfg)
},
d.UI.OAuthHandlerTwitch)
d.UI.OAuthHandlerTwitch,
d.GetOAuthListenPorts,
)
if err != nil {
return err
}
@@ -277,6 +283,7 @@ func (d *StreamD) initYouTubeBackend(ctx context.Context) error {
return d.setPlatformConfig(ctx, youtube.ID, cfg)
},
d.UI.OAuthHandlerYouTube,
d.GetOAuthListenPorts,
)
if err != nil {
return fmt.Errorf("unable to initialize the backend 'YouTube': %w", err)

View File

@@ -19,8 +19,11 @@ import (
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
"github.com/xaionaro-go/streamctl/pkg/streamd/cache"
"github.com/xaionaro-go/streamctl/pkg/streamd/config"
"github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc"
"github.com/xaionaro-go/streamctl/pkg/streamd/ui"
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
"github.com/xaionaro-go/streamctl/pkg/streamserver"
"github.com/xaionaro-go/streamctl/pkg/streamserver/types"
"github.com/xaionaro-go/streamctl/pkg/xpath"
)
@@ -51,6 +54,12 @@ type StreamD struct {
StreamControllers StreamControllers
Variables sync.Map
OAuthListenPortsLocker sync.Mutex
OAuthListenPorts map[uint16]struct{}
StreamServerLocker sync.Mutex
StreamServer *streamserver.StreamServer
}
var _ api.StreamD = (*StreamD)(nil)
@@ -68,6 +77,8 @@ func New(
SaveConfigFunc: saveCfgFunc,
Config: config,
Cache: &cache.Cache{},
OAuthListenPorts: map[uint16]struct{}{},
StreamServer: streamserver.New(),
}
err := d.readCache(ctx)
@@ -228,7 +239,7 @@ func (d *StreamD) initTwitchData(ctx context.Context) bool {
return false
}
allCategories, err := twitch.GetAllCategories(ctx)
allCategories, err := twitch.GetAllCategories(d.ctxForController(ctx))
if err != nil {
d.UI.DisplayError(err)
return false
@@ -269,7 +280,7 @@ func (d *StreamD) initYoutubeData(ctx context.Context) bool {
return false
}
broadcasts, err := youtube.ListBroadcasts(ctx)
broadcasts, err := youtube.ListBroadcasts(d.ctxForController(ctx))
if err != nil {
d.UI.DisplayError(err)
return false
@@ -360,7 +371,7 @@ func (d *StreamD) StartStream(
if err != nil {
return fmt.Errorf("unable to convert the profile into OBS profile: %w", err)
}
err = d.StreamControllers.OBS.StartStream(ctx, title, description, *profile, customArgs...)
err = d.StreamControllers.OBS.StartStream(d.ctxForController(ctx), title, description, *profile, customArgs...)
if err != nil {
return fmt.Errorf("unable to start the stream on OBS: %w", err)
}
@@ -370,7 +381,7 @@ func (d *StreamD) StartStream(
if err != nil {
return fmt.Errorf("unable to convert the profile into Twitch profile: %w", err)
}
err = d.StreamControllers.Twitch.StartStream(ctx, title, description, *profile, customArgs...)
err = d.StreamControllers.Twitch.StartStream(d.ctxForController(ctx), title, description, *profile, customArgs...)
if err != nil {
return fmt.Errorf("unable to start the stream on Twitch: %w", err)
}
@@ -380,7 +391,7 @@ func (d *StreamD) StartStream(
if err != nil {
return fmt.Errorf("unable to convert the profile into YouTube profile: %w", err)
}
err = d.StreamControllers.YouTube.StartStream(ctx, title, description, *profile, customArgs...)
err = d.StreamControllers.YouTube.StartStream(d.ctxForController(ctx), title, description, *profile, customArgs...)
if err != nil {
return fmt.Errorf("unable to start the stream on YouTube: %w", err)
}
@@ -393,11 +404,11 @@ func (d *StreamD) StartStream(
func (d *StreamD) EndStream(ctx context.Context, platID streamcontrol.PlatformName) error {
switch platID {
case obs.ID:
return d.StreamControllers.OBS.EndStream(ctx)
return d.StreamControllers.OBS.EndStream(d.ctxForController(ctx))
case twitch.ID:
return d.StreamControllers.Twitch.EndStream(ctx)
return d.StreamControllers.Twitch.EndStream(d.ctxForController(ctx))
case youtube.ID:
return d.StreamControllers.YouTube.EndStream(ctx)
return d.StreamControllers.YouTube.EndStream(d.ctxForController(ctx))
default:
return fmt.Errorf("unexpected platform ID '%s'", platID)
}
@@ -499,7 +510,7 @@ func (d *StreamD) GetStreamStatus(
return nil, fmt.Errorf("controller '%s' is not initialized", platID)
}
return c.GetStreamStatus(ctx)
return c.GetStreamStatus(d.ctxForController(ctx))
}
func (d *StreamD) SetTitle(
@@ -512,7 +523,7 @@ func (d *StreamD) SetTitle(
return err
}
return c.SetTitle(ctx, title)
return c.SetTitle(d.ctxForController(ctx), title)
}
func (d *StreamD) SetDescription(
@@ -525,7 +536,12 @@ func (d *StreamD) SetDescription(
return err
}
return c.SetDescription(ctx, description)
return c.SetDescription(d.ctxForController(ctx), description)
}
// TODO: delete this function (yes, it is not needed at all)
func (d *StreamD) ctxForController(ctx context.Context) context.Context {
return ctx
}
func (d *StreamD) ApplyProfile(
@@ -534,12 +550,12 @@ func (d *StreamD) ApplyProfile(
profile streamcontrol.AbstractStreamProfile,
customArgs ...any,
) error {
c, err := d.streamController(ctx, platID)
c, err := d.streamController(d.ctxForController(ctx), platID)
if err != nil {
return err
}
return c.ApplyProfile(ctx, profile, customArgs...)
return c.ApplyProfile(d.ctxForController(ctx), profile, customArgs...)
}
func (d *StreamD) UpdateStream(
@@ -549,17 +565,17 @@ func (d *StreamD) UpdateStream(
profile streamcontrol.AbstractStreamProfile,
customArgs ...any,
) error {
err := d.SetTitle(ctx, platID, title)
err := d.SetTitle(d.ctxForController(ctx), platID, title)
if err != nil {
return fmt.Errorf("unable to set the title: %w", err)
}
err = d.SetDescription(ctx, platID, description)
err = d.SetDescription(d.ctxForController(ctx), platID, description)
if err != nil {
return fmt.Errorf("unable to set the description: %w", err)
}
err = d.ApplyProfile(ctx, platID, profile, customArgs...)
err = d.ApplyProfile(d.ctxForController(ctx), platID, profile, customArgs...)
if err != nil {
return fmt.Errorf("unable to apply the profile: %w", err)
}
@@ -617,7 +633,7 @@ func (d *StreamD) OBSGetSceneList(
return nil, fmt.Errorf("OBS is not initialized")
}
return obs.GetSceneList(ctx)
return obs.GetSceneList(d.ctxForController(ctx))
}
func (d *StreamD) OBSSetCurrentProgramScene(
@@ -629,5 +645,224 @@ func (d *StreamD) OBSSetCurrentProgramScene(
return fmt.Errorf("OBS is not initialized")
}
return obs.SetCurrentProgramScene(ctx, req)
return obs.SetCurrentProgramScene(d.ctxForController(ctx), req)
}
func (d *StreamD) SubmitOAuthCode(
ctx context.Context,
req *streamd_grpc.SubmitOAuthCodeRequest,
) (*streamd_grpc.SubmitOAuthCodeReply, error) {
err := d.UI.OnSubmittedOAuthCode(
ctx,
streamcontrol.PlatformName(req.GetPlatID()),
req.GetCode(),
)
if err != nil {
return nil, err
}
return &streamd_grpc.SubmitOAuthCodeReply{}, nil
}
func (d *StreamD) AddOAuthListenPort(port uint16) {
logger.Default().Debugf("AddOAuthListenPort(%d)", port)
defer logger.Default().Debugf("/AddOAuthListenPort(%d)", port)
d.OAuthListenPortsLocker.Lock()
defer d.OAuthListenPortsLocker.Unlock()
d.OAuthListenPorts[port] = struct{}{}
}
func (d *StreamD) RemoveOAuthListenPort(port uint16) {
logger.Default().Debugf("RemoveOAuthListenPort(%d)", port)
defer logger.Default().Debugf("/RemoveOAuthListenPort(%d)", port)
d.OAuthListenPortsLocker.Lock()
defer d.OAuthListenPortsLocker.Unlock()
delete(d.OAuthListenPorts, port)
}
func (d *StreamD) GetOAuthListenPorts() []uint16 {
d.OAuthListenPortsLocker.Lock()
defer d.OAuthListenPortsLocker.Unlock()
var ports []uint16
for k := range d.OAuthListenPorts {
ports = append(ports, k)
}
sort.Slice(ports, func(i, j int) bool {
return ports[i] < ports[j]
})
logger.Default().Debugf("oauth ports: %#+v", ports)
return ports
}
func serverTypeServer2API(t types.ServerType) api.StreamServerType {
switch t {
case types.ServerTypeUndefined:
return api.StreamServerTypeUndefined
case types.ServerTypeRTSP:
return api.StreamServerTypeRTSP
case types.ServerTypeRTMP:
return api.StreamServerTypeRTMP
default:
panic(fmt.Errorf("unexpected server type: %v", t))
}
}
func serverTypeAPI2Server(t api.StreamServerType) types.ServerType {
switch t {
case api.StreamServerTypeUndefined:
return types.ServerTypeUndefined
case api.StreamServerTypeRTSP:
return types.ServerTypeRTSP
case api.StreamServerTypeRTMP:
return types.ServerTypeRTMP
default:
panic(fmt.Errorf("unexpected server type: %v", t))
}
}
func (d *StreamD) ListStreamServers(
ctx context.Context,
) ([]api.StreamServer, error) {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
servers := d.StreamServer.ListServers(ctx)
var result []api.StreamServer
for _, src := range servers {
result = append(result, api.StreamServer{
Type: serverTypeServer2API(src.Type()),
ListenAddr: src.ListenAddr(),
})
}
return result, nil
}
func (d *StreamD) StartStreamServer(
ctx context.Context,
serverType api.StreamServerType,
listenAddr string,
) error {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
return d.StreamServer.StartServer(
ctx,
serverTypeAPI2Server(serverType),
listenAddr,
)
}
func (d *StreamD) StopStreamServer(
ctx context.Context,
listenAddr string,
) error {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
for _, server := range d.StreamServer.ListServers(ctx) {
if server.ListenAddr() == listenAddr {
return d.StreamServer.StopServer(ctx, server)
}
}
return fmt.Errorf("have not found any stream listeners at %s", listenAddr)
}
func (d *StreamD) ListIncomingStreams(
ctx context.Context,
) ([]api.IncomingStream, error) {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
var result []api.IncomingStream
for _, src := range d.StreamServer.ListIncomingStreams(ctx) {
result = append(result, api.IncomingStream{
StreamID: api.StreamID(src.StreamID),
})
}
return result, nil
}
func (d *StreamD) ListStreamDestinations(
ctx context.Context,
) ([]api.StreamDestination, error) {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
streamDestinations, err := d.StreamServer.ListStreamDestinations(ctx)
if err != nil {
return nil, err
}
c := make([]api.StreamDestination, 0, len(streamDestinations))
for _, dst := range streamDestinations {
c = append(c, api.StreamDestination{
StreamID: api.StreamID(dst.StreamID),
URL: dst.URL,
})
}
return c, nil
}
func (d *StreamD) AddStreamDestination(
ctx context.Context,
streamID api.StreamID,
url string,
) error {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
return d.StreamServer.AddStreamDestination(ctx, types.StreamID(streamID), url)
}
func (d *StreamD) RemoveStreamDestination(
ctx context.Context,
streamID api.StreamID,
) error {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
return d.StreamServer.RemoveStreamDestination(ctx, types.StreamID(streamID))
}
func (d *StreamD) listStreamForwards(
ctx context.Context,
) ([]api.StreamForward, error) {
var result []api.StreamForward
streamForwards, err := d.StreamServer.ListStreamForwards(ctx)
if err != nil {
return nil, err
}
for _, streamFwd := range streamForwards {
result = append(result, api.StreamForward{
StreamIDSrc: api.StreamID(streamFwd.StreamIDSrc),
StreamIDDst: api.StreamID(streamFwd.StreamIDDst),
})
}
return result, nil
}
func (d *StreamD) ListStreamForwards(
ctx context.Context,
) ([]api.StreamForward, error) {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
return d.listStreamForwards(ctx)
}
func (d *StreamD) AddStreamForward(
ctx context.Context,
streamIDSrc api.StreamID,
streamIDDst api.StreamID,
) error {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
return d.StreamServer.AddStreamForward(ctx, types.StreamID(streamIDSrc), types.StreamID(streamIDDst))
}
func (d *StreamD) RemoveStreamForward(
ctx context.Context,
streamIDSrc api.StreamID,
streamIDDst api.StreamID,
) error {
d.StreamServerLocker.Lock()
defer d.StreamServerLocker.Unlock()
return d.StreamServer.RemoveStreamForward(ctx, types.StreamID(streamIDSrc), types.StreamID(streamIDDst))
}

View File

@@ -31,4 +31,9 @@ type UI interface {
ctx context.Context,
cfg *streamcontrol.PlatformConfig[obs.PlatformSpecificConfig, obs.StreamProfile],
) (bool, error)
OnSubmittedOAuthCode(
ctx context.Context,
platID streamcontrol.PlatformName,
code string,
) error
}

View File

@@ -48,7 +48,7 @@ func (p *Panel) setImage(
err = p.StreamD.SetVariable(ctx, key, b)
if err != nil {
p.DisplayError(fmt.Errorf("unable to set the screenshot: %w", err))
logger.Error(ctx, fmt.Errorf("unable to set the screenshot: %w", err))
}
}
@@ -151,7 +151,10 @@ func (p *Panel) setScreenshot(
}
if bounds.Max.X > ScreenshotMaxWidth || bounds.Max.Y > ScreenshotMaxHeight {
screenshot = imgFitTo(screenshot, bounds.Max)
screenshot = imgFitTo(screenshot, image.Point{
X: ScreenshotMaxWidth,
Y: ScreenshotMaxHeight,
})
logger.Tracef(ctx, "rescaled the screenshot from %#+v to %#+v", bounds, screenshot.Bounds())
}

View File

@@ -2,14 +2,21 @@ package streampanel
import (
"context"
"image"
"sync"
//"image"
"time"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"github.com/anthonynsimon/bild/adjust"
"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"
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
)
@@ -62,14 +69,18 @@ func (p *Panel) updateMonitorPage(
logger.Tracef(ctx, "updateMonitorPage")
defer logger.Tracef(ctx, "/updateMonitorPage")
{
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
img, err := p.getImage(ctx, consts.ImageScreenshot)
if err != nil {
logger.Error(ctx, err)
} else {
s := p.screenshotContainer.Size()
img = imgFitTo(img, image.Point{X: int(s.Width), Y: int(s.Height)})
//s := p.mainWindow.Canvas().Size()
//img = imgFitTo(img, image.Point{X: int(s.Width), Y: int(s.Height)})
img = adjust.Brightness(img, -0.5)
imgFyne := canvas.NewImageFromImage(img)
imgFyne.FillMode = canvas.ImageFillOriginal
@@ -79,15 +90,17 @@ func (p *Panel) updateMonitorPage(
p.screenshotContainer.Objects = append(p.screenshotContainer.Objects, imgFyne)
p.screenshotContainer.Refresh()
}
}
}()
{
wg.Add(1)
go func() {
defer wg.Done()
img, err := p.getImage(ctx, consts.ImageChat)
if err != nil {
logger.Error(ctx, err)
} else {
s := p.chatContainer.Size()
img = imgFitTo(img, image.Point{X: int(s.Width), Y: int(s.Height)})
//s := p.mainWindow.Canvas().Size()
//img = imgFitTo(img, image.Point{X: int(s.Width), Y: int(s.Height)})
imgFyne := canvas.NewImageFromImage(img)
imgFyne.FillMode = canvas.ImageFillOriginal
@@ -96,5 +109,53 @@ func (p *Panel) updateMonitorPage(
p.chatContainer.Objects = append(p.chatContainer.Objects, layout.NewSpacer(), container.NewHBox(imgFyne, layout.NewSpacer()))
p.chatContainer.Refresh()
}
}()
for _, platID := range []streamcontrol.PlatformName{
obs.ID,
youtube.ID,
twitch.ID,
} {
wg.Add(1)
go func() {
defer wg.Done()
dst := p.streamStatus[platID]
ok, err := p.StreamD.IsBackendEnabled(ctx, platID)
if err != nil {
logger.Error(ctx, err)
dst.Importance = widget.LowImportance
dst.SetText("error")
return
}
if !ok {
dst.SetText("disabled")
return
}
streamStatus, err := p.StreamD.GetStreamStatus(ctx, platID)
if err != nil {
logger.Error(ctx, err)
dst.SetText("error")
return
}
if !streamStatus.IsActive {
dst.Importance = widget.DangerImportance
dst.SetText("stopped")
return
}
dst.Importance = widget.SuccessImportance
if streamStatus.StartedAt != nil {
duration := time.Since(*streamStatus.StartedAt)
dst.SetText(duration.Truncate(time.Second).String())
} else {
dst.SetText("started")
}
}()
}
wg.Wait()
}

View File

@@ -41,6 +41,7 @@ import (
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
"github.com/xaionaro-go/streamctl/pkg/streamd/client"
streamdconfig "github.com/xaionaro-go/streamctl/pkg/streamd/config"
"github.com/xaionaro-go/streamctl/pkg/streamd/grpc/go/streamd_grpc"
"github.com/xaionaro-go/streamctl/pkg/streampanel/config"
"github.com/xaionaro-go/streamctl/pkg/streampanel/consts"
"github.com/xaionaro-go/streamctl/pkg/xpath"
@@ -58,6 +59,12 @@ type Panel struct {
StreamD api.StreamD
Screenshoter Screenshoter
OnInternallySubmittedOAuthCode func(
ctx context.Context,
platID streamcontrol.PlatformName,
code string,
) error
screenshoterClose context.CancelFunc
screenshoterLocker sync.Mutex
@@ -82,6 +89,8 @@ type Panel struct {
screenshotContainer *fyne.Container
chatContainer *fyne.Container
streamStatus map[streamcontrol.PlatformName]*widget.Label
filterValue string
youtubeCheck *widget.Check
@@ -100,6 +109,8 @@ type Panel struct {
imageLocker sync.Mutex
imageLastDownloaded map[consts.ImageID][]byte
lastDisplayedError error
}
func New(
@@ -122,6 +133,7 @@ func New(
Config: Options(opts).ApplyOverrides(cfg),
Screenshoter: screenshoter.New(screenshot.Implementation{}),
imageLastDownloaded: map[consts.ImageID][]byte{},
streamStatus: map[streamcontrol.PlatformName]*widget.Label{},
}, nil
}
@@ -193,31 +205,15 @@ func (p *Panel) Loop(ctx context.Context, opts ...LoopOption) error {
loadingWindowText.ParseMarkdown(fmt.Sprintf("# %s", msg))
}
}
if streamD, ok := p.StreamD.(*client.Client); ok && false {
oauthURLChan, err := streamD.SubscriberToOAuthURLs(ctx)
if err != nil {
p.DisplayError(fmt.Errorf("unable to subscribe to OAuth requests of streamd: %w", err))
}
if streamD, ok := p.StreamD.(*client.Client); ok {
p.startOAuthListenerForRemoteStreamD(ctx, streamD)
} else {
// TODO: delete this hardcoding of the port
streamD := p.StreamD.(*streamd.StreamD)
streamD.AddOAuthListenPort(8091)
go func() {
for {
select {
case <-ctx.Done():
return
case authURL, ok := <-oauthURLChan:
if !ok {
return
}
if authURL == "" {
logger.Errorf(ctx, "received an empty authURL")
time.Sleep(1 * time.Second)
continue
}
oauthhandler.LaunchBrowser(authURL)
time.Sleep(1 * time.Second) // throttling the execution to avoid hanging the OS
}
}
<-ctx.Done()
streamD.RemoveOAuthListenPort(8091)
}()
}
@@ -247,6 +243,64 @@ func (p *Panel) Loop(ctx context.Context, opts ...LoopOption) error {
return nil
}
func (p *Panel) startOAuthListenerForRemoteStreamD(
ctx context.Context,
streamD *client.Client,
) {
ctx, cancelFn := context.WithCancel(ctx)
receiver, listenPort, err := oauthhandler.NewCodeReceiver(ctx, 0)
if err != nil {
cancelFn()
p.DisplayError(fmt.Errorf("unable to start listener for OAuth responses: %w", err))
return
}
oauthURLChan, err := streamD.SubscriberToOAuthURLs(ctx, listenPort)
if err != nil {
cancelFn()
p.DisplayError(fmt.Errorf("unable to subscribe to OAuth requests of streamd: %w", err))
return
}
go func() {
defer cancelFn()
defer p.DisplayError(fmt.Errorf("oauth handler was closed"))
for {
select {
case <-ctx.Done():
return
case req, ok := <-oauthURLChan:
if !ok {
logger.Errorf(ctx, "oauth request receiver is closed")
return
}
if req == nil || req.AuthURL == "" {
logger.Errorf(ctx, "received an empty oauth request")
time.Sleep(1 * time.Second)
continue
}
if err := p.openBrowser(req.GetAuthURL()); err != nil {
p.DisplayError(fmt.Errorf("unable to open browser with URL '%s': %w", req.GetAuthURL(), err))
continue
}
code := <-receiver
logger.Debugf(ctx, "received oauth code: %s", code)
_, err := p.StreamD.SubmitOAuthCode(ctx, &streamd_grpc.SubmitOAuthCodeRequest{
PlatID: req.GetPlatID(),
Code: code,
})
if err != nil {
p.DisplayError(fmt.Errorf("unable to submit the oauth code of '%s': %w", req.GetPlatID(), err))
continue
}
}
}
}()
}
func (p *Panel) newLoadingWindow(ctx context.Context) fyne.Window {
logger.FromCtx(ctx).Debugf("newLoadingWindow")
defer logger.FromCtx(ctx).Debugf("endof newLoadingWindow")
@@ -383,20 +437,35 @@ func (p *Panel) InputOBSConnectInfo(
return true, nil
}
func (p *Panel) OnSubmittedOAuthCode(
ctx context.Context,
platID streamcontrol.PlatformName,
code string,
) error {
logger.Debugf(ctx, "OnSubmittedOAuthCode(ctx, '%s', '%s')", platID, code)
return nil
}
func (p *Panel) OAuthHandlerTwitch(ctx context.Context, arg oauthhandler.OAuthHandlerArgument) error {
logger.Infof(ctx, "OAuthHandlerTwitch: %#+v", arg)
defer logger.Infof(ctx, "/OAuthHandlerTwitch")
return p.oauthHandler(ctx, arg)
return p.oauthHandler(ctx, twitch.ID, arg)
}
func (p *Panel) OAuthHandlerYouTube(ctx context.Context, arg oauthhandler.OAuthHandlerArgument) error {
logger.Infof(ctx, "OAuthHandlerYouTube: %#+v", arg)
defer logger.Infof(ctx, "/OAuthHandlerYouTube")
return p.oauthHandler(ctx, arg)
return p.oauthHandler(ctx, youtube.ID, arg)
}
func (p *Panel) oauthHandler(ctx context.Context, arg oauthhandler.OAuthHandlerArgument) error {
codeCh, err := oauthhandler.NewCodeReceiver(arg.RedirectURL)
func (p *Panel) oauthHandler(
ctx context.Context,
platID streamcontrol.PlatformName,
arg oauthhandler.OAuthHandlerArgument,
) error {
ctx, cancelFn := context.WithCancel(ctx)
defer cancelFn()
codeCh, _, err := oauthhandler.NewCodeReceiver(ctx, arg.ListenPort)
if err != nil {
return err
}
@@ -410,7 +479,17 @@ func (p *Panel) oauthHandler(ctx context.Context, arg oauthhandler.OAuthHandlerA
// Wait for the web server to get the code.
code := <-codeCh
logger.Debugf(ctx, "received the auth code")
return arg.ExchangeFn(code)
err = arg.ExchangeFn(code)
if err != nil {
return fmt.Errorf("unable to exchange the code: %w", err)
}
if p.OnInternallySubmittedOAuthCode != nil {
err := p.OnInternallySubmittedOAuthCode(ctx, platID, code)
if err != nil {
return fmt.Errorf("OnInternallySubmittedOAuthCode return an error: %w", err)
}
}
return nil
}
func (p *Panel) openBrowser(authURL string) error {
@@ -1393,10 +1472,30 @@ func (p *Panel) initMainWindow(
monitorBackgroundFyne.FillMode = canvas.ImageFillStretch
p.screenshotContainer = container.NewBorder(nil, nil, nil, nil)
obsLabel := widget.NewLabel("OBS:")
obsLabel.Importance = widget.LowImportance
p.streamStatus[obs.ID] = widget.NewLabel("")
twLabel := widget.NewLabel("TW:")
twLabel.Importance = widget.LowImportance
p.streamStatus[twitch.ID] = widget.NewLabel("")
ytLabel := widget.NewLabel("YT:")
ytLabel.Importance = widget.LowImportance
p.streamStatus[youtube.ID] = widget.NewLabel("")
streamInfoContainer := container.NewBorder(
nil,
nil,
nil,
container.NewVBox(
container.NewHBox(obsLabel, p.streamStatus[obs.ID]),
container.NewHBox(twLabel, p.streamStatus[twitch.ID]),
container.NewHBox(ytLabel, p.streamStatus[youtube.ID]),
),
)
p.chatContainer = container.NewBorder(nil, nil, nil, nil)
monitorPage := container.NewStack(
monitorBackgroundFyne,
p.screenshotContainer,
streamInfoContainer,
p.chatContainer,
)
@@ -2322,6 +2421,10 @@ func (p *Panel) DisplayError(err error) {
logger.Debugf(p.defaultContext, "DisplayError('%v')", err)
defer logger.Debugf(p.defaultContext, "/DisplayError('%v')", err)
if err == nil {
return
}
errorMessage := fmt.Sprintf("Error: %v\n\nstack trace:\n%s", err, debug.Stack())
textWidget := widget.NewMultiLineEntry()
textWidget.SetText(errorMessage)
@@ -2333,6 +2436,15 @@ func (p *Panel) DisplayError(err error) {
p.displayErrorLocker.Lock()
defer p.displayErrorLocker.Unlock()
if p.lastDisplayedError != nil {
// protection against flood:
if err.Error() == p.lastDisplayedError.Error() {
return
}
}
p.lastDisplayedError = err
if p.displayErrorWindow != nil {
p.displayErrorWindow.SetContent(container.NewVSplit(p.displayErrorWindow.Content(), textWidget))
return

View File

@@ -0,0 +1,9 @@
package consts
const (
UserAgent = "streamcontrol/0.1"
)
const (
StreamNotFound = "stream not found"
)

View File

@@ -0,0 +1,237 @@
package rtmp
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/flv"
"github.com/AlexxIT/go2rtc/pkg/rtmp"
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
"github.com/rs/zerolog/log"
"github.com/xaionaro-go/streamctl/pkg/streamserver/consts"
"github.com/xaionaro-go/streamctl/pkg/streamserver/streams"
"github.com/xaionaro-go/streamctl/pkg/streamserver/types"
)
type RTMPServer struct {
Config Config
StreamHandler *streams.StreamHandler
Listener net.Listener
CancelFn context.CancelFunc
}
type Config struct {
Listen string `yaml:"listen" json:"listen"`
}
func New(
ctx context.Context,
cfg Config,
streamHandler *streams.StreamHandler,
) (*RTMPServer, error) {
if cfg.Listen == "" {
cfg.Listen = "127.0.0.1:1935"
}
ln, err := net.Listen("tcp", cfg.Listen)
if err != nil {
return nil, fmt.Errorf("unable to start listening '%s': %w", cfg.Listen, err)
}
ctx, cancelFn := context.WithCancel(ctx)
s := &RTMPServer{
Config: cfg,
StreamHandler: streamHandler,
CancelFn: cancelFn,
Listener: ln,
}
go func() {
<-ctx.Done()
err := ln.Close()
errmon.ObserveErrorCtx(ctx, err)
}()
go func() {
for {
if ctx.Err() != nil {
return
}
conn, err := ln.Accept()
if err != nil {
errmon.ObserveErrorCtx(ctx, err)
return
}
go func() {
if err = s.tcpHandle(conn); err != nil {
errmon.ObserveErrorCtx(ctx, err)
}
}()
}
}()
return s, nil
}
func (s *RTMPServer) Type() types.ServerType {
return types.ServerTypeRTMP
}
func (s *RTMPServer) ListenAddr() string {
return s.Listener.Addr().String()
}
func (s *RTMPServer) Close() error {
s.CancelFn()
return nil
}
func (s *RTMPServer) tcpHandle(netConn net.Conn) error {
rtmpConn, err := rtmp.NewServer(netConn)
if err != nil {
return err
}
if err = rtmpConn.ReadCommands(); err != nil {
return err
}
switch rtmpConn.Intent {
case rtmp.CommandPlay:
stream := s.StreamHandler.Get(rtmpConn.App)
if stream == nil {
return errors.New("stream not found: " + rtmpConn.App)
}
cons := flv.NewConsumer()
if err = stream.AddConsumer(cons); err != nil {
return err
}
defer stream.RemoveConsumer(cons)
if err = rtmpConn.WriteStart(); err != nil {
return err
}
_, _ = cons.WriteTo(rtmpConn)
return nil
case rtmp.CommandPublish:
stream := s.StreamHandler.Get(rtmpConn.App)
if stream == nil {
return errors.New("stream not found: " + rtmpConn.App)
}
if err = rtmpConn.WriteStart(); err != nil {
return err
}
prod, err := rtmpConn.Producer()
if err != nil {
return err
}
stream.AddProducer(prod)
defer stream.RemoveProducer(prod)
_ = prod.Start()
return nil
}
return errors.New("rtmp: unknown command: " + rtmpConn.Intent)
}
func StreamsHandle(url string) (core.Producer, error) {
return rtmp.DialPlay(url)
}
func StreamsConsumerHandle(url string) (core.Consumer, func(context.Context) error, error) {
cons := flv.NewConsumer()
run := func(ctx context.Context) error {
wr, err := rtmp.DialPublish(url)
if err != nil {
return fmt.Errorf("unable to connect to '%s': %w", url, err)
}
ctx, cancelFn := context.WithCancel(ctx)
defer cancelFn()
go func() {
<-ctx.Done()
cancelFn()
err := wr.(io.Closer).Close()
errmon.ObserveErrorCtx(ctx, err)
}()
_, err = cons.WriteTo(wr)
if err != nil {
return fmt.Errorf("unable to write: %w", err)
}
return nil
}
return cons, run, nil
}
func (s *RTMPServer) apiHandle(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
s.outputFLV(w, r)
} else {
s.inputFLV(w, r)
}
}
func (s *RTMPServer) outputFLV(w http.ResponseWriter, r *http.Request) {
src := r.URL.Query().Get("src")
stream := s.StreamHandler.Get(src)
if stream == nil {
http.Error(w, consts.StreamNotFound, http.StatusNotFound)
return
}
cons := flv.NewConsumer()
cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()
return
}
h := w.Header()
h.Set("Content-Type", "video/x-flv")
_, _ = cons.WriteTo(w)
stream.RemoveConsumer(cons)
}
func (s *RTMPServer) inputFLV(w http.ResponseWriter, r *http.Request) {
dst := r.URL.Query().Get("dst")
stream := s.StreamHandler.Get(dst)
if stream == nil {
http.Error(w, consts.StreamNotFound, http.StatusNotFound)
return
}
client, err := flv.Open(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
stream.AddProducer(client)
if err = client.Start(); err != nil && err != io.EOF {
log.Warn().Err(err).Caller().Send()
}
stream.RemoveProducer(client)
}

View File

@@ -0,0 +1,302 @@
package rtsp
import (
"context"
"fmt"
"io"
"net"
"net/url"
"strings"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/rtsp"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/xaionaro-go/streamctl/pkg/streamserver/consts"
"github.com/xaionaro-go/streamctl/pkg/streamserver/streams"
"github.com/xaionaro-go/streamctl/pkg/streamserver/types"
)
type RTSPServer struct {
Config Config
Listener net.Listener
DefaultMedias []*core.Media
StreamHandler *streams.StreamHandler
Handlers []HandlerFunc
CancelFn context.CancelFunc
}
type Config struct {
ListenAddr string
Username string
Password string
DefaultQuery string
PacketSize uint16
}
func New(
ctx context.Context,
cfg Config,
streamHandler *streams.StreamHandler,
) (*RTSPServer, error) {
if cfg.ListenAddr == "" {
cfg.ListenAddr = "127.0.0.1:8554"
}
if cfg.DefaultQuery == "" {
cfg.DefaultQuery = "video&audio"
}
ln, err := net.Listen("tcp", cfg.ListenAddr)
if err != nil {
return nil, fmt.Errorf("unable to listen '%s': %w", cfg.ListenAddr, err)
}
logger.Default().WithField("addr", cfg.ListenAddr).Info("[rtsp] listen")
ctx, cancelFn := context.WithCancel(ctx)
s := &RTSPServer{
Config: cfg,
Listener: ln,
StreamHandler: streamHandler,
CancelFn: cancelFn,
}
if query, err := url.ParseQuery(cfg.DefaultQuery); err == nil {
s.DefaultMedias = ParseQuery(query)
}
go func() {
<-ctx.Done()
err := ln.Close()
errmon.ObserveErrorCtx(ctx, err)
}()
go func() {
for {
if ctx.Err() != nil {
return
}
conn, err := ln.Accept()
if err != nil {
return
}
c := rtsp.NewServer(conn)
c.PacketSize = cfg.PacketSize
// skip check auth for localhost
if cfg.Username != "" && !conn.RemoteAddr().(*net.TCPAddr).IP.IsLoopback() {
c.Auth(cfg.Username, cfg.Password)
}
go s.tcpHandler(c)
}
}()
return s, nil
}
type HandlerFunc func(conn *rtsp.Conn) bool
func (s *RTSPServer) HandleFunc(handler HandlerFunc) {
s.Handlers = append(s.Handlers, handler)
}
func Handler(rawURL string) (core.Producer, error) {
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
conn := rtsp.NewClient(rawURL)
conn.Backchannel = true
conn.UserAgent = consts.UserAgent
if rawQuery != "" {
query := streams.ParseQuery(rawQuery)
conn.Backchannel = query.Get("backchannel") == "1"
conn.Media = query.Get("media")
conn.Timeout = core.Atoi(query.Get("timeout"))
conn.Transport = query.Get("transport")
}
if logger.Default().Level() >= logger.LevelTrace {
conn.Listen(func(msg any) {
switch msg := msg.(type) {
case *tcp.Request:
logger.Default().Tracef("[rtsp] client request:\n%s", msg)
case *tcp.Response:
logger.Default().Tracef("[rtsp] client response:\n%s", msg)
case string:
logger.Default().Tracef("[rtsp] client msg: %s", msg)
}
})
}
if err := conn.Dial(); err != nil {
return nil, err
}
if err := conn.Describe(); err != nil {
if !conn.Backchannel {
return nil, err
}
logger.Default().Tracef("[rtsp] describe (backchannel=%t) err: %v", conn.Backchannel, err)
// second try without backchannel, we need to reconnect
conn.Backchannel = false
if err = conn.Dial(); err != nil {
return nil, err
}
if err = conn.Describe(); err != nil {
return nil, err
}
}
return conn, nil
}
func (s *RTSPServer) tcpHandler(conn *rtsp.Conn) {
var name string
var closer func()
trace := logger.Default().Level() >= logger.LevelTrace
conn.Listen(func(msg any) {
if trace {
switch msg := msg.(type) {
case *tcp.Request:
logger.Default().Tracef("[rtsp] server request:\n%s", msg)
case *tcp.Response:
logger.Default().Tracef("[rtsp] server response:\n%s", msg)
}
}
switch msg {
case rtsp.MethodDescribe:
if len(conn.URL.Path) == 0 {
logger.Default().Warn("[rtsp] server empty URL on DESCRIBE")
return
}
name = conn.URL.Path[1:]
stream := s.StreamHandler.Get(name)
if stream == nil {
return
}
logger.Default().WithField("stream", name).Debug("[rtsp] new consumer")
conn.SessionName = consts.UserAgent
query := conn.URL.Query()
conn.Medias = ParseQuery(query)
if conn.Medias == nil {
for _, media := range s.DefaultMedias {
conn.Medias = append(conn.Medias, media.Clone())
}
}
if s := query.Get("pkt_size"); s != "" {
conn.PacketSize = uint16(core.Atoi(s))
}
if err := stream.AddConsumer(conn); err != nil {
logger.Default().WithField("error", err).WithField("stream", name).Warn("[rtsp]")
return
}
closer = func() {
stream.RemoveConsumer(conn)
}
case rtsp.MethodAnnounce:
if len(conn.URL.Path) == 0 {
logger.Default().Warn("[rtsp] server empty URL on ANNOUNCE")
return
}
name = conn.URL.Path[1:]
stream := s.StreamHandler.Get(name)
if stream == nil {
return
}
query := conn.URL.Query()
if s := query.Get("timeout"); s != "" {
conn.Timeout = core.Atoi(s)
}
logger.Default().WithField("stream", name).Debug("[rtsp] new producer")
stream.AddProducer(conn)
closer = func() {
stream.RemoveProducer(conn)
}
}
})
if err := conn.Accept(); err != nil {
if err != io.EOF {
logger.Default().WithField("error", err).Warn()
}
if closer != nil {
closer()
}
_ = conn.Close()
return
}
for _, handler := range s.Handlers {
if handler(conn) {
return
}
}
if closer != nil {
if err := conn.Handle(); err != nil {
logger.Default().WithField("error", err).Debug("[rtsp] handle")
}
closer()
logger.Default().WithField("stream", name).Debug("[rtsp] disconnect")
}
_ = conn.Close()
}
func (s *RTSPServer) Type() types.ServerType {
return types.ServerTypeRTSP
}
func (s *RTSPServer) ListenAddr() string {
return s.Listener.Addr().String()
}
func (s *RTSPServer) Close() error {
s.CancelFn()
return nil
}
func ParseQuery(query map[string][]string) []*core.Media {
if v := query["mp4"]; v != nil {
return []*core.Media{
{
Kind: core.KindVideo,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{
{Name: core.CodecH264},
{Name: core.CodecH265},
},
},
{
Kind: core.KindAudio,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{
{Name: core.CodecAAC},
},
},
}
}
return core.ParseQuery(query)
}

View File

@@ -0,0 +1,351 @@
package streamserver
import (
"context"
"fmt"
"sync"
rtmpserver "github.com/xaionaro-go/streamctl/pkg/streamserver/server/rtmp"
rtspserver "github.com/xaionaro-go/streamctl/pkg/streamserver/server/rtsp"
"github.com/xaionaro-go/streamctl/pkg/streamserver/streams"
"github.com/xaionaro-go/streamctl/pkg/streamserver/types"
)
type StreamServer struct {
sync.Mutex
StreamHandler *streams.StreamHandler
ServerHandlers []types.ServerHandler
StreamDestinations []types.StreamDestination
}
func New() *StreamServer {
s := streams.NewStreamHandler()
s.HandleFunc("rtmp", rtmpserver.StreamsHandle)
s.HandleFunc("rtmps", rtmpserver.StreamsHandle)
s.HandleFunc("rtmpx", rtmpserver.StreamsHandle)
s.HandleConsumerFunc("rtmp", rtmpserver.StreamsConsumerHandle)
s.HandleConsumerFunc("rtmps", rtmpserver.StreamsConsumerHandle)
s.HandleConsumerFunc("rtmpx", rtmpserver.StreamsConsumerHandle)
s.HandleFunc("rtsp", rtspserver.Handler)
s.HandleFunc("rtsps", rtspserver.Handler)
s.HandleFunc("rtspx", rtspserver.Handler)
return &StreamServer{
StreamHandler: s,
}
}
func (s *StreamServer) ListServers(
ctx context.Context,
) []types.ServerHandler {
s.Lock()
defer s.Unlock()
c := make([]types.ServerHandler, len(s.ServerHandlers))
copy(c, s.ServerHandlers)
return c
}
func (s *StreamServer) StartServer(
ctx context.Context,
serverType types.ServerType,
listenAddr string,
) error {
var srv types.ServerHandler
var err error
switch serverType {
case types.ServerTypeRTMP:
srv, err = rtmpserver.New(ctx, rtmpserver.Config{
Listen: listenAddr,
}, s.StreamHandler)
case types.ServerTypeRTSP:
srv, err = rtspserver.New(ctx, rtspserver.Config{
ListenAddr: listenAddr,
}, s.StreamHandler)
default:
return fmt.Errorf("unexpected server type %v", serverType)
}
if err != nil {
return err
}
s.Lock()
defer s.Unlock()
s.ServerHandlers = append(s.ServerHandlers, srv)
return nil
}
func (s *StreamServer) StopServer(
ctx context.Context,
server types.ServerHandler,
) error {
s.Lock()
defer s.Unlock()
for i := range s.ServerHandlers {
if s.ServerHandlers[i] == server {
s.ServerHandlers = append(s.ServerHandlers[:i], s.ServerHandlers[i+1:]...)
return server.Close()
}
}
return fmt.Errorf("server not found")
}
func (s *StreamServer) AddIncomingStream(
ctx context.Context,
streamID types.StreamID,
) error {
s.Lock()
defer s.Unlock()
return s.addIncomingStream(ctx, streamID)
}
func (s *StreamServer) addIncomingStream(
_ context.Context,
streamID types.StreamID,
) error {
if s.StreamHandler.Get(string(streamID)) != nil {
return fmt.Errorf("stream '%s' already exists", streamID)
}
_, err := s.StreamHandler.New(string(streamID), "")
if err != nil {
return fmt.Errorf("unable to create the stream '%s': %w", streamID, err)
}
return nil
}
type IncomingStream struct {
StreamID types.StreamID
}
func (s *StreamServer) ListIncomingStreams(
ctx context.Context,
) []IncomingStream {
s.Lock()
defer s.Unlock()
var result []IncomingStream
for _, name := range s.StreamHandler.GetAll() {
result = append(
result,
IncomingStream{
StreamID: types.StreamID(name),
},
)
}
return result
}
func (s *StreamServer) RemoveIncomingStream(
ctx context.Context,
streamID types.StreamID,
) error {
s.Lock()
defer s.Unlock()
return s.removeIncomingStream(ctx, streamID)
}
func (s *StreamServer) removeIncomingStream(
_ context.Context,
streamID types.StreamID,
) error {
if s.StreamHandler.Get(string(streamID)) == nil {
return fmt.Errorf("stream '%s' does not exist", streamID)
}
s.StreamHandler.Delete(string(streamID))
return nil
}
type StreamForward struct {
StreamIDSrc types.StreamID
StreamIDDst types.StreamID
}
func (s *StreamServer) AddStreamForward(
ctx context.Context,
streamIDSrc types.StreamID,
streamIDDst types.StreamID,
) error {
s.Lock()
defer s.Unlock()
return s.addStreamForward(ctx, streamIDSrc, streamIDDst)
}
func (s *StreamServer) addStreamForward(
ctx context.Context,
streamIDSrc types.StreamID,
streamIDDst types.StreamID,
) error {
streamSrc := s.StreamHandler.Get(string(streamIDSrc))
if streamSrc != nil {
return fmt.Errorf("unable to find stream ID '%s'", streamIDSrc)
}
dst, err := s.findStreamDestinationByID(ctx, streamIDDst)
if err != nil {
return fmt.Errorf("unable to find stream destination '%s': %w", streamIDDst, err)
}
_, err = streamSrc.Publish(ctx, dst.URL)
if err != nil {
return fmt.Errorf("unable to start publishing '%s' to '%s': %w", streamIDSrc, dst.URL, err)
}
return nil
}
func (s *StreamServer) ListStreamForwards(
ctx context.Context,
) ([]StreamForward, error) {
s.Lock()
defer s.Unlock()
return s.listStreamForwards(ctx)
}
func (s *StreamServer) listStreamForwards(
ctx context.Context,
) ([]StreamForward, error) {
var result []StreamForward
for _, name := range s.StreamHandler.GetAll() {
stream := s.StreamHandler.Get(name)
if stream == nil {
continue
}
for _, fwd := range stream.Forwardings() {
streamIDSrc := types.StreamID(name)
streamDst, err := s.findStreamDestinationByURL(ctx, fwd.URL)
if err != nil {
return nil, fmt.Errorf("unable to convert URL '%s' to a stream ID: %w", fwd.URL, err)
}
result = append(result, StreamForward{
StreamIDSrc: streamIDSrc,
StreamIDDst: streamDst.StreamID,
})
}
}
return result, nil
}
func (s *StreamServer) RemoveStreamForward(
ctx context.Context,
streamIDSrc types.StreamID,
streamIDDst types.StreamID,
) error {
s.Lock()
defer s.Unlock()
return s.removeStreamForward(ctx, streamIDSrc, streamIDDst)
}
func (s *StreamServer) removeStreamForward(
ctx context.Context,
streamIDSrc types.StreamID,
streamIDDst types.StreamID,
) error {
stream := s.StreamHandler.Get(string(streamIDSrc))
if stream == nil {
return fmt.Errorf("unable to find a source stream with ID '%s'", streamIDSrc)
}
for _, fwd := range stream.Forwardings() {
streamDst, err := s.findStreamDestinationByURL(ctx, fwd.URL)
if err != nil {
return fmt.Errorf("unable to convert URL '%s' to a stream ID: %w", fwd.URL, err)
}
if streamDst.StreamID != streamIDDst {
continue
}
err = fwd.Close()
if err != nil {
return fmt.Errorf("unable to close forwarding from %s to %s (%s): %w", streamIDSrc, streamIDDst, fwd.URL, err)
}
stream.Cleanup()
return nil
}
return fmt.Errorf("unable to find stream forwarding from '%s' to '%s'", streamIDSrc, streamIDDst)
}
func (s *StreamServer) ListStreamDestinations(
ctx context.Context,
) ([]types.StreamDestination, error) {
s.Mutex.Lock()
defer s.Mutex.Unlock()
return s.listStreamDestinations(ctx)
}
func (s *StreamServer) listStreamDestinations(
_ context.Context,
) ([]types.StreamDestination, error) {
c := make([]types.StreamDestination, len(s.StreamDestinations))
copy(c, s.StreamDestinations)
return c, nil
}
func (s *StreamServer) AddStreamDestination(
ctx context.Context,
streamID types.StreamID,
url string,
) error {
s.Mutex.Lock()
defer s.Mutex.Unlock()
return s.addStreamDestination(ctx, streamID, url)
}
func (s *StreamServer) addStreamDestination(
_ context.Context,
streamID types.StreamID,
url string,
) error {
s.StreamDestinations = append(s.StreamDestinations, types.StreamDestination{
StreamID: streamID,
URL: url,
})
return nil
}
func (s *StreamServer) RemoveStreamDestination(
ctx context.Context,
streamID types.StreamID,
) error {
s.Mutex.Lock()
defer s.Mutex.Unlock()
streamForwards, err := s.listStreamForwards(ctx)
if err != nil {
return fmt.Errorf("unable to list stream forwardings: %w", err)
}
for _, fwd := range streamForwards {
if fwd.StreamIDDst == streamID {
s.removeStreamForward(ctx, fwd.StreamIDSrc, fwd.StreamIDDst)
}
}
for i := range s.StreamDestinations {
if s.StreamDestinations[i].StreamID == streamID {
s.StreamDestinations = append(s.StreamDestinations[:i], s.StreamDestinations[i+1:]...)
return nil
}
}
return fmt.Errorf("have not found stream destination with id %s", streamID)
}
func (s *StreamServer) findStreamDestinationByURL(
_ context.Context,
url string,
) (types.StreamDestination, error) {
for _, dst := range s.StreamDestinations {
if dst.URL == url {
return dst, nil
}
}
return types.StreamDestination{}, fmt.Errorf("unable to find a stream destination by URL '%s'", url)
}
func (s *StreamServer) findStreamDestinationByID(
_ context.Context,
streamID types.StreamID,
) (types.StreamDestination, error) {
for _, dst := range s.StreamDestinations {
if dst.StreamID == streamID {
return dst, nil
}
}
return types.StreamDestination{}, fmt.Errorf("unable to find a stream destination by StreamID '%s'", streamID)
}

View File

@@ -0,0 +1,39 @@
MIT License
Copyright (c) 2022 Alexey Khit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
A remark by Dmitrii Okunev:
The initial code of this package (package `streams`) was copied from
repository https://github.com/AlexxIT/go2rtc of revision
a4885c2c3abce58074d04878bba0d72105642a9b.
The content of the LICENSE file is attached above. I had to modify
the code (see the details in the paragraph below), and you are free
to use whatever license you like to use the modifications applied
to this package: CC-0, WTFPL-v2, MIT, or whatever else license.
The only reason I had to copy and modify this code, is because
a lot of packages (including `streams`) in `go2rtc` are shielded
from importing by Go tooling through placing them
in the `internal/` directory.

View File

@@ -0,0 +1,161 @@
package streams
import (
"errors"
"strings"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/facebookincubator/go-belt/tool/logger"
)
func (s *Stream) AddConsumer(cons core.Consumer) (err error) {
// support for multiple simultaneous pending from different consumers
consN := s.pending.Add(1) - 1
var prodErrors = make([]error, len(s.producers))
var prodMedias []*core.Media
var prodStarts []*Producer
// Step 1. Get consumer medias
consMedias := cons.GetMedias()
for _, consMedia := range consMedias {
logger.Default().Tracef("[streams] check cons=%d media=%s", consN, consMedia)
producers:
for prodN, prod := range s.producers {
if prodErrors[prodN] != nil {
logger.Default().Tracef("[streams] skip cons=%d prod=%d", consN, prodN)
continue
}
if err = prod.Dial(); err != nil {
logger.Default().WithField("error", err).Tracef("[streams] dial cons=%d prod=%d", consN, prodN)
prodErrors[prodN] = err
continue
}
// Step 2. Get producer medias (not tracks yet)
for _, prodMedia := range prod.GetMedias() {
logger.Default().Tracef("[streams] check cons=%d prod=%d media=%s", consN, prodN, prodMedia)
prodMedias = append(prodMedias, prodMedia)
// Step 3. Match consumer/producer codecs list
prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
if prodCodec == nil {
continue
}
var track *core.Receiver
switch prodMedia.Direction {
case core.DirectionRecvonly:
logger.Default().Tracef("[streams] match cons=%d <= prod=%d", consN, prodN)
// Step 4. Get recvonly track from producer
if track, err = prod.GetTrack(prodMedia, prodCodec); err != nil {
logger.Default().WithField("error", err).Info("[streams] can't get track")
prodErrors[prodN] = err
continue
}
// Step 5. Add track to consumer
if err = cons.AddTrack(consMedia, consCodec, track); err != nil {
logger.Default().WithField("error", err).Info("[streams] can't add track")
continue
}
case core.DirectionSendonly:
logger.Default().Tracef("[streams] match cons=%d => prod=%d", consN, prodN)
// Step 4. Get recvonly track from consumer (backchannel)
if track, err = cons.(core.Producer).GetTrack(consMedia, consCodec); err != nil {
logger.Default().WithField("error", err).Info("[streams] can't get track")
continue
}
// Step 5. Add track to producer
if err = prod.AddTrack(prodMedia, prodCodec, track); err != nil {
logger.Default().WithField("error", err).Info("[streams] can't add track")
prodErrors[prodN] = err
continue
}
}
prodStarts = append(prodStarts, prod)
if !consMedia.MatchAll() {
break producers
}
}
}
}
// stop producers if they don't have readers
if s.pending.Add(-1) == 0 {
s.stopProducers()
}
if len(prodStarts) == 0 {
return formatError(consMedias, prodMedias, prodErrors)
}
s.mu.Lock()
s.consumers = append(s.consumers, cons)
s.mu.Unlock()
// there may be duplicates, but that's not a problem
for _, prod := range prodStarts {
prod.start()
}
return nil
}
func formatError(consMedias, prodMedias []*core.Media, prodErrors []error) error {
// 1. Return errors if any not nil
var text string
for _, err := range prodErrors {
if err != nil {
text = appendString(text, err.Error())
}
}
if len(text) != 0 {
return errors.New("streams: " + text)
}
// 2. Return "codecs not matched"
if prodMedias != nil {
var prod, cons string
for _, media := range prodMedias {
if media.Direction == core.DirectionRecvonly {
for _, codec := range media.Codecs {
prod = appendString(prod, codec.PrintName())
}
}
}
for _, media := range consMedias {
if media.Direction == core.DirectionSendonly {
for _, codec := range media.Codecs {
cons = appendString(cons, codec.PrintName())
}
}
}
return errors.New("streams: codecs not matched: " + prod + " => " + cons)
}
// 3. Return unknown error
return errors.New("streams: unknown error")
}
func appendString(s, elem string) string {
if strings.Contains(s, elem) {
return s
}
if len(s) == 0 {
return elem
}
return s + ", " + elem
}

View File

@@ -0,0 +1,175 @@
package streams
import (
"encoding/json"
"fmt"
"strings"
)
func AppendDOT(dot []byte, stream *Stream) []byte {
for _, prod := range stream.producers {
if prod.conn == nil {
continue
}
c, err := marshalConn(prod.conn)
if err != nil {
continue
}
dot = c.appendDOT(dot, "producer")
}
for _, cons := range stream.consumers {
c, err := marshalConn(cons)
if err != nil {
continue
}
dot = c.appendDOT(dot, "consumer")
}
return dot
}
func marshalConn(v any) (*conn, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var c conn
if err = json.Unmarshal(b, &c); err != nil {
return nil, err
}
return &c, nil
}
const bytesK = "KMGTP"
func humanBytes(i int) string {
if i < 1000 {
return fmt.Sprintf("%d B", i)
}
f := float64(i) / 1000
var n uint8
for f >= 1000 && n < 5 {
f /= 1000
n++
}
return fmt.Sprintf("%.2f %cB", f, bytesK[n])
}
type node struct {
ID uint32 `json:"id"`
Codec map[string]any `json:"codec"`
Parent uint32 `json:"parent"`
Childs []uint32 `json:"childs"`
Bytes int `json:"bytes"`
//Packets uint32 `json:"packets"`
//Drops uint32 `json:"drops"`
}
var codecKeys = []string{"codec_name", "sample_rate", "channels", "profile", "level"}
func (n *node) name() string {
if name, ok := n.Codec["codec_name"].(string); ok {
return name
}
return "unknown"
}
func (n *node) codec() []byte {
b := make([]byte, 0, 128)
for _, k := range codecKeys {
if v := n.Codec[k]; v != nil {
b = fmt.Appendf(b, "%s=%v\n", k, v)
}
}
if l := len(b); l > 0 {
return b[:l-1]
}
return b
}
func (n *node) appendDOT(dot []byte, group string) []byte {
dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", n.ID, group, n.name(), n.codec())
//for _, sink := range n.Childs {
// dot = fmt.Appendf(dot, "%d -> %d;\n", n.ID, sink)
//}
return dot
}
type conn struct {
ID uint32 `json:"id"`
FormatName string `json:"format_name"`
Protocol string `json:"protocol"`
RemoteAddr string `json:"remote_addr"`
Source string `json:"source"`
URL string `json:"url"`
UserAgent string `json:"user_agent"`
Receivers []node `json:"receivers"`
Senders []node `json:"senders"`
BytesRecv int `json:"bytes_recv"`
BytesSend int `json:"bytes_send"`
}
func (c *conn) appendDOT(dot []byte, group string) []byte {
host := c.host()
dot = fmt.Appendf(dot, "%s [group=host];\n", host)
dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", c.ID, group, c.FormatName, c.label())
if group == "producer" {
dot = fmt.Appendf(dot, "%s -> %d [label=%q];\n", host, c.ID, humanBytes(c.BytesRecv))
} else {
dot = fmt.Appendf(dot, "%d -> %s [label=%q];\n", c.ID, host, humanBytes(c.BytesSend))
}
for _, recv := range c.Receivers {
dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", c.ID, recv.ID, humanBytes(recv.Bytes))
dot = recv.appendDOT(dot, "node")
}
for _, send := range c.Senders {
dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.Parent, c.ID, humanBytes(send.Bytes))
//dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.ID, c.ID, humanBytes(send.Bytes))
//dot = send.appendDOT(dot, "node")
}
return dot
}
func (c *conn) host() (s string) {
if c.Protocol == "pipe" {
return "127.0.0.1"
}
if s = c.RemoteAddr; s == "" {
return "unknown"
}
if i := strings.Index(s, "forwarded"); i > 0 {
s = s[i+10:]
}
if s[0] == '[' {
if i := strings.Index(s, "]"); i > 0 {
return s[1:i]
}
}
if i := strings.IndexAny(s, " ,:"); i > 0 {
return s[:i]
}
return
}
func (c *conn) label() string {
var sb strings.Builder
sb.WriteString("format_name=" + c.FormatName)
if c.Protocol != "" {
sb.WriteString("\nprotocol=" + c.Protocol)
}
if c.Source != "" {
sb.WriteString("\nsource=" + c.Source)
}
if c.URL != "" {
sb.WriteString("\nurl=" + c.URL)
}
if c.UserAgent != "" {
sb.WriteString("\nuser_agent=" + c.UserAgent)
}
return sb.String()
}

View File

@@ -0,0 +1,92 @@
package streams
import (
"context"
"errors"
"strings"
"github.com/AlexxIT/go2rtc/pkg/core"
)
type Handler func(source string) (core.Producer, error)
func (s *StreamHandler) HandleFunc(scheme string, handler Handler) {
s.handlers[scheme] = handler
}
func (s *StreamHandler) HasProducer(url string) bool {
if i := strings.IndexByte(url, ':'); i > 0 {
scheme := url[:i]
if _, ok := s.handlers[scheme]; ok {
return true
}
if _, ok := s.redirects[scheme]; ok {
return true
}
}
return false
}
func (s *StreamHandler) GetProducer(url string) (core.Producer, error) {
if i := strings.IndexByte(url, ':'); i > 0 {
scheme := url[:i]
if redirect, ok := s.redirects[scheme]; ok {
location, err := redirect(url)
if err != nil {
return nil, err
}
if location != "" {
return s.GetProducer(location)
}
}
if handler, ok := s.handlers[scheme]; ok {
return handler(url)
}
}
return nil, errors.New("streams: unsupported scheme: " + url)
}
// Redirect can return: location URL or error or empty URL and error
type Redirect func(url string) (string, error)
func (s *StreamHandler) RedirectFunc(scheme string, redirect Redirect) {
s.redirects[scheme] = redirect
}
func (s *StreamHandler) Location(url string) (string, error) {
if i := strings.IndexByte(url, ':'); i > 0 {
scheme := url[:i]
if redirect, ok := s.redirects[scheme]; ok {
return redirect(url)
}
}
return "", nil
}
// TODO: rework
type ConsumerHandler func(url string) (core.Consumer, func(context.Context) error, error)
func (s *StreamHandler) HandleConsumerFunc(scheme string, handler ConsumerHandler) {
s.consumerHandlers[scheme] = handler
}
func (s *StreamHandler) GetConsumer(url string) (core.Consumer, func(context.Context) error, error) {
if i := strings.IndexByte(url, ':'); i > 0 {
scheme := url[:i]
if handler, ok := s.consumerHandlers[scheme]; ok {
return handler(url)
}
}
return nil, nil, errors.New("streams: unsupported scheme: " + url)
}

View File

@@ -0,0 +1,22 @@
package streams
import (
"net/url"
"strings"
)
func ParseQuery(s string) url.Values {
if len(s) == 0 {
return nil
}
params := url.Values{}
for _, key := range strings.Split(s, "#") {
var value string
i := strings.IndexByte(key, '=')
if i > 0 {
key, value = key[:i], key[i+1:]
}
params[key] = append(params[key], value)
}
return params
}

View File

@@ -0,0 +1,155 @@
package streams
import (
"errors"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
)
func (s *Stream) Play(source string) error {
s.mu.Lock()
for _, producer := range s.producers {
if producer.state == stateInternal && producer.conn != nil {
_ = producer.conn.Stop()
}
}
s.mu.Unlock()
if source == "" {
return nil
}
var src core.Producer
for _, producer := range s.producers {
if producer.conn == nil {
continue
}
cons, ok := producer.conn.(core.Consumer)
if !ok {
continue
}
if src == nil {
var err error
if src, err = s.streamHandler.GetProducer(source); err != nil {
return err
}
}
if !matchMedia(src, cons) {
continue
}
s.AddInternalProducer(src)
go func() {
_ = src.Start()
s.RemoveProducer(src)
}()
return nil
}
for _, producer := range s.producers {
// start new client
dst, err := s.streamHandler.GetProducer(producer.url)
if err != nil {
continue
}
// check if client support consumer interface
cons, ok := dst.(core.Consumer)
if !ok {
_ = dst.Stop()
continue
}
// start new producer
if src == nil {
if src, err = s.streamHandler.GetProducer(source); err != nil {
return err
}
}
if !matchMedia(src, cons) {
_ = dst.Stop()
continue
}
s.AddInternalProducer(src)
s.AddInternalConsumer(cons)
go func() {
_ = dst.Start()
_ = src.Stop()
s.RemoveInternalConsumer(cons)
}()
go func() {
_ = src.Start()
// little timeout before stop dst, so the buffer can be transferred
time.Sleep(time.Second)
_ = dst.Stop()
s.RemoveProducer(src)
}()
return nil
}
return errors.New("can't find consumer")
}
func (s *Stream) AddInternalProducer(conn core.Producer) {
producer := &Producer{conn: conn, state: stateInternal}
s.mu.Lock()
s.producers = append(s.producers, producer)
s.mu.Unlock()
}
func (s *Stream) AddInternalConsumer(conn core.Consumer) {
s.mu.Lock()
s.consumers = append(s.consumers, conn)
s.mu.Unlock()
}
func (s *Stream) RemoveInternalConsumer(conn core.Consumer) {
s.mu.Lock()
for i, consumer := range s.consumers {
if consumer == conn {
s.consumers = append(s.consumers[:i], s.consumers[i+1:]...)
break
}
}
s.mu.Unlock()
}
func matchMedia(prod core.Producer, cons core.Consumer) bool {
for _, consMedia := range cons.GetMedias() {
for _, prodMedia := range prod.GetMedias() {
if prodMedia.Direction != core.DirectionRecvonly {
continue
}
prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
if prodCodec == nil {
continue
}
track, err := prod.GetTrack(prodMedia, prodCodec)
if err != nil {
continue
}
if err = cons.AddTrack(consMedia, consCodec, track); err != nil {
continue
}
return true
}
}
return false
}

View File

@@ -0,0 +1,271 @@
package streams
import (
"encoding/json"
"errors"
"strings"
"sync"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/facebookincubator/go-belt/tool/logger"
)
type state byte
const (
stateNone state = iota
stateMedias
stateTracks
stateStart
stateExternal
stateInternal
)
type Producer struct {
core.Listener
url string
template string
conn core.Producer
receivers []*core.Receiver
senders []*core.Receiver
state state
mu sync.Mutex
workerID int
streamHandler *StreamHandler
}
const SourceTemplate = "{input}"
func (s *StreamHandler) NewProducer(source string) *Producer {
if strings.Contains(source, SourceTemplate) {
return &Producer{streamHandler: s, template: source}
}
return &Producer{streamHandler: s, url: source}
}
func (p *Producer) SetSource(s string) {
if p.template == "" {
p.url = s
} else {
p.url = strings.Replace(p.template, SourceTemplate, s, 1)
}
}
func (p *Producer) Dial() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.state == stateNone {
conn, err := p.streamHandler.GetProducer(p.url)
if err != nil {
return err
}
p.conn = conn
p.state = stateMedias
}
return nil
}
func (p *Producer) GetMedias() []*core.Media {
p.mu.Lock()
defer p.mu.Unlock()
if p.conn == nil {
return nil
}
return p.conn.GetMedias()
}
func (p *Producer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.state == stateNone {
return nil, errors.New("get track from none state")
}
for _, track := range p.receivers {
if track.Codec == codec {
return track, nil
}
}
track, err := p.conn.GetTrack(media, codec)
if err != nil {
return nil, err
}
p.receivers = append(p.receivers, track)
if p.state == stateMedias {
p.state = stateTracks
}
return track, nil
}
func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.state == stateNone {
return errors.New("add track from none state")
}
if err := p.conn.(core.Consumer).AddTrack(media, codec, track); err != nil {
return err
}
p.senders = append(p.senders, track)
if p.state == stateMedias {
p.state = stateTracks
}
return nil
}
func (p *Producer) MarshalJSON() ([]byte, error) {
if conn := p.conn; conn != nil {
return json.Marshal(conn)
}
info := map[string]string{"url": p.url}
return json.Marshal(info)
}
func (p *Producer) start() {
p.mu.Lock()
defer p.mu.Unlock()
if p.state != stateTracks {
return
}
logger.Default().Debugf("[streams] start producer url=%s", p.url)
p.state = stateStart
p.workerID++
go p.worker(p.conn, p.workerID)
}
func (p *Producer) worker(conn core.Producer, workerID int) {
if err := conn.Start(); err != nil {
p.mu.Lock()
closed := p.workerID != workerID
p.mu.Unlock()
if closed {
return
}
logger.Default().Warn(struct{ URL string }{URL: p.url}, err)
}
p.reconnect(workerID, 0)
}
func (p *Producer) reconnect(workerID, retry int) {
p.mu.Lock()
defer p.mu.Unlock()
if p.workerID != workerID {
logger.Default().Tracef("[streams] stop reconnect url=%s", p.url)
return
}
logger.Default().Debugf("[streams] retry=%d to url=%s", retry, p.url)
conn, err := p.streamHandler.GetProducer(p.url)
if err != nil {
logger.Default().Debugf("[streams] producer=%s", err)
timeout := time.Minute
if retry < 5 {
timeout = time.Second
} else if retry < 10 {
timeout = time.Second * 5
} else if retry < 20 {
timeout = time.Second * 10
}
time.AfterFunc(timeout, func() {
p.reconnect(workerID, retry+1)
})
return
}
for _, media := range conn.GetMedias() {
switch media.Direction {
case core.DirectionRecvonly:
for i, receiver := range p.receivers {
codec := media.MatchCodec(receiver.Codec)
if codec == nil {
continue
}
track, err := conn.GetTrack(media, codec)
if err != nil {
continue
}
receiver.Replace(track)
p.receivers[i] = track
break
}
case core.DirectionSendonly:
for _, sender := range p.senders {
codec := media.MatchCodec(sender.Codec)
if codec == nil {
continue
}
_ = conn.(core.Consumer).AddTrack(media, codec, sender)
}
}
}
// stop previous connection after moving tracks (fix ghost exec/ffmpeg)
_ = p.conn.Stop()
// swap connections
p.conn = conn
go p.worker(conn, workerID)
}
func (p *Producer) stop() {
p.mu.Lock()
defer p.mu.Unlock()
switch p.state {
case stateExternal:
logger.Default().Tracef("[streams] skip stop external producer")
return
case stateNone:
logger.Default().Tracef("[streams] skip stop none producer")
return
case stateStart:
p.workerID++
}
logger.Default().Tracef("[streams] stop producer url=%s", p.url)
if p.conn != nil {
_ = p.conn.Stop()
p.conn = nil
}
p.state = stateNone
p.receivers = nil
p.senders = nil
}

View File

@@ -0,0 +1,69 @@
package streams
import (
"context"
"fmt"
"github.com/facebookincubator/go-belt/tool/logger"
)
func (s *Stream) Publish(
ctx context.Context,
url string,
) (*StreamForwarding, error) {
streamFwd := NewStreamForwarding(s.streamHandler)
err := streamFwd.Start(ctx, s, url)
if err != nil {
return nil, fmt.Errorf("unable to start stream forwarding to '%s': %w", url, err)
}
s.mu.Lock()
defer s.mu.Unlock()
s.cleanup()
s.forwardings = append(s.forwardings, streamFwd)
return streamFwd, nil
}
func (s *Stream) Cleanup() {
s.mu.Lock()
defer s.mu.Unlock()
s.cleanup()
}
func (s *Stream) cleanup() {
c := make([]*StreamForwarding, 0, len(s.forwardings))
for _, fwd := range s.forwardings {
if fwd.IsClosed() {
continue
}
c = append(c, fwd)
}
s.forwardings = c
}
func (s *Stream) Forwardings() []*StreamForwarding {
s.mu.Lock()
defer s.mu.Unlock()
s.cleanup()
c := make([]*StreamForwarding, 0, len(s.forwardings))
c = append(c, s.forwardings...)
return c
}
func (s *StreamHandler) Publish(
ctx context.Context,
stream *Stream,
destination any,
) {
switch v := destination.(type) {
case string:
if _, err := stream.Publish(ctx, v); err != nil {
logger.Default().Error(err)
}
case []any:
for _, v := range v {
s.Publish(ctx, stream, v)
}
}
}

View File

@@ -0,0 +1,131 @@
package streams
import (
"encoding/json"
"sync"
"sync/atomic"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/facebookincubator/go-belt/tool/logger"
)
type Stream struct {
streamHandler *StreamHandler
producers []*Producer
consumers []core.Consumer
mu sync.Mutex
pending atomic.Int32
forwardings []*StreamForwarding
}
func (s *StreamHandler) NewStream(source any) *Stream {
switch source := source.(type) {
case string:
return &Stream{
producers: []*Producer{s.NewProducer(source)},
streamHandler: s,
}
case []any:
stream := new(Stream)
stream.streamHandler = s
for _, src := range source {
str, ok := src.(string)
if !ok {
logger.Default().Errorf("[stream] NewStream: Expected string, got %v", src)
continue
}
stream.producers = append(stream.producers, s.NewProducer(str))
}
return stream
case map[string]any:
return s.NewStream(source["url"])
case nil:
stream := new(Stream)
stream.streamHandler = s
return stream
default:
panic(core.Caller())
}
}
func (s *Stream) Sources() (sources []string) {
for _, prod := range s.producers {
sources = append(sources, prod.url)
}
return
}
func (s *Stream) SetSource(source string) {
for _, prod := range s.producers {
prod.SetSource(source)
}
}
func (s *Stream) RemoveConsumer(cons core.Consumer) {
_ = cons.Stop()
s.mu.Lock()
for i, consumer := range s.consumers {
if consumer == cons {
s.consumers = append(s.consumers[:i], s.consumers[i+1:]...)
break
}
}
s.mu.Unlock()
s.stopProducers()
}
func (s *Stream) AddProducer(prod core.Producer) {
producer := &Producer{conn: prod, state: stateExternal}
s.mu.Lock()
s.producers = append(s.producers, producer)
s.mu.Unlock()
}
func (s *Stream) RemoveProducer(prod core.Producer) {
s.mu.Lock()
for i, producer := range s.producers {
if producer.conn == prod {
s.producers = append(s.producers[:i], s.producers[i+1:]...)
break
}
}
s.mu.Unlock()
}
func (s *Stream) stopProducers() {
if s.pending.Load() > 0 {
logger.Default().Tracef("[streams] skip stop pending producer")
return
}
s.mu.Lock()
producers:
for _, producer := range s.producers {
for _, track := range producer.receivers {
if len(track.Senders()) > 0 {
continue producers
}
}
for _, track := range producer.senders {
if len(track.Senders()) > 0 {
continue producers
}
}
producer.stop()
}
s.mu.Unlock()
}
func (s *Stream) MarshalJSON() ([]byte, error) {
var info = struct {
Producers []*Producer `json:"producers"`
Consumers []core.Consumer `json:"consumers"`
}{
Producers: s.producers,
Consumers: s.consumers,
}
return json.Marshal(info)
}

View File

@@ -0,0 +1,94 @@
package streams
import (
"context"
"fmt"
"sync"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
"github.com/hashicorp/go-multierror"
)
type StreamForwarding struct {
sync.Mutex
Stream *Stream
Consumer core.Consumer
StreamHandler *StreamHandler
CancelFunc context.CancelFunc
URL string
}
func NewStreamForwarding(streamHandler *StreamHandler) *StreamForwarding {
return &StreamForwarding{StreamHandler: streamHandler}
}
func (sf *StreamForwarding) Start(
ctx context.Context,
s *Stream,
url string,
) error {
sf.Lock()
defer sf.Unlock()
cons, run, err := sf.StreamHandler.GetConsumer(url)
if err != nil {
return fmt.Errorf("unable to initialize consumer of '%s': %w", url, err)
}
sf.Stream = s
sf.URL = url
sf.Consumer = cons
if err = s.AddConsumer(cons); err != nil {
return fmt.Errorf("unable to add consumer: %w", err)
}
ctx, cancelFn := context.WithCancel(ctx)
sf.CancelFunc = cancelFn
go func() {
err := run(ctx)
errmon.ObserveErrorCtx(ctx, err)
s.RemoveConsumer(cons)
// TODO: more smart retry
time.Sleep(5 * time.Second)
_, err = s.Publish(ctx, url)
if err != nil {
errmon.ObserveErrorCtx(ctx, err)
err := sf.Close()
errmon.ObserveErrorCtx(ctx, err)
}
}()
return nil
}
func (sf *StreamForwarding) IsClosed() bool {
sf.Lock()
defer sf.Unlock()
return sf.isClosed()
}
func (sf *StreamForwarding) isClosed() bool {
return sf.CancelFunc == nil
}
func (sf *StreamForwarding) Close() error {
sf.Lock()
defer sf.Unlock()
if sf.isClosed() {
return nil
}
sf.CancelFunc()
var result *multierror.Error
err := sf.Consumer.Stop()
if err != nil {
result = multierror.Append(result, err)
}
sf.Stream = nil
sf.Consumer = nil
sf.CancelFunc = nil
return result.ErrorOrNil()
}

View File

@@ -0,0 +1,101 @@
package streams
import (
"errors"
"fmt"
"net/url"
"regexp"
"sync"
)
func (s *StreamHandler) Get(name string) *Stream {
return s.streams[name]
}
var sanitize = regexp.MustCompile(`\s`)
// Validate - not allow creating dynamic streams with spaces in the source
func (s *StreamHandler) Validate(source string) error {
if sanitize.MatchString(source) {
return errors.New("streams: invalid dynamic source")
}
return nil
}
func (s *StreamHandler) New(name string, source string) (*Stream, error) {
if err := s.Validate(source); err != nil {
return nil, err
}
stream := s.NewStream(source)
s.streams[name] = stream
return stream, nil
}
func (s *StreamHandler) CreateOrUpdate(name string, source string) (*Stream, error) {
s.streamsMu.Lock()
defer s.streamsMu.Unlock()
// check if source links to some stream name from go2rtc
if u, err := url.Parse(source); err == nil && u.Scheme == "rtsp" && len(u.Path) > 1 {
rtspName := u.Path[1:]
if stream, ok := s.streams[rtspName]; ok {
if s.streams[name] != stream {
// link (alias) streams[name] to streams[rtspName]
s.streams[name] = stream
}
return stream, nil
}
}
if stream, ok := s.streams[source]; ok {
if name != source {
// link (alias) streams[name] to streams[source]
s.streams[name] = stream
}
return stream, nil
}
// check if src has supported scheme
if !s.HasProducer(source) {
return nil, fmt.Errorf("scheme is not supported")
}
// check an existing stream with this name
if stream, ok := s.streams[name]; ok {
stream.SetSource(source)
return stream, nil
}
// create new stream with this name
return s.New(name, source)
}
func (s *StreamHandler) GetAll() (names []string) {
for name := range s.streams {
names = append(names, name)
}
return
}
func (s *StreamHandler) Delete(id string) {
delete(s.streams, id)
}
type StreamHandler struct {
streams map[string]*Stream
streamsMu sync.Mutex
consumerHandlers map[string]ConsumerHandler
handlers map[string]Handler
redirects map[string]Redirect
}
func NewStreamHandler() *StreamHandler {
return &StreamHandler{
streams: map[string]*Stream{},
streamsMu: sync.Mutex{},
consumerHandlers: map[string]ConsumerHandler{},
handlers: map[string]Handler{},
redirects: map[string]Redirect{},
}
}

View File

@@ -0,0 +1,25 @@
package types
import "io"
type ServerType int
const (
ServerTypeUndefined = ServerType(iota)
ServerTypeRTMP
ServerTypeRTSP
)
type ServerHandler interface {
io.Closer
Type() ServerType
ListenAddr() string
}
type StreamDestination struct {
StreamID StreamID
URL string
}
type StreamID string