mirror of
https://github.com/xaionaro-go/streamctl.git
synced 2025-09-27 03:45:52 +08:00
Multiple updates
This commit is contained in:
@@ -2,8 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
_ "net/http/pprof"
|
_ "net/http/pprof"
|
||||||
"os"
|
"os"
|
||||||
@@ -19,11 +19,13 @@ import (
|
|||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery"
|
grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
|
"github.com/xaionaro-go/eventbus"
|
||||||
"github.com/xaionaro-go/grpcproxy/grpcproxyserver"
|
"github.com/xaionaro-go/grpcproxy/grpcproxyserver"
|
||||||
"github.com/xaionaro-go/grpcproxy/protobuf/go/proxy_grpc"
|
"github.com/xaionaro-go/grpcproxy/protobuf/go/proxy_grpc"
|
||||||
"github.com/xaionaro-go/obs-grpc-proxy/protobuf/go/obs_grpc"
|
"github.com/xaionaro-go/obs-grpc-proxy/protobuf/go/obs_grpc"
|
||||||
"github.com/xaionaro-go/observability"
|
"github.com/xaionaro-go/observability"
|
||||||
"github.com/xaionaro-go/streamctl/cmd/streamd/ui"
|
"github.com/xaionaro-go/streamctl/cmd/streamd/ui"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/cert"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamd"
|
"github.com/xaionaro-go/streamctl/pkg/streamd"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamd/config"
|
"github.com/xaionaro-go/streamctl/pkg/streamd/config"
|
||||||
@@ -127,6 +129,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer belt.Flush(ctx)
|
defer belt.Flush(ctx)
|
||||||
|
|
||||||
|
eventbus.LoggingEnabled = true
|
||||||
|
|
||||||
configPathExpanded, err := xpath.Expand(*configPath)
|
configPathExpanded, err := xpath.Expand(*configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Fatalf("unable to get the path to the data file: %v", err)
|
l.Fatalf("unable to get the path to the data file: %v", err)
|
||||||
@@ -181,7 +185,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", *listenAddr)
|
cert, err := cert.GenerateSelfSignedForServer()
|
||||||
|
if err != nil {
|
||||||
|
logger.Panicf(ctx, "unable to generate the certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := tls.Listen("tcp", *listenAddr, &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
NextProtos: []string{"h2"},
|
||||||
|
})
|
||||||
|
//listener, err := net.Listen("tcp", *listenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to listen: %v", err)
|
log.Fatalf("failed to listen: %v", err)
|
||||||
}
|
}
|
||||||
|
@@ -5,4 +5,4 @@ Website = "https://github.com/xaionaro/streamctl"
|
|||||||
Name = "streampanel"
|
Name = "streampanel"
|
||||||
ID = "center.dx.streampanel"
|
ID = "center.dx.streampanel"
|
||||||
Version = "0.1.0"
|
Version = "0.1.0"
|
||||||
Build = 432
|
Build = 433
|
||||||
|
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/facebookincubator/go-belt"
|
"github.com/facebookincubator/go-belt"
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"github.com/xaionaro-go/eventbus"
|
||||||
"github.com/xaionaro-go/observability"
|
"github.com/xaionaro-go/observability"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,6 +116,8 @@ func initRuntime(
|
|||||||
|
|
||||||
seppukuIfMemHugeLeak(ctx)
|
seppukuIfMemHugeLeak(ctx)
|
||||||
|
|
||||||
|
eventbus.LoggingEnabled = true
|
||||||
|
|
||||||
ctx, cancelFn := context.WithCancel(ctx)
|
ctx, cancelFn := context.WithCancel(ctx)
|
||||||
return ctx, func() {
|
return ctx, func() {
|
||||||
defer belt.Flush(ctx)
|
defer belt.Flush(ctx)
|
||||||
|
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"github.com/xaionaro-go/obs-grpc-proxy/protobuf/go/obs_grpc"
|
"github.com/xaionaro-go/obs-grpc-proxy/protobuf/go/obs_grpc"
|
||||||
"github.com/xaionaro-go/observability"
|
"github.com/xaionaro-go/observability"
|
||||||
"github.com/xaionaro-go/streamctl/cmd/streamd/ui"
|
"github.com/xaionaro-go/streamctl/cmd/streamd/ui"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/cert"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/mainprocess"
|
"github.com/xaionaro-go/streamctl/pkg/mainprocess"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamd"
|
"github.com/xaionaro-go/streamctl/pkg/streamd"
|
||||||
@@ -257,7 +259,17 @@ func initGRPCServers(
|
|||||||
) (net.Listener, *grpc.Server, *server.GRPCServer, obs_grpc.OBSServer, proxy_grpc.NetworkProxyServer) {
|
) (net.Listener, *grpc.Server, *server.GRPCServer, obs_grpc.OBSServer, proxy_grpc.NetworkProxyServer) {
|
||||||
logger.Debugf(ctx, "initGRPCServers")
|
logger.Debugf(ctx, "initGRPCServers")
|
||||||
defer logger.Debugf(ctx, "/initGRPCServers")
|
defer logger.Debugf(ctx, "/initGRPCServers")
|
||||||
listener, err := net.Listen("tcp", listenAddr)
|
|
||||||
|
cert, err := cert.GenerateSelfSignedForServer()
|
||||||
|
if err != nil {
|
||||||
|
logger.Panicf(ctx, "unable to generate the certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf(ctx, "generated certificate %#+v", cert)
|
||||||
|
listener, err := tls.Listen("tcp", listenAddr, &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
NextProtos: []string{"h2"},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Panicf(ctx, "failed to listen: %v", err)
|
logger.Panicf(ctx, "failed to listen: %v", err)
|
||||||
}
|
}
|
||||||
|
6
go.mod
6
go.mod
@@ -27,8 +27,6 @@ replace github.com/asticode/go-astiav v0.36.0 => github.com/xaionaro-go/astiav v
|
|||||||
|
|
||||||
replace github.com/bluenviron/mediacommon/v2 v2.0.1-0.20250324151931-b8ce69d15d3d => github.com/xaionaro-go/mediacommon/v2 v2.0.0-20250420012906-03d6d69ac3b7
|
replace github.com/bluenviron/mediacommon/v2 v2.0.1-0.20250324151931-b8ce69d15d3d => github.com/xaionaro-go/mediacommon/v2 v2.0.0-20250420012906-03d6d69ac3b7
|
||||||
|
|
||||||
replace github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef => github.com/mysteriumnetwork/EventBus v0.0.0-20220414214953-84469ec2b111
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/facebookincubator/go-belt v0.0.0-20250308011339-62fb7027b11f
|
github.com/facebookincubator/go-belt v0.0.0-20250308011339-62fb7027b11f
|
||||||
github.com/go-git/go-billy/v5 v5.6.2
|
github.com/go-git/go-billy/v5 v5.6.2
|
||||||
@@ -36,6 +34,7 @@ require (
|
|||||||
github.com/hashicorp/go-multierror v1.1.1
|
github.com/hashicorp/go-multierror v1.1.1
|
||||||
github.com/nicklaw5/helix/v2 v2.30.1-0.20240715193454-0151ccccf980
|
github.com/nicklaw5/helix/v2 v2.30.1-0.20240715193454-0151ccccf980
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
|
github.com/xaionaro-go/eventbus v0.0.0-20250712221024-f0986ef769fa
|
||||||
github.com/xaionaro-go/logrustash v0.0.0-20240804141650-d48034780a5f // indirect
|
github.com/xaionaro-go/logrustash v0.0.0-20240804141650-d48034780a5f // indirect
|
||||||
golang.org/x/oauth2 v0.30.0
|
golang.org/x/oauth2 v0.30.0
|
||||||
google.golang.org/api v0.239.0
|
google.golang.org/api v0.239.0
|
||||||
@@ -256,7 +255,6 @@ require (
|
|||||||
github.com/adeithe/go-twitch v0.3.1
|
github.com/adeithe/go-twitch v0.3.1
|
||||||
github.com/andreykaipov/goobs v1.4.1
|
github.com/andreykaipov/goobs v1.4.1
|
||||||
github.com/anthonynsimon/bild v0.14.0
|
github.com/anthonynsimon/bild v0.14.0
|
||||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
|
|
||||||
github.com/asticode/go-astiav v0.36.0
|
github.com/asticode/go-astiav v0.36.0
|
||||||
github.com/bamiaux/rez v0.0.0-20170731184118-29f4463c688b
|
github.com/bamiaux/rez v0.0.0-20170731184118-29f4463c688b
|
||||||
github.com/bluenviron/gortsplib/v4 v4.12.4-0.20250324174248-61372cfa6800
|
github.com/bluenviron/gortsplib/v4 v4.12.4-0.20250324174248-61372cfa6800
|
||||||
@@ -326,6 +324,8 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046
|
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046
|
||||||
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250424061409-ccd60fbc7c1c
|
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250424061409-ccd60fbc7c1c
|
||||||
|
github.com/coder/websocket v1.8.13
|
||||||
|
github.com/joeyak/go-twitch-eventsub/v3 v3.0.0
|
||||||
github.com/phuslu/goid v1.0.2 // indirect
|
github.com/phuslu/goid v1.0.2 // indirect
|
||||||
github.com/pion/datachannel v1.5.10 // indirect
|
github.com/pion/datachannel v1.5.10 // indirect
|
||||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||||
|
8
go.sum
8
go.sum
@@ -216,6 +216,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH
|
|||||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||||
|
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||||
|
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||||
@@ -613,6 +615,8 @@ github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4
|
|||||||
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
|
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
|
||||||
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
|
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
|
||||||
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
|
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
|
||||||
|
github.com/joeyak/go-twitch-eventsub/v3 v3.0.0 h1:6BDgmYJynNDyCP7P+wM9jPQnE3leJAi58nohDnzliJ4=
|
||||||
|
github.com/joeyak/go-twitch-eventsub/v3 v3.0.0/go.mod h1:rpqOjYP1ftWDj3H4D8fA58AdOpkvK9YvODoduDpPCQU=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||||
@@ -741,8 +745,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
|||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
github.com/mysteriumnetwork/EventBus v0.0.0-20220414214953-84469ec2b111 h1:7s+VqlctjdVjy1z0slV2giUawTnv1A6vWj9oKKfgPhI=
|
|
||||||
github.com/mysteriumnetwork/EventBus v0.0.0-20220414214953-84469ec2b111/go.mod h1:ef8wV5ITJhXSTG1sUkcHPAQF7lh83c7l875IvrYU7H0=
|
|
||||||
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||||
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
@@ -1088,6 +1090,8 @@ github.com/xaionaro-go/avpipeline v0.0.0-20250525204026-17104bc4baca h1:Cls4rEim
|
|||||||
github.com/xaionaro-go/avpipeline v0.0.0-20250525204026-17104bc4baca/go.mod h1:LMh5Qi7cuntcktUezfA9toVCUCCsx9pjyGDWe9GLt9A=
|
github.com/xaionaro-go/avpipeline v0.0.0-20250525204026-17104bc4baca/go.mod h1:LMh5Qi7cuntcktUezfA9toVCUCCsx9pjyGDWe9GLt9A=
|
||||||
github.com/xaionaro-go/datacounter v1.0.4 h1:+QMZLmu73R5WGkQfUPwlXF/JFN+Weo4iuDZkiL2wVm8=
|
github.com/xaionaro-go/datacounter v1.0.4 h1:+QMZLmu73R5WGkQfUPwlXF/JFN+Weo4iuDZkiL2wVm8=
|
||||||
github.com/xaionaro-go/datacounter v1.0.4/go.mod h1:Sf9vBevuV6w5iE6K3qJ9pWVKcyS60clWBUSQLjt5++c=
|
github.com/xaionaro-go/datacounter v1.0.4/go.mod h1:Sf9vBevuV6w5iE6K3qJ9pWVKcyS60clWBUSQLjt5++c=
|
||||||
|
github.com/xaionaro-go/eventbus v0.0.0-20250712221024-f0986ef769fa h1:p4rzmAuKfYTgoHp4yL3qIL3Js4P3I91yhe654L4gnFo=
|
||||||
|
github.com/xaionaro-go/eventbus v0.0.0-20250712221024-f0986ef769fa/go.mod h1:zSbWHZpDvsRhjD3Sr3bruqqsWotjXvsIKmx6/THwXFw=
|
||||||
github.com/xaionaro-go/fyne/v2 v2.0.0-20250622004601-3a26ee69528a h1:awMQXlaweeiSZB4rSNfMmJGJriyn1ca/m/lglBi9uyA=
|
github.com/xaionaro-go/fyne/v2 v2.0.0-20250622004601-3a26ee69528a h1:awMQXlaweeiSZB4rSNfMmJGJriyn1ca/m/lglBi9uyA=
|
||||||
github.com/xaionaro-go/fyne/v2 v2.0.0-20250622004601-3a26ee69528a/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo=
|
github.com/xaionaro-go/fyne/v2 v2.0.0-20250622004601-3a26ee69528a/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo=
|
||||||
github.com/xaionaro-go/go-rtmp v0.0.0-20241009130244-1e3160f27f42 h1:izCjREd+62HDF9FRYqUI7dgJNdUxAIysEuqed8lBcDY=
|
github.com/xaionaro-go/go-rtmp v0.0.0-20241009130244-1e3160f27f42 h1:izCjREd+62HDF9FRYqUI7dgJNdUxAIysEuqed8lBcDY=
|
||||||
|
44
pkg/cert/generate_self_signed.go
Normal file
44
pkg/cert/generate_self_signed.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package cert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateSelfSignedForServer() (tls.Certificate, error) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{
|
||||||
|
Organization: []string{"DX.center"},
|
||||||
|
},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||||
|
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
DNSNames: []string{"wingout.dx.center"},
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, pub, priv)
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
|
||||||
|
|
||||||
|
return tls.X509KeyPair(certPEM, keyPEM)
|
||||||
|
}
|
@@ -33,6 +33,7 @@ func (s *ChatMessagesStorage) loadLocked(ctx context.Context) (_err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to parse file '%s': %w", s.FilePath, err)
|
return fmt.Errorf("unable to parse file '%s': %w", s.FilePath, err)
|
||||||
}
|
}
|
||||||
|
logger.Debugf(ctx, "loaded %d messages", len(s.Messages))
|
||||||
s.sortAndDeduplicateAndTruncate(ctx)
|
s.sortAndDeduplicateAndTruncate(ctx)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
39
pkg/ringbuffer/ring_buffer.go
Normal file
39
pkg/ringbuffer/ring_buffer.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package ringbuffer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/xaionaro-go/xsync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RingBuffer[T comparable] struct {
|
||||||
|
Storage []T
|
||||||
|
CurrentWriteIndex uint
|
||||||
|
Locker xsync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func New[T comparable](size uint) *RingBuffer[T] {
|
||||||
|
return &RingBuffer[T]{
|
||||||
|
Storage: make([]T, 0, size),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RingBuffer[T]) Add(item T) {
|
||||||
|
r.Locker.Do(context.TODO(), func() {
|
||||||
|
if r.CurrentWriteIndex >= uint(len(r.Storage)) {
|
||||||
|
r.Storage = r.Storage[:len(r.Storage)+1]
|
||||||
|
}
|
||||||
|
r.Storage[r.CurrentWriteIndex] = item
|
||||||
|
r.CurrentWriteIndex++
|
||||||
|
if r.CurrentWriteIndex >= uint(cap(r.Storage)) {
|
||||||
|
r.CurrentWriteIndex = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RingBuffer[T]) Contains(item T) bool {
|
||||||
|
return xsync.DoR1(context.TODO(), &r.Locker, func() bool {
|
||||||
|
return slices.Contains(r.Storage, item)
|
||||||
|
})
|
||||||
|
}
|
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
http "github.com/Danny-Dasilva/fhttp"
|
http "github.com/Danny-Dasilva/fhttp"
|
||||||
"github.com/davecgh/go-spew/spew"
|
"github.com/davecgh/go-spew/spew"
|
||||||
|
"github.com/facebookincubator/go-belt"
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/scorfly/gokick"
|
"github.com/scorfly/gokick"
|
||||||
@@ -64,6 +65,7 @@ func New(
|
|||||||
cfg Config,
|
cfg Config,
|
||||||
saveCfgFn func(Config) error,
|
saveCfgFn func(Config) error,
|
||||||
) (*Kick, error) {
|
) (*Kick, error) {
|
||||||
|
ctx = belt.WithField(ctx, "controller", ID)
|
||||||
if cfg.Config.Channel == "" {
|
if cfg.Config.Channel == "" {
|
||||||
return nil, fmt.Errorf("channel is not set")
|
return nil, fmt.Errorf("channel is not set")
|
||||||
}
|
}
|
||||||
|
@@ -115,6 +115,20 @@ type ChatMessage struct {
|
|||||||
Username string
|
Username string
|
||||||
MessageID ChatMessageID
|
MessageID ChatMessageID
|
||||||
Message string
|
Message string
|
||||||
|
Paid Money
|
||||||
|
}
|
||||||
|
|
||||||
|
type Currency int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CurrencyNone = Currency(iota)
|
||||||
|
CurrencyUSD
|
||||||
|
CurrencyOther
|
||||||
|
)
|
||||||
|
|
||||||
|
type Money struct {
|
||||||
|
Currency Currency
|
||||||
|
Amount float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamControllerCommons interface {
|
type StreamControllerCommons interface {
|
||||||
|
34
pkg/streamcontrol/twitch/auth/access_token_app.go
Normal file
34
pkg/streamcontrol/twitch/auth/access_token_app.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/nicklaw5/helix/v2"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewTokenByApp(
|
||||||
|
ctx context.Context,
|
||||||
|
client *helix.Client,
|
||||||
|
) (secret.String, error) {
|
||||||
|
logger.Debugf(ctx, "getNewTokenByApp")
|
||||||
|
defer func() { logger.Debugf(ctx, "/getNewTokenByApp") }()
|
||||||
|
|
||||||
|
resp, err := client.RequestAppAccessToken(nil)
|
||||||
|
if err != nil {
|
||||||
|
return secret.New(""), fmt.Errorf("unable to get app access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ErrorStatus != 0 {
|
||||||
|
return secret.New(""), fmt.Errorf(
|
||||||
|
"unable to get app access token (the response contains an error): %d %v: %v",
|
||||||
|
resp.ErrorStatus,
|
||||||
|
resp.Error,
|
||||||
|
resp.ErrorMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secret.New(resp.Data.AccessToken), nil
|
||||||
|
}
|
43
pkg/streamcontrol/twitch/auth/access_token_user.go
Normal file
43
pkg/streamcontrol/twitch/auth/access_token_user.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/nicklaw5/helix/v2"
|
||||||
|
"github.com/xaionaro-go/observability"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewTokenByUser(
|
||||||
|
ctx context.Context,
|
||||||
|
client *helix.Client,
|
||||||
|
clientCode secret.String,
|
||||||
|
) (secret.String, secret.String, error) {
|
||||||
|
logger.Debugf(ctx, "getNewTokenByUser")
|
||||||
|
defer func() { logger.Debugf(ctx, "/getNewTokenByUser") }()
|
||||||
|
|
||||||
|
if clientCode.Get() == "" {
|
||||||
|
return secret.New(""), secret.New(""), fmt.Errorf("internal error: ClientCode is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf(ctx, "requesting user access token...")
|
||||||
|
resp, err := client.RequestUserAccessToken(clientCode.Get())
|
||||||
|
if observability.IsOnInsecureDebug(ctx) {
|
||||||
|
logger.Debugf(ctx, "requesting user access token result: %#+v %v", resp, err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return secret.New(""), secret.New(""), fmt.Errorf("unable to get user access token: %w", err)
|
||||||
|
}
|
||||||
|
if resp.ErrorStatus != 0 {
|
||||||
|
return secret.New(""), secret.New(""), fmt.Errorf(
|
||||||
|
"unable to query: %d %v: %v",
|
||||||
|
resp.ErrorStatus,
|
||||||
|
resp.Error,
|
||||||
|
resp.ErrorMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secret.New(resp.Data.AccessToken), secret.New(resp.Data.RefreshToken), nil
|
||||||
|
}
|
181
pkg/streamcontrol/twitch/auth/client_code.go
Normal file
181
pkg/streamcontrol/twitch/auth/client_code.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/hashicorp/go-multierror"
|
||||||
|
"github.com/nicklaw5/helix/v2"
|
||||||
|
"github.com/xaionaro-go/observability"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OAuthHandler func(context.Context, oauthhandler.OAuthHandlerArgument) error
|
||||||
|
|
||||||
|
func NewClientCode(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID string,
|
||||||
|
oauthHandler OAuthHandler,
|
||||||
|
getOAuthListenPortsFn func() []uint16,
|
||||||
|
onNewClientCode func(string),
|
||||||
|
) (_err error) {
|
||||||
|
logger.Debugf(ctx, "getNewClientCode")
|
||||||
|
defer func() { logger.Debugf(ctx, "/getNewClientCode: %v", _err) }()
|
||||||
|
|
||||||
|
if oauthHandler == nil {
|
||||||
|
oauthHandler = oauthhandler.OAuth2HandlerViaCLI
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, ctxCancelFunc := context.WithCancel(ctx)
|
||||||
|
cancelFunc := func() {
|
||||||
|
logger.Debugf(ctx, "cancelling the context")
|
||||||
|
ctxCancelFunc()
|
||||||
|
}
|
||||||
|
|
||||||
|
var errWg sync.WaitGroup
|
||||||
|
var resultErr error
|
||||||
|
errCh := make(chan error)
|
||||||
|
errWg.Add(1)
|
||||||
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
alreadyListening[listenPort] = struct{}{}
|
||||||
|
|
||||||
|
logger.Debugf(ctx, "starting the oauth handler at port %d", listenPort)
|
||||||
|
wg.Add(1)
|
||||||
|
{
|
||||||
|
listenPort := listenPort
|
||||||
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer func() { logger.Debugf(ctx, "ended the oauth handler at port %d", listenPort) }()
|
||||||
|
defer wg.Done()
|
||||||
|
authURL := GetAuthorizationURL(
|
||||||
|
&helix.AuthorizationURLParams{
|
||||||
|
ResponseType: "code", // or "token"
|
||||||
|
Scopes: []string{
|
||||||
|
"user:read:chat",
|
||||||
|
"chat:read",
|
||||||
|
"chat:edit",
|
||||||
|
"channel:manage:broadcast",
|
||||||
|
"moderator:manage:chat_messages",
|
||||||
|
"moderator:manage:banned_users",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clientID,
|
||||||
|
RedirectURI(listenPort),
|
||||||
|
)
|
||||||
|
|
||||||
|
arg := oauthhandler.OAuthHandlerArgument{
|
||||||
|
AuthURL: authURL,
|
||||||
|
ListenPort: listenPort,
|
||||||
|
ExchangeFn: func(ctx context.Context, code string) (_err error) {
|
||||||
|
logger.Debugf(ctx, "ExchangeFn()")
|
||||||
|
defer func() { logger.Debugf(ctx, "/ExchangeFn(): %v", _err) }()
|
||||||
|
if code == "" {
|
||||||
|
return fmt.Errorf("code is empty")
|
||||||
|
}
|
||||||
|
onNewClientCode(code)
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: either support only one port as in New, or support multiple
|
||||||
|
// ports as we do below
|
||||||
|
getPortsFn := getOAuthListenPortsFn
|
||||||
|
if getPortsFn == nil {
|
||||||
|
return fmt.Errorf("the function GetOAuthListenPorts is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, listenPort := range getPortsFn() {
|
||||||
|
startHandlerForPort(listenPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer wg.Done()
|
||||||
|
t := time.NewTicker(time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
ports := getPortsFn()
|
||||||
|
logger.Tracef(ctx, "oauth listener ports: %#+v", ports)
|
||||||
|
|
||||||
|
for _, listenPort := range ports {
|
||||||
|
startHandlerForPort(listenPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
})
|
||||||
|
<-ctx.Done()
|
||||||
|
logger.Debugf(ctx, "did successfully took a new client code? -- %v", success)
|
||||||
|
if !success {
|
||||||
|
errWg.Wait()
|
||||||
|
return resultErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RedirectURI(listenPort uint16) string {
|
||||||
|
return fmt.Sprintf("http://localhost:%d/", listenPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
@@ -1,25 +0,0 @@
|
|||||||
package twitch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/adeithe/go-twitch"
|
|
||||||
"github.com/adeithe/go-twitch/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
type chatClientImpl struct {
|
|
||||||
*irc.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ ChatClient = (*chatClientImpl)(nil)
|
|
||||||
|
|
||||||
func (c *chatClientImpl) Join(channelIDs ...string) error {
|
|
||||||
return c.Client.Join(channelIDs...)
|
|
||||||
}
|
|
||||||
func (c *chatClientImpl) OnShardMessage(callback func(shard int, msg irc.ChatMessage)) {
|
|
||||||
c.Client.OnShardMessage(callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChatClient() *chatClientImpl {
|
|
||||||
return &chatClientImpl{
|
|
||||||
Client: twitch.IRC(),
|
|
||||||
}
|
|
||||||
}
|
|
31
pkg/streamcontrol/twitch/chat_client_irc.go
Normal file
31
pkg/streamcontrol/twitch/chat_client_irc.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package twitch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/adeithe/go-twitch"
|
||||||
|
"github.com/adeithe/go-twitch/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type chatClientIRC struct {
|
||||||
|
*irc.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ChatClientIRC = (*chatClientIRC)(nil)
|
||||||
|
|
||||||
|
func (c *chatClientIRC) Join(channelIDs ...string) error {
|
||||||
|
return c.Client.Join(channelIDs...)
|
||||||
|
}
|
||||||
|
func (c *chatClientIRC) OnShardMessage(callback func(shard int, msg irc.ChatMessage)) {
|
||||||
|
c.Client.OnShardMessage(callback)
|
||||||
|
}
|
||||||
|
func (c *chatClientIRC) Close(ctx context.Context) error {
|
||||||
|
c.Client.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChatClientIRC() *chatClientIRC {
|
||||||
|
return &chatClientIRC{
|
||||||
|
Client: twitch.IRC(),
|
||||||
|
}
|
||||||
|
}
|
@@ -2,110 +2,11 @@ package twitch
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/adeithe/go-twitch/irc"
|
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
|
||||||
"github.com/xaionaro-go/observability"
|
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChatClient interface {
|
type ChatHandler interface {
|
||||||
Join(channelIDs ...string) error
|
Close(ctx context.Context) error
|
||||||
OnShardMessage(func(shard int, msg irc.ChatMessage))
|
MessagesChan() <-chan streamcontrol.ChatMessage
|
||||||
Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatHandler struct {
|
|
||||||
client ChatClient
|
|
||||||
cancelFunc context.CancelFunc
|
|
||||||
waitGroup sync.WaitGroup
|
|
||||||
messagesInChan chan irc.ChatMessage
|
|
||||||
messagesOutChan chan streamcontrol.ChatMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewChatHandler(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID string,
|
|
||||||
) (*ChatHandler, error) {
|
|
||||||
var errs []error
|
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
|
||||||
h, err := newChatHandler(ctx, newChatClient(), channelID)
|
|
||||||
if err == nil {
|
|
||||||
return h, nil
|
|
||||||
}
|
|
||||||
err = fmt.Errorf("attempt #%d failed: %w", attempt, err)
|
|
||||||
logger.Errorf(ctx, "%v", err)
|
|
||||||
errs = append(errs, err)
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
}
|
|
||||||
return nil, errors.Join(errs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChatHandler(
|
|
||||||
ctx context.Context,
|
|
||||||
chatClient ChatClient,
|
|
||||||
channelID string,
|
|
||||||
) (*ChatHandler, error) {
|
|
||||||
err := chatClient.Join(channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to join channel '%s': %w", channelID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancelFn := context.WithCancel(ctx)
|
|
||||||
h := &ChatHandler{
|
|
||||||
client: chatClient,
|
|
||||||
cancelFunc: cancelFn,
|
|
||||||
messagesInChan: make(chan irc.ChatMessage),
|
|
||||||
messagesOutChan: make(chan streamcontrol.ChatMessage, 100),
|
|
||||||
}
|
|
||||||
|
|
||||||
h.waitGroup.Add(1)
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
|
||||||
defer h.waitGroup.Done()
|
|
||||||
defer func() {
|
|
||||||
h.client.Close()
|
|
||||||
// h.Client.Close above waits inside for everything to finish,
|
|
||||||
// so we can safely close the channel here:
|
|
||||||
close(h.messagesInChan)
|
|
||||||
close(h.messagesOutChan)
|
|
||||||
}()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case ev := <-h.messagesInChan:
|
|
||||||
select {
|
|
||||||
case h.messagesOutChan <- streamcontrol.ChatMessage{
|
|
||||||
CreatedAt: ev.CreatedAt,
|
|
||||||
UserID: streamcontrol.ChatUserID(ev.Sender.Username),
|
|
||||||
Username: ev.Sender.Username,
|
|
||||||
MessageID: streamcontrol.ChatMessageID(ev.ID),
|
|
||||||
Message: ev.Text, // TODO: investigate if we need ev.IRCMessage.Text
|
|
||||||
}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
chatClient.OnShardMessage(h.onShardMessage)
|
|
||||||
return h, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ChatHandler) onShardMessage(shard int, msg irc.ChatMessage) {
|
|
||||||
h.messagesInChan <- msg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ChatHandler) Close() error {
|
|
||||||
h.cancelFunc()
|
|
||||||
h.waitGroup.Wait()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ChatHandler) MessagesChan() <-chan streamcontrol.ChatMessage {
|
|
||||||
return h.messagesOutChan
|
|
||||||
}
|
}
|
||||||
|
126
pkg/streamcontrol/twitch/chat_handler_irc.go
Normal file
126
pkg/streamcontrol/twitch/chat_handler_irc.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package twitch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/adeithe/go-twitch/irc"
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/xaionaro-go/observability"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatClientIRC interface {
|
||||||
|
Join(channelIDs ...string) error
|
||||||
|
OnShardMessage(func(shard int, msg irc.ChatMessage))
|
||||||
|
Close(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatHandlerIRC struct {
|
||||||
|
client ChatClientIRC
|
||||||
|
cancelFunc context.CancelFunc
|
||||||
|
waitGroup sync.WaitGroup
|
||||||
|
messagesInChan chan irc.ChatMessage
|
||||||
|
messagesOutChan chan streamcontrol.ChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ChatHandler = (*ChatHandlerIRC)(nil)
|
||||||
|
|
||||||
|
func NewChatHandlerIRC(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID string,
|
||||||
|
) (_ret *ChatHandlerIRC, _err error) {
|
||||||
|
logger.Debugf(ctx, "NewChatHandlerIRC")
|
||||||
|
defer func() { logger.Debugf(ctx, "/NewChatHandlerIRC: %v", _err) }()
|
||||||
|
var errs []error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
h, err := newChatHandlerIRC(ctx, newChatClientIRC(), channelID)
|
||||||
|
if err == nil {
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
err = fmt.Errorf("attempt #%d failed: %w", attempt, err)
|
||||||
|
logger.Errorf(ctx, "%v", err)
|
||||||
|
errs = append(errs, err)
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
return nil, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChatHandlerIRC(
|
||||||
|
ctx context.Context,
|
||||||
|
chatClient ChatClientIRC,
|
||||||
|
channelID string,
|
||||||
|
) (_ret *ChatHandlerIRC, _err error) {
|
||||||
|
logger.Debugf(ctx, "newChatHandlerIRC")
|
||||||
|
defer func() { logger.Debugf(ctx, "/newChatHandlerIRC: %v", _err) }()
|
||||||
|
err := chatClient.Join(channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to join channel '%s': %w", channelID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancelFn := context.WithCancel(ctx)
|
||||||
|
h := &ChatHandlerIRC{
|
||||||
|
client: chatClient,
|
||||||
|
cancelFunc: cancelFn,
|
||||||
|
messagesInChan: make(chan irc.ChatMessage),
|
||||||
|
messagesOutChan: make(chan streamcontrol.ChatMessage, 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.waitGroup.Add(1)
|
||||||
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer logger.Debugf(ctx, "newChatHandlerIRC: closed")
|
||||||
|
defer h.waitGroup.Done()
|
||||||
|
defer func() {
|
||||||
|
h.client.Close(ctx)
|
||||||
|
// h.Client.Close above waits inside for everything to finish,
|
||||||
|
// so we can safely close the channel here:
|
||||||
|
close(h.messagesInChan)
|
||||||
|
close(h.messagesOutChan)
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Debugf(ctx, "newChatHandlerIRC: closing: %v", ctx.Err())
|
||||||
|
return
|
||||||
|
case ev, ok := <-h.messagesInChan:
|
||||||
|
if !ok {
|
||||||
|
logger.Debugf(ctx, "newChatHandlerIRC: input channel closed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case h.messagesOutChan <- streamcontrol.ChatMessage{
|
||||||
|
CreatedAt: ev.CreatedAt,
|
||||||
|
UserID: streamcontrol.ChatUserID(ev.Sender.Username),
|
||||||
|
Username: ev.Sender.Username,
|
||||||
|
MessageID: streamcontrol.ChatMessageID(ev.ID),
|
||||||
|
Message: ev.Text, // TODO: investigate if we need ev.IRCMessage.Text
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
logger.Warnf(ctx, "the queue is full, skipping the message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
chatClient.OnShardMessage(h.onShardMessage)
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatHandlerIRC) onShardMessage(shard int, msg irc.ChatMessage) {
|
||||||
|
ctx := context.TODO()
|
||||||
|
logger.Debugf(ctx, "newChatHandlerIRC: onShardMessage")
|
||||||
|
h.messagesInChan <- msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatHandlerIRC) Close(ctx context.Context) error {
|
||||||
|
h.cancelFunc()
|
||||||
|
h.waitGroup.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatHandlerIRC) MessagesChan() <-chan streamcontrol.ChatMessage {
|
||||||
|
return h.messagesOutChan
|
||||||
|
}
|
@@ -11,25 +11,25 @@ import (
|
|||||||
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
)
|
)
|
||||||
|
|
||||||
type chatClientMock struct {
|
type chatClientIRCMock struct {
|
||||||
join func(channelIDs ...string) error
|
join func(channelIDs ...string) error
|
||||||
onShardMessage func(func(shard int, msg irc.ChatMessage))
|
onShardMessage func(func(shard int, msg irc.ChatMessage))
|
||||||
close func()
|
close func(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ ChatClient = (*chatClientMock)(nil)
|
var _ ChatClientIRC = (*chatClientIRCMock)(nil)
|
||||||
|
|
||||||
func (c *chatClientMock) Join(channelIDs ...string) error {
|
func (c *chatClientIRCMock) Join(channelIDs ...string) error {
|
||||||
return c.join(channelIDs...)
|
return c.join(channelIDs...)
|
||||||
}
|
}
|
||||||
func (c *chatClientMock) OnShardMessage(callback func(shard int, msg irc.ChatMessage)) {
|
func (c *chatClientIRCMock) OnShardMessage(callback func(shard int, msg irc.ChatMessage)) {
|
||||||
c.onShardMessage(callback)
|
c.onShardMessage(callback)
|
||||||
}
|
}
|
||||||
func (c *chatClientMock) Close() {
|
func (c *chatClientIRCMock) Close(ctx context.Context) error {
|
||||||
c.close()
|
return c.close(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestChatHandler(t *testing.T) {
|
func TestChatHandlerIRC(t *testing.T) {
|
||||||
ctx := context.TODO()
|
ctx := context.TODO()
|
||||||
const channelID = "test-channel-id"
|
const channelID = "test-channel-id"
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ func TestChatHandler(t *testing.T) {
|
|||||||
callback func(shard int, msg irc.ChatMessage)
|
callback func(shard int, msg irc.ChatMessage)
|
||||||
closeCount = 0
|
closeCount = 0
|
||||||
)
|
)
|
||||||
h, err := newChatHandler(ctx, &chatClientMock{
|
h, err := newChatHandlerIRC(ctx, &chatClientIRCMock{
|
||||||
join: func(channelIDs ...string) error {
|
join: func(channelIDs ...string) error {
|
||||||
joinedChannelIDs = append(joinedChannelIDs, channelIDs...)
|
joinedChannelIDs = append(joinedChannelIDs, channelIDs...)
|
||||||
return nil
|
return nil
|
||||||
@@ -46,8 +46,9 @@ func TestChatHandler(t *testing.T) {
|
|||||||
onShardMessage: func(_callback func(shard int, msg irc.ChatMessage)) {
|
onShardMessage: func(_callback func(shard int, msg irc.ChatMessage)) {
|
||||||
callback = _callback
|
callback = _callback
|
||||||
},
|
},
|
||||||
close: func() {
|
close: func(ctx context.Context) error {
|
||||||
closeCount++
|
closeCount++
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}, channelID)
|
}, channelID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -84,7 +85,7 @@ func TestChatHandler(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
require.Equal(t, 0, closeCount)
|
require.Equal(t, 0, closeCount)
|
||||||
h.Close()
|
h.Close(ctx)
|
||||||
require.Equal(t, 1, closeCount)
|
require.Equal(t, 1, closeCount)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
305
pkg/streamcontrol/twitch/chat_handler_sub.go
Normal file
305
pkg/streamcontrol/twitch/chat_handler_sub.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package twitch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
twitcheventsub "github.com/joeyak/go-twitch-eventsub/v3"
|
||||||
|
"github.com/nicklaw5/helix/v2"
|
||||||
|
"github.com/xaionaro-go/observability"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatHandlerSub struct {
|
||||||
|
client TwitchSubscriptionClient
|
||||||
|
wsConn *websocket.Conn
|
||||||
|
broadcasterID string
|
||||||
|
cancelFunc context.CancelFunc
|
||||||
|
waitGroup sync.WaitGroup
|
||||||
|
messagesOutChan chan streamcontrol.ChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ChatHandler = (*ChatHandlerSub)(nil)
|
||||||
|
|
||||||
|
type TwitchSubscriptionClient interface {
|
||||||
|
CreateEventSubSubscription(payload *helix.EventSubSubscription) (*helix.EventSubSubscriptionsResponse, error)
|
||||||
|
GetUsers(params *helix.UsersParams) (*helix.UsersResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatHandlerSub(
|
||||||
|
ctx context.Context,
|
||||||
|
client TwitchSubscriptionClient,
|
||||||
|
broadcasterID string,
|
||||||
|
onClose func(context.Context),
|
||||||
|
) (_ret *ChatHandlerSub, _err error) {
|
||||||
|
logger.Debugf(ctx, "NewChatHandlerSub")
|
||||||
|
defer func() { logger.Debugf(ctx, "/NewChatHandlerSub: %v", _err) }()
|
||||||
|
|
||||||
|
const urlString = "wss://eventsub.wss.twitch.tv/ws"
|
||||||
|
c, _, err := websocket.Dial(ctx, urlString, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to initiate a websocket connection to '%s': %w", urlString, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var myUserID string
|
||||||
|
{
|
||||||
|
resp, err := client.GetUsers(&helix.UsersParams{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to get my user info: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Data.Users) != 1 {
|
||||||
|
return nil, fmt.Errorf("expected to get one user info, but received %d", len(resp.Data.Users))
|
||||||
|
}
|
||||||
|
myUserID = resp.Data.Users[0].ID
|
||||||
|
logger.Debugf(ctx, "my user ID: %v", myUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancelFn := context.WithCancel(ctx)
|
||||||
|
h := &ChatHandlerSub{
|
||||||
|
client: client,
|
||||||
|
wsConn: c,
|
||||||
|
broadcasterID: broadcasterID,
|
||||||
|
cancelFunc: cancelFn,
|
||||||
|
messagesOutChan: make(chan streamcontrol.ChatMessage, 100),
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if _err != nil {
|
||||||
|
_ = h.Close(ctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, sessMsgBytes, err := c.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to get my session ID: %w", err)
|
||||||
|
}
|
||||||
|
var sessMsg SessionWelcomeMessage
|
||||||
|
err = json.Unmarshal(sessMsgBytes, &sessMsg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to deserialize the session ID message '%s': %w", sessMsgBytes, err)
|
||||||
|
}
|
||||||
|
sessID := sessMsg.Payload.Session.ID
|
||||||
|
logger.Debugf(ctx, "session ID: '%s' (%s)", sessID, sessMsgBytes)
|
||||||
|
|
||||||
|
params := &helix.EventSubSubscription{
|
||||||
|
Type: string(twitcheventsub.SubChannelChatMessage),
|
||||||
|
Version: "1",
|
||||||
|
Condition: helix.EventSubCondition{
|
||||||
|
BroadcasterUserID: broadcasterID,
|
||||||
|
UserID: myUserID,
|
||||||
|
},
|
||||||
|
Transport: helix.EventSubTransport{
|
||||||
|
Method: "websocket",
|
||||||
|
SessionID: sessID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := client.CreateEventSubSubscription(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to create a subscription (%#+v): %w", params, err)
|
||||||
|
}
|
||||||
|
if resp.ErrorMessage != "" {
|
||||||
|
return nil, fmt.Errorf("got an error during subscription (%#+v): %s", params, resp.ErrorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.waitGroup.Add(1)
|
||||||
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer logger.Debugf(ctx, "NewChatHandlerSub: closed")
|
||||||
|
if onClose != nil {
|
||||||
|
defer onClose(ctx)
|
||||||
|
}
|
||||||
|
defer h.waitGroup.Done()
|
||||||
|
defer func() {
|
||||||
|
close(h.messagesOutChan)
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Debugf(ctx, "NewChatHandlerSub: context closed: %v", ctx.Err())
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_, messageSerialized, err := c.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to read the message: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var header struct {
|
||||||
|
Metadata MessageMetadata `json:"metadata"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(messageSerialized, &header); err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to un-JSON-ize message '%s': %v", messageSerialized, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, header.Metadata.MessageTimestamp)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to parse timestamp '%s': %v", header.Metadata.MessageTimestamp, err)
|
||||||
|
t = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgAbstract any
|
||||||
|
switch header.Metadata.MessageType {
|
||||||
|
case "session_welcome":
|
||||||
|
msgAbstract = &SessionWelcomeMessage{}
|
||||||
|
case "notification":
|
||||||
|
var msg NotificationMessage
|
||||||
|
err := json.Unmarshal(messageSerialized, &msg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to unserialize the notification message '%s': %v", messageSerialized, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Tracef(ctx, "notification: %#+v", msg)
|
||||||
|
switch twitcheventsub.EventSubscription(msg.Payload.Subscription.Type) {
|
||||||
|
case twitcheventsub.SubChannelChatMessage:
|
||||||
|
var chatEvent ChatMessageEvent
|
||||||
|
err := json.Unmarshal(msg.Payload.Event, &chatEvent)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to unserialize the chat message '%s': %v", msg.Payload.Event, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Tracef(ctx, "chat message: %#+v", msg)
|
||||||
|
msg := streamcontrol.ChatMessage{
|
||||||
|
CreatedAt: t,
|
||||||
|
UserID: streamcontrol.ChatUserID(chatEvent.ChatterUserID),
|
||||||
|
Username: chatEvent.ChatterUserName,
|
||||||
|
MessageID: streamcontrol.ChatMessageID(chatEvent.MessageID),
|
||||||
|
Message: chatEvent.Message.Text,
|
||||||
|
}
|
||||||
|
logger.Tracef(ctx, "resulting chat: %#+v", msg)
|
||||||
|
select {
|
||||||
|
case h.messagesOutChan <- msg:
|
||||||
|
default:
|
||||||
|
logger.Errorf(ctx, "the queue is full, have to drop %#+v", msg)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.Warnf(ctx, "got an event on a channel I haven't subscribed to: '%s': %s", msg.Payload.Subscription.Type, messageSerialized)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case "session_keepalive":
|
||||||
|
msgAbstract = &KeepaliveMessage{}
|
||||||
|
case "reconnect":
|
||||||
|
msgAbstract = &ReconnectMessage{}
|
||||||
|
case "revocation":
|
||||||
|
msgAbstract = &RevocationMessage{}
|
||||||
|
default:
|
||||||
|
logger.Debugf(ctx, "unknown message type: '%s': '%s'", header.Metadata.MessageType, messageSerialized)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(messageSerialized, &msgAbstract)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to unserialize the %T message '%s': %v", msgAbstract, messageSerialized, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Tracef(ctx, "received %T: %#+v", msgAbstract, msgAbstract)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatHandlerSub) Close(ctx context.Context) error {
|
||||||
|
h.cancelFunc()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatHandlerSub) MessagesChan() <-chan streamcontrol.ChatMessage {
|
||||||
|
return h.messagesOutChan
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common metadata for all messages
|
||||||
|
type MessageMetadata struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
MessageType string `json:"message_type"`
|
||||||
|
MessageTimestamp string `json:"message_timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session (used in session_welcome and reconnect)
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"`
|
||||||
|
ReconnectURL *string `json:"reconnect_url"` // can be null
|
||||||
|
ConnectedAt string `json:"connected_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload for session_welcome
|
||||||
|
type WelcomePayload struct {
|
||||||
|
Session Session `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session Welcome message
|
||||||
|
type SessionWelcomeMessage struct {
|
||||||
|
Metadata MessageMetadata `json:"metadata"`
|
||||||
|
Payload WelcomePayload `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription info (used in notifications)
|
||||||
|
type Subscription struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Cost int `json:"cost"`
|
||||||
|
Condition map[string]string `json:"condition"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Transport struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
} `json:"transport"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: Channel Chat Message Event (payload.event for chat messages)
|
||||||
|
type ChatMessageEvent struct {
|
||||||
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
||||||
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
||||||
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
||||||
|
ChatterUserID string `json:"chatter_user_id"`
|
||||||
|
ChatterUserLogin string `json:"chatter_user_login"`
|
||||||
|
ChatterUserName string `json:"chatter_user_name"`
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Message struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"message"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Badges []struct {
|
||||||
|
SetID string `json:"set_id"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Info string `json:"info"`
|
||||||
|
} `json:"badges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationPayload struct {
|
||||||
|
Subscription Subscription `json:"subscription"`
|
||||||
|
Event json.RawMessage `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationMessage struct {
|
||||||
|
Metadata MessageMetadata `json:"metadata"`
|
||||||
|
Payload NotificationPayload `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeepaliveMessage struct {
|
||||||
|
Metadata MessageMetadata `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReconnectPayload struct {
|
||||||
|
Session Session `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReconnectMessage struct {
|
||||||
|
Metadata MessageMetadata `json:"metadata"`
|
||||||
|
Payload ReconnectPayload `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RevocationPayload struct {
|
||||||
|
Subscription Subscription `json:"subscription"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RevocationMessage struct {
|
||||||
|
Metadata MessageMetadata `json:"metadata"`
|
||||||
|
Payload RevocationPayload `json:"payload"`
|
||||||
|
}
|
@@ -7,7 +7,15 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/facebookincubator/go-belt"
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger/implementation/zap"
|
||||||
|
"github.com/nicklaw5/helix/v2"
|
||||||
|
"github.com/xaionaro-go/observability"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/secret"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch"
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func assertNoError(err error) {
|
func assertNoError(err error) {
|
||||||
@@ -18,10 +26,21 @@ func assertNoError(err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.TODO()
|
l := zap.Default().WithLevel(logger.LevelTrace)
|
||||||
flag.Usage = func() {
|
ctx := context.Background()
|
||||||
fmt.Fprintf(os.Stderr, "syntax: chatlistener <channel_id>\n")
|
ctx = logger.CtxWithLogger(ctx, l)
|
||||||
|
ctx = observability.OnInsecureDebug(ctx)
|
||||||
|
logger.Default = func() logger.Logger {
|
||||||
|
return l
|
||||||
}
|
}
|
||||||
|
defer belt.Flush(ctx)
|
||||||
|
oldUsage := flag.Usage
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "syntax: chatlistener [options] <channel_id>\n")
|
||||||
|
oldUsage()
|
||||||
|
}
|
||||||
|
clientID := flag.String("client-id", "", "client ID for a WebSockets subscription (if not provided IRC will be used, instead)")
|
||||||
|
clientSecret := flag.String("client-secret", "", "client secret for a WebSockets subscription (if not provided IRC will be used, instead)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
if flag.NArg() != 1 {
|
if flag.NArg() != 1 {
|
||||||
flag.Usage()
|
flag.Usage()
|
||||||
@@ -29,7 +48,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
channelID := flag.Arg(0)
|
channelID := flag.Arg(0)
|
||||||
|
|
||||||
h, err := twitch.NewChatHandler(ctx, channelID)
|
var (
|
||||||
|
h twitch.ChatHandler
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if *clientID != "" && *clientSecret != "" {
|
||||||
|
h, err = newChatHandlerWebsockets(ctx, *clientID, *clientSecret, channelID)
|
||||||
|
} else {
|
||||||
|
h, err = twitch.NewChatHandlerIRC(ctx, channelID)
|
||||||
|
}
|
||||||
assertNoError(err)
|
assertNoError(err)
|
||||||
|
|
||||||
fmt.Println("started")
|
fmt.Println("started")
|
||||||
@@ -37,3 +64,51 @@ func main() {
|
|||||||
fmt.Printf("%#+v\n", ev)
|
fmt.Printf("%#+v\n", ev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oauthListenerPort = 8091
|
||||||
|
|
||||||
|
func newChatHandlerWebsockets(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID string,
|
||||||
|
clientSecret string,
|
||||||
|
channelID string,
|
||||||
|
) (*twitch.ChatHandlerSub, error) {
|
||||||
|
options := &helix.Options{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
RedirectURI: auth.RedirectURI(oauthListenerPort),
|
||||||
|
}
|
||||||
|
client, err := helix.NewClientWithContext(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to create a helix client object: %w", err)
|
||||||
|
}
|
||||||
|
var clientCode secret.String
|
||||||
|
err = auth.NewClientCode(
|
||||||
|
ctx,
|
||||||
|
clientID,
|
||||||
|
oauthhandler.OAuth2HandlerViaBrowser,
|
||||||
|
func() []uint16 {
|
||||||
|
return []uint16{oauthListenerPort}
|
||||||
|
}, func(code string) {
|
||||||
|
clientCode.Set(code)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to get a client code: %w", err)
|
||||||
|
}
|
||||||
|
accessToken, refreshToken, err := auth.NewTokenByUser(ctx, client, clientCode)
|
||||||
|
client.SetUserAccessToken(accessToken.Get())
|
||||||
|
client.SetRefreshToken(refreshToken.Get())
|
||||||
|
userID, err := twitch.GetUserID(ctx, client, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to get the user ID for login '%s': %w", channelID, err)
|
||||||
|
}
|
||||||
|
if observability.IsOnInsecureDebug(ctx) {
|
||||||
|
logger.Tracef(ctx, "user access token: %v", accessToken.Get())
|
||||||
|
}
|
||||||
|
h, err := twitch.NewChatHandlerSub(ctx, client, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to get a chat handler: %w", err)
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
@@ -9,31 +9,34 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/facebookincubator/go-belt"
|
||||||
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
|
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
"github.com/nicklaw5/helix/v2"
|
"github.com/nicklaw5/helix/v2"
|
||||||
"github.com/xaionaro-go/observability"
|
"github.com/xaionaro-go/observability"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/buildvars"
|
"github.com/xaionaro-go/streamctl/pkg/buildvars"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
|
"github.com/xaionaro-go/streamctl/pkg/ringbuffer"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/secret"
|
"github.com/xaionaro-go/streamctl/pkg/secret"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch/auth"
|
||||||
"github.com/xaionaro-go/xsync"
|
"github.com/xaionaro-go/xsync"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Twitch struct {
|
type Twitch struct {
|
||||||
closeCtx context.Context
|
closeCtx context.Context
|
||||||
closeFn context.CancelFunc
|
closeFn context.CancelFunc
|
||||||
chatHandler *ChatHandler
|
chatHandlerSub *ChatHandlerSub
|
||||||
client *helix.Client
|
chatHandlerIRC *ChatHandlerIRC
|
||||||
config Config
|
client *helix.Client
|
||||||
broadcasterID string
|
config Config
|
||||||
lazyInitOnce sync.Once
|
broadcasterID string
|
||||||
saveCfgFn func(Config) error
|
lazyInitOnce sync.Once
|
||||||
tokenLocker xsync.Mutex
|
saveCfgFn func(Config) error
|
||||||
prepareLocker xsync.Mutex
|
tokenLocker xsync.Mutex
|
||||||
clientID string
|
prepareLocker xsync.Mutex
|
||||||
clientSecret secret.String
|
clientID string
|
||||||
|
clientSecret secret.String
|
||||||
}
|
}
|
||||||
|
|
||||||
const twitchDebug = false
|
const twitchDebug = false
|
||||||
@@ -45,6 +48,7 @@ func New(
|
|||||||
cfg Config,
|
cfg Config,
|
||||||
saveCfgFn func(Config) error,
|
saveCfgFn func(Config) error,
|
||||||
) (*Twitch, error) {
|
) (*Twitch, error) {
|
||||||
|
ctx = belt.WithField(ctx, "controller", ID)
|
||||||
if cfg.Config.Channel == "" {
|
if cfg.Config.Channel == "" {
|
||||||
return nil, fmt.Errorf("'channel' is not set")
|
return nil, fmt.Errorf("'channel' is not set")
|
||||||
}
|
}
|
||||||
@@ -84,11 +88,11 @@ func New(
|
|||||||
clientSecret: secret.New(clientSecret),
|
clientSecret: secret.New(clientSecret),
|
||||||
}
|
}
|
||||||
|
|
||||||
h, err := NewChatHandler(ctx, cfg.Config.Channel)
|
h, err := NewChatHandlerIRC(ctx, cfg.Config.Channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to initialize a chat handler for channel '%s': %w", cfg.Config.Channel, err)
|
return nil, fmt.Errorf("unable to initialize a chat handler for channel '%s': %w", cfg.Config.Channel, err)
|
||||||
}
|
}
|
||||||
t.chatHandler = h
|
t.chatHandlerIRC = h
|
||||||
|
|
||||||
client, err := t.getClient(ctx, oauthPorts[0])
|
client, err := t.getClient(ctx, oauthPorts[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,7 +139,7 @@ func New(
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserID(
|
func GetUserID(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
client *helix.Client,
|
client *helix.Client,
|
||||||
login string,
|
login string,
|
||||||
@@ -171,7 +175,7 @@ func (t *Twitch) prepareNoLock(ctx context.Context) error {
|
|||||||
if t.broadcasterID != "" {
|
if t.broadcasterID != "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.broadcasterID, err = getUserID(ctx, t.client, t.config.Config.Channel)
|
t.broadcasterID, err = GetUserID(ctx, t.client, t.config.Config.Channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf(ctx, "unable to get broadcaster ID: %v", err)
|
logger.Errorf(ctx, "unable to get broadcaster ID: %v", err)
|
||||||
return
|
return
|
||||||
@@ -183,6 +187,20 @@ func (t *Twitch) prepareNoLock(ctx context.Context) error {
|
|||||||
t.config.Config.Channel,
|
t.config.Config.Channel,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if t.chatHandlerSub == nil {
|
||||||
|
t.chatHandlerSub, err = NewChatHandlerSub(
|
||||||
|
t.closeCtx, t.client, t.broadcasterID,
|
||||||
|
func(ctx context.Context) {
|
||||||
|
t.prepareLocker.Do(ctx, func() {
|
||||||
|
t.chatHandlerSub = nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to initialize websockets based chat listener: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,146 +546,25 @@ func (t *Twitch) getNewToken(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func authRedirectURI(listenPort uint16) string {
|
|
||||||
return fmt.Sprintf("http://localhost:%d/", listenPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Twitch) getNewClientCode(
|
func (t *Twitch) getNewClientCode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (_err error) {
|
) (_err error) {
|
||||||
logger.Debugf(ctx, "getNewClientCode")
|
return auth.NewClientCode(
|
||||||
defer func() { logger.Debugf(ctx, "/getNewClientCode: %v", _err) }()
|
ctx,
|
||||||
|
t.clientID,
|
||||||
oauthHandler := t.config.Config.CustomOAuthHandler
|
t.config.Config.CustomOAuthHandler,
|
||||||
if oauthHandler == nil {
|
t.config.Config.GetOAuthListenPorts,
|
||||||
oauthHandler = oauthhandler.OAuth2HandlerViaCLI
|
func(code string) {
|
||||||
}
|
t.config.Config.ClientCode.Set(code)
|
||||||
|
err := t.saveCfgFn(t.config)
|
||||||
ctx, ctxCancelFunc := context.WithCancel(ctx)
|
|
||||||
cancelFunc := func() {
|
|
||||||
logger.Debugf(ctx, "cancelling the context")
|
|
||||||
ctxCancelFunc()
|
|
||||||
}
|
|
||||||
|
|
||||||
var errWg sync.WaitGroup
|
|
||||||
var resultErr error
|
|
||||||
errCh := make(chan error)
|
|
||||||
errWg.Add(1)
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
|
||||||
errWg.Done()
|
|
||||||
for err := range errCh {
|
|
||||||
errmon.ObserveErrorCtx(ctx, err)
|
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
|
|
||||||
}
|
|
||||||
alreadyListening[listenPort] = struct{}{}
|
|
||||||
|
|
||||||
logger.Debugf(ctx, "starting the oauth handler at port %d", listenPort)
|
|
||||||
wg.Add(1)
|
|
||||||
{
|
|
||||||
listenPort := listenPort
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
|
||||||
defer func() { logger.Debugf(ctx, "ended the oauth handler at port %d", listenPort) }()
|
|
||||||
defer wg.Done()
|
|
||||||
authURL := GetAuthorizationURL(
|
|
||||||
&helix.AuthorizationURLParams{
|
|
||||||
ResponseType: "code", // or "token"
|
|
||||||
Scopes: []string{
|
|
||||||
"channel:manage:broadcast",
|
|
||||||
"moderator:manage:chat_messages",
|
|
||||||
"moderator:manage:banned_users",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
t.clientID,
|
|
||||||
authRedirectURI(listenPort),
|
|
||||||
)
|
|
||||||
|
|
||||||
arg := oauthhandler.OAuthHandlerArgument{
|
|
||||||
AuthURL: authURL,
|
|
||||||
ListenPort: listenPort,
|
|
||||||
ExchangeFn: func(ctx context.Context, code string) (_err error) {
|
|
||||||
logger.Debugf(ctx, "ExchangeFn()")
|
|
||||||
defer func() { logger.Debugf(ctx, "/ExchangeFn(): %v", _err) }()
|
|
||||||
if code == "" {
|
|
||||||
return fmt.Errorf("code is empty")
|
|
||||||
}
|
|
||||||
t.config.Config.ClientCode.Set(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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: either support only one port as in New, or support multiple
|
|
||||||
// ports as we do below
|
|
||||||
getPortsFn := t.config.Config.GetOAuthListenPorts
|
|
||||||
if getPortsFn == nil {
|
|
||||||
return fmt.Errorf("the function GetOAuthListenPorts is not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, listenPort := range getPortsFn() {
|
|
||||||
startHandlerForPort(listenPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Add(1)
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
|
||||||
defer wg.Done()
|
|
||||||
t := time.NewTicker(time.Second)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
}
|
|
||||||
ports := getPortsFn()
|
|
||||||
logger.Tracef(ctx, "oauth listener ports: %#+v", ports)
|
|
||||||
|
|
||||||
for _, listenPort := range ports {
|
|
||||||
startHandlerForPort(listenPort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
|
||||||
wg.Wait()
|
|
||||||
close(errCh)
|
|
||||||
})
|
|
||||||
<-ctx.Done()
|
|
||||||
logger.Debugf(ctx, "did successfully took a new client code? -- %v", success)
|
|
||||||
if !success {
|
|
||||||
errWg.Wait()
|
|
||||||
return resultErr
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Twitch) getNewTokenByUser(
|
func (t *Twitch) getNewTokenByUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "getNewTokenByUser")
|
|
||||||
defer func() { logger.Debugf(ctx, "/getNewTokenByUser") }()
|
|
||||||
|
|
||||||
if t.config.Config.ClientCode.Get() == "" {
|
if t.config.Config.ClientCode.Get() == "" {
|
||||||
err := t.getNewClientCode(ctx)
|
err := t.getNewClientCode(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -675,29 +572,17 @@ func (t *Twitch) getNewTokenByUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.config.Config.ClientCode.Get() == "" {
|
accessToken, refreshToken, err := auth.NewTokenByUser(ctx, t.client, t.config.Config.ClientCode)
|
||||||
return fmt.Errorf("internal error: ClientCode is empty")
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to get an access token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf(ctx, "requesting user access token...")
|
logger.Debugf(ctx, "setting the user access token")
|
||||||
resp, err := t.client.RequestUserAccessToken(t.config.Config.ClientCode.Get())
|
t.client.SetUserAccessToken(accessToken.Get())
|
||||||
logger.Debugf(ctx, "requesting user access token result: %#+v %v", resp, err)
|
t.client.SetRefreshToken(refreshToken.Get())
|
||||||
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.Set("")
|
t.config.Config.ClientCode.Set("")
|
||||||
t.config.Config.UserAccessToken.Set(resp.Data.AccessToken)
|
t.config.Config.UserAccessToken = accessToken
|
||||||
t.config.Config.RefreshToken.Set(resp.Data.RefreshToken)
|
t.config.Config.RefreshToken = refreshToken
|
||||||
err = t.saveCfgFn(t.config)
|
err = t.saveCfgFn(t.config)
|
||||||
errmon.ObserveErrorCtx(ctx, err)
|
errmon.ObserveErrorCtx(ctx, err)
|
||||||
return nil
|
return nil
|
||||||
@@ -706,24 +591,13 @@ func (t *Twitch) getNewTokenByUser(
|
|||||||
func (t *Twitch) getNewTokenByApp(
|
func (t *Twitch) getNewTokenByApp(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "getNewTokenByApp")
|
accessToken, err := auth.NewTokenByApp(ctx, t.client)
|
||||||
defer func() { logger.Debugf(ctx, "/getNewTokenByApp") }()
|
|
||||||
|
|
||||||
resp, err := t.client.RequestAppAccessToken(nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to get app access token: %w", err)
|
return 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")
|
logger.Debugf(ctx, "setting the app access token")
|
||||||
t.client.SetAppAccessToken(resp.Data.AccessToken)
|
t.client.SetAppAccessToken(accessToken.Get())
|
||||||
t.config.Config.AppAccessToken.Set(resp.Data.AccessToken)
|
t.config.Config.AppAccessToken = accessToken
|
||||||
err = t.saveCfgFn(t.config)
|
err = t.saveCfgFn(t.config)
|
||||||
errmon.ObserveErrorCtx(ctx, err)
|
errmon.ObserveErrorCtx(ctx, err)
|
||||||
return nil
|
return nil
|
||||||
@@ -739,7 +613,7 @@ func (t *Twitch) getClient(
|
|||||||
options := &helix.Options{
|
options := &helix.Options{
|
||||||
ClientID: t.clientID,
|
ClientID: t.clientID,
|
||||||
ClientSecret: t.clientSecret.Get(),
|
ClientSecret: t.clientSecret.Get(),
|
||||||
RedirectURI: authRedirectURI(oauthListenPort), // TODO: delete this hardcode
|
RedirectURI: auth.RedirectURI(oauthListenPort), // TODO: delete this hardcode
|
||||||
}
|
}
|
||||||
client, err := helix.NewClientWithContext(ctx, options)
|
client, err := helix.NewClientWithContext(ctx, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -748,31 +622,6 @@ func (t *Twitch) getClient(
|
|||||||
return client, nil
|
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(
|
func (t *Twitch) GetAllCategories(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) ([]helix.Game, error) {
|
) ([]helix.Game, error) {
|
||||||
@@ -837,22 +686,100 @@ func (t *Twitch) GetChatMessagesChan(
|
|||||||
logger.Debugf(ctx, "GetChatMessagesChan")
|
logger.Debugf(ctx, "GetChatMessagesChan")
|
||||||
defer func() { logger.Debugf(ctx, "/GetChatMessagesChan") }()
|
defer func() { logger.Debugf(ctx, "/GetChatMessagesChan") }()
|
||||||
|
|
||||||
|
if err := t.prepare(ctx); err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to prepare the client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
outCh := make(chan streamcontrol.ChatMessage)
|
outCh := make(chan streamcontrol.ChatMessage)
|
||||||
|
recentMsgIDs := ringbuffer.New[streamcontrol.ChatMessageID](10)
|
||||||
|
|
||||||
|
sendEvent := func(ev streamcontrol.ChatMessage) {
|
||||||
|
recentMsgIDs.Add(ev.MessageID)
|
||||||
|
select {
|
||||||
|
case outCh <- ev:
|
||||||
|
default:
|
||||||
|
logger.Warnf(ctx, "the queue is full, dropping message %#+v", ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alreadySeen := func(msgID streamcontrol.ChatMessageID) bool {
|
||||||
|
return recentMsgIDs.Contains(msgID)
|
||||||
|
}
|
||||||
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
defer func() {
|
defer func() {
|
||||||
logger.Debugf(ctx, "closing the messages channel")
|
logger.Debugf(ctx, "closing the messages channel")
|
||||||
close(outCh)
|
close(outCh)
|
||||||
}()
|
}()
|
||||||
|
var (
|
||||||
|
chSub <-chan streamcontrol.ChatMessage
|
||||||
|
chIRC <-chan streamcontrol.ChatMessage
|
||||||
|
)
|
||||||
|
t.prepareLocker.Do(ctx, func() {
|
||||||
|
if t.chatHandlerSub != nil {
|
||||||
|
chSub = t.chatHandlerSub.MessagesChan()
|
||||||
|
}
|
||||||
|
if t.chatHandlerIRC != nil {
|
||||||
|
chIRC = t.chatHandlerIRC.MessagesChan()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
logger.Debugf(ctx, "chSub == %p; chIRC == %p", chSub, chIRC)
|
||||||
for {
|
for {
|
||||||
|
if chSub == nil {
|
||||||
|
t.prepareLocker.Do(ctx, func() {
|
||||||
|
if t.chatHandlerSub != nil {
|
||||||
|
chSub = t.chatHandlerSub.MessagesChan()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if chSub == nil && chIRC == nil {
|
||||||
|
logger.Debugf(ctx, "both channels are closed")
|
||||||
|
return
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case ev, ok := <-t.chatHandler.MessagesChan():
|
case ev, ok := <-chSub:
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Debugf(ctx, "the input channel is closed")
|
chSub = nil
|
||||||
return
|
logger.Debugf(ctx, "the API receiver channel closed")
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
outCh <- ev
|
logger.Tracef(ctx, "received a message from API: %#+v", ev)
|
||||||
|
if alreadySeen(ev.MessageID) {
|
||||||
|
logger.Tracef(ctx, "already seen message %s", ev.MessageID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sendEvent(ev)
|
||||||
|
case evIRC, ok := <-chIRC:
|
||||||
|
if !ok {
|
||||||
|
chIRC = nil
|
||||||
|
logger.Debugf(ctx, "the IRC receiver channel closed")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Tracef(ctx, "received a message from IRC: %#+v", evIRC)
|
||||||
|
if alreadySeen(evIRC.MessageID) {
|
||||||
|
logger.Tracef(ctx, "already seen message %s", evIRC.MessageID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// not previously seen message:
|
||||||
|
select {
|
||||||
|
case evSub, ok := <-chSub:
|
||||||
|
if !ok {
|
||||||
|
chSub = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
logger.Tracef(ctx, "received a message from API: %#+v", evIRC)
|
||||||
|
sendEvent(evSub)
|
||||||
|
if alreadySeen(evIRC.MessageID) {
|
||||||
|
logger.Tracef(ctx, "the same message")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
logger.Warnf(ctx, "received a message from IRC, but not from API")
|
||||||
|
}
|
||||||
|
sendEvent(evIRC)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@@ -1,17 +1,15 @@
|
|||||||
package twitch
|
package twitch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/xaionaro-go/streamctl/pkg/buildvars"
|
"github.com/xaionaro-go/streamctl/pkg/buildvars"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
|
|
||||||
"github.com/xaionaro-go/streamctl/pkg/secret"
|
"github.com/xaionaro-go/streamctl/pkg/secret"
|
||||||
streamctl "github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
streamctl "github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ID = streamctl.PlatformName("twitch")
|
const ID = streamctl.PlatformName("twitch")
|
||||||
|
|
||||||
type OAuthHandler func(context.Context, oauthhandler.OAuthHandlerArgument) error
|
type OAuthHandler = auth.OAuthHandler
|
||||||
|
|
||||||
type PlatformSpecificConfig struct {
|
type PlatformSpecificConfig struct {
|
||||||
Channel string
|
Channel string
|
||||||
|
@@ -2,85 +2,45 @@ package youtube
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
ytchat "github.com/abhinavxd/youtube-live-chat-downloader/v2"
|
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
"github.com/xaionaro-go/observability"
|
"github.com/xaionaro-go/observability"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
|
"google.golang.org/api/googleapi"
|
||||||
|
"google.golang.org/api/youtube/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const youtubeWatchURLString = `https://www.youtube.com/watch`
|
|
||||||
|
|
||||||
func chatCustomCookies() []*http.Cookie {
|
|
||||||
// borrowed from: https://github.com/abhinavxd/youtube-live-chat-downloader/blob/main/example/main.go
|
|
||||||
return []*http.Cookie{
|
|
||||||
{Name: "PREF",
|
|
||||||
Value: "tz=Europe.Rome",
|
|
||||||
MaxAge: 300},
|
|
||||||
{Name: "CONSENT",
|
|
||||||
Value: fmt.Sprintf("YES+yt.432048971.it+FX+%d", 100+rand.Intn(999-100+1)),
|
|
||||||
MaxAge: 300},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var youtubeWatchURL *url.URL
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
var err error
|
|
||||||
youtubeWatchURL, err = url.Parse(youtubeWatchURLString)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ytchat.AddCookies(chatCustomCookies())
|
|
||||||
}
|
|
||||||
|
|
||||||
func ytWatchURL(videoID string) *url.URL {
|
|
||||||
result := ptr(*youtubeWatchURL)
|
|
||||||
query := result.Query()
|
|
||||||
query.Add("v", videoID)
|
|
||||||
result.RawQuery = query.Encode()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatListener struct {
|
type ChatListener struct {
|
||||||
videoID string
|
videoID string
|
||||||
continuationCode string
|
liveChatID string
|
||||||
clientConfig ytchat.YtCfg
|
client YouTubeChatClient
|
||||||
wg sync.WaitGroup
|
|
||||||
cancelFunc context.CancelFunc
|
wg sync.WaitGroup
|
||||||
messagesOutChan chan streamcontrol.ChatMessage
|
cancelFunc context.CancelFunc
|
||||||
|
messagesOutChan chan streamcontrol.ChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type YouTubeChatClient interface {
|
||||||
|
GetLiveChatMessages(ctx context.Context, chatID string, pageToken string, parts []string) (*youtube.LiveChatMessageListResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewChatListener(
|
func NewChatListener(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
ytClient YouTubeChatClient,
|
||||||
videoID string,
|
videoID string,
|
||||||
|
liveChatID string,
|
||||||
) (*ChatListener, error) {
|
) (*ChatListener, error) {
|
||||||
if videoID == "" {
|
|
||||||
return nil, fmt.Errorf("video ID is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
watchURL := ytWatchURL(videoID)
|
|
||||||
|
|
||||||
continuationCode, cfg, err := ytchat.ParseInitialData(watchURL.String())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to fetch the initial data for chat messages retrieval (URL: %s): %w", watchURL, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancelFunc := context.WithCancel(ctx)
|
ctx, cancelFunc := context.WithCancel(ctx)
|
||||||
l := &ChatListener{
|
l := &ChatListener{
|
||||||
videoID: videoID,
|
videoID: videoID,
|
||||||
continuationCode: continuationCode,
|
liveChatID: liveChatID,
|
||||||
clientConfig: cfg,
|
client: ytClient,
|
||||||
cancelFunc: cancelFunc,
|
cancelFunc: cancelFunc,
|
||||||
messagesOutChan: make(chan streamcontrol.ChatMessage, 100),
|
messagesOutChan: make(chan streamcontrol.ChatMessage, 100),
|
||||||
}
|
}
|
||||||
l.wg.Add(1)
|
l.wg.Add(1)
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
@@ -90,52 +50,81 @@ func NewChatListener(
|
|||||||
close(l.messagesOutChan)
|
close(l.messagesOutChan)
|
||||||
}()
|
}()
|
||||||
err := l.listenLoop(ctx)
|
err := l.listenLoop(ctx)
|
||||||
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ytchat.ErrLiveStreamOver) {
|
if err != nil && !errors.Is(err, context.Canceled) && !errors.As(err, &ErrChatEnded{}) {
|
||||||
logger.Errorf(ctx, "the listener loop returned an error: %v", err)
|
logger.Errorf(ctx, "the listener loop returned an error: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return l, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatFetchRetryInterval = time.Second
|
|
||||||
|
|
||||||
func (l *ChatListener) listenLoop(ctx context.Context) (_err error) {
|
func (l *ChatListener) listenLoop(ctx context.Context) (_err error) {
|
||||||
logger.Debugf(ctx, "listenLoop")
|
logger.Debugf(ctx, "listenLoop")
|
||||||
defer func() { logger.Debugf(ctx, "/listenLoop: %v", _err) }()
|
defer func() { logger.Debugf(ctx, "/listenLoop: %v", _err) }()
|
||||||
|
|
||||||
|
var pageToken string
|
||||||
for {
|
for {
|
||||||
msgs, newContinuation, err := ytchat.FetchContinuationChat(l.continuationCode, l.clientConfig)
|
select {
|
||||||
switch err {
|
case <-ctx.Done():
|
||||||
case nil:
|
return ctx.Err()
|
||||||
case ytchat.ErrLiveStreamOver:
|
|
||||||
return err
|
|
||||||
default:
|
default:
|
||||||
logger.Errorf(
|
}
|
||||||
ctx,
|
response, err := l.client.GetLiveChatMessages(
|
||||||
"unable to get a continuation for %v: %v; retrying in %v",
|
ctx,
|
||||||
l.videoID,
|
l.liveChatID,
|
||||||
chatFetchRetryInterval,
|
pageToken,
|
||||||
err,
|
[]string{"snippet", "authorDetails"},
|
||||||
)
|
)
|
||||||
time.Sleep(chatFetchRetryInterval)
|
if err != nil {
|
||||||
|
gErr := &googleapi.Error{}
|
||||||
|
if !errors.As(err, &gErr) {
|
||||||
|
logger.Warnf(ctx, "unable to get chat messages: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range gErr.Errors {
|
||||||
|
switch e.Reason {
|
||||||
|
case "liveChatEnded":
|
||||||
|
return ErrChatEnded{ChatID: l.liveChatID}
|
||||||
|
case "liveChatDisabled":
|
||||||
|
return ErrChatDisabled{ChatID: l.liveChatID}
|
||||||
|
case "liveChatNotFound":
|
||||||
|
return ErrChatNotFound{ChatID: l.liveChatID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(err)
|
||||||
|
logger.Warnf(ctx, "unable to get chat messages: %v (%s)", err, b)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
l.continuationCode = newContinuation
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
for _, item := range response.Items {
|
||||||
l.messagesOutChan <- streamcontrol.ChatMessage{
|
publishedAt, err := ParseTimestamp(item.Snippet.PublishedAt)
|
||||||
CreatedAt: msg.Timestamp,
|
if err != nil {
|
||||||
UserID: streamcontrol.ChatUserID(msg.AuthorName),
|
logger.Errorf(ctx, "unable to parse the timestamp '%s': %v", item.Snippet.PublishedAt, err)
|
||||||
Username: msg.AuthorName,
|
}
|
||||||
// TODO: find a way to extract the message ID,
|
msg := streamcontrol.ChatMessage{
|
||||||
// in the mean while we we use a soft key for that:
|
CreatedAt: publishedAt,
|
||||||
MessageID: streamcontrol.ChatMessageID(fmt.Sprintf("%s/%s", msg.AuthorName, msg.Message)),
|
UserID: streamcontrol.ChatUserID(item.AuthorDetails.ChannelId),
|
||||||
Message: msg.Message,
|
Username: item.AuthorDetails.DisplayName,
|
||||||
|
MessageID: streamcontrol.ChatMessageID(item.Id),
|
||||||
|
Message: item.Snippet.DisplayMessage,
|
||||||
|
}
|
||||||
|
if item.Snippet.SuperChatDetails != nil {
|
||||||
|
msg.Paid.Currency = streamcontrol.CurrencyOther
|
||||||
|
msg.Paid.Amount = float64(item.Snippet.SuperChatDetails.AmountMicros) / 1000000
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case l.messagesOutChan <- msg:
|
||||||
|
default:
|
||||||
|
logger.Errorf(ctx, "the queue is full, have to drop %#+v", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pageToken = response.NextPageToken
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond * time.Duration(response.PollingIntervalMillis))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ChatListener) Close() error {
|
func (h *ChatListener) Close(ctx context.Context) error {
|
||||||
h.cancelFunc()
|
h.cancelFunc()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -143,3 +132,7 @@ func (h *ChatListener) Close() error {
|
|||||||
func (h *ChatListener) MessagesChan() <-chan streamcontrol.ChatMessage {
|
func (h *ChatListener) MessagesChan() <-chan streamcontrol.ChatMessage {
|
||||||
return h.messagesOutChan
|
return h.messagesOutChan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ChatListener) GetVideoID() string {
|
||||||
|
return h.videoID
|
||||||
|
}
|
||||||
|
158
pkg/streamcontrol/youtube/chat_listener_obsolete.go
Normal file
158
pkg/streamcontrol/youtube/chat_listener_obsolete.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package youtube
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ytchat "github.com/abhinavxd/youtube-live-chat-downloader/v2"
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/xaionaro-go/observability"
|
||||||
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const youtubeWatchURLString = `https://www.youtube.com/watch`
|
||||||
|
|
||||||
|
func chatCustomCookies() []*http.Cookie {
|
||||||
|
// borrowed from: https://github.com/abhinavxd/youtube-live-chat-downloader/blob/main/example/main.go
|
||||||
|
return []*http.Cookie{
|
||||||
|
{Name: "PREF",
|
||||||
|
Value: "tz=Europe.Rome",
|
||||||
|
MaxAge: 300},
|
||||||
|
{Name: "CONSENT",
|
||||||
|
Value: fmt.Sprintf("YES+yt.432048971.it+FX+%d", 100+rand.Intn(999-100+1)),
|
||||||
|
MaxAge: 300},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var youtubeWatchURL *url.URL
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
youtubeWatchURL, err = url.Parse(youtubeWatchURLString)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ytchat.AddCookies(chatCustomCookies())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ytWatchURL(videoID string) *url.URL {
|
||||||
|
result := ptr(*youtubeWatchURL)
|
||||||
|
query := result.Query()
|
||||||
|
query.Add("v", videoID)
|
||||||
|
result.RawQuery = query.Encode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatListenerOBSOLETE struct {
|
||||||
|
videoID string
|
||||||
|
continuationCode string
|
||||||
|
clientConfig ytchat.YtCfg
|
||||||
|
wg sync.WaitGroup
|
||||||
|
cancelFunc context.CancelFunc
|
||||||
|
messagesOutChan chan streamcontrol.ChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatListenerOBSOLETE(
|
||||||
|
ctx context.Context,
|
||||||
|
videoID string,
|
||||||
|
onClose func(context.Context, *chatListener),
|
||||||
|
) (*ChatListenerOBSOLETE, error) {
|
||||||
|
if videoID == "" {
|
||||||
|
return nil, fmt.Errorf("video ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
watchURL := ytWatchURL(videoID)
|
||||||
|
|
||||||
|
continuationCode, cfg, err := ytchat.ParseInitialData(watchURL.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to fetch the initial data for chat messages retrieval (URL: %s): %w", watchURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancelFunc := context.WithCancel(ctx)
|
||||||
|
l := &ChatListenerOBSOLETE{
|
||||||
|
videoID: videoID,
|
||||||
|
continuationCode: continuationCode,
|
||||||
|
clientConfig: cfg,
|
||||||
|
cancelFunc: cancelFunc,
|
||||||
|
messagesOutChan: make(chan streamcontrol.ChatMessage, 100),
|
||||||
|
}
|
||||||
|
l.wg.Add(1)
|
||||||
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer l.wg.Done()
|
||||||
|
if onClose != nil {
|
||||||
|
defer onClose(ctx, l)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
logger.Debugf(ctx, "the listener loop is finished")
|
||||||
|
close(l.messagesOutChan)
|
||||||
|
}()
|
||||||
|
err := l.listenLoop(ctx)
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ytchat.ErrLiveStreamOver) {
|
||||||
|
logger.Errorf(ctx, "the listener loop returned an error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatFetchRetryInterval = time.Second
|
||||||
|
|
||||||
|
func (l *ChatListenerOBSOLETE) listenLoop(ctx context.Context) (_err error) {
|
||||||
|
logger.Debugf(ctx, "listenLoop")
|
||||||
|
defer func() { logger.Debugf(ctx, "/listenLoop: %v", _err) }()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
msgs, newContinuation, err := ytchat.FetchContinuationChat(l.continuationCode, l.clientConfig)
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
case ytchat.ErrLiveStreamOver:
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
logger.Errorf(
|
||||||
|
ctx,
|
||||||
|
"unable to get a continuation for %v: %v; retrying in %v",
|
||||||
|
l.videoID,
|
||||||
|
chatFetchRetryInterval,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
time.Sleep(chatFetchRetryInterval)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l.continuationCode = newContinuation
|
||||||
|
|
||||||
|
for _, msg := range msgs {
|
||||||
|
l.messagesOutChan <- streamcontrol.ChatMessage{
|
||||||
|
CreatedAt: msg.Timestamp,
|
||||||
|
UserID: streamcontrol.ChatUserID(msg.AuthorName),
|
||||||
|
Username: msg.AuthorName,
|
||||||
|
// TODO: find a way to extract the message ID,
|
||||||
|
// in the mean while we we use a soft key for that:
|
||||||
|
MessageID: streamcontrol.ChatMessageID(fmt.Sprintf("%s/%s", msg.AuthorName, msg.Message)),
|
||||||
|
Message: msg.Message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatListenerOBSOLETE) Close(ctx context.Context) error {
|
||||||
|
h.cancelFunc()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatListenerOBSOLETE) MessagesChan() <-chan streamcontrol.ChatMessage {
|
||||||
|
return h.messagesOutChan
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatListenerOBSOLETE) GetVideoID() string {
|
||||||
|
return h.videoID
|
||||||
|
}
|
29
pkg/streamcontrol/youtube/error.go
Normal file
29
pkg/streamcontrol/youtube/error.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package youtube
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrChatNotFound struct {
|
||||||
|
ChatID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrChatNotFound) Error() string {
|
||||||
|
return fmt.Sprintf("chat '%s' not found", e.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrChatDisabled struct {
|
||||||
|
ChatID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrChatDisabled) Error() string {
|
||||||
|
return fmt.Sprintf("chat '%s' is disabled", e.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrChatEnded struct {
|
||||||
|
ChatID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrChatEnded) Error() string {
|
||||||
|
return fmt.Sprintf("chat '%s' ended", e.ChatID)
|
||||||
|
}
|
@@ -48,6 +48,8 @@ type YouTube struct {
|
|||||||
currentLiveBroadcastsLocker xsync.Mutex
|
currentLiveBroadcastsLocker xsync.Mutex
|
||||||
currentLiveBroadcasts []*youtube.LiveBroadcast
|
currentLiveBroadcasts []*youtube.LiveBroadcast
|
||||||
|
|
||||||
|
chatListeners map[string]*chatListener
|
||||||
|
|
||||||
messagesOutChan chan streamcontrol.ChatMessage
|
messagesOutChan chan streamcontrol.ChatMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,11 +60,14 @@ const (
|
|||||||
debugUseMockClient = false
|
debugUseMockClient = false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type chatListener = ChatListenerOBSOLETE
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cfg Config,
|
cfg Config,
|
||||||
saveCfgFn func(Config) error,
|
saveCfgFn func(Config) error,
|
||||||
) (*YouTube, error) {
|
) (*YouTube, error) {
|
||||||
|
ctx = belt.WithField(ctx, "controller", ID)
|
||||||
if cfg.Config.ClientID == "" || cfg.Config.ClientSecret.Get() == "" {
|
if cfg.Config.ClientID == "" || cfg.Config.ClientSecret.Get() == "" {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"'clientid' or/and 'clientsecret' is/are not set; go to https://console.cloud.google.com/apis/credentials and create an app if it not created, yet",
|
"'clientid' or/and 'clientsecret' is/are not set; go to https://console.cloud.google.com/apis/credentials and create an app if it not created, yet",
|
||||||
@@ -76,6 +81,8 @@ func New(
|
|||||||
SaveConfigFunc: saveCfgFn,
|
SaveConfigFunc: saveCfgFn,
|
||||||
CancelFunc: cancelFn,
|
CancelFunc: cancelFn,
|
||||||
|
|
||||||
|
chatListeners: map[string]*chatListener{},
|
||||||
|
|
||||||
messagesOutChan: make(chan streamcontrol.ChatMessage, 100),
|
messagesOutChan: make(chan streamcontrol.ChatMessage, 100),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,7 +962,7 @@ func (yt *YouTube) StartStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
yt.currentLiveBroadcasts = append(yt.currentLiveBroadcasts, newBroadcast)
|
yt.currentLiveBroadcasts = append(yt.currentLiveBroadcasts, newBroadcast)
|
||||||
err = yt.startChatListener(ctx, newBroadcast.Id)
|
err = yt.startChatListener(ctx, newBroadcast)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf(ctx, "unable to start a chat listener for video '%s': %v", newBroadcast.Id, err)
|
logger.Errorf(ctx, "unable to start a chat listener for video '%s': %v", newBroadcast.Id, err)
|
||||||
}
|
}
|
||||||
@@ -979,21 +986,38 @@ func setProfile(broadcast *youtube.LiveBroadcast, profile StreamProfile) {
|
|||||||
|
|
||||||
func (yt *YouTube) startChatListener(
|
func (yt *YouTube) startChatListener(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
videoID string,
|
broadcast *youtube.LiveBroadcast,
|
||||||
) (_err error) {
|
) (_err error) {
|
||||||
|
videoID := broadcast.Id
|
||||||
|
chatID := broadcast.Snippet.LiveChatId
|
||||||
ctx = belt.WithField(ctx, "video_id", videoID)
|
ctx = belt.WithField(ctx, "video_id", videoID)
|
||||||
|
ctx = belt.WithField(ctx, "chat_id", chatID)
|
||||||
ctx = xcontext.DetachDone(ctx)
|
ctx = xcontext.DetachDone(ctx)
|
||||||
|
|
||||||
logger.Debugf(ctx, "startChatListener(ctx, '%s')", videoID)
|
logger.Debugf(ctx, "startChatListener(ctx, '%s':'%s')", videoID, chatID)
|
||||||
defer func() { logger.Debugf(ctx, "/startChatListener(ctx, '%s'): %v", videoID, _err) }()
|
defer func() { logger.Debugf(ctx, "/startChatListener(ctx, '%s':'%s'): %v", videoID, chatID, _err) }()
|
||||||
|
|
||||||
chatListener, err := NewChatListener(ctx, videoID)
|
_chatListener, err := NewChatListenerOBSOLETE(ctx, videoID, func(
|
||||||
|
ctx context.Context,
|
||||||
|
_chatListener *chatListener,
|
||||||
|
) {
|
||||||
|
yt.deleteChatListener(ctx, _chatListener)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to initialize the chat listener instance: %w", err)
|
return fmt.Errorf("unable to initialize the chat listener instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldListener := xsync.DoR1(ctx, &yt.locker, func() *chatListener {
|
||||||
|
oldListener := yt.chatListeners[broadcast.Id]
|
||||||
|
yt.chatListeners[broadcast.Id] = _chatListener
|
||||||
|
return oldListener
|
||||||
|
})
|
||||||
|
if err := oldListener.Close(ctx); err != nil {
|
||||||
|
logger.Debugf(ctx, "unable to close the old chat listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
err := yt.processChatListener(ctx, chatListener)
|
err := yt.processChatListener(ctx, _chatListener)
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
logger.Errorf(ctx, "unable to process the chat listener for '%s': %v", videoID, err)
|
logger.Errorf(ctx, "unable to process the chat listener for '%s': %v", videoID, err)
|
||||||
}
|
}
|
||||||
@@ -1001,18 +1025,45 @@ func (yt *YouTube) startChatListener(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (yt *YouTube) deleteChatListenerByBroadcast(
|
||||||
|
ctx context.Context,
|
||||||
|
broadcast *youtube.LiveBroadcast,
|
||||||
|
) error {
|
||||||
|
chatListener := yt.getChatListener(ctx, broadcast)
|
||||||
|
if chatListener == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return yt.deleteChatListener(ctx, chatListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (yt *YouTube) deleteChatListener(
|
||||||
|
ctx context.Context,
|
||||||
|
chatListener *chatListener,
|
||||||
|
) error {
|
||||||
|
err := chatListener.Close(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf(ctx, "unable to close the chat listener for %s: %v", chatListener.GetVideoID(), err)
|
||||||
|
}
|
||||||
|
yt.locker.Do(ctx, func() {
|
||||||
|
if yt.chatListeners[chatListener.GetVideoID()] == chatListener {
|
||||||
|
delete(yt.chatListeners, chatListener.GetVideoID())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (yt *YouTube) processChatListener(
|
func (yt *YouTube) processChatListener(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
chatListener *ChatListener,
|
chatListener *chatListener,
|
||||||
) (_err error) {
|
) (_err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
err := chatListener.Close()
|
err := yt.deleteChatListener(ctx, chatListener)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf(ctx, "unable to close the chat listener for '%s': %v", chatListener.videoID, err)
|
logger.Errorf(ctx, "unable to delete the chat listener for '%s': %v", chatListener.GetVideoID(), err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
defer func() {
|
defer func() {
|
||||||
logger.Debugf(ctx, "stopped listening for chat messages in '%s': %v", chatListener.videoID, _err)
|
logger.Debugf(ctx, "stopped listening for chat messages in '%s': %v", chatListener.GetVideoID(), _err)
|
||||||
}()
|
}()
|
||||||
inChan := chatListener.MessagesChan()
|
inChan := chatListener.MessagesChan()
|
||||||
for {
|
for {
|
||||||
@@ -1031,6 +1082,15 @@ func (yt *YouTube) processChatListener(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (yt *YouTube) getChatListener(
|
||||||
|
ctx context.Context,
|
||||||
|
broadcast *youtube.LiveBroadcast,
|
||||||
|
) *chatListener {
|
||||||
|
return xsync.DoR1(ctx, &yt.locker, func() *chatListener {
|
||||||
|
return yt.chatListeners[broadcast.Id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (yt *YouTube) EndStream(
|
func (yt *YouTube) EndStream(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) error {
|
) error {
|
||||||
@@ -1043,6 +1103,9 @@ func (yt *YouTube) EndStream(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return yt.updateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
|
return yt.updateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
|
||||||
|
if err := yt.deleteChatListenerByBroadcast(ctx, broadcast); err != nil {
|
||||||
|
logger.Warnf(ctx, "unable to delete the chat listener for %s: %v", broadcast.Id, err)
|
||||||
|
}
|
||||||
broadcast.ContentDetails.EnableAutoStop = true
|
broadcast.ContentDetails.EnableAutoStop = true
|
||||||
broadcast.ContentDetails.MonitorStream.ForceSendFields = []string{"BroadcastStreamDelayMs"}
|
broadcast.ContentDetails.MonitorStream.ForceSendFields = []string{"BroadcastStreamDelayMs"}
|
||||||
if _, ok := expectedVideoIDs[broadcast.Id]; !ok {
|
if _, ok := expectedVideoIDs[broadcast.Id]; !ok {
|
||||||
@@ -1055,6 +1118,18 @@ func (yt *YouTube) EndStream(
|
|||||||
const timeLayout = "2006-01-02T15:04:05-0700"
|
const timeLayout = "2006-01-02T15:04:05-0700"
|
||||||
const timeLayoutFallback = time.RFC3339
|
const timeLayoutFallback = time.RFC3339
|
||||||
|
|
||||||
|
func ParseTimestamp(s string) (time.Time, error) {
|
||||||
|
ts, err0 := time.Parse(timeLayout, s)
|
||||||
|
if err0 == nil {
|
||||||
|
return ts, nil
|
||||||
|
}
|
||||||
|
ts, err1 := time.Parse(timeLayoutFallback, s)
|
||||||
|
if err1 == nil {
|
||||||
|
return ts, nil
|
||||||
|
}
|
||||||
|
return time.Now(), errors.Join(err0, err1)
|
||||||
|
}
|
||||||
|
|
||||||
func (yt *YouTube) GetStreamStatus(
|
func (yt *YouTube) GetStreamStatus(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (_ret *streamcontrol.StreamStatus, _err error) {
|
) (_ret *streamcontrol.StreamStatus, _err error) {
|
||||||
@@ -1074,18 +1149,9 @@ func (yt *YouTube) GetStreamStatus(
|
|||||||
var requestStatsVideoIDs []string
|
var requestStatsVideoIDs []string
|
||||||
err := yt.IterateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
|
err := yt.IterateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
|
||||||
ts := broadcast.Snippet.ActualStartTime
|
ts := broadcast.Snippet.ActualStartTime
|
||||||
_startedAt, err := time.Parse(timeLayout, ts)
|
_startedAt, err := ParseTimestamp(ts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_startedAt, err = time.Parse(timeLayoutFallback, ts)
|
return fmt.Errorf("unable to parse '%s': %w", ts, err)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"unable to parse '%s' with layouts '%s' and '%s': %w",
|
|
||||||
ts,
|
|
||||||
timeLayout,
|
|
||||||
timeLayoutFallback,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
startedAt = &_startedAt
|
startedAt = &_startedAt
|
||||||
if broadcast.Statistics != nil {
|
if broadcast.Statistics != nil {
|
||||||
@@ -1124,7 +1190,7 @@ func (yt *YouTube) GetStreamStatus(
|
|||||||
if _, ok := ids[newBroadcast.Id]; ok {
|
if _, ok := ids[newBroadcast.Id]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err = yt.startChatListener(ctx, newBroadcast.Id)
|
err = yt.startChatListener(ctx, newBroadcast)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf(ctx, "unable to start a chat listener for video '%s': %v", newBroadcast.Id, err)
|
logger.Errorf(ctx, "unable to start a chat listener for video '%s': %v", newBroadcast.Id, err)
|
||||||
}
|
}
|
||||||
|
@@ -37,6 +37,7 @@ func (t BroadcastType) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type YouTubeClient interface {
|
type YouTubeClient interface {
|
||||||
|
YouTubeChatClient
|
||||||
Ping(context.Context) error
|
Ping(context.Context) error
|
||||||
GetBroadcasts(ctx context.Context, t BroadcastType, ids []string, parts []string, pageToken string) (*youtube.LiveBroadcastListResponse, error)
|
GetBroadcasts(ctx context.Context, t BroadcastType, ids []string, parts []string, pageToken string) (*youtube.LiveBroadcastListResponse, error)
|
||||||
UpdateBroadcast(context.Context, *youtube.LiveBroadcast, []string) error
|
UpdateBroadcast(context.Context, *youtube.LiveBroadcast, []string) error
|
||||||
@@ -309,3 +310,18 @@ func (c *YouTubeClientV3) DeleteChatMessage(
|
|||||||
do := c.Service.LiveChatMessages.Delete(messageID).Context(ctx).Do
|
do := c.Service.LiveChatMessages.Delete(messageID).Context(ctx).Do
|
||||||
return wrapRequest(ctx, c.RequestWrapper, do)
|
return wrapRequest(ctx, c.RequestWrapper, do)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *YouTubeClientV3) GetLiveChatMessages(
|
||||||
|
ctx context.Context,
|
||||||
|
chatID string,
|
||||||
|
pageToken string,
|
||||||
|
parts []string,
|
||||||
|
) (_ret *youtube.LiveChatMessageListResponse, _err error) {
|
||||||
|
logger.Tracef(ctx, "GetLiveChatMessages")
|
||||||
|
defer func() { logger.Tracef(ctx, "/GetLiveChatMessages: %v", _err) }()
|
||||||
|
q := c.Service.LiveChatMessages.List(chatID, parts).Context(ctx)
|
||||||
|
if pageToken != "" {
|
||||||
|
q = q.PageToken(pageToken)
|
||||||
|
}
|
||||||
|
return q.Do()
|
||||||
|
}
|
||||||
|
@@ -21,7 +21,7 @@ func init() {
|
|||||||
tzLosAngeles, err = time.LoadLocation("America/Los_Angeles")
|
tzLosAngeles, err = time.LoadLocation("America/Los_Angeles")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "unable to get the timezone of Los_Angeles")
|
fmt.Fprintf(os.Stderr, "unable to get the timezone of Los_Angeles")
|
||||||
tzLosAngeles = time.FixedZone("America/Los_Angeles", -7 * 3600)
|
tzLosAngeles = time.FixedZone("America/Los_Angeles", -7*3600)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,3 +208,13 @@ func (c *YouTubeClientCalcPoints) DeleteChatMessage(
|
|||||||
defer func() { c.addUsedPointsIfNoError(ctx, 1, _err) }()
|
defer func() { c.addUsedPointsIfNoError(ctx, 1, _err) }()
|
||||||
return c.Client.DeleteChatMessage(ctx, messageID)
|
return c.Client.DeleteChatMessage(ctx, messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *YouTubeClientCalcPoints) GetLiveChatMessages(
|
||||||
|
ctx context.Context,
|
||||||
|
chatID string,
|
||||||
|
pageToken string,
|
||||||
|
parts []string,
|
||||||
|
) (_ret *youtube.LiveChatMessageListResponse, _err error) {
|
||||||
|
defer func() { c.addUsedPointsIfNoError(ctx, 1, _err) }()
|
||||||
|
return c.Client.GetLiveChatMessages(ctx, chatID, pageToken, parts)
|
||||||
|
}
|
||||||
|
@@ -177,3 +177,14 @@ func (c *YouTubeClientMock) DeleteChatMessage(
|
|||||||
defer func() { logger.Tracef(ctx, "/DeleteChatMessage: %v", _err) }()
|
defer func() { logger.Tracef(ctx, "/DeleteChatMessage: %v", _err) }()
|
||||||
return fmt.Errorf("not implemented")
|
return fmt.Errorf("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *YouTubeClientMock) GetLiveChatMessages(
|
||||||
|
ctx context.Context,
|
||||||
|
chatID string,
|
||||||
|
pageToken string,
|
||||||
|
parts []string,
|
||||||
|
) (_ret *youtube.LiveChatMessageListResponse, _err error) {
|
||||||
|
logger.Tracef(ctx, "GetLiveChatMessages")
|
||||||
|
defer func() { logger.Tracef(ctx, "/GetLiveChatMessages: %v", _err) }()
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
25
pkg/streamcontrol/youtube/youtube_test.go
Normal file
25
pkg/streamcontrol/youtube/youtube_test.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package youtube
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseTimestamp(t *testing.T) {
|
||||||
|
type testCase struct {
|
||||||
|
input string
|
||||||
|
output time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range []testCase{
|
||||||
|
{input: "2025-07-09T11:49:53Z", output: time.Date(2025, 7, 9, 11, 49, 53, 0, time.UTC)},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.input, func(t *testing.T) {
|
||||||
|
r, err := ParseTimestamp(testCase.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, testCase.output, r.UTC())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@@ -55,7 +55,7 @@ func (d *StreamD) startListeningForChatMessages(
|
|||||||
if err := d.ChatMessagesStorage.AddMessage(ctx, msg); err != nil {
|
if err := d.ChatMessagesStorage.AddMessage(ctx, msg); err != nil {
|
||||||
logger.Errorf(ctx, "unable to add the message %#+v to the chat messages storage: %v", msg, err)
|
logger.Errorf(ctx, "unable to add the message %#+v to the chat messages storage: %v", msg, err)
|
||||||
}
|
}
|
||||||
d.publishEvent(ctx, msg)
|
publishEvent(ctx, d.EventBus, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -113,7 +113,7 @@ func (d *StreamD) SubscribeToChatMessages(
|
|||||||
defer func() { logger.Tracef(ctx, "/SubscribeToChatMessages(ctx, %v, %v): %p %v", since, limit, _ret, _err) }()
|
defer func() { logger.Tracef(ctx, "/SubscribeToChatMessages(ctx, %v, %v): %p %v", since, limit, _ret, _err) }()
|
||||||
|
|
||||||
return eventSubToChan(
|
return eventSubToChan(
|
||||||
ctx, d,
|
ctx, d.EventBus, 1000,
|
||||||
func(ctx context.Context, outCh chan api.ChatMessage) {
|
func(ctx context.Context, outCh chan api.ChatMessage) {
|
||||||
logger.Tracef(ctx, "backfilling the channel")
|
logger.Tracef(ctx, "backfilling the channel")
|
||||||
defer func() { logger.Tracef(ctx, "/backfilling the channel") }()
|
defer func() { logger.Tracef(ctx, "/backfilling the channel") }()
|
||||||
|
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto"
|
"crypto"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -42,7 +43,7 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/backoff"
|
"google.golang.org/grpc/backoff"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/stats"
|
"google.golang.org/grpc/stats"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
@@ -163,7 +164,9 @@ func (c *Client) connect(
|
|||||||
) (*grpc.ClientConn, error) {
|
) (*grpc.ClientConn, error) {
|
||||||
opts := []grpc.DialOption{
|
opts := []grpc.DialOption{
|
||||||
grpc.WithTransportCredentials(
|
grpc.WithTransportCredentials(
|
||||||
insecure.NewCredentials(),
|
credentials.NewTLS(&tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
grpc.WithConnectParams(grpc.ConnectParams{
|
grpc.WithConnectParams(grpc.ConnectParams{
|
||||||
Backoff: backoff.Config{
|
Backoff: backoff.Config{
|
||||||
|
@@ -4,11 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/davecgh/go-spew/spew"
|
"github.com/davecgh/go-spew/spew"
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/xaionaro-go/eventbus"
|
||||||
"github.com/xaionaro-go/observability"
|
"github.com/xaionaro-go/observability"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/expression"
|
"github.com/xaionaro-go/streamctl/pkg/expression"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
|
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
|
||||||
@@ -58,6 +58,19 @@ func (d *StreamD) submitEvent(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publishEvent[E any](
|
||||||
|
ctx context.Context,
|
||||||
|
bus *eventbus.EventBus,
|
||||||
|
event E,
|
||||||
|
) {
|
||||||
|
logger.Debugf(ctx, "publishEvent[%T](ctx, %#+v)", event, event)
|
||||||
|
defer logger.Debugf(ctx, "/publishEvent[%T](ctx, %#+v)", event, event)
|
||||||
|
result := eventbus.SendEvent(ctx, bus, event)
|
||||||
|
if result.DropCountImmediate != 0 || result.DropCountDeferred != 0 {
|
||||||
|
logger.Warnf(ctx, "unable to deliver the event to some of the subscriptions: %d + %d", result.DropCountImmediate, result.DropCountDeferred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (d *StreamD) doAction(
|
func (d *StreamD) doAction(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
a action.Action,
|
a action.Action,
|
||||||
@@ -92,118 +105,95 @@ func (d *StreamD) doAction(
|
|||||||
|
|
||||||
func eventSubToChan[T any](
|
func eventSubToChan[T any](
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
d *StreamD,
|
eventBus *eventbus.EventBus,
|
||||||
|
queueSize uint,
|
||||||
onReady func(ctx context.Context, outCh chan T),
|
onReady func(ctx context.Context, outCh chan T),
|
||||||
) (<-chan T, error) {
|
) (<-chan T, error) {
|
||||||
var sample T
|
var topic T
|
||||||
logger.Debugf(ctx, "eventSubToChan[%T]", sample)
|
logger.Debugf(ctx, "eventSubToChan[%T]", topic)
|
||||||
defer func() { logger.Debugf(ctx, "/eventSubToChan[%T]", sample) }()
|
defer func() { logger.Debugf(ctx, "/eventSubToChan[%T]", topic) }()
|
||||||
|
return eventSubToChanUsingTopic(ctx, eventBus, queueSize, onReady, topic)
|
||||||
topic := eventTopic(sample)
|
|
||||||
return eventSubToChanUsingTopic(ctx, d, onReady, topic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func eventSubToChanUsingTopic[T any](
|
func eventSubToChanUsingTopic[T, E any](
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
d *StreamD,
|
eventBus *eventbus.EventBus,
|
||||||
onReady func(ctx context.Context, outCh chan T),
|
queueSize uint,
|
||||||
topic string,
|
onReady func(ctx context.Context, outCh chan E),
|
||||||
) (<-chan T, error) {
|
topic T,
|
||||||
var mutex sync.Mutex
|
) (<-chan E, error) {
|
||||||
r := make(chan T)
|
var sample E
|
||||||
callback := func(in T) {
|
logger.Debugf(ctx, "eventSubToChanUsingTopic[%T, %T]", topic, sample)
|
||||||
mutex.Lock()
|
defer func() { logger.Debugf(ctx, "/eventSubToChanUsingTopic[%T, %T]", topic, sample) }()
|
||||||
defer mutex.Unlock()
|
|
||||||
logger.Tracef(ctx, "eventSubToChanUsingTopic(%T): received %#+v", topic, in)
|
|
||||||
|
|
||||||
select {
|
opts := eventbus.Options{
|
||||||
case <-ctx.Done():
|
eventbus.OptionQueueSize(1),
|
||||||
return
|
eventbus.OptionOnOverflow(eventbus.OnOverflowPileUpOrClose(queueSize, 10*time.Second)),
|
||||||
default:
|
eventbus.OptionOnUnsubscribe[E](func(_ context.Context, sub *eventbus.Subscription[E]) {
|
||||||
}
|
logger.Debugf(ctx, "eventSubToChanUsingTopic[%T, %T]: unsubscribed", topic, sample)
|
||||||
|
}),
|
||||||
select {
|
|
||||||
case r <- in:
|
|
||||||
case <-time.After(time.Minute):
|
|
||||||
logger.Errorf(ctx, "unable to notify about '%s': timeout", topic)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if onReady != nil {
|
if onReady != nil {
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
opts = append(opts,
|
||||||
mutex.Lock()
|
eventbus.OptionOnSubscribed[E](func(
|
||||||
defer mutex.Unlock()
|
ctx context.Context,
|
||||||
err := d.EventBus.SubscribeAsync(topic, callback, true)
|
sub *eventbus.Subscription[E],
|
||||||
if err != nil {
|
) {
|
||||||
logger.Errorf(ctx, "unable to subscribe: %v", err)
|
onReady(ctx, sub.EventChan())
|
||||||
return
|
}),
|
||||||
}
|
)
|
||||||
onReady(ctx, r)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
err := d.EventBus.SubscribeAsync(topic, callback, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to subscribe: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
sub := eventbus.SubscribeWithCustomTopic[T, E](ctx, eventBus, topic, opts...)
|
||||||
<-ctx.Done()
|
return sub.EventChan(), nil
|
||||||
|
|
||||||
d.EventBus.Unsubscribe(topic, callback)
|
|
||||||
d.EventBus.WaitAsync()
|
|
||||||
close(r)
|
|
||||||
})
|
|
||||||
|
|
||||||
return r, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) SubscribeToDashboardChanges(
|
func (d *StreamD) SubscribeToDashboardChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (<-chan api.DiffDashboard, error) {
|
) (<-chan api.DiffDashboard, error) {
|
||||||
return eventSubToChan[api.DiffDashboard](ctx, d, nil)
|
return eventSubToChan[api.DiffDashboard](ctx, d.EventBus, 1000, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) SubscribeToConfigChanges(
|
func (d *StreamD) SubscribeToConfigChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (<-chan api.DiffConfig, error) {
|
) (<-chan api.DiffConfig, error) {
|
||||||
return eventSubToChan[api.DiffConfig](ctx, d, nil)
|
return eventSubToChan[api.DiffConfig](ctx, d.EventBus, 1000, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) SubscribeToStreamsChanges(
|
func (d *StreamD) SubscribeToStreamsChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (<-chan api.DiffStreams, error) {
|
) (<-chan api.DiffStreams, error) {
|
||||||
return eventSubToChan[api.DiffStreams](ctx, d, nil)
|
return eventSubToChan[api.DiffStreams](ctx, d.EventBus, 1000, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) SubscribeToStreamServersChanges(
|
func (d *StreamD) SubscribeToStreamServersChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (<-chan api.DiffStreamServers, error) {
|
) (<-chan api.DiffStreamServers, error) {
|
||||||
return eventSubToChan[api.DiffStreamServers](ctx, d, nil)
|
return eventSubToChan[api.DiffStreamServers](ctx, d.EventBus, 1000, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) SubscribeToStreamDestinationsChanges(
|
func (d *StreamD) SubscribeToStreamDestinationsChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (<-chan api.DiffStreamDestinations, error) {
|
) (<-chan api.DiffStreamDestinations, error) {
|
||||||
return eventSubToChan[api.DiffStreamDestinations](ctx, d, nil)
|
return eventSubToChan[api.DiffStreamDestinations](ctx, d.EventBus, 1000, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) SubscribeToIncomingStreamsChanges(
|
func (d *StreamD) SubscribeToIncomingStreamsChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (<-chan api.DiffIncomingStreams, error) {
|
) (<-chan api.DiffIncomingStreams, error) {
|
||||||
return eventSubToChan[api.DiffIncomingStreams](ctx, d, nil)
|
return eventSubToChan[api.DiffIncomingStreams](ctx, d.EventBus, 1000, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) SubscribeToStreamForwardsChanges(
|
func (d *StreamD) SubscribeToStreamForwardsChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (<-chan api.DiffStreamForwards, error) {
|
) (<-chan api.DiffStreamForwards, error) {
|
||||||
return eventSubToChan[api.DiffStreamForwards](ctx, d, nil)
|
return eventSubToChan[api.DiffStreamForwards](ctx, d.EventBus, 1000, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) SubscribeToStreamPlayersChanges(
|
func (d *StreamD) SubscribeToStreamPlayersChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (<-chan api.DiffStreamPlayers, error) {
|
) (<-chan api.DiffStreamPlayers, error) {
|
||||||
return eventSubToChan[api.DiffStreamPlayers](ctx, d, nil)
|
return eventSubToChan[api.DiffStreamPlayers](ctx, d.EventBus, 1000, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) notifyStreamPlayerStart(
|
func (d *StreamD) notifyStreamPlayerStart(
|
||||||
@@ -213,5 +203,5 @@ func (d *StreamD) notifyStreamPlayerStart(
|
|||||||
logger.Debugf(ctx, "notifyStreamPlayerStart")
|
logger.Debugf(ctx, "notifyStreamPlayerStart")
|
||||||
defer logger.Debugf(ctx, "/notifyStreamPlayerStart")
|
defer logger.Debugf(ctx, "/notifyStreamPlayerStart")
|
||||||
|
|
||||||
d.publishEvent(ctx, api.DiffStreamPlayers{})
|
publishEvent(ctx, d.EventBus, api.DiffStreamPlayers{})
|
||||||
}
|
}
|
||||||
|
@@ -1,31 +0,0 @@
|
|||||||
package streamd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
|
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Event interface{}
|
|
||||||
|
|
||||||
func eventTopic(
|
|
||||||
event Event,
|
|
||||||
) string {
|
|
||||||
t := reflect.ValueOf(event).Type()
|
|
||||||
if t.Kind() == reflect.Pointer {
|
|
||||||
t = t.Elem()
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("type:", t.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *StreamD) publishEvent(
|
|
||||||
ctx context.Context,
|
|
||||||
event Event,
|
|
||||||
) {
|
|
||||||
topic := eventTopic(event)
|
|
||||||
logger.Debugf(ctx, "publishEvent(ctx, %#+v): %s", event, topic)
|
|
||||||
defer logger.Debugf(ctx, "/publishEvent(ctx, %#+v): %s", event, topic)
|
|
||||||
d.EventBus.Publish(topic, event)
|
|
||||||
}
|
|
@@ -62,8 +62,10 @@ func (d *StreamD) getImageBytes(
|
|||||||
|
|
||||||
func (d *StreamD) initImageTaker(ctx context.Context) error {
|
func (d *StreamD) initImageTaker(ctx context.Context) error {
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
ctxCh, cancelFn := context.WithCancel(ctx)
|
||||||
|
defer cancelFn()
|
||||||
defer logger.Debugf(ctx, "/imageTaker")
|
defer logger.Debugf(ctx, "/imageTaker")
|
||||||
ch, err := d.SubscribeToDashboardChanges(ctx)
|
ch, restartCh, err := autoResubscribe(ctxCh, d.SubscribeToDashboardChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf(ctx, "unable to subscribe to dashboard changes: %v", err)
|
logger.Errorf(ctx, "unable to subscribe to dashboard changes: %v", err)
|
||||||
return
|
return
|
||||||
@@ -72,6 +74,8 @@ func (d *StreamD) initImageTaker(ctx context.Context) error {
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
case <-restartCh:
|
||||||
|
d.restartImageTaker(ctx)
|
||||||
case <-ch:
|
case <-ch:
|
||||||
d.restartImageTaker(ctx)
|
d.restartImageTaker(ctx)
|
||||||
}
|
}
|
||||||
@@ -85,7 +89,9 @@ func (d *StreamD) restartImageTaker(ctx context.Context) error {
|
|||||||
return xsync.DoA1R1(ctx, &d.imageTakerLocker, d.restartImageTakerNoLock, ctx)
|
return xsync.DoA1R1(ctx, &d.imageTakerLocker, d.restartImageTakerNoLock, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) restartImageTakerNoLock(ctx context.Context) error {
|
func (d *StreamD) restartImageTakerNoLock(ctx context.Context) (_err error) {
|
||||||
|
logger.Debugf(ctx, "restartImageTakerNoLock")
|
||||||
|
defer func() { logger.Debugf(ctx, "/restartImageTakerNoLock: %v", _err) }()
|
||||||
if d.imageTakerCancel != nil {
|
if d.imageTakerCancel != nil {
|
||||||
d.imageTakerCancel()
|
d.imageTakerCancel()
|
||||||
d.imageTakerCancel = nil
|
d.imageTakerCancel = nil
|
||||||
|
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
|
"github.com/xaionaro-go/observability"
|
||||||
"github.com/xaionaro-go/player/pkg/player/protobuf/go/player_grpc"
|
"github.com/xaionaro-go/player/pkg/player/protobuf/go/player_grpc"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamd"
|
"github.com/xaionaro-go/streamctl/pkg/streamd"
|
||||||
@@ -1649,21 +1650,37 @@ func wrapChan[T any, E any](
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
for {
|
for {
|
||||||
var input E
|
var input E
|
||||||
|
var ok bool
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case input = <-ch:
|
case input, ok = <-ch:
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("channel is closed")
|
||||||
}
|
}
|
||||||
result := parse(input)
|
result := parse(input)
|
||||||
err := sender.Send(&result)
|
sendCtx, cancelFn := context.WithTimeout(ctx, time.Minute)
|
||||||
if err != nil {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
return fmt.Errorf(
|
errCh <- sender.Send(&result)
|
||||||
"unable to send %#+v: %w",
|
})
|
||||||
result,
|
select {
|
||||||
err,
|
case <-sendCtx.Done():
|
||||||
)
|
logger.Warnf(ctx, "sending timed out")
|
||||||
|
cancelFn()
|
||||||
|
return sendCtx.Err()
|
||||||
|
case err := <-errCh:
|
||||||
|
cancelFn()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"unable to send %#+v: %w",
|
||||||
|
result,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/andreykaipov/goobs"
|
"github.com/andreykaipov/goobs"
|
||||||
"github.com/andreykaipov/goobs/api/events"
|
"github.com/andreykaipov/goobs/api/events"
|
||||||
"github.com/andreykaipov/goobs/api/events/subscriptions"
|
"github.com/andreykaipov/goobs/api/events/subscriptions"
|
||||||
|
"github.com/facebookincubator/go-belt"
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
"github.com/xaionaro-go/object"
|
"github.com/xaionaro-go/object"
|
||||||
@@ -331,6 +332,7 @@ func (d *StreamD) processOBSEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) initTwitchBackend(ctx context.Context) error {
|
func (d *StreamD) initTwitchBackend(ctx context.Context) error {
|
||||||
|
ctx = belt.WithField(ctx, "controller", twitch.ID)
|
||||||
twitch, err := newTwitch(
|
twitch, err := newTwitch(
|
||||||
ctx,
|
ctx,
|
||||||
d.Config.Backends[twitch.ID],
|
d.Config.Backends[twitch.ID],
|
||||||
@@ -348,6 +350,7 @@ func (d *StreamD) initTwitchBackend(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) initKickBackend(ctx context.Context) error {
|
func (d *StreamD) initKickBackend(ctx context.Context) error {
|
||||||
|
ctx = belt.WithField(ctx, "controller", kick.ID)
|
||||||
cacheHashBeforeInit, _ := object.CalcCryptoHash(d.Cache.Kick)
|
cacheHashBeforeInit, _ := object.CalcCryptoHash(d.Cache.Kick)
|
||||||
kick, err := newKick(
|
kick, err := newKick(
|
||||||
kick.CtxWithCache(ctx, &d.Cache.Kick),
|
kick.CtxWithCache(ctx, &d.Cache.Kick),
|
||||||
@@ -373,6 +376,7 @@ func (d *StreamD) initKickBackend(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) initYouTubeBackend(ctx context.Context) error {
|
func (d *StreamD) initYouTubeBackend(ctx context.Context) error {
|
||||||
|
ctx = belt.WithField(ctx, "controller", youtube.ID)
|
||||||
youTube, err := newYouTube(
|
youTube, err := newYouTube(
|
||||||
ctx,
|
ctx,
|
||||||
d.Config.Backends[youtube.ID],
|
d.Config.Backends[youtube.ID],
|
||||||
|
@@ -11,11 +11,11 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
eventbus "github.com/asaskevich/EventBus"
|
|
||||||
"github.com/facebookincubator/go-belt"
|
"github.com/facebookincubator/go-belt"
|
||||||
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
|
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
|
"github.com/xaionaro-go/eventbus"
|
||||||
"github.com/xaionaro-go/observability"
|
"github.com/xaionaro-go/observability"
|
||||||
"github.com/xaionaro-go/player/pkg/player"
|
"github.com/xaionaro-go/player/pkg/player"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/chatmessagesstorage"
|
"github.com/xaionaro-go/streamctl/pkg/chatmessagesstorage"
|
||||||
@@ -92,7 +92,7 @@ type StreamD struct {
|
|||||||
StreamStatusCache *memoize.MemoizeData
|
StreamStatusCache *memoize.MemoizeData
|
||||||
OBSState OBSState
|
OBSState OBSState
|
||||||
|
|
||||||
EventBus eventbus.Bus
|
EventBus *eventbus.EventBus
|
||||||
|
|
||||||
TimersLocker xsync.Mutex
|
TimersLocker xsync.Mutex
|
||||||
NextTimerID uint64
|
NextTimerID uint64
|
||||||
@@ -259,7 +259,7 @@ func (d *StreamD) secretsProviderUpdater(ctx context.Context) (_err error) {
|
|||||||
logger.Debugf(ctx, "secretsProviderUpdater")
|
logger.Debugf(ctx, "secretsProviderUpdater")
|
||||||
defer logger.Debugf(ctx, "/secretsProviderUpdater: %v", _err)
|
defer logger.Debugf(ctx, "/secretsProviderUpdater: %v", _err)
|
||||||
|
|
||||||
cfgChangeCh, err := eventSubToChan[api.DiffConfig](ctx, d, nil)
|
cfgChangeCh, err := eventSubToChan[api.DiffConfig](ctx, d.EventBus, 1000, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to subscribe to config changes: %w", err)
|
return fmt.Errorf("unable to subscribe to config changes: %w", err)
|
||||||
}
|
}
|
||||||
@@ -302,7 +302,7 @@ func (d *StreamD) initStreamServer(ctx context.Context) (_err error) {
|
|||||||
//newBrowserOpenerAdapter(d),
|
//newBrowserOpenerAdapter(d),
|
||||||
)
|
)
|
||||||
assert(d.StreamServer != nil)
|
assert(d.StreamServer != nil)
|
||||||
defer d.publishEvent(ctx, api.DiffStreamServers{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamServers{})
|
||||||
return d.StreamServer.Init(
|
return d.StreamServer.Init(
|
||||||
ctx,
|
ctx,
|
||||||
sstypes.InitOptionDefaultStreamPlayerOptions(d.streamPlayerOptions()),
|
sstypes.InitOptionDefaultStreamPlayerOptions(d.streamPlayerOptions()),
|
||||||
@@ -566,9 +566,9 @@ func (d *StreamD) setConfig(ctx context.Context, cfg *config.Config) (_ret error
|
|||||||
}
|
}
|
||||||
if !dashboardCfgEqual {
|
if !dashboardCfgEqual {
|
||||||
logger.Debugf(ctx, "dashboard config changed")
|
logger.Debugf(ctx, "dashboard config changed")
|
||||||
d.publishEvent(ctx, api.DiffDashboard{})
|
publishEvent(ctx, d.EventBus, api.DiffDashboard{})
|
||||||
}
|
}
|
||||||
d.publishEvent(ctx, api.DiffConfig{})
|
publishEvent(ctx, d.EventBus, api.DiffConfig{})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Debugf(ctx, "SetConfig: %#+v", *cfg)
|
logger.Debugf(ctx, "SetConfig: %#+v", *cfg)
|
||||||
@@ -648,7 +648,7 @@ func (d *StreamD) StartStream(
|
|||||||
logger.Debugf(ctx, "StartStream(%s)", platID)
|
logger.Debugf(ctx, "StartStream(%s)", platID)
|
||||||
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
||||||
defer func() { logger.Debugf(ctx, "/StartStream(%s): %v", platID, _err) }()
|
defer func() { logger.Debugf(ctx, "/StartStream(%s): %v", platID, _err) }()
|
||||||
defer d.publishEvent(ctx, api.DiffStreams{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreams{})
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
d.StreamStatusCache.InvalidateCache(ctx)
|
d.StreamStatusCache.InvalidateCache(ctx)
|
||||||
@@ -776,7 +776,7 @@ func (d *StreamD) EndStream(ctx context.Context, platID streamcontrol.PlatformNa
|
|||||||
logger.Debugf(ctx, "EndStream(ctx, '%s')", platID)
|
logger.Debugf(ctx, "EndStream(ctx, '%s')", platID)
|
||||||
defer logger.Debugf(ctx, "/EndStream(ctx, '%s')", platID)
|
defer logger.Debugf(ctx, "/EndStream(ctx, '%s')", platID)
|
||||||
|
|
||||||
defer d.publishEvent(ctx, api.DiffStreams{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreams{})
|
||||||
|
|
||||||
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
||||||
defer d.StreamStatusCache.InvalidateCache(ctx)
|
defer d.StreamStatusCache.InvalidateCache(ctx)
|
||||||
@@ -902,6 +902,7 @@ func (d *StreamD) streamController(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
platID streamcontrol.PlatformName,
|
platID streamcontrol.PlatformName,
|
||||||
) (streamcontrol.AbstractStreamController, error) {
|
) (streamcontrol.AbstractStreamController, error) {
|
||||||
|
ctx = belt.WithField(ctx, "controller", platID)
|
||||||
var result streamcontrol.AbstractStreamController
|
var result streamcontrol.AbstractStreamController
|
||||||
switch platID {
|
switch platID {
|
||||||
case obs.ID:
|
case obs.ID:
|
||||||
@@ -978,7 +979,7 @@ func (d *StreamD) SetTitle(
|
|||||||
platID streamcontrol.PlatformName,
|
platID streamcontrol.PlatformName,
|
||||||
title string,
|
title string,
|
||||||
) error {
|
) error {
|
||||||
defer d.publishEvent(ctx, api.DiffStreams{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreams{})
|
||||||
|
|
||||||
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
||||||
c, err := d.streamController(ctx, platID)
|
c, err := d.streamController(ctx, platID)
|
||||||
@@ -995,7 +996,7 @@ func (d *StreamD) SetDescription(
|
|||||||
platID streamcontrol.PlatformName,
|
platID streamcontrol.PlatformName,
|
||||||
description string,
|
description string,
|
||||||
) error {
|
) error {
|
||||||
defer d.publishEvent(ctx, api.DiffStreams{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreams{})
|
||||||
|
|
||||||
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
||||||
c, err := d.streamController(ctx, platID)
|
c, err := d.streamController(ctx, platID)
|
||||||
@@ -1018,7 +1019,7 @@ func (d *StreamD) ApplyProfile(
|
|||||||
profile streamcontrol.AbstractStreamProfile,
|
profile streamcontrol.AbstractStreamProfile,
|
||||||
customArgs ...any,
|
customArgs ...any,
|
||||||
) error {
|
) error {
|
||||||
defer d.publishEvent(ctx, api.DiffStreams{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreams{})
|
||||||
|
|
||||||
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
||||||
c, err := d.streamController(d.ctxForController(ctx), platID)
|
c, err := d.streamController(d.ctxForController(ctx), platID)
|
||||||
@@ -1037,7 +1038,7 @@ func (d *StreamD) UpdateStream(
|
|||||||
profile streamcontrol.AbstractStreamProfile,
|
profile streamcontrol.AbstractStreamProfile,
|
||||||
customArgs ...any,
|
customArgs ...any,
|
||||||
) error {
|
) error {
|
||||||
defer d.publishEvent(ctx, api.DiffStreams{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreams{})
|
||||||
|
|
||||||
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
return xsync.RDoR1(ctx, &d.ControllersLocker, func() error {
|
||||||
err := d.SetTitle(d.ctxForController(ctx), platID, title)
|
err := d.SetTitle(d.ctxForController(ctx), platID, title)
|
||||||
@@ -1160,7 +1161,7 @@ func (d *StreamD) StartStreamServer(
|
|||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "StartStreamServer")
|
logger.Debugf(ctx, "StartStreamServer")
|
||||||
defer logger.Debugf(ctx, "/StartStreamServer")
|
defer logger.Debugf(ctx, "/StartStreamServer")
|
||||||
defer d.publishEvent(ctx, api.DiffStreamServers{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamServers{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1204,7 +1205,7 @@ func (d *StreamD) StopStreamServer(
|
|||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "StopStreamServer")
|
logger.Debugf(ctx, "StopStreamServer")
|
||||||
defer logger.Debugf(ctx, "/StopStreamServer")
|
defer logger.Debugf(ctx, "/StopStreamServer")
|
||||||
defer d.publishEvent(ctx, api.DiffStreamServers{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamServers{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1235,7 +1236,7 @@ func (d *StreamD) AddIncomingStream(
|
|||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "AddIncomingStream")
|
logger.Debugf(ctx, "AddIncomingStream")
|
||||||
defer logger.Debugf(ctx, "/AddIncomingStream")
|
defer logger.Debugf(ctx, "/AddIncomingStream")
|
||||||
defer d.publishEvent(ctx, api.DiffIncomingStreams{})
|
defer publishEvent(ctx, d.EventBus, api.DiffIncomingStreams{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1261,7 +1262,7 @@ func (d *StreamD) RemoveIncomingStream(
|
|||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "RemoveIncomingStream")
|
logger.Debugf(ctx, "RemoveIncomingStream")
|
||||||
defer logger.Debugf(ctx, "/RemoveIncomingStream")
|
defer logger.Debugf(ctx, "/RemoveIncomingStream")
|
||||||
defer d.publishEvent(ctx, api.DiffIncomingStreams{})
|
defer publishEvent(ctx, d.EventBus, api.DiffIncomingStreams{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1357,7 +1358,7 @@ func (d *StreamD) AddStreamDestination(
|
|||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "AddStreamDestination")
|
logger.Debugf(ctx, "AddStreamDestination")
|
||||||
defer logger.Debugf(ctx, "/AddStreamDestination")
|
defer logger.Debugf(ctx, "/AddStreamDestination")
|
||||||
defer d.publishEvent(ctx, api.DiffStreamDestinations{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamDestinations{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1390,7 +1391,7 @@ func (d *StreamD) UpdateStreamDestination(
|
|||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "UpdateStreamDestination")
|
logger.Debugf(ctx, "UpdateStreamDestination")
|
||||||
defer logger.Debugf(ctx, "/UpdateStreamDestination")
|
defer logger.Debugf(ctx, "/UpdateStreamDestination")
|
||||||
defer d.publishEvent(ctx, api.DiffStreamDestinations{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamDestinations{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1421,7 +1422,7 @@ func (d *StreamD) RemoveStreamDestination(
|
|||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "RemoveStreamDestination")
|
logger.Debugf(ctx, "RemoveStreamDestination")
|
||||||
defer logger.Debugf(ctx, "/RemoveStreamDestination")
|
defer logger.Debugf(ctx, "/RemoveStreamDestination")
|
||||||
defer d.publishEvent(ctx, api.DiffStreamDestinations{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamDestinations{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1483,7 +1484,7 @@ func (d *StreamD) AddStreamForward(
|
|||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "AddStreamForward")
|
logger.Debugf(ctx, "AddStreamForward")
|
||||||
defer logger.Debugf(ctx, "/AddStreamForward")
|
defer logger.Debugf(ctx, "/AddStreamForward")
|
||||||
defer d.publishEvent(ctx, api.DiffStreamForwards{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamForwards{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1520,7 +1521,7 @@ func (d *StreamD) UpdateStreamForward(
|
|||||||
) (_err error) {
|
) (_err error) {
|
||||||
logger.Debugf(ctx, "UpdateStreamForward")
|
logger.Debugf(ctx, "UpdateStreamForward")
|
||||||
defer func() { logger.Debugf(ctx, "/UpdateStreamForward: %v", _err) }()
|
defer func() { logger.Debugf(ctx, "/UpdateStreamForward: %v", _err) }()
|
||||||
defer d.publishEvent(ctx, api.DiffStreamForwards{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamForwards{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1554,7 +1555,7 @@ func (d *StreamD) RemoveStreamForward(
|
|||||||
) error {
|
) error {
|
||||||
logger.Debugf(ctx, "RemoveStreamForward")
|
logger.Debugf(ctx, "RemoveStreamForward")
|
||||||
defer logger.Debugf(ctx, "/RemoveStreamForward")
|
defer logger.Debugf(ctx, "/RemoveStreamForward")
|
||||||
defer d.publishEvent(ctx, api.DiffStreamForwards{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamForwards{})
|
||||||
|
|
||||||
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
return xsync.DoR1(ctx, &d.StreamServerLocker, func() error {
|
||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
@@ -1619,7 +1620,7 @@ func (d *StreamD) AddStreamPlayer(
|
|||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
return fmt.Errorf("stream server is not initialized")
|
return fmt.Errorf("stream server is not initialized")
|
||||||
}
|
}
|
||||||
defer d.publishEvent(ctx, api.DiffStreamPlayers{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamPlayers{})
|
||||||
var result *multierror.Error
|
var result *multierror.Error
|
||||||
result = multierror.Append(result, d.StreamServer.AddStreamPlayer(
|
result = multierror.Append(result, d.StreamServer.AddStreamPlayer(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -1664,7 +1665,7 @@ func (d *StreamD) UpdateStreamPlayer(
|
|||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
return fmt.Errorf("stream server is not initialized")
|
return fmt.Errorf("stream server is not initialized")
|
||||||
}
|
}
|
||||||
defer d.publishEvent(ctx, api.DiffStreamPlayers{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamPlayers{})
|
||||||
var result *multierror.Error
|
var result *multierror.Error
|
||||||
result = multierror.Append(result, d.StreamServer.UpdateStreamPlayer(
|
result = multierror.Append(result, d.StreamServer.UpdateStreamPlayer(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -1687,7 +1688,7 @@ func (d *StreamD) RemoveStreamPlayer(
|
|||||||
if d.StreamServer == nil {
|
if d.StreamServer == nil {
|
||||||
return fmt.Errorf("stream server is not initialized")
|
return fmt.Errorf("stream server is not initialized")
|
||||||
}
|
}
|
||||||
defer d.publishEvent(ctx, api.DiffStreamPlayers{})
|
defer publishEvent(ctx, d.EventBus, api.DiffStreamPlayers{})
|
||||||
var result *multierror.Error
|
var result *multierror.Error
|
||||||
result = multierror.Append(result, d.StreamServer.RemoveStreamPlayer(
|
result = multierror.Append(result, d.StreamServer.RemoveStreamPlayer(
|
||||||
ctx,
|
ctx,
|
||||||
|
61
pkg/streamd/subscribe.go
Normal file
61
pkg/streamd/subscribe.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package streamd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/xaionaro-go/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
func autoResubscribe[T any](
|
||||||
|
ctx context.Context,
|
||||||
|
fn func(context.Context) (<-chan T, error),
|
||||||
|
) (<-chan T, <-chan struct{}, error) {
|
||||||
|
input, err := fn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
result := make(chan T, 1)
|
||||||
|
restartCh := make(chan struct{}, 1)
|
||||||
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer func() {
|
||||||
|
var sample T
|
||||||
|
logger.Debugf(ctx, "autoResubscribe[%T] handler is closed", sample)
|
||||||
|
}()
|
||||||
|
defer close(result)
|
||||||
|
defer close(restartCh)
|
||||||
|
for {
|
||||||
|
for {
|
||||||
|
var (
|
||||||
|
ev T
|
||||||
|
ok bool
|
||||||
|
)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case ev, ok = <-input:
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
logger.Debugf(ctx, "the input channel is closed; reconnect")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case result <- ev:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
input, err = fn(ctx)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
logger.Warnf(ctx, "unable to reconnect: %w")
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
restartCh <- struct{}{}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result, restartCh, nil
|
||||||
|
}
|
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/facebookincubator/go-belt/tool/logger"
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/xaionaro-go/eventbus"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
|
"github.com/xaionaro-go/streamctl/pkg/streamd/api"
|
||||||
"github.com/xaionaro-go/streamctl/pkg/streamd/consts"
|
"github.com/xaionaro-go/streamctl/pkg/streamd/consts"
|
||||||
)
|
)
|
||||||
@@ -43,8 +44,10 @@ func (d *StreamD) GetVariableHash(
|
|||||||
return hash, nil
|
return hash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func topicForVariable(key consts.VarKey) string {
|
type subscriptionTopic string
|
||||||
return fmt.Sprintf("var:%s", key)
|
|
||||||
|
func topicForVariable(key consts.VarKey) subscriptionTopic {
|
||||||
|
return subscriptionTopic(fmt.Sprintf("var:%s", key))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *StreamD) SetVariable(
|
func (d *StreamD) SetVariable(
|
||||||
@@ -55,7 +58,12 @@ func (d *StreamD) SetVariable(
|
|||||||
logger.Tracef(ctx, "SetVariable(ctx, '%s', value [len == %d])", key, len(value))
|
logger.Tracef(ctx, "SetVariable(ctx, '%s', value [len == %d])", key, len(value))
|
||||||
defer logger.Tracef(ctx, "/SetVariable(ctx, '%s', value [len == %d])", key, len(value))
|
defer logger.Tracef(ctx, "/SetVariable(ctx, '%s', value [len == %d])", key, len(value))
|
||||||
d.Variables.Store(key, value)
|
d.Variables.Store(key, value)
|
||||||
d.EventBus.Publish(topicForVariable(key), value)
|
eventbus.SendEventWithCustomTopic(
|
||||||
|
ctx,
|
||||||
|
d.EventBus,
|
||||||
|
topicForVariable(key),
|
||||||
|
value,
|
||||||
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,5 +71,10 @@ func (d *StreamD) SubscribeToVariable(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
varKey consts.VarKey,
|
varKey consts.VarKey,
|
||||||
) (<-chan api.VariableValue, error) {
|
) (<-chan api.VariableValue, error) {
|
||||||
return eventSubToChanUsingTopic[api.VariableValue](ctx, d, nil, topicForVariable(varKey))
|
return eventSubToChanUsingTopic[subscriptionTopic, api.VariableValue](
|
||||||
|
ctx,
|
||||||
|
d.EventBus, 10,
|
||||||
|
nil,
|
||||||
|
topicForVariable(varKey),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@@ -17,8 +17,8 @@ import (
|
|||||||
"github.com/xaionaro-go/xsync"
|
"github.com/xaionaro-go/xsync"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
var (
|
||||||
ChatLogSize = 20
|
ChatLogSize = 35
|
||||||
)
|
)
|
||||||
|
|
||||||
type chatUIInterface interface {
|
type chatUIInterface interface {
|
||||||
@@ -67,7 +67,9 @@ func (p *Panel) getChatUIs(ctx context.Context) []chatUIInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Panel) initChatMessagesHandler(ctx context.Context) error {
|
func (p *Panel) initChatMessagesHandler(ctx context.Context) error {
|
||||||
msgCh, err := p.StreamD.SubscribeToChatMessages(ctx, time.Now().Add(-7*24*time.Hour), ChatLogSize)
|
msgCh, _, err := autoResubscribe(ctx, func(ctx context.Context) (<-chan api.ChatMessage, error) {
|
||||||
|
return p.StreamD.SubscribeToChatMessages(ctx, time.Now().Add(-60*24*time.Hour), uint64(ChatLogSize))
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to subscribe to chat messages: %w", err)
|
return fmt.Errorf("unable to subscribe to chat messages: %w", err)
|
||||||
}
|
}
|
||||||
|
8
pkg/streampanel/chat_android.go
Normal file
8
pkg/streampanel/chat_android.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build android
|
||||||
|
// +build android
|
||||||
|
|
||||||
|
package streampanel
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
ChatLogSize = 20
|
||||||
|
}
|
@@ -127,6 +127,7 @@ func (ui *chatUIAsText) Rebuild(
|
|||||||
ui.newItem(ctx, itemIdx, msg)
|
ui.newItem(ctx, itemIdx, msg)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
ui.Text.Refresh()
|
||||||
if ui.OnAdd != nil {
|
if ui.OnAdd != nil {
|
||||||
ui.OnAdd(ctx, api.ChatMessage{})
|
ui.OnAdd(ctx, api.ChatMessage{})
|
||||||
}
|
}
|
||||||
@@ -153,6 +154,10 @@ func (ui *chatUIAsText) Append(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ui.newItem(ctx, itemIdx, *msg)
|
ui.newItem(ctx, itemIdx, *msg)
|
||||||
|
ui.Text.Refresh()
|
||||||
|
if ui.OnAdd != nil {
|
||||||
|
ui.OnAdd(ctx, *msg)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -221,6 +221,7 @@ func (p *monitorPage) startUpdatingNoLock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer logger.Debugf(ctx, "startUpdatingNoLock: the handler closed")
|
||||||
updateData := func() {
|
updateData := func() {
|
||||||
inStreams, err := streamD.ListIncomingStreams(ctx)
|
inStreams, err := streamD.ListIncomingStreams(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -232,12 +233,20 @@ func (p *monitorPage) startUpdatingNoLock(
|
|||||||
}
|
}
|
||||||
updateData()
|
updateData()
|
||||||
|
|
||||||
ch, err := streamD.SubscribeToIncomingStreamsChanges(ctx)
|
ch, restartCh, err := autoResubscribe(ctx, streamD.SubscribeToIncomingStreamsChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.parent().DisplayError(err)
|
p.parent().DisplayError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for range ch {
|
for {
|
||||||
|
var ok bool
|
||||||
|
select {
|
||||||
|
case _, ok = <-restartCh:
|
||||||
|
case _, ok = <-ch:
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
logger.Debugf(ctx, "got event IncomingStreamsChange")
|
logger.Debugf(ctx, "got event IncomingStreamsChange")
|
||||||
updateData()
|
updateData()
|
||||||
}
|
}
|
||||||
|
@@ -525,7 +525,9 @@ func (p *Panel) startOAuthListenerForRemoteStreamD(
|
|||||||
return fmt.Errorf("unable to start listener for OAuth responses: %w", err)
|
return fmt.Errorf("unable to start listener for OAuth responses: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
oauthURLChan, err := streamD.SubscribeToOAuthURLs(ctx, listenPort)
|
oauthURLChan, restartOAuthURLChan, err := autoResubscribe(ctx, func(ctx context.Context) (<-chan *streamd_grpc.OAuthRequest, error) {
|
||||||
|
return streamD.SubscribeToOAuthURLs(ctx, listenPort)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancelFn()
|
cancelFn()
|
||||||
return fmt.Errorf("unable to subscribe to OAuth requests of streamd: %w", err)
|
return fmt.Errorf("unable to subscribe to OAuth requests of streamd: %w", err)
|
||||||
@@ -541,6 +543,8 @@ func (p *Panel) startOAuthListenerForRemoteStreamD(
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
case <-restartOAuthURLChan:
|
||||||
|
continue
|
||||||
case req, ok := <-oauthURLChan:
|
case req, ok := <-oauthURLChan:
|
||||||
logger.Debugf(ctx, "<-oauthURLChan")
|
logger.Debugf(ctx, "<-oauthURLChan")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1864,14 +1868,14 @@ func (p *Panel) subscribeUpdateControlPage(ctx context.Context) {
|
|||||||
logger.Debugf(ctx, "subscribe to streams and config changes")
|
logger.Debugf(ctx, "subscribe to streams and config changes")
|
||||||
defer logger.Debugf(ctx, "/subscribe to streams and config changes")
|
defer logger.Debugf(ctx, "/subscribe to streams and config changes")
|
||||||
|
|
||||||
chStreams, err := p.StreamD.SubscribeToStreamsChanges(ctx)
|
chStreams, restartChStreams, err := autoResubscribe(ctx, p.StreamD.SubscribeToStreamsChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.DisplayError(err)
|
p.DisplayError(err)
|
||||||
//return
|
//return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: deduplicate with localConfigCacheUpdater
|
// TODO: deduplicate with localConfigCacheUpdater
|
||||||
chConfigs, err := p.StreamD.SubscribeToConfigChanges(ctx)
|
chConfigs, restartChConfigs, err := autoResubscribe(ctx, p.StreamD.SubscribeToConfigChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.DisplayError(err)
|
p.DisplayError(err)
|
||||||
//return
|
//return
|
||||||
@@ -1880,16 +1884,23 @@ func (p *Panel) subscribeUpdateControlPage(ctx context.Context) {
|
|||||||
p.getUpdatedStatus(ctx)
|
p.getUpdatedStatus(ctx)
|
||||||
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer logger.Debugf(ctx, "subscribeUpdateControlPage: the handler closed")
|
||||||
t := time.NewTicker(time.Second * 5)
|
t := time.NewTicker(time.Second * 5)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
|
var ok bool
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-chStreams:
|
case _, ok = <-chStreams:
|
||||||
case <-chConfigs:
|
case _, ok = <-restartChStreams:
|
||||||
|
case _, ok = <-chConfigs:
|
||||||
|
case _, ok = <-restartChConfigs:
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
}
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
p.getUpdatedStatus(ctx)
|
p.getUpdatedStatus(ctx)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2404,7 +2415,7 @@ func (p *Panel) localConfigCacheUpdater(ctx context.Context) (_err error) {
|
|||||||
logger.Debugf(ctx, "localConfigCacheUpdater")
|
logger.Debugf(ctx, "localConfigCacheUpdater")
|
||||||
defer logger.Debugf(ctx, "/localConfigCacheUpdater: %v", _err)
|
defer logger.Debugf(ctx, "/localConfigCacheUpdater: %v", _err)
|
||||||
|
|
||||||
cfgChangeCh, err := p.StreamD.SubscribeToConfigChanges(ctx)
|
cfgChangeCh, restartCfgChangeCh, err := autoResubscribe(ctx, p.StreamD.SubscribeToConfigChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to subscribe to config changes: %w", err)
|
return fmt.Errorf("unable to subscribe to config changes: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2431,27 +2442,33 @@ func (p *Panel) localConfigCacheUpdater(ctx context.Context) (_err error) {
|
|||||||
defer logger.Debugf(ctx, "/localConfigUpdaterLoop")
|
defer logger.Debugf(ctx, "/localConfigUpdaterLoop")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
var ok bool
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-cfgChangeCh:
|
case _, ok = <-cfgChangeCh:
|
||||||
newCfg, err := p.StreamD.GetConfig(ctx)
|
case _, ok = <-restartCfgChangeCh:
|
||||||
if err != nil {
|
|
||||||
logger.Errorf(ctx, "unable to get the new config: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
err = newCfg.Convert()
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf(ctx, "unable to convert the config: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.configCacheLocker.Do(ctx, func() {
|
|
||||||
p.configCache = newCfg
|
|
||||||
})
|
|
||||||
logger.Debugf(ctx, "updated the config cache")
|
|
||||||
observability.SecretsProviderFromCtx(ctx).(*observability.SecretsStaticProvider).ParseSecretsFrom(newCfg)
|
|
||||||
logger.Debugf(ctx, "updated the secrets")
|
|
||||||
}
|
}
|
||||||
|
if !ok {
|
||||||
|
logger.Errorf(ctx, "the channel is closed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newCfg, err := p.StreamD.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to get the new config: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = newCfg.Convert()
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf(ctx, "unable to convert the config: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.configCacheLocker.Do(ctx, func() {
|
||||||
|
p.configCache = newCfg
|
||||||
|
})
|
||||||
|
logger.Debugf(ctx, "updated the config cache")
|
||||||
|
observability.SecretsProviderFromCtx(ctx).(*observability.SecretsStaticProvider).ParseSecretsFrom(newCfg)
|
||||||
|
logger.Debugf(ctx, "updated the secrets")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@@ -59,13 +59,26 @@ func (p *Panel) initRestreamPage(
|
|||||||
}
|
}
|
||||||
updateData()
|
updateData()
|
||||||
|
|
||||||
ch, err := p.StreamD.SubscribeToIncomingStreamsChanges(ctx)
|
ch, restartCh, err := autoResubscribe(ctx, p.StreamD.SubscribeToIncomingStreamsChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.DisplayError(err)
|
p.DisplayError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for range ch {
|
for range ch {
|
||||||
logger.Debugf(ctx, "got event IncomingStreamsChange")
|
var ok bool
|
||||||
|
select {
|
||||||
|
case _, ok = <-ch:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "got event IncomingStreamsChange")
|
||||||
|
}
|
||||||
|
case _, ok = <-restartCh:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "restarted SubscribeToIncomingStreamsChanges")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
updateData()
|
updateData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -81,18 +94,32 @@ func (p *Panel) initRestreamPage(
|
|||||||
}
|
}
|
||||||
updateData()
|
updateData()
|
||||||
|
|
||||||
ch, err := p.StreamD.SubscribeToStreamServersChanges(ctx)
|
ch, restartCh, err := autoResubscribe(ctx, p.StreamD.SubscribeToStreamServersChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.DisplayError(err)
|
p.DisplayError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for range ch {
|
for {
|
||||||
logger.Debugf(ctx, "got event StreamServersChange")
|
var ok bool
|
||||||
|
select {
|
||||||
|
case _, ok = <-ch:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "got event StreamServersChange")
|
||||||
|
}
|
||||||
|
case _, ok = <-restartCh:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "restarted SubscribeToStreamServersChanges")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
updateData()
|
updateData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer logger.Debugf(ctx, "/SubscribeToStreamDestinationsChanges")
|
||||||
updateData := func() {
|
updateData := func() {
|
||||||
dsts, err := p.StreamD.ListStreamDestinations(ctx)
|
dsts, err := p.StreamD.ListStreamDestinations(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,18 +130,32 @@ func (p *Panel) initRestreamPage(
|
|||||||
}
|
}
|
||||||
updateData()
|
updateData()
|
||||||
|
|
||||||
ch, err := p.StreamD.SubscribeToStreamDestinationsChanges(ctx)
|
ch, restartCh, err := autoResubscribe(ctx, p.StreamD.SubscribeToStreamDestinationsChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.DisplayError(err)
|
p.DisplayError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for range ch {
|
for {
|
||||||
logger.Debugf(ctx, "got event StreamDestinationsChange")
|
var ok bool
|
||||||
|
select {
|
||||||
|
case _, ok = <-ch:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "got event StreamDestinationsChange")
|
||||||
|
}
|
||||||
|
case _, ok = <-restartCh:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "restarted SubscribeToStreamDestinationsChanges")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
updateData()
|
updateData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer logger.Debugf(ctx, "/SubscribeToStreamForwardsChanges")
|
||||||
updateData := func() {
|
updateData := func() {
|
||||||
streamFwds, err := p.StreamD.ListStreamForwards(ctx)
|
streamFwds, err := p.StreamD.ListStreamForwards(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,18 +166,32 @@ func (p *Panel) initRestreamPage(
|
|||||||
}
|
}
|
||||||
updateData()
|
updateData()
|
||||||
|
|
||||||
ch, err := p.StreamD.SubscribeToStreamForwardsChanges(ctx)
|
ch, restartCh, err := autoResubscribe(ctx, p.StreamD.SubscribeToStreamForwardsChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.DisplayError(err)
|
p.DisplayError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for range ch {
|
for {
|
||||||
logger.Debugf(ctx, "got event StreamForwardsChange")
|
var ok bool
|
||||||
|
select {
|
||||||
|
case _, ok = <-ch:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "got event StreamForwardsChange")
|
||||||
|
}
|
||||||
|
case _, ok = <-restartCh:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "restarted SubscribeToStreamForwardsChanges")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
updateData()
|
updateData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
observability.Go(ctx, func(ctx context.Context) {
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer logger.Debugf(ctx, "/SubscribeToStreamPlayersChanges")
|
||||||
updateData := func() {
|
updateData := func() {
|
||||||
streamPlayers, err := p.StreamD.ListStreamPlayers(ctx)
|
streamPlayers, err := p.StreamD.ListStreamPlayers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,13 +202,26 @@ func (p *Panel) initRestreamPage(
|
|||||||
}
|
}
|
||||||
updateData()
|
updateData()
|
||||||
|
|
||||||
ch, err := p.StreamD.SubscribeToStreamPlayersChanges(ctx)
|
ch, restartCh, err := autoResubscribe(ctx, p.StreamD.SubscribeToStreamPlayersChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.DisplayError(err)
|
p.DisplayError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for range ch {
|
for {
|
||||||
logger.Debugf(ctx, "got event StreamPlayersChange")
|
var ok bool
|
||||||
|
select {
|
||||||
|
case _, ok = <-ch:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "got event StreamPlayersChange")
|
||||||
|
}
|
||||||
|
case _, ok = <-restartCh:
|
||||||
|
if ok {
|
||||||
|
logger.Debugf(ctx, "restarted SubscribeToStreamPlayersChanges")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
updateData()
|
updateData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
61
pkg/streampanel/subscribe.go
Normal file
61
pkg/streampanel/subscribe.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package streampanel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/facebookincubator/go-belt/tool/logger"
|
||||||
|
"github.com/xaionaro-go/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
func autoResubscribe[T any](
|
||||||
|
ctx context.Context,
|
||||||
|
fn func(context.Context) (<-chan T, error),
|
||||||
|
) (<-chan T, <-chan struct{}, error) {
|
||||||
|
input, err := fn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
result := make(chan T, 1)
|
||||||
|
restartCh := make(chan struct{}, 1)
|
||||||
|
observability.Go(ctx, func(ctx context.Context) {
|
||||||
|
defer func() {
|
||||||
|
var sample T
|
||||||
|
logger.Debugf(ctx, "autoResubscribe[%T] handler is closed", sample)
|
||||||
|
}()
|
||||||
|
defer close(result)
|
||||||
|
defer close(restartCh)
|
||||||
|
for {
|
||||||
|
for {
|
||||||
|
var (
|
||||||
|
ev T
|
||||||
|
ok bool
|
||||||
|
)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case ev, ok = <-input:
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
logger.Debugf(ctx, "the input channel is closed; reconnect")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case result <- ev:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
input, err = fn(ctx)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
logger.Warnf(ctx, "unable to reconnect: %w")
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
restartCh <- struct{}{}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result, restartCh, nil
|
||||||
|
}
|
Reference in New Issue
Block a user