Skip to content

Commit

Permalink
Migrate Twitch clips to Helix API (#144)
Browse files Browse the repository at this point in the history
Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
  • Loading branch information
zneix and pajlada authored May 9, 2021
1 parent 2f54293 commit 0e7af8b
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 66 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Breaking: Environment variable `CHATTERINO_API_CACHE_TWITCH_CLIENT_ID` was renamed to `CHATTERINO_API_TWITCH_CLIENT_ID`. (#144)
- Dev, Breaking: Replaced `dankeroni/gotwitch` with `nicklaw5/helix`. This change requires you to add new environment variable: `CHATTERINO_API_TWITCH_CLIENT_SECRET` - it's a client secret generated for your Twitch application.

## 1.0.2

- Twitter profile pictures are now returned in their original quality. (#131)
Expand Down
3 changes: 1 addition & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ go 1.15

require (
github.com/PuerkitoBio/goquery v1.6.1
github.com/dankeroni/gotwitch v0.0.0-20190429150511-5924c422419a
github.com/discord/lilliput v0.0.0-20210410064651-6e127f25858d
github.com/frankban/quicktest v1.12.1
github.com/go-chi/chi/v5 v5.0.3
github.com/golang/mock v1.5.0
github.com/koffeinsource/go-imgur v0.3.0
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/pajlada/jsonapi v0.0.0-20181126225824-ff9660de882a // indirect
github.com/nicklaw5/helix v1.14.0
github.com/patrickmn/go-cache v2.1.0+incompatible
google.golang.org/api v0.46.0
honnef.co/go/tools v0.1.4
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/dankeroni/gotwitch v0.0.0-20190429150511-5924c422419a h1:Y9MLN64hkshOJeudF4UwBQX9kjBlvjBeH3ru59Y2FZs=
github.com/dankeroni/gotwitch v0.0.0-20190429150511-5924c422419a/go.mod h1:lWs5CtxMaI7rSsvehIPXHnuZ3h5ojuCjWtMrYme8sKk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/discord/lilliput v0.0.0-20210410064651-6e127f25858d h1:jWQgeT6mu5HOHTYkG38bK3gEmCDPTl93mtXmFeSvFmY=
github.com/discord/lilliput v0.0.0-20210410064651-6e127f25858d/go.mod h1:0euuUBAD72MAYRm2ElLaG1h0nBR+CgpfnKc/U6y/uE8=
Expand Down Expand Up @@ -155,8 +153,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pajlada/jsonapi v0.0.0-20181126225824-ff9660de882a h1:1T/17yKaIdv9trE0yD9Izkok3xsPfcjVNA2OB1WDjII=
github.com/pajlada/jsonapi v0.0.0-20181126225824-ff9660de882a/go.mod h1:fzPt+3DvAnKAz1lu+AMv4h6Xc8+2JwLxvmqIJQefs7k=
github.com/nicklaw5/helix v1.14.0 h1:yJI+dUDxFzmlSelNygWs/lhirvuzCqgIXIZy05JdHVk=
github.com/nicklaw5/helix v1.14.0/go.mod h1:XeeXY7oY5W+MVMu6wF4qGm8uvjZ1/Nss0FqprVkXKrg=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand Down
22 changes: 10 additions & 12 deletions internal/mocks/mock_TwitchAPIClient.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 19 additions & 6 deletions internal/resolvers/twitch/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,35 @@ import (
"github.com/Chatterino/api/pkg/cache"
"github.com/Chatterino/api/pkg/humanize"
"github.com/Chatterino/api/pkg/resolver"
"github.com/nicklaw5/helix"
)

func load(clipSlug string, r *http.Request) (interface{}, time.Duration, error) {
log.Println("[TwitchClip] GET", clipSlug)
clip, _, err := v5API.GetClip(clipSlug)

response, err := helixAPI.GetClips(&helix.ClipsParams{IDs: []string{clipSlug}})
if err != nil {
log.Println("[TwitchClip] Error getting clip", clipSlug, ":", err.Error())

return &resolver.Response{
Status: http.StatusInternalServerError,
Message: "An internal error occured while fetching the Twitch clip",
}, cache.NoSpecialDur, nil
}

if len(response.Data.Clips) != 1 {
return noTwitchClipWithThisIDFound, cache.NoSpecialDur, nil
}

var clip = response.Data.Clips[0]

data := twitchClipsTooltipData{
Title: clip.Title,
AuthorName: clip.Curator.DisplayName,
ChannelName: clip.Broadcaster.DisplayName,
AuthorName: clip.CreatorName,
ChannelName: clip.BroadcasterName,
Duration: humanize.DurationSeconds(time.Duration(clip.Duration) * time.Second),
CreationDate: humanize.CreationDate(clip.CreatedAt),
Views: humanize.Number(uint64(clip.Views)),
CreationDate: humanize.CreationDateRFC3339(clip.CreatedAt),
Views: humanize.Number(uint64(clip.ViewCount)),
}

var tooltip bytes.Buffer
Expand All @@ -39,6 +52,6 @@ func load(clipSlug string, r *http.Request) (interface{}, time.Duration, error)
return &resolver.Response{
Status: 200,
Tooltip: url.PathEscape(tooltip.String()),
Thumbnail: clip.Thumbnails.Medium,
Thumbnail: clip.ThumbnailURL,
}, cache.NoSpecialDur, nil
}
80 changes: 47 additions & 33 deletions internal/resolvers/twitch/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ package twitch
import (
"net/url"
"testing"
"time"

"github.com/Chatterino/api/internal/mocks"
"github.com/Chatterino/api/pkg/resolver"
"github.com/dankeroni/gotwitch"
qt "github.com/frankban/quicktest"
"github.com/golang/mock/gomock"
"github.com/nicklaw5/helix"
)

func testLoadAndUnescape(c *qt.C, clipSlug string) (cleanTooltip string) {
Expand All @@ -32,22 +31,27 @@ func TestLoad(t *testing.T) {
c := qt.New(t)
mockCtrl := gomock.NewController(c)
m := mocks.NewMockTwitchAPIClient(mockCtrl)
v5API = m
helixAPI = m

c.Run("Normal clip", func(c *qt.C) {
const slug = "KKona"
var clipResponse gotwitch.V5GetClipResponse
clipResponse.Title = "Clipped it LUL"
clipResponse.Broadcaster.DisplayName = "pajlada"
clipResponse.Curator.DisplayName = "supinic"
clipResponse.Duration = 30
clipResponse.CreatedAt = time.Date(2019, time.November, 14, 04, 20, 6, 9, time.UTC)
clipResponse.Views = 69

clip := helix.Clip{
Title: "Clipped it LUL",
BroadcasterName: "pajlada",
CreatorName: "supinic",
Duration: 30,
CreatedAt: "2019-11-14T04:20:06.09Z",
ViewCount: 69,
}

response := &helix.ClipsResponse{}
response.Data.Clips = []helix.Clip{clip}

m.
EXPECT().
GetClip(gomock.Eq(slug)).
Return(clipResponse, nil, nil)
GetClips(gomock.Eq(&helix.ClipsParams{IDs: []string{slug}})).
Return(response, nil)

const expectedTooltip = `<div style="text-align: left;"><b>Clipped it LUL</b><hr><b>Clipped by:</b> supinic<br><b>Channel:</b> pajlada<br><b>Duration:</b> 30s<br><b>Created:</b> 14 Nov 2019<br><b>Views:</b> 69</div>`

Expand All @@ -57,41 +61,51 @@ func TestLoad(t *testing.T) {
})

c.Run("Normal clip (Number formatting)", func(c *qt.C) {
const slug = "KKona"
var clipResponse gotwitch.V5GetClipResponse
clipResponse.Title = "Clipped it LUL"
clipResponse.Broadcaster.DisplayName = "pajlada"
clipResponse.Curator.DisplayName = "supinic"
clipResponse.Duration = 30
clipResponse.CreatedAt = time.Date(2019, time.November, 14, 04, 20, 6, 9, time.UTC)
clipResponse.Views = 6969
const slug = "KKaper"

clip := helix.Clip{
Title: "Clipped it LUL",
BroadcasterName: "pajlada",
CreatorName: "suspinic",
Duration: 30.1,
CreatedAt: "2019-11-14T04:20:06.09Z",
ViewCount: 6969,
}

response := &helix.ClipsResponse{}
response.Data.Clips = []helix.Clip{clip}

m.
EXPECT().
GetClip(gomock.Eq(slug)).
Return(clipResponse, nil, nil)
GetClips(gomock.Eq(&helix.ClipsParams{IDs: []string{slug}})).
Return(response, nil)

const expectedTooltip = `<div style="text-align: left;"><b>Clipped it LUL</b><hr><b>Clipped by:</b> supinic<br><b>Channel:</b> pajlada<br><b>Duration:</b> 30s<br><b>Created:</b> 14 Nov 2019<br><b>Views:</b> 6,969</div>`
const expectedTooltip = `<div style="text-align: left;"><b>Clipped it LUL</b><hr><b>Clipped by:</b> suspinic<br><b>Channel:</b> pajlada<br><b>Duration:</b> 30s<br><b>Created:</b> 14 Nov 2019<br><b>Views:</b> 6,969</div>`

cleanTooltip := testLoadAndUnescape(c, slug)

c.Assert(cleanTooltip, qt.Equals, expectedTooltip)
})

c.Run("Normal clip (HTML)", func(c *qt.C) {
const slug = "KKona"
var clipResponse gotwitch.V5GetClipResponse
clipResponse.Title = "Clipped it <b>LUL</b>"
clipResponse.Broadcaster.DisplayName = "<b>pajlada</b>"
clipResponse.Curator.DisplayName = "<b>supinic</b>"
clipResponse.Duration = 30
clipResponse.CreatedAt = time.Date(2019, time.November, 14, 04, 20, 6, 9, time.UTC)
clipResponse.Views = 69
const slug = "KKool"

clip := helix.Clip{
Title: "Clipped it <b>LUL</b>",
BroadcasterName: "<b>pajlada</b>",
CreatorName: "<b>supinic</b>",
Duration: 30,
CreatedAt: "2019-11-14T04:20:06.09Z",
ViewCount: 69,
}

response := &helix.ClipsResponse{}
response.Data.Clips = []helix.Clip{clip}

m.
EXPECT().
GetClip(gomock.Eq(slug)).
Return(clipResponse, nil, nil)
GetClips(gomock.Eq(&helix.ClipsParams{IDs: []string{slug}})).
Return(response, nil)

const expectedTooltip = `<div style="text-align: left;"><b>Clipped it &lt;b&gt;LUL&lt;/b&gt;</b><hr><b>Clipped by:</b> &lt;b&gt;supinic&lt;/b&gt;<br><b>Channel:</b> &lt;b&gt;pajlada&lt;/b&gt;<br><b>Duration:</b> 30s<br><b>Created:</b> 14 Nov 2019<br><b>Views:</b> 69</div>`

Expand Down
38 changes: 29 additions & 9 deletions internal/resolvers/twitch/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,18 @@ import (
"encoding/json"
"html/template"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/Chatterino/api/pkg/cache"
"github.com/Chatterino/api/pkg/resolver"
"github.com/Chatterino/api/pkg/utils"
"github.com/dankeroni/gotwitch"
"github.com/nicklaw5/helix"
)

type TwitchAPIClient interface {
GetClip(clipSlug string) (clip gotwitch.V5GetClipResponse, r *http.Response, err error)
GetClips(params *helix.ClipsParams) (clip *helix.ClipsResponse, err error)
}

const (
Expand All @@ -38,17 +36,39 @@ var (

clipCache = cache.New("twitchclip", load, 1*time.Hour)

v5API TwitchAPIClient
helixAPI TwitchAPIClient
)

func New() (resolvers []resolver.CustomURLManager) {
clientID, exists := os.LookupEnv("CHATTERINO_API_CACHE_TWITCH_CLIENT_ID")
if !exists {
log.Println("No CHATTERINO_API_CACHE_TWITCH_CLIENT_ID specified, won't do special responses for twitch clips")
clientID, existsClientID := utils.LookupEnv("TWITCH_CLIENT_ID")
clientSecret, existsClientSecret := utils.LookupEnv("TWITCH_CLIENT_SECRET")

if !existsClientID {
log.Println("No CHATTERINO_API_TWITCH_CLIENT_ID specified, won't do special responses for Twitch clips")
return
}

v5API = gotwitch.NewV5(clientID)
if !existsClientSecret {
log.Println("No CHATTERINO_API_TWITCH_CLIENT_SECRET specified, won't do special responses for Twitch clips")
return
}

var err error

helixAPI, err = helix.NewClient(&helix.Options{
ClientID: clientID,
ClientSecret: clientSecret,
})

if err != nil {
log.Fatalf("[Helix] Error initializing API client: %s", err.Error())
}

waitForFirstAppAccessToken := make(chan struct{})

// Initialize methods responsible for refreshing oauth
go initAppAccessToken(helixAPI.(*helix.Client), waitForFirstAppAccessToken)
<-waitForFirstAppAccessToken

// Find clips that look like https://clips.twitch.tv/SlugHere
resolvers = append(resolvers, resolver.CustomURLManager{
Expand Down
36 changes: 36 additions & 0 deletions internal/resolvers/twitch/token_refresh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package twitch

import (
"log"
"time"

"github.com/nicklaw5/helix"
)

// initAppAccessToken requests and sets app access token to the provided helix.Client
// and initializes a ticker running every 24 Hours which re-requests and sets app access token
func initAppAccessToken(helixAPI *helix.Client, tokenFetched chan struct{}) {
response, err := helixAPI.RequestAppAccessToken([]string{})

if err != nil {
log.Fatalf("[Helix] Error requesting app access token: %s , \n %s", err.Error(), response.Error)
}

log.Printf("[Helix] Requested access token, status: %d, expires in: %d", response.StatusCode, response.Data.ExpiresIn)
helixAPI.SetAppAccessToken(response.Data.AccessToken)
close(tokenFetched)

// initialize the ticker
ticker := time.NewTicker(24 * time.Hour)

for range ticker.C {
response, err := helixAPI.RequestAppAccessToken([]string{})
if err != nil {
log.Printf("[Helix] Failed to re-request app access token from ticker, status: %d", response.StatusCode)
continue
}
log.Printf("[Helix] Re-requested access token from ticker, status: %d, expires in: %d", response.StatusCode, response.Data.ExpiresIn)

helixAPI.SetAppAccessToken(response.Data.AccessToken)
}
}

0 comments on commit 0e7af8b

Please sign in to comment.