Initial commit, part 1

This commit is contained in:
Dmitrii Okunev
2024-02-12 13:26:48 +00:00
commit b98d94a66d
14 changed files with 1810 additions and 0 deletions

121
LICENSE Normal file
View File

@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
```
xaionaro@void:~/go/src/github.com/xaionaro-go/streamctl$ go run ./cmd/streamctl/
Usage:
/tmp/go-build2502186757/b001/exe/streamctl [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
generate-config
help Help about any command
set-description
set-title
stream-end
stream-start
Flags:
--config-path string the path to the config file (default "~/.streamctl.yaml")
-h, --help help for /tmp/go-build2502186757/b001/exe/streamctl
--log-level Level (default warning)
Use "/tmp/go-build2502186757/b001/exe/streamctl [command] --help" for more information about a command.
```

View File

@@ -0,0 +1,282 @@
package commands
import (
"context"
"encoding/json"
"fmt"
"os"
"path"
"strings"
"sync"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/goccy/go-yaml"
"github.com/spf13/cobra"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/twitch"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol/youtube"
)
var (
// Access these variables only from a main package:
Root = &cobra.Command{
Use: os.Args[0],
PersistentPreRun: func(cmd *cobra.Command, args []string) {
ctx := cmd.Context()
l := logger.FromCtx(ctx).WithLevel(LoggerLevel)
ctx = logger.CtxWithLogger(ctx, l)
cmd.SetContext(ctx)
logger.Debugf(ctx, "log-level: %v", LoggerLevel)
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
ctx := cmd.Context()
logger.Debug(ctx, "end")
},
}
GenerateConfig = &cobra.Command{
Use: "generate-config",
Args: cobra.ExactArgs(0),
Run: generateConfig,
}
SetTitle = &cobra.Command{
Use: "set-title",
Args: cobra.ExactArgs(1),
Run: setTitle,
}
SetDescription = &cobra.Command{
Use: "set-description",
Args: cobra.ExactArgs(1),
Run: setDescription,
}
StreamStart = &cobra.Command{
Use: "stream-start",
Args: cobra.ExactArgs(0),
Run: streamStart,
}
StreamEnd = &cobra.Command{
Use: "stream-end",
Args: cobra.ExactArgs(0),
Run: streamEnd,
}
LoggerLevel = logger.LevelWarning
)
func init() {
Root.PersistentFlags().Var(&LoggerLevel, "log-level", "")
Root.PersistentFlags().String("config-path", "~/.streamctl.yaml", "the path to the config file")
StreamStart.PersistentFlags().String("title", "", "stream title")
StreamStart.PersistentFlags().String("description", "", "stream description")
StreamStart.PersistentFlags().String("profile", "", "profile")
StreamStart.PersistentFlags().StringArray("youtube-templates", nil, "the list of templates used to create streams; if nothing is provided, then a stream won't be created")
Root.AddCommand(GenerateConfig)
Root.AddCommand(SetTitle)
Root.AddCommand(SetDescription)
Root.AddCommand(StreamStart)
Root.AddCommand(StreamEnd)
}
func getConfigPath() string {
cfgPathRaw, err := Root.Flags().GetString("config-path")
if err != nil {
logger.Panic(Root.Context(), err)
}
return expandPath(cfgPathRaw)
}
func homeDir() string {
dirname, err := os.UserHomeDir()
if err != nil {
logger.Panic(Root.Context(), err)
}
return dirname
}
func expandPath(rawPath string) string {
switch {
case strings.HasPrefix(rawPath, "~/"):
return path.Join(homeDir(), rawPath[2:])
}
return rawPath
}
const (
idTwitch = "twitch"
idYoutube = "youtube"
)
func newConfig() streamctl.Config {
cfg := streamctl.Config{}
twitch.InitConfig(cfg, idTwitch)
youtube.InitConfig(cfg, idYoutube)
return cfg
}
func generateConfig(cmd *cobra.Command, args []string) {
cfgPath := getConfigPath()
if _, err := os.Stat(cfgPath); err == nil {
logger.Panicf(cmd.Context(), "file '%s' already exists", cfgPath)
}
cfg := newConfig()
cfg[idTwitch].StreamProfiles = map[string]streamctl.StreamProfile{"some_profile": twitch.StreamProfile{}}
cfg[idYoutube].StreamProfiles = map[string]streamctl.StreamProfile{"some_profile": youtube.StreamProfile{}}
err := writeConfigToPath(cmd.Context(), cfgPath, cfg)
if err != nil {
logger.Panic(cmd.Context(), err)
}
}
func writeConfigToPath(
ctx context.Context,
cfgPath string,
cfg streamctl.Config,
) error {
b, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("unable to serialize config %#+v: %w", cfg, err)
}
err = os.WriteFile(cfgPath, b, 0750)
if err != nil {
return fmt.Errorf("unable to write config to file '%s': %w", cfgPath, err)
}
logger.Infof(ctx, "wrote to '%s' config <%s>", cfgPath, b)
return nil
}
func saveConfig(ctx context.Context, cfg streamctl.Config) error {
cfgPath := getConfigPath()
return writeConfigToPath(ctx, cfgPath, cfg)
}
func readConfigFromPath(cfgPath string, cfg *streamctl.Config) error {
b, err := os.ReadFile(cfgPath)
if err != nil {
return fmt.Errorf("unable to read file '%s': %w", cfgPath, err)
}
err = yaml.Unmarshal(b, cfg)
if err != nil {
return fmt.Errorf("unable to unserialize config: %w: <%s>", err, b)
}
return nil
}
func readConfig() streamctl.Config {
cfgPath := getConfigPath()
cfg := newConfig()
err := readConfigFromPath(cfgPath, &cfg)
if err != nil {
logger.Panic(Root.Context(), err)
}
if b, err := json.Marshal(cfg); err == nil {
logger.Debugf(Root.Context(), "cfg == %s", b)
} else {
logger.Debugf(Root.Context(), "cfg == %#+v", cfg)
}
return cfg
}
func getStreamControllers(ctx context.Context, cfg streamctl.Config) streamctl.StreamControllers {
var saveConfigLock sync.Mutex
var result streamctl.StreamControllers
twitchCfg := streamctl.GetPlatformConfig[twitch.PlatformSpecificConfig, twitch.StreamProfile](ctx, cfg, idTwitch)
if twitchCfg == nil {
logger.Infof(ctx, "twitch config was not found")
} else {
twitch, err := twitch.New(ctx, *twitchCfg,
func(c twitch.Config) error {
saveConfigLock.Lock()
defer saveConfigLock.Unlock()
cfg[idTwitch] = &streamctl.AbstractPlatformConfig{
Config: c.Config,
StreamProfiles: streamctl.ToAbstractStreamProfiles(c.StreamProfiles),
}
return saveConfig(ctx, cfg)
},
)
if err != nil {
logger.Panic(ctx, err)
}
result = append(result, streamctl.ToAbstract(twitch))
}
youtubeCfg := streamctl.GetPlatformConfig[youtube.PlatformSpecificConfig, youtube.StreamProfile](ctx, cfg, idYoutube)
if youtubeCfg == nil {
logger.Infof(ctx, "youtube config was not found")
} else {
youtube, err := youtube.New(ctx, *youtubeCfg,
func(c youtube.Config) error {
saveConfigLock.Lock()
defer saveConfigLock.Unlock()
cfg[idYoutube] = &streamctl.AbstractPlatformConfig{
Config: c.Config,
StreamProfiles: streamctl.ToAbstractStreamProfiles(c.StreamProfiles),
}
return saveConfig(ctx, cfg)
},
)
if err != nil {
logger.Panic(ctx, err)
}
result = append(result, streamctl.ToAbstract(youtube))
}
return result
}
func assertNoError(ctx context.Context, err error) {
if err != nil {
logger.Panic(ctx, err)
}
}
func setTitle(cmd *cobra.Command, args []string) {
ctx, cfg := cmd.Context(), readConfig()
streamControllers := getStreamControllers(ctx, cfg)
assertNoError(ctx, streamControllers.SetTitle(ctx, args[0]))
assertNoError(ctx, streamControllers.Flush(ctx))
}
func setDescription(cmd *cobra.Command, args []string) {
ctx, cfg := cmd.Context(), readConfig()
streamControllers := getStreamControllers(ctx, cfg)
assertNoError(ctx, streamControllers.SetDescription(ctx, args[0]))
assertNoError(ctx, streamControllers.Flush(ctx))
}
func streamStart(cmd *cobra.Command, args []string) {
ctx, cfg := cmd.Context(), readConfig()
streamControllers := getStreamControllers(ctx, cfg)
title, err := cmd.Flags().GetString("title")
assertNoError(ctx, err)
description, err := cmd.Flags().GetString("description")
assertNoError(ctx, err)
profileName, err := cmd.Flags().GetString("profile")
assertNoError(ctx, err)
youtubeTemplateBroadcastIDs, err := cmd.Flags().GetStringArray("youtube-templates")
assertNoError(ctx, err)
var profiles []streamctl.StreamProfile
for _, platCfg := range cfg {
p := platCfg.StreamProfiles[profileName]
if p == nil {
continue
}
profiles = append(profiles, p)
}
assertNoError(ctx, streamControllers.StartStream(ctx, title, description, profiles, youtube.FlagBroadcastTemplateIDs(youtubeTemplateBroadcastIDs)))
assertNoError(ctx, streamControllers.Flush(ctx))
}
func streamEnd(cmd *cobra.Command, args []string) {
ctx, cfg := cmd.Context(), readConfig()
streamControllers := getStreamControllers(ctx, cfg)
assertNoError(ctx, streamControllers.EndStream(ctx))
assertNoError(ctx, streamControllers.Flush(ctx))
}

25
cmd/streamctl/main.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"context"
"github.com/facebookincubator/go-belt"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/facebookincubator/go-belt/tool/logger/implementation/zap"
"github.com/xaionaro-go/streamctl/cmd/streamctl/commands"
)
func main() {
l := zap.Default().WithLevel(commands.LoggerLevel)
ctx := context.Background()
ctx = logger.CtxWithLogger(ctx, l)
logger.Default = func() logger.Logger {
return l
}
defer belt.Flush(ctx)
err := commands.Root.ExecuteContext(ctx)
if err != nil {
logger.Panic(ctx, err)
}
}

56
go.mod Normal file
View File

@@ -0,0 +1,56 @@
module github.com/xaionaro-go/streamctl
go 1.21
require (
github.com/facebookincubator/go-belt v0.0.0-20230703220935-39cd348f1a38
github.com/goccy/go-yaml v1.11.3
github.com/hashicorp/go-multierror v1.1.1
github.com/nicklaw5/helix/v2 v2.26.0
github.com/spf13/cobra v1.8.0
google.golang.org/api v0.163.0
)
require (
cloud.google.com/go/compute v1.23.3 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
github.com/DataDog/gostackparse v0.6.0 // indirect
github.com/fatih/color v1.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ng/slices v0.0.0-20230703171042-6195d35636a2 // indirect
github.com/go-ng/sort v0.0.0-20220617173827-2cc7cd04f7c7 // indirect
github.com/go-ng/xsort v0.0.0-20220617174223-1d146907bccc // indirect
github.com/golang-jwt/jwt/v4 v4.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/spf13/pflag v1.0.5 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 // indirect
go.opentelemetry.io/otel v1.22.0 // indirect
go.opentelemetry.io/otel/metric v1.22.0 // indirect
go.opentelemetry.io/otel/trace v1.22.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/oauth2 v0.16.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe // indirect
google.golang.org/grpc v1.61.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
)

240
go.sum Normal file
View File

@@ -0,0 +1,240 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/gostackparse v0.6.0 h1:egCGQviIabPwsyoWpGvIBGrEnNWez35aEO7OJ1vBI4o=
github.com/DataDog/gostackparse v0.6.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/facebookincubator/go-belt v0.0.0-20230703220935-39cd348f1a38 h1:6bNfYFYry8V+BHinBMDEPPnd19unWa76lQhsx88xU6k=
github.com/facebookincubator/go-belt v0.0.0-20230703220935-39cd348f1a38/go.mod h1:GX1P3GiO+6E2SxTZaI3z1cqlTGfE1GnKxEsxBVemVnY=
github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ng/slices v0.0.0-20230703171042-6195d35636a2 h1:UkoycH6lT7QfBw3LqHLe6GdFRhxScvVaI7A5oiAjy5s=
github.com/go-ng/slices v0.0.0-20230703171042-6195d35636a2/go.mod h1:bVEceuoz83G4yjq9Os7lCYe+lf46uY8EFEHkxSCywvM=
github.com/go-ng/sort v0.0.0-20220617173827-2cc7cd04f7c7 h1:Ng6QMSlQSB+goG6430/Fp7O4YO2BJZXZJaldtg+7kEc=
github.com/go-ng/sort v0.0.0-20220617173827-2cc7cd04f7c7/go.mod h1:QUXmOopthsqLYJ+rAybuCf16J7qQm60TLVdQR0w1Nus=
github.com/go-ng/xsort v0.0.0-20220617174223-1d146907bccc h1:VNz633GRJx2/hL0SpBNoNlLid4xtyi7LSJP1kHpD2Fo=
github.com/go-ng/xsort v0.0.0-20220617174223-1d146907bccc/go.mod h1:Pz/V4pxeXP0hjBlXIrm2ehR0GJ0l4Bon3fsOl6TmoJs=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/goccy/go-yaml v1.11.3 h1:B3W9IdWbvrUu2OYQGwvU1nZtvMQJPBKgBUuweJjLj6I=
github.com/goccy/go-yaml v1.11.3/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU=
github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/nicklaw5/helix/v2 v2.26.0 h1:Qkc/R0eCDdWtUmnczk2g03+mObPUfc49Kz2Bt4B5d0g=
github.com/nicklaw5/helix/v2 v2.26.0/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 h1:sv9kVfal0MK0wBMCOGr+HeJm9v803BkJxGrk2au7j08=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0/go.mod h1:SK2UL73Zy1quvRPonmOmRDiWk1KBV3LyIeeIxcEApWw=
go.opentelemetry.io/otel v1.22.0 h1:xS7Ku+7yTFvDfDraDIJVpw7XPyuHlB9MCiqqX5mcJ6Y=
go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI=
go.opentelemetry.io/otel/metric v1.22.0 h1:lypMQnGyJYeuYPhOM/bgjbFM6WE44W1/T45er4d8Hhg=
go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY=
go.opentelemetry.io/otel/trace v1.22.0 h1:Hg6pPujv0XG9QaVbGOBVHunyuLcCC3jN7WEhPx83XD0=
go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.163.0 h1:4BBDpPaSH+H28NhnX+WwjXxbRLQ7TWuEKp4BQyEjxvk=
google.golang.org/api v0.163.0/go.mod h1:6SulDkfoBIg4NFmCuZ39XeeAgSHCPecfSUuDyYlAHs0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac h1:ZL/Teoy/ZGnzyrqK/Optxxp2pmVh+fmJ97slxSRyzUg=
google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k=
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo=
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe h1:bQnxqljG/wqi4NTXu2+DJ3n7APcEA882QZ1JvhQAq9o=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0=
google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -0,0 +1,107 @@
package oauthhandler
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os/exec"
"runtime"
"github.com/facebookincubator/go-belt/tool/logger"
)
type OAuth2Handler struct {
authURL string
exchangeFn func(code string) error
receiverAddr string
}
func NewOAuth2Handler(
authURL string,
exchangeFn func(code string) error,
receiverAddr string,
) *OAuth2Handler {
return &OAuth2Handler{
authURL: authURL,
exchangeFn: exchangeFn,
receiverAddr: receiverAddr,
}
}
// it is guaranteed exchangeFn was called if error is nil.
func (h *OAuth2Handler) Handle(ctx context.Context) error {
if h.receiverAddr != "" {
err := h.handleViaBrowser()
if err == nil {
return nil
}
logger.Errorf(ctx, "unable to authenticate automatically: %v", err)
}
return h.handleViaCLI()
}
func (h *OAuth2Handler) handleViaCLI() error {
fmt.Printf(
"It is required to get an oauth2 token. "+
"Please open the link below in the browser:\n\n\t%s\n\n",
h.authURL,
)
fmt.Printf("Enter the code: ")
var code string
if _, err := fmt.Scan(&code); err != nil {
log.Fatalf("Unable to read authorization code %v", err)
}
return h.exchangeFn(code)
}
func (h *OAuth2Handler) handleViaBrowser() error {
codeCh, err := h.newCodeReceiver()
if err != nil {
return err
}
err = launchBrowser(h.authURL)
if err != nil {
return err
}
fmt.Printf("Your browser has been launched (URL: %s).\nPlease approve the permissions.\n", h.authURL)
// Wait for the web server to get the code.
code := <-codeCh
return h.exchangeFn(code)
}
func (h *OAuth2Handler) newCodeReceiver() (codeCh chan string, err error) {
// this function was mostly borrowed from https://developers.google.com/youtube/v3/code_samples/go#authorize_a_request
listener, err := net.Listen("tcp", h.receiverAddr)
if err != nil {
return nil, err
}
codeCh = make(chan string)
go http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code")
codeCh <- code
listener.Close()
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "Received code: %v\r\nYou can now safely close this browser window.", code)
}))
return codeCh, nil
}
func launchBrowser(url string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()
case "linux":
return exec.Command("xdg-open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
}
return fmt.Errorf("unsupported platform: <%s>", runtime.GOOS)
}

103
pkg/streamcontrol/config.go Normal file
View File

@@ -0,0 +1,103 @@
package streamctl
import (
"context"
"fmt"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/goccy/go-yaml"
)
type PlatformConfig[T any, S StreamProfile] struct {
Config T
StreamProfiles map[string]S
}
type AbstractPlatformConfig = PlatformConfig[any, StreamProfile]
type Config map[string]*AbstractPlatformConfig
func (cfg *Config) UnmarshalYAML(b []byte) error {
t := map[string]*AbstractPlatformConfig{}
err := yaml.Unmarshal(b, &t)
if err != nil {
return fmt.Errorf("unable to unmarshal YAML of the root of the config: %w", err)
}
for k, v := range t {
b, err := yaml.Marshal(v.Config)
if err != nil {
return fmt.Errorf("unable to re-marshal YAML of config %#+v: %w", v, err)
}
vOrig, ok := (*cfg)[k]
if !ok {
continue
}
cfgCfg := vOrig.Config
err = yaml.Unmarshal(b, cfgCfg)
if err != nil {
return fmt.Errorf("unable to unmarshal YAML of platform-config %s: %w", b, err)
}
(*cfg)[k].Config = cfgCfg
}
for k := range *cfg {
_, ok := t[k]
if !ok {
delete(*cfg, k)
}
}
return nil
}
func GetPlatformConfig[T any, S any](ctx context.Context, cfg Config, id string) *PlatformConfig[T, S] {
platCfg, ok := cfg[id]
if !ok {
logger.Debugf(ctx, "config '%s' was not found in cfg: %#+v", id, cfg)
return nil
}
platCfgCfg, ok := platCfg.Config.(*T)
if !ok {
var zeroValue T
logger.Errorf(ctx, "unable to get the config: expected type '%T', but received type '%T'", zeroValue, platCfg.Config)
return nil
}
return &PlatformConfig[T, S]{
Config: *platCfgCfg,
StreamProfiles: GetStreamProfiles[S](platCfg.StreamProfiles),
}
}
func GetStreamProfiles[S any](streamProfiles map[string]StreamProfile) map[string]S {
s := make(map[string]S, len(streamProfiles))
for k, pI := range streamProfiles {
p, ok := pI.(S)
if !ok {
var zeroS S
panic(fmt.Errorf("expected type %T, but received type %T", zeroS, pI))
}
s[k] = p
}
return s
}
func InitConfig[T any, S any](cfg Config, id string, platCfg PlatformConfig[T, S]) {
if _, ok := cfg[id]; ok {
panic(fmt.Errorf("id '%s' is already registered", id))
}
cfg[id] = &PlatformConfig[any, StreamProfile]{
Config: &platCfg.Config,
}
}
func ToAbstractStreamProfiles[S StreamProfile](in map[string]S) map[string]StreamProfile {
m := make(map[string]StreamProfile, len(in))
for k, v := range in {
m[k] = v
}
return m
}

View File

@@ -0,0 +1,22 @@
package streamctl
import "fmt"
type ErrInvalidStreamProfileType struct {
Received StreamProfile
Expected StreamProfile
}
var _ error = ErrInvalidStreamProfileType{}
func (e ErrInvalidStreamProfileType) Error() string {
return fmt.Sprintf("received an invalid stream profile type: expected:%T, received:%T", e.Expected, e.Received)
}
type ErrNoStreamControllerForProfile struct {
StreamProfile StreamProfile
}
func (e ErrNoStreamControllerForProfile) Error() string {
return fmt.Sprintf("no StreamController found for profile %T", e.StreamProfile)
}

View File

@@ -0,0 +1,269 @@
package streamctl
import (
"context"
"fmt"
"reflect"
"sync"
"time"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/hashicorp/go-multierror"
)
type StreamProfile interface {
}
type StreamControllerCommons interface {
SetTitle(ctx context.Context, title string) error
SetDescription(ctx context.Context, description string) error
InsertAdsCuePoint(ctx context.Context, ts time.Time, duration time.Duration) error
Flush(ctx context.Context) error
EndStream(ctx context.Context) error
}
type StreamController[ProfileType StreamProfile] interface {
StreamControllerCommons
ApplyProfile(ctx context.Context, profile ProfileType) error
StartStream(ctx context.Context, title string, description string, profile ProfileType, customArgs ...any) error
}
type AbstractStreamController interface {
StreamController[StreamProfile]
GetImplementation() StreamControllerCommons
StreamProfileType() reflect.Type
}
type abstractStreamController struct {
StreamController StreamControllerCommons
applyProfile func(ctx context.Context, profile StreamProfile) error
startStream func(ctx context.Context, title string, description string, profile StreamProfile, customArgs ...any) error
StreamProfileTypeValue reflect.Type
}
func (c *abstractStreamController) GetImplementation() StreamControllerCommons {
return c.StreamController
}
func (c *abstractStreamController) ApplyProfile(
ctx context.Context,
profile StreamProfile,
) error {
return c.applyProfile(ctx, profile)
}
func (c *abstractStreamController) SetTitle(
ctx context.Context,
title string,
) error {
return c.GetImplementation().SetTitle(ctx, title)
}
func (c *abstractStreamController) SetDescription(
ctx context.Context,
description string,
) error {
return c.GetImplementation().SetDescription(ctx, description)
}
func (c *abstractStreamController) InsertAdsCuePoint(
ctx context.Context,
ts time.Time,
duration time.Duration,
) error {
return c.GetImplementation().InsertAdsCuePoint(ctx, ts, duration)
}
func (c *abstractStreamController) Flush(
ctx context.Context,
) error {
return c.GetImplementation().Flush(ctx)
}
func (c *abstractStreamController) StartStream(
ctx context.Context,
title string,
description string,
profile StreamProfile,
customArgs ...any,
) error {
return c.startStream(ctx, title, description, profile)
}
func (c *abstractStreamController) EndStream(
ctx context.Context,
) error {
return c.GetImplementation().EndStream(ctx)
}
func (c *abstractStreamController) StreamProfileType() reflect.Type {
return c.StreamProfileTypeValue
}
func ToAbstract[T StreamProfile](c StreamController[T]) AbstractStreamController {
var zeroProfile T
profileType := reflect.TypeOf(zeroProfile)
return &abstractStreamController{
StreamController: c,
applyProfile: func(ctx context.Context, _profile StreamProfile) error {
profile, ok := _profile.(T)
if !ok {
return ErrInvalidStreamProfileType{Expected: zeroProfile, Received: _profile}
}
return c.ApplyProfile(ctx, profile)
},
startStream: func(ctx context.Context, title string, description string, _profile StreamProfile, customArgs ...any) error {
profile, ok := _profile.(T)
if !ok {
return ErrInvalidStreamProfileType{Expected: zeroProfile, Received: _profile}
}
return c.StartStream(ctx, title, description, profile, customArgs...)
},
StreamProfileTypeValue: profileType,
}
}
type StreamControllers []AbstractStreamController
func (s StreamControllers) ApplyProfiles(
ctx context.Context,
profiles []StreamProfile,
) error {
m := map[reflect.Type]AbstractStreamController{}
for _, c := range s {
m[c.StreamProfileType()] = c
}
var wg sync.WaitGroup
errCh := make(chan error)
for _, p := range profiles {
wg.Add(1)
go func(p StreamProfile) {
defer wg.Done()
profileType := reflect.TypeOf(p)
c, ok := m[profileType]
if !ok {
errCh <- ErrNoStreamControllerForProfile{StreamProfile: p}
return
}
if err := c.ApplyProfile(ctx, p); err != nil {
errCh <- fmt.Errorf("StreamController %T return error: %w", c.GetImplementation(), err)
return
}
}(p)
}
go func() {
wg.Wait()
close(errCh)
}()
var result error
for err := range errCh {
result = multierror.Append(result, err)
}
return result
}
func (s StreamControllers) SetTitle(
ctx context.Context,
title string,
) error {
return s.concurrently(func(c AbstractStreamController) error {
err := c.SetTitle(ctx, title)
logger.Debugf(ctx, "SetTitle: %T: <%s>: %v", c.GetImplementation(), title, err)
if err != nil {
return fmt.Errorf("StreamController %T return error: %w", c.GetImplementation(), err)
}
return nil
})
}
func (s StreamControllers) SetDescription(
ctx context.Context,
description string,
) error {
return s.concurrently(func(c AbstractStreamController) error {
logger.Debugf(ctx, "SetDescription: %T: <%s>", c.GetImplementation(), description)
if err := c.SetDescription(ctx, description); err != nil {
return fmt.Errorf("StreamController %T return error: %w", c.GetImplementation(), err)
}
return nil
})
}
func (s StreamControllers) InsertAdsCuePoint(
ctx context.Context,
ts time.Time,
duration time.Duration,
) error {
return s.concurrently(func(c AbstractStreamController) error {
if err := c.InsertAdsCuePoint(ctx, ts, duration); err != nil {
return fmt.Errorf("StreamController %T return error: %w", c.GetImplementation(), err)
}
return nil
})
}
func (s StreamControllers) StartStream(
ctx context.Context,
title string,
description string,
profiles []StreamProfile,
customArgs ...any,
) error {
m := map[reflect.Type]StreamProfile{}
for _, p := range profiles {
m[reflect.TypeOf(p)] = p
}
return s.concurrently(func(c AbstractStreamController) error {
if err := c.StartStream(ctx, title, description, m[c.StreamProfileType()], customArgs...); err != nil {
return fmt.Errorf("StreamController %T return error: %w", c.GetImplementation(), err)
}
return nil
})
}
func (s StreamControllers) EndStream(
ctx context.Context,
) error {
return s.concurrently(func(c AbstractStreamController) error {
if err := c.EndStream(ctx); err != nil {
return fmt.Errorf("StreamController %T return error: %w", c.GetImplementation(), err)
}
return nil
})
}
func (s StreamControllers) Flush(
ctx context.Context,
) error {
return s.concurrently(func(c AbstractStreamController) error {
if err := c.Flush(ctx); err != nil {
return fmt.Errorf("StreamController %T return error: %w", c.GetImplementation(), err)
}
return nil
})
}
func (s StreamControllers) concurrently(callback func(c AbstractStreamController) error) error {
var wg sync.WaitGroup
errCh := make(chan error)
for _, c := range s {
wg.Add(1)
go func(c AbstractStreamController) {
defer wg.Done()
if err := callback(c); err != nil {
errCh <- err
}
}(c)
}
go func() {
wg.Wait()
close(errCh)
}()
var result error
for err := range errCh {
result = multierror.Append(result, err)
}
return result
}

View File

@@ -0,0 +1,22 @@
package twitch
import (
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
)
type PlatformSpecificConfig struct {
Channel string
ClientID string
ClientSecret string
ClientCode string
AuthType string
AppAccessToken string
UserAccessToken string
RefreshToken string
}
type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile]
func InitConfig(cfg streamctl.Config, id string) {
streamctl.InitConfig(cfg, id, Config{})
}

View File

@@ -0,0 +1,234 @@
package twitch
import (
"context"
"fmt"
"time"
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/nicklaw5/helix/v2"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
)
type StreamProfile struct {
Language string
Tags []string
}
type ConfigStorage interface {
Get(k string) any
Set(k string, v any)
Save() error
}
type Twitch struct {
client *helix.Client
broadcasterID string
}
var _ streamctl.StreamController[StreamProfile] = (*Twitch)(nil)
func New(
ctx context.Context,
cfg Config,
safeCfgFn func(Config) error,
) (*Twitch, error) {
if cfg.Config.Channel == "" {
return nil, fmt.Errorf("'channel' is not set")
}
if cfg.Config.ClientID == "" || cfg.Config.ClientSecret == "" {
return nil, fmt.Errorf("'clientid' or/and 'clientsecret' is/are not set; go to https://dev.twitch.tv/console/apps/create and create an app if it not created, yet")
}
client, err := getClient(ctx, cfg, safeCfgFn)
if err != nil {
return nil, err
}
logger.Debugf(ctx, "initialized a client")
broadcasterID, err := getUserID(ctx, client, cfg.Config.Channel)
if err != nil {
return nil, fmt.Errorf("unable to get the user ID: %w", err)
}
logger.Debugf(ctx, "broadcaster_id: %s (login: %s)", broadcasterID, cfg.Config.Channel)
return &Twitch{
client: client,
broadcasterID: broadcasterID,
}, nil
}
func getUserID(
ctx context.Context,
client *helix.Client,
login string,
) (string, error) {
resp, err := client.GetUsers(&helix.UsersParams{
Logins: []string{login},
})
if err != nil {
return "", fmt.Errorf("unable to query user info: %w", err)
}
if len(resp.Data.Users) != 1 {
return "", fmt.Errorf("expected 1 user with login, but received %d users", len(resp.Data.Users))
}
return resp.Data.Users[0].ID, nil
}
func (t *Twitch) editChannelInfo(
ctx context.Context,
params *helix.EditChannelInformationParams,
) error {
if params == nil {
return fmt.Errorf("params == nil")
}
params.BroadcasterID = t.broadcasterID
resp, err := t.client.EditChannelInformation(params)
if err != nil {
return fmt.Errorf("unable to update the channel info (%#+v): %w", *params, err)
}
if resp.ErrorStatus != 0 {
return fmt.Errorf("unable to update the channel info (%#+v), the response reported an error: %d %v: %v", *params, resp.ErrorStatus, resp.Error, resp.ErrorMessage)
}
logger.Debugf(ctx, "success")
return nil
}
func (t *Twitch) ApplyProfile(
ctx context.Context,
profile StreamProfile,
) error {
return t.editChannelInfo(ctx, &helix.EditChannelInformationParams{
BroadcasterLanguage: profile.Language,
Tags: profile.Tags,
})
}
func (t *Twitch) SetTitle(
ctx context.Context,
title string,
) error {
return t.editChannelInfo(ctx, &helix.EditChannelInformationParams{
Title: title,
})
}
func (t *Twitch) SetDescription(
ctx context.Context,
description string,
) error {
// Twitch streams has no description:
return nil
}
func (t *Twitch) InsertAdsCuePoint(
ctx context.Context,
ts time.Time,
duration time.Duration,
) error {
// Unfortunately, we do not support sending ads cues.
// So nothing to do here:
return nil
}
func (t *Twitch) Flush(
ctx context.Context,
) error {
// Unfortunately, we do not support sending accumulated changes, and we change things immediately right away.
// So nothing to do here:
return nil
}
func (t *Twitch) StartStream(
ctx context.Context,
title string,
description string,
profile StreamProfile,
customArgs ...any,
) error {
// Twitch starts a stream automatically, nothing to do:
return nil
}
func (t *Twitch) EndStream(
ctx context.Context,
) error {
// Twitch ends a stream automatically, nothing to do:
return nil
}
func getClient(
ctx context.Context,
cfg Config,
safeCfgFn func(Config) error,
) (*helix.Client, error) {
client, err := helix.NewClient(&helix.Options{
ClientID: cfg.Config.ClientID,
ClientSecret: cfg.Config.ClientSecret,
RedirectURI: "http://localhost/",
})
if err != nil {
return nil, fmt.Errorf("unable to create a helix client object: %w", err)
}
client.OnUserAccessTokenRefreshed(func(newAccessToken, newRefreshToken string) {
logger.Debugf(ctx, "updated tokens")
cfg.Config.UserAccessToken = newAccessToken
cfg.Config.RefreshToken = newRefreshToken
err := safeCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err)
})
switch cfg.Config.AuthType {
case "user":
client.SetUserAccessToken(cfg.Config.UserAccessToken)
client.SetRefreshToken(cfg.Config.RefreshToken)
if cfg.Config.UserAccessToken == "" {
if cfg.Config.ClientCode == "" {
url := client.GetAuthorizationURL(&helix.AuthorizationURLParams{
ResponseType: "code", // or "token"
Scopes: []string{"channel:manage:broadcast"},
})
return nil, fmt.Errorf("not supported, yet; the auth URL is <%s>, please inject the ClientCode manually", url)
}
resp, err := client.RequestUserAccessToken(cfg.Config.ClientCode)
if err != nil {
return nil, fmt.Errorf("unable to get user access token: %w", err)
}
if resp.ErrorStatus != 0 {
return nil, fmt.Errorf("unable to query: %d %v: %v", resp.ErrorStatus, resp.Error, resp.ErrorMessage)
}
client.SetUserAccessToken(resp.Data.AccessToken)
client.SetRefreshToken(resp.Data.RefreshToken)
cfg.Config.ClientCode = ""
cfg.Config.UserAccessToken = resp.Data.AccessToken
cfg.Config.RefreshToken = resp.Data.RefreshToken
err = safeCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err)
}
case "app":
if cfg.Config.AppAccessToken != "" {
logger.Debugf(ctx, "already have an app access token")
client.SetUserAccessToken(cfg.Config.AppAccessToken) // shouldn't it be "SetAppAccessToken"?
break
}
logger.Debugf(ctx, "do not have an app access token")
resp, err := client.RequestAppAccessToken(nil)
if err != nil {
return nil, fmt.Errorf("unable to get app access token: %w", err)
}
if resp.ErrorStatus != 0 {
return nil, fmt.Errorf("unable to get app access token (the response contains an error): %d %v: %v", resp.ErrorStatus, resp.Error, resp.ErrorMessage)
}
logger.Debugf(ctx, "setting the app access token")
client.SetAppAccessToken(resp.Data.AccessToken)
cfg.Config.AppAccessToken = resp.Data.AccessToken
err = safeCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err)
default:
return nil, fmt.Errorf("invalid AuthType: <%s>", cfg.Config.AuthType)
}
return client, nil
}

View File

@@ -0,0 +1,18 @@
package youtube
import (
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
"golang.org/x/oauth2"
)
type PlatformSpecificConfig struct {
ClientID string
ClientSecret string
Token *oauth2.Token
}
type Config = streamctl.PlatformConfig[PlatformSpecificConfig, StreamProfile]
func InitConfig(cfg streamctl.Config, id string) {
streamctl.InitConfig(cfg, id, Config{})
}

View File

@@ -0,0 +1,290 @@
package youtube
import (
"context"
"fmt"
"time"
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
type StreamProfile struct {
Tags []string
}
type YouTube struct {
YouTubeService *youtube.Service
}
var _ streamctl.StreamController[StreamProfile] = (*YouTube)(nil)
func New(
ctx context.Context,
cfg Config,
safeCfgFn func(Config) error,
) (*YouTube, error) {
if cfg.Config.ClientID == "" || cfg.Config.ClientSecret == "" {
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")
}
if cfg.Config.Token == nil {
t, err := getToken(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("unable to get an access token: %w", err)
}
cfg.Config.Token = t
err = safeCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err)
}
tokenSource := getAuthCfg(cfg).TokenSource(ctx, cfg.Config.Token)
youtubeService, err := youtube.NewService(ctx, option.WithTokenSource(tokenSource))
if err != nil {
return nil, err
}
yt := &YouTube{
YouTubeService: youtubeService,
}
go func() {
ticker := time.NewTicker(time.Minute)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
logger.Debugf(ctx, "checking if the token changed")
token, err := tokenSource.Token()
if err != nil {
logger.Errorf(ctx, "unable to get a token: %v", err)
continue
}
if token.AccessToken == cfg.Config.Token.AccessToken {
logger.Debugf(ctx, "the token have not change")
continue
}
logger.Debugf(ctx, "the token have changed")
cfg.Config.Token = token
err = safeCfgFn(cfg)
errmon.ObserveErrorCtx(ctx, err)
}
}
}()
return yt, nil
}
func getAuthCfg(cfg Config) *oauth2.Config {
return &oauth2.Config{
ClientID: cfg.Config.ClientID,
ClientSecret: cfg.Config.ClientSecret,
Endpoint: google.Endpoint,
RedirectURL: "http://localhost:8090",
Scopes: []string{
"https://www.googleapis.com/auth/youtube",
},
}
}
func getToken(ctx context.Context, cfg Config) (*oauth2.Token, error) {
googleAuthCfg := getAuthCfg(cfg)
authURL := googleAuthCfg.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
var tok *oauth2.Token
oauthHandler := oauthhandler.NewOAuth2Handler(authURL, func(code string) error {
_tok, err := googleAuthCfg.Exchange(ctx, code)
if err != nil {
return fmt.Errorf("unable to get a token: %w", err)
}
tok = _tok
return nil
}, "")
err := oauthHandler.Handle(ctx)
if err != nil {
return nil, err
}
return tok, nil
}
func (yt *YouTube) iterateActiveBroadcasts(
ctx context.Context,
callback func(broadcast *youtube.LiveBroadcast) error,
parts ...string,
) error {
broadcasts, err := yt.YouTubeService.LiveBroadcasts.
List(append([]string{"id"}, parts...)).
BroadcastStatus("active").
Context(ctx).Do()
if err != nil {
return fmt.Errorf("unable to get the list of active broadcasts: %w", err)
}
for _, broadcast := range broadcasts.Items {
if err := callback(broadcast); err != nil {
if err != nil {
return fmt.Errorf("got an error with broadcast %v: %w", broadcast.Id, err)
}
}
}
return nil
}
func (yt *YouTube) updateActiveBroadcasts(
ctx context.Context,
updateBroadcast func(broadcast *youtube.LiveBroadcast) error,
parts ...string,
) error {
return yt.iterateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
if err := updateBroadcast(broadcast); err != nil {
return fmt.Errorf("unable to update broadcast %v: %w", broadcast.Id, err)
}
_, err := yt.YouTubeService.LiveBroadcasts.Update(parts, broadcast).Context(ctx).Do()
if err != nil {
return fmt.Errorf("unable to update broadcast %v: %w", broadcast.Id, err)
}
return nil
}, parts...)
}
func (yt *YouTube) ApplyProfile(
ctx context.Context,
profile StreamProfile,
) error {
return yt.updateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
if broadcast.Snippet == nil {
return fmt.Errorf("YouTube have not provided the current snippet of broadcast %v", broadcast.Id)
}
setProfile(broadcast, profile)
return nil
}, "snippet")
}
func (yt *YouTube) SetTitle(
ctx context.Context,
title string,
) error {
return yt.updateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
if broadcast.Snippet == nil {
return fmt.Errorf("YouTube have not provided the current snippet of broadcast %v", broadcast.Id)
}
setTitle(broadcast, title)
return nil
}, "snippet")
}
func (yt *YouTube) SetDescription(
ctx context.Context,
description string,
) error {
return yt.updateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
if broadcast.Snippet == nil {
return fmt.Errorf("YouTube have not provided the current snippet of broadcast %v", broadcast.Id)
}
setDescription(broadcast, description)
return nil
}, "snippet")
}
func (yt *YouTube) InsertAdsCuePoint(
ctx context.Context,
ts time.Time,
duration time.Duration,
) error {
return yt.iterateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
_, err := yt.YouTubeService.LiveBroadcasts.InsertCuepoint(&youtube.Cuepoint{
CueType: "cueTypeAd",
DurationSecs: int64(duration.Seconds()),
WalltimeMs: uint64(ts.UnixMilli()),
}).Context(ctx).Do()
return err
})
}
type FlagBroadcastTemplateIDs []string
func (yt *YouTube) StartStream(
ctx context.Context,
title string,
description string,
profile StreamProfile,
customArgs ...any,
) error {
var templateBroadcastIDs []string
for _, templateBroadcastIDCandidate := range customArgs {
_templateBroadcastIDs, ok := templateBroadcastIDCandidate.(FlagBroadcastTemplateIDs)
if ok {
templateBroadcastIDs = _templateBroadcastIDs
break
}
}
var broadcasts []*youtube.LiveBroadcast
for _, templateBroadcastID := range templateBroadcastIDs {
response, err := yt.YouTubeService.LiveBroadcasts.
List([]string{"id", "snippet", "contentDetails", "monetizationDetails", "status"}).
Id(templateBroadcastID).
Context(ctx).Do()
if err != nil {
return fmt.Errorf("unable to get the list of active broadcasts: %w", err)
}
if len(response.Items) != 1 {
return fmt.Errorf("expected 1 broadcast with id %v, but found %d", templateBroadcastID, len(response.Items))
}
broadcasts = append(broadcasts, response.Items...)
}
for _, broadcast := range broadcasts {
broadcast.ContentDetails.EnableAutoStart = true
broadcast.ContentDetails.EnableAutoStop = false
broadcast.Snippet.ScheduledStartTime = time.Now().UTC().Format("2006-01-02T15:04:05") + ".00Z"
broadcast.Snippet.ScheduledEndTime = time.Now().Add(time.Hour*12).UTC().Format("2006-01-02T15:04:05") + ".00Z"
setTitle(broadcast, title)
setDescription(broadcast, description)
setProfile(broadcast, profile)
_, err := yt.YouTubeService.LiveBroadcasts.Insert([]string{"snippet", "contentDetails"}, broadcast).Context(ctx).Do()
if err != nil {
return fmt.Errorf("unable to create a broadcast: %w", err)
}
}
return nil
}
func setTitle(broadcast *youtube.LiveBroadcast, title string) {
broadcast.Snippet.Title = title
}
func setDescription(broadcast *youtube.LiveBroadcast, description string) {
broadcast.Snippet.Description = description
}
func setProfile(broadcast *youtube.LiveBroadcast, profile StreamProfile) {
// Don't know how to set the tags :(
}
func (yt *YouTube) EndStream(
ctx context.Context,
) error {
return yt.updateActiveBroadcasts(ctx, func(broadcast *youtube.LiveBroadcast) error {
broadcast.ContentDetails.EnableAutoStop = true
return nil
}, "contentDetails")
}
func (yt *YouTube) Flush(
ctx context.Context,
) error {
// Unfortunately, we do not support sending accumulated changes, and we change things immediately right away.
// So nothing to do here:
return nil
}