mirror of
https://github.com/xaionaro-go/streamctl.git
synced 2025-09-26 19:41:17 +08:00
Initial commit, part 1
This commit is contained in:
121
LICENSE
Normal file
121
LICENSE
Normal 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
21
README.md
Normal 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.
|
||||
```
|
282
cmd/streamctl/commands/commands.go
Normal file
282
cmd/streamctl/commands/commands.go
Normal 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
25
cmd/streamctl/main.go
Normal 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
56
go.mod
Normal 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
240
go.sum
Normal 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=
|
107
pkg/oauthhandler/oauth2_handler.go
Normal file
107
pkg/oauthhandler/oauth2_handler.go
Normal 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
103
pkg/streamcontrol/config.go
Normal 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
|
||||
}
|
22
pkg/streamcontrol/error.go
Normal file
22
pkg/streamcontrol/error.go
Normal 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)
|
||||
}
|
269
pkg/streamcontrol/stream_control.go
Normal file
269
pkg/streamcontrol/stream_control.go
Normal 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
|
||||
}
|
22
pkg/streamcontrol/twitch/config.go
Normal file
22
pkg/streamcontrol/twitch/config.go
Normal 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{})
|
||||
}
|
234
pkg/streamcontrol/twitch/twitch.go
Normal file
234
pkg/streamcontrol/twitch/twitch.go
Normal 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
|
||||
}
|
18
pkg/streamcontrol/youtube/config.go
Normal file
18
pkg/streamcontrol/youtube/config.go
Normal 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{})
|
||||
}
|
290
pkg/streamcontrol/youtube/youtube.go
Normal file
290
pkg/streamcontrol/youtube/youtube.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user