Skip to content

Commit

Permalink
Use GQL api
Browse files Browse the repository at this point in the history
  • Loading branch information
jybp committed Jan 27, 2021
1 parent b7ea21a commit cf87409
Show file tree
Hide file tree
Showing 4 changed files with 59 additions and 97 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Makefile

twitchdl.exe
twitchdl
!twitchdl/

Expand Down
1 change: 1 addition & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ archives:
amd64: x86_64
checksum:
name_template: 'checksums.txt'
format: binary
snapshot:
name_template: "{{ .Tag }}-next"
changelog:
Expand Down
110 changes: 57 additions & 53 deletions twitch/twitch.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httputil"
"net/url"
"path"
"strings"
"time"

"github.com/pkg/errors"
)
Expand All @@ -30,9 +30,6 @@ func ID(URL string) (string, error) {
return id, nil
}

// https://dev.twitch.tv/docs/api/reference/
// https://github.com/videolan/vlc/blob/0b018b348f47cda82863809ab0385cb993c8aa33/share/lua/playlist/twitch.lua#L81

// Client manages communication with the twitch API.
type Client struct {
client *http.Client
Expand All @@ -43,43 +40,60 @@ type Client struct {

// New returns a new twitch API client.
func New(client *http.Client, clientID string) Client {
return Client{client, clientID, "https://api.twitch.tv/", "http://usher.twitch.tv/"}
return Client{client, clientID, "https://gql.twitch.tv/gql", "http://usher.twitch.tv/"}
}

// Custom returns a new twitch API client with custom API endpoints
func Custom(client *http.Client, clientID, apiURL, usherAPIURL string) Client {
return Client{client, clientID, apiURL, usherAPIURL}
}

type token struct {
Token string `json:"token"`
Sig string `json:"sig"`
}

func (c *Client) vodToken(ctx context.Context, id string) (token, error) {
u := fmt.Sprintf("%sapi/vods/%s/access_token?client_id=%s", c.apiURL, id, c.clientID)
resp, err := c.client.Get(u)
func (c *Client) vodToken(ctx context.Context, id string) (token, sig string, err error) {
gqlPayload := `{"operationName":"PlaybackAccessToken_Template","query":"query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}","variables":{"isLive":false,"login":"","isVod":true,"vodID":"%s","playerType":"site"}}`
body := strings.NewReader(fmt.Sprintf(gqlPayload, id))
req, err := http.NewRequest(http.MethodPost, c.apiURL, body)
if err != nil {
return "", "", errors.WithStack(err)
}
req.Header.Set("Client-Id", c.clientID)
dump, err := httputil.DumpRequestOut(req, true)
if err != nil {
return token{}, errors.WithStack(err)
return "", "", errors.WithStack(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", errors.Errorf("%v\n%s", err, string(dump))
}
defer resp.Body.Close()
if s := resp.StatusCode; s < 200 || s >= 300 {
b, _ := ioutil.ReadAll(resp.Body)
return token{}, errors.Errorf("%d\n%s\n%s", s, u, string(b))
return "", "", errors.Errorf("invalid status code %d\n%s", s, string(dump))
}

type respPayload struct {
Data struct {
VideoPlaybackAccessToken struct {
Value string `json:"value"`
Signature string `json:"signature"`
} `json:"videoPlaybackAccessToken"`
} `json:"data"`
}
var t token
return t, errors.WithStack(json.NewDecoder(resp.Body).Decode(&t))

var p respPayload
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return "", "", errors.Errorf("%v\n%s", err, string(dump))
}
return p.Data.VideoPlaybackAccessToken.Value, p.Data.VideoPlaybackAccessToken.Signature, nil
}

// M3U8 retrieves the M3U8 file of a specific VOD.
func (c *Client) M3U8(ctx context.Context, id string) ([]byte, error) {
tok, err := c.vodToken(ctx, id)
tok, sig, err := c.vodToken(ctx, id)
if err != nil {
return nil, errors.WithStack(err)
}

u := fmt.Sprintf("%svod/%s?nauth=%s&nauthsig=%s&allow_audio_only=true&allow_source=true",
c.usherAPIURL, id, tok.Token, tok.Sig)
c.usherAPIURL, id, tok, sig)
resp, err := c.client.Get(u)
if err != nil {
return nil, errors.WithStack(err)
Expand All @@ -94,52 +108,42 @@ func (c *Client) M3U8(ctx context.Context, id string) ([]byte, error) {

// VOD describes a twitch VOD.
type VOD struct {
ID string `json:"id"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Title string `json:"title"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
PublishedAt time.Time `json:"published_at"`
URL string `json:"url"`
ThumbnailURL string `json:"thumbnail_url"`
Viewable string `json:"viewable"`
ViewCount int `json:"view_count"`
Language string `json:"language"`
Type string `json:"type"`
Duration string `json:"duration"`
}

type data struct {
Data []VOD `json:"data"`
Pagination struct {
Cursor string `json:"cursor"`
} `json:"pagination"`
Title string
}

// VOD retrieves the video informations of a specific VOD.
func (c *Client) VOD(ctx context.Context, id string) (VOD, error) {
u := fmt.Sprintf("%shelix/videos?id=%s", c.apiURL, id)
req, err := http.NewRequest(http.MethodGet, u, nil)
gqlPayload := `{"operationName":"VideoMetadata","variables":{"channelLogin":"","videoID":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"226edb3e692509f727fd56821f5653c05740242c82b0388883e0c0e75dcbf687"}}}`
body := strings.NewReader(fmt.Sprintf(gqlPayload, id))
req, err := http.NewRequest(http.MethodPost, c.apiURL, body)
if err != nil {
return VOD{}, errors.WithStack(err)
}
req.Header.Add("Client-ID", c.clientID)
resp, err := c.client.Do(req)
dump, err := httputil.DumpRequestOut(req, true)
if err != nil {
return VOD{}, errors.WithStack(err)
}
req.Header.Set("Client-Id", c.clientID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return VOD{}, errors.Errorf("%v\n%s", err, string(dump))
}
defer resp.Body.Close()
if s := resp.StatusCode; s < 200 || s >= 300 {
b, _ := ioutil.ReadAll(resp.Body)
return VOD{}, errors.Errorf("%d\n%s\n%s", s, u, string(b))
return VOD{}, errors.Errorf("invalid status code %d\n%s", s, string(dump))
}
var d data
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
return VOD{}, errors.WithStack(err)

type respPayload struct {
Data struct {
Video struct {
Title string `json:"title"`
} `json:"video"`
} `json:"data"`
}
if len(d.Data) != 1 {
return VOD{}, errors.New("unexpected data length")

var p respPayload
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return VOD{}, errors.Errorf("%v\n%s", err, string(dump))
}
return d.Data[0], nil
return VOD{Title: p.Data.Video.Title}, nil
}
44 changes: 0 additions & 44 deletions twitch/twitch_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package twitch_test

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -38,43 +34,3 @@ func TestID_Stream(t *testing.T) {
_, err := twitch.ID("https://www.twitch.tv/test")
assert.NotNil(t, err)
}

func setup(t *testing.T, cliendID string) (client twitch.Client, mux *http.ServeMux, teardown func()) {
mux = http.NewServeMux()
server := httptest.NewServer(mux)
url := server.URL + "/"
client = twitch.Custom(http.DefaultClient, cliendID, url, url)
return client, mux, server.Close
}

func TestVODM3U8(t *testing.T) {
client, mux, teardown := setup(t, "client_id")
defer teardown()

mux.HandleFunc("/api/vods/123/access_token", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET method, got: %s", r.Method)
}
assert.Equal(t, "client_id", r.URL.Query().Get("client_id"))
fmt.Fprintf(w, `{"token":"token","sig":"sig"}`)
})
mux.HandleFunc("/vod/123", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET method, got: %s", r.Method)
}
assert.Equal(t, "token", r.URL.Query().Get("nauth"))
assert.Equal(t, "sig", r.URL.Query().Get("nauthsig"))
assert.Equal(t, "true", r.URL.Query().Get("allow_audio_only"))
assert.Equal(t, "true", r.URL.Query().Get("allow_source"))
fmt.Fprintf(w, "#EXTM3U\n")
})

m3u, err := client.M3U8(context.Background(), "123")
if err != nil {
t.Fatalf("%+v", err)
}
assert.Equal(t, "#EXTM3U\n", string(m3u))
if testing.Verbose() {
t.Logf("%s", string(m3u))
}
}

0 comments on commit cf87409

Please sign in to comment.