diff --git a/app/widget_maker.go b/app/widget_maker.go index 836a9842..f0c3d450 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -57,6 +57,7 @@ import ( "github.com/wtfutil/wtf/modules/transmission" "github.com/wtfutil/wtf/modules/travisci" "github.com/wtfutil/wtf/modules/trello" + "github.com/wtfutil/wtf/modules/twitch" "github.com/wtfutil/wtf/modules/twitter" "github.com/wtfutil/wtf/modules/twitterstats" "github.com/wtfutil/wtf/modules/unknown" @@ -256,6 +257,9 @@ func MakeWidget( case "trello": settings := trello.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = trello.NewWidget(app, settings) + case "twitch": + settings := twitch.NewSettingsFromYAML(moduleName, moduleConfig, config) + widget = twitch.NewWidget(app, pages, settings) case "twitter": settings := twitter.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = twitter.NewWidget(app, pages, settings) diff --git a/go.mod b/go.mod index 1469ce4d..2f9bdd06 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/microsoft/azure-devops-go-api/azuredevops v0.0.0-20191014190507-26902c1d4325 github.com/mmcdole/gofeed v1.0.0-beta2 github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf // indirect + github.com/nicklaw5/helix v0.5.4 github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4 github.com/olekukonko/tablewriter v0.0.4 github.com/onsi/ginkgo v1.10.3 // indirect @@ -56,6 +57,7 @@ require ( golang.org/x/net v0.0.0-20190923162816-aa69164e4478 // indirect golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 golang.org/x/sys v0.0.0-20190922100055-0a153f010e69 // indirect + golang.org/x/text v0.3.2 google.golang.org/api v0.15.0 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/jarcoal/httpmock.v1 v1.0.0-20181110093347-3be5f16b70eb // indirect diff --git a/go.sum b/go.sum index aa20ec99..d4d0421f 100644 --- a/go.sum +++ b/go.sum @@ -200,6 +200,8 @@ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nicklaw5/helix v0.5.4 h1:OAyIHMdmzsNwYJOZscvxAS6eU63YFjtE++CEKvAgujY= +github.com/nicklaw5/helix v0.5.4/go.mod h1:nRcok4VLg8ONQYW/iXBZ24wcfiJjTlDbhgk0ZatOrUY= github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4 h1:JnVsYEQzhEcOspy6ngIYNF2u0h2mjkXZptzX0IzZQ4g= github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4/go.mod h1:RL5+WRxWTAXqqCi9i+eZlHrUtO7AQujUqWi+xMohmc4= diff --git a/modules/twitch/client.go b/modules/twitch/client.go new file mode 100644 index 00000000..1c478ba4 --- /dev/null +++ b/modules/twitch/client.go @@ -0,0 +1,27 @@ +package twitch + +import ( + "fmt" + "github.com/nicklaw5/helix" +) + +type Twitch struct { + client *helix.Client +} + +func NewClient(clientId string) *Twitch { + client, err := helix.NewClient(&helix.Options{ + ClientID: clientId, + }) + if err != nil { + fmt.Println(err) + } + return &Twitch{client: client} +} + +func (t *Twitch) TopStreams(params *helix.StreamsParams) (*helix.StreamsResponse, error) { + if params == nil { + params = &helix.StreamsParams{} + } + return t.client.GetStreams(params) +} diff --git a/modules/twitch/keyboard.go b/modules/twitch/keyboard.go new file mode 100644 index 00000000..eff4fa0a --- /dev/null +++ b/modules/twitch/keyboard.go @@ -0,0 +1,16 @@ +package twitch + +import "github.com/gdamore/tcell" + +func (widget *Widget) initializeKeyboardControls() { + widget.InitializeCommonControls(widget.Refresh) + + widget.SetKeyboardChar("j", widget.Next, "Select next item") + widget.SetKeyboardChar("k", widget.Prev, "Select previous item") + widget.SetKeyboardChar("o", widget.openTwitch, "Open target URL in browser") + + widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item") + widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item") + widget.SetKeyboardKey(tcell.KeyEnter, widget.openTwitch, "Open stream in browser") + widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") +} diff --git a/modules/twitch/settings.go b/modules/twitch/settings.go new file mode 100644 index 00000000..612c49d5 --- /dev/null +++ b/modules/twitch/settings.go @@ -0,0 +1,45 @@ +package twitch + +import ( + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" + "github.com/wtfutil/wtf/utils" + "os" +) + +const ( + defaultFocusable = true +) + +type Settings struct { + common *cfg.Common + + numberOfResults int `help:"Number of results to show. Default is 10." optional:"true"` + clientId string `help:"Client Id (default is env var TWITCH_CLIENT_ID)"` + languages []string `help:"Stream languages" optional:"true"` + gameIds []string `help:"Twitch Game IDs" optional:"true"` + streamType string `help:"Type of stream 'live' (default), 'all', 'vodcast'" optional:"true"` + userIds []string `help:"Twitch user ids" optional:"true"` + userLogins []string `help:"Twitch user names" optional:"true"` +} + +func defaultLanguage() []interface{} { + var defaults []interface{} + defaults = append(defaults, "en") + return defaults +} + +func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { + twitch := ymlConfig.UString("twitch") + settings := Settings{ + common: cfg.NewCommonSettingsFromModule(name, twitch, defaultFocusable, ymlConfig, globalConfig), + numberOfResults: ymlConfig.UInt("numberOfResults", 10), + clientId: ymlConfig.UString("clientId", os.Getenv("TWITCH_CLIENT_ID")), + languages: utils.ToStrs(ymlConfig.UList("languages", defaultLanguage())), + streamType: ymlConfig.UString("streamType", "live"), + gameIds: utils.ToStrs(ymlConfig.UList("gameIds", make([]interface{}, 0))), + userIds: utils.ToStrs(ymlConfig.UList("userIds", make([]interface{}, 0))), + userLogins: utils.ToStrs(ymlConfig.UList("userLogins", make([]interface{}, 0))), + } + return &settings +} diff --git a/modules/twitch/widget.go b/modules/twitch/widget.go new file mode 100644 index 00000000..fc948534 --- /dev/null +++ b/modules/twitch/widget.go @@ -0,0 +1,133 @@ +package twitch + +import ( + "errors" + "fmt" + "github.com/nicklaw5/helix" + "github.com/rivo/tview" + "github.com/wtfutil/wtf/utils" + "github.com/wtfutil/wtf/view" +) + +type Widget struct { + view.KeyboardWidget + view.ScrollableWidget + + settings *Settings + err error + twitch *Twitch + topStreams []*Stream +} + +type Stream struct { + Streamer string + ViewerCount int + Language string + GameID string + Title string +} + +func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *Widget { + widget := &Widget{ + KeyboardWidget: view.NewKeyboardWidget(app, pages, settings.common), + ScrollableWidget: view.NewScrollableWidget(app, settings.common), + settings: settings, + twitch: NewClient(settings.clientId), + } + + widget.SetRenderFunction(widget.Render) + widget.initializeKeyboardControls() + widget.View.SetInputCapture(widget.InputCapture) + widget.KeyboardWidget.SetView(widget.View) + + return widget +} + +func (widget *Widget) Refresh() { + response, err := widget.twitch.TopStreams(&helix.StreamsParams{ + First: widget.settings.numberOfResults, + GameIDs: widget.settings.gameIds, + Language: widget.settings.languages, + Type: widget.settings.streamType, + UserIDs: widget.settings.gameIds, + UserLogins: widget.settings.userLogins, + }) + + if err != nil { + handleError(widget, err) + } else if response.ErrorMessage != "" { + handleError(widget, errors.New(response.ErrorMessage)) + } else { + streams := makeStreams(response) + widget.topStreams = streams + widget.err = nil + if len(streams) <= widget.settings.numberOfResults { + widget.SetItemCount(len(widget.topStreams)) + } else { + widget.topStreams = streams[:widget.settings.numberOfResults] + widget.SetItemCount(len(widget.topStreams)) + } + } + widget.Render() +} + +func (widget *Widget) Render() { + widget.Redraw(widget.content) +} + +func makeStreams(response *helix.StreamsResponse) []*Stream { + streams := make([]*Stream, 0) + for _, b := range response.Data.Streams { + streams = append(streams, &Stream{ + b.UserName, + b.ViewerCount, + b.Language, + b.GameID, + b.Title, + }) + } + return streams +} + +func handleError(widget *Widget, err error) { + widget.err = err + widget.topStreams = nil + widget.SetItemCount(0) +} + +func (widget *Widget) content() (string, string, bool) { + var title = "Top Streams" + if widget.CommonSettings().Title != "" { + title = widget.CommonSettings().Title + } + if widget.err != nil { + return title, widget.err.Error(), true + } + if len(widget.topStreams) == 0 { + return title, "No data", false + } + var str string + + for idx, stream := range widget.topStreams { + row := fmt.Sprintf( + "[%s]%2d. [red]%s [white]%s", + widget.RowColor(idx), + idx+1, + utils.PrettyNumber(float64(stream.ViewerCount)), + stream.Streamer, + ) + str += utils.HighlightableHelper(widget.View, row, idx, len(stream.Streamer)) + } + + return title, str, false +} + +// Opens stream in the browser +func (widget *Widget) openTwitch() { + sel := widget.GetSelected() + if sel >= 0 && widget.topStreams != nil && sel < len(widget.topStreams) { + stream := widget.topStreams[sel] + fullLink := "https://twitch.com/" + stream.Streamer + utils.OpenFile(fullLink) + } +} diff --git a/utils/text.go b/utils/text.go index a7ec19e7..0c694a38 100644 --- a/utils/text.go +++ b/utils/text.go @@ -2,6 +2,9 @@ package utils import ( "fmt" + "golang.org/x/text/language" + "golang.org/x/text/message" + "math" "strings" "github.com/rivo/tview" @@ -72,3 +75,13 @@ func Truncate(src string, maxLen int, withEllipse bool) string { } return src } + +// Formats number as string with 1000 delimiters and, if necessary, rounds it to 2 decimals +func PrettyNumber(number float64) string { + p := message.NewPrinter(language.English) + if number == math.Trunc(number) { + return p.Sprintf("%.0f", number) + } else { + return p.Sprintf("%.2f", number) + } +} diff --git a/utils/text_test.go b/utils/text_test.go index 73901d37..4ed65924 100644 --- a/utils/text_test.go +++ b/utils/text_test.go @@ -43,3 +43,17 @@ func Test_Truncate(t *testing.T) { // Only supports non-ellipsed emoji assert.Equal(t, "🌮🚙", Truncate("🌮🚙💥👾", 2, false)) } + +func Test_PrettyNumber(t *testing.T) { + assert.Equal(t, "1,000,000", PrettyNumber(1000000)) + assert.Equal(t, "1,000,000.99", PrettyNumber(1000000.99)) + assert.Equal(t, "1,000,000", PrettyNumber(1000000.00)) + assert.Equal(t, "100,000", PrettyNumber(100000)) + assert.Equal(t, "100,000.01", PrettyNumber(100000.009)) + assert.Equal(t, "10,000", PrettyNumber(10000)) + assert.Equal(t, "1,000", PrettyNumber(1000)) + assert.Equal(t, "1,000", PrettyNumber(1000)) + assert.Equal(t, "100", PrettyNumber(100)) + assert.Equal(t, "0", PrettyNumber(0)) + assert.Equal(t, "0.10", PrettyNumber(0.1)) +}