feat: add twitch channel points miner runtime
This commit is contained in:
+355
-1
@@ -4,13 +4,32 @@
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"parasocial/internal/gql"
|
||||
)
|
||||
|
||||
const (
|
||||
browserUserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0"
|
||||
spadeSettingsRegex = `(https://static\.twitchcdn\.net/config/settings.*?js|https://assets\.twitch\.tv/config/settings.*?\.js)`
|
||||
spadeURLRegex = `"spade_url":"(.*?)"`
|
||||
)
|
||||
|
||||
var (
|
||||
defaultPageURL = "https://www.twitch.tv/"
|
||||
settingsAssetPattern = regexp.MustCompile(spadeSettingsRegex)
|
||||
spadeURLPattern = regexp.MustCompile(spadeURLRegex)
|
||||
)
|
||||
|
||||
// GQLClient is the minimal GraphQL interface the Twitch service depends on.
|
||||
type GQLClient interface {
|
||||
Do(context.Context, gql.Request, any) error
|
||||
@@ -18,7 +37,9 @@ type GQLClient interface {
|
||||
|
||||
// Service exposes the Twitch lookups the current app needs.
|
||||
type Service struct {
|
||||
GQL GQLClient
|
||||
GQL GQLClient
|
||||
HTTPClient *http.Client
|
||||
Session gql.Session
|
||||
}
|
||||
|
||||
// Viewer is the authenticated Twitch account.
|
||||
@@ -44,6 +65,54 @@ type PlaybackToken struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
// ChannelPointsContext describes current balance and any immediately claimable bonus chest.
|
||||
type ChannelPointsContext struct {
|
||||
Balance int
|
||||
ClaimID string
|
||||
}
|
||||
|
||||
// Game describes the current Twitch category for a live stream.
|
||||
type Game struct {
|
||||
ID string
|
||||
Name string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// StreamMetadata describes the live stream state used for watch telemetry.
|
||||
type StreamMetadata struct {
|
||||
BroadcastID string
|
||||
Title string
|
||||
Game *Game
|
||||
ViewerCount int
|
||||
Tags []Tag
|
||||
ChannelLogin string
|
||||
}
|
||||
|
||||
// Tag is one stream tag entry returned by Twitch.
|
||||
type Tag struct {
|
||||
ID string
|
||||
LocalizedName string
|
||||
}
|
||||
|
||||
// MinuteWatchedPayload is the Spade telemetry body Twitch accepts for simulated viewing.
|
||||
type MinuteWatchedPayload []minuteWatchedEvent
|
||||
|
||||
type minuteWatchedEvent struct {
|
||||
Event string `json:"event"`
|
||||
Properties minuteWatchedProperties `json:"properties"`
|
||||
}
|
||||
|
||||
type minuteWatchedProperties struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
BroadcastID string `json:"broadcast_id"`
|
||||
Player string `json:"player"`
|
||||
UserID string `json:"user_id"`
|
||||
Live bool `json:"live"`
|
||||
Channel string `json:"channel"`
|
||||
Game string `json:"game,omitempty"`
|
||||
GameID string `json:"game_id,omitempty"`
|
||||
}
|
||||
|
||||
// StreamerStatus describes one row's resolution state in the UI.
|
||||
type StreamerStatus string
|
||||
|
||||
@@ -159,3 +228,288 @@ func (s *Service) PlaybackAccessToken(ctx context.Context, login string) (*Playb
|
||||
Value: data.StreamPlaybackAccessToken.Value,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadChannelPointsContext fetches one streamer's current balance and any available bonus claim.
|
||||
func (s *Service) LoadChannelPointsContext(ctx context.Context, login string) (*ChannelPointsContext, error) {
|
||||
var data struct {
|
||||
Community *struct {
|
||||
Channel *struct {
|
||||
Self *struct {
|
||||
CommunityPoints *struct {
|
||||
Balance int `json:"balance"`
|
||||
AvailableClaim *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"availableClaim"`
|
||||
} `json:"communityPoints"`
|
||||
} `json:"self"`
|
||||
} `json:"channel"`
|
||||
} `json:"community"`
|
||||
}
|
||||
if err := s.GQL.Do(ctx, gql.ChannelPointsContext(login), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data.Community == nil || data.Community.Channel == nil || data.Community.Channel.Self == nil || data.Community.Channel.Self.CommunityPoints == nil {
|
||||
return nil, fmt.Errorf("channel points context missing community points for %s", login)
|
||||
}
|
||||
context := &ChannelPointsContext{Balance: data.Community.Channel.Self.CommunityPoints.Balance}
|
||||
if claim := data.Community.Channel.Self.CommunityPoints.AvailableClaim; claim != nil {
|
||||
context.ClaimID = claim.ID
|
||||
}
|
||||
return context, nil
|
||||
}
|
||||
|
||||
// ClaimCommunityPoints claims one available channel points bonus chest.
|
||||
func (s *Service) ClaimCommunityPoints(ctx context.Context, channelID, claimID string) error {
|
||||
var data struct {
|
||||
ClaimCommunityPoints *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"claimCommunityPoints"`
|
||||
}
|
||||
if err := s.GQL.Do(ctx, gql.ClaimCommunityPoints(channelID, claimID), &data); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.ClaimCommunityPoints == nil {
|
||||
return fmt.Errorf("claim community points missing response for channel %s", channelID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamMetadata fetches live stream metadata used to build minute-watched telemetry.
|
||||
func (s *Service) StreamMetadata(ctx context.Context, login string) (*StreamMetadata, error) {
|
||||
var data struct {
|
||||
User *struct {
|
||||
BroadcastSettings *struct {
|
||||
Title string `json:"title"`
|
||||
Game *struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
} `json:"game"`
|
||||
} `json:"broadcastSettings"`
|
||||
Stream *struct {
|
||||
ID string `json:"id"`
|
||||
ViewersCount int `json:"viewersCount"`
|
||||
Tags []struct {
|
||||
ID string `json:"id"`
|
||||
LocalizedName string `json:"localizedName"`
|
||||
} `json:"tags"`
|
||||
} `json:"stream"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := s.GQL.Do(ctx, gql.VideoPlayerStreamInfoOverlayChannel(login), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data.User == nil || data.User.Stream == nil {
|
||||
return nil, fmt.Errorf("stream metadata missing live stream for %s", login)
|
||||
}
|
||||
if data.User.BroadcastSettings == nil {
|
||||
return nil, fmt.Errorf("stream metadata missing broadcast settings for %s", login)
|
||||
}
|
||||
|
||||
metadata := &StreamMetadata{
|
||||
BroadcastID: data.User.Stream.ID,
|
||||
Title: strings.TrimSpace(data.User.BroadcastSettings.Title),
|
||||
ViewerCount: data.User.Stream.ViewersCount,
|
||||
ChannelLogin: login,
|
||||
}
|
||||
if game := data.User.BroadcastSettings.Game; game != nil {
|
||||
metadata.Game = &Game{
|
||||
ID: game.ID,
|
||||
Name: game.Name,
|
||||
DisplayName: game.DisplayName,
|
||||
}
|
||||
}
|
||||
for _, tag := range data.User.Stream.Tags {
|
||||
metadata.Tags = append(metadata.Tags, Tag{
|
||||
ID: tag.ID,
|
||||
LocalizedName: tag.LocalizedName,
|
||||
})
|
||||
}
|
||||
if metadata.BroadcastID == "" {
|
||||
return nil, fmt.Errorf("stream metadata missing broadcast id for %s", login)
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// FetchSpadeURL loads the public Twitch page and extracts the Spade telemetry endpoint.
|
||||
func (s *Service) FetchSpadeURL(ctx context.Context, login string) (string, error) {
|
||||
pageReq, err := http.NewRequestWithContext(ctx, http.MethodGet, defaultPageURL+login, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pageReq.Header.Set("User-Agent", browserUserAgent)
|
||||
pageResp, err := s.httpClient().Do(pageReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch streamer page: %w", err)
|
||||
}
|
||||
defer pageResp.Body.Close()
|
||||
pageBody, err := io.ReadAll(pageResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read streamer page: %w", err)
|
||||
}
|
||||
if pageResp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("fetch streamer page: status %d", pageResp.StatusCode)
|
||||
}
|
||||
settingsURL := settingsAssetPattern.FindString(string(pageBody))
|
||||
if settingsURL == "" {
|
||||
return "", fmt.Errorf("streamer page missing settings asset for %s", login)
|
||||
}
|
||||
|
||||
settingsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, settingsURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
settingsReq.Header.Set("User-Agent", browserUserAgent)
|
||||
settingsResp, err := s.httpClient().Do(settingsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch settings asset: %w", err)
|
||||
}
|
||||
defer settingsResp.Body.Close()
|
||||
settingsBody, err := io.ReadAll(settingsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read settings asset: %w", err)
|
||||
}
|
||||
if settingsResp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("fetch settings asset: status %d", settingsResp.StatusCode)
|
||||
}
|
||||
|
||||
match := spadeURLPattern.FindSubmatch(settingsBody)
|
||||
if len(match) < 2 {
|
||||
return "", fmt.Errorf("settings asset missing spade_url for %s", login)
|
||||
}
|
||||
return strings.ReplaceAll(string(match[1]), `\/`, `/`), nil
|
||||
}
|
||||
|
||||
// TouchPlayback walks the HLS playlist chain Twitch expects before telemetry is posted.
|
||||
func (s *Service) TouchPlayback(ctx context.Context, login string, token *PlaybackToken) error {
|
||||
if token == nil || token.Signature == "" || token.Value == "" {
|
||||
return errors.New("playback access token is incomplete")
|
||||
}
|
||||
masterURL := gql.HLSMasterPlaylistURL(login, token.Signature, token.Value)
|
||||
variantURL, err := s.fetchPlaylistTarget(ctx, masterURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch master playlist: %w", err)
|
||||
}
|
||||
mediaURL, err := s.fetchPlaylistTarget(ctx, variantURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch variant playlist: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, mediaURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", s.requestUserAgent())
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("head media playlist: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("head media playlist: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildMinuteWatchedPayload constructs the Spade telemetry payload for one live stream.
|
||||
func BuildMinuteWatchedPayload(userID, channelID, login string, metadata *StreamMetadata) MinuteWatchedPayload {
|
||||
broadcastID := ""
|
||||
if metadata != nil {
|
||||
broadcastID = metadata.BroadcastID
|
||||
}
|
||||
payload := MinuteWatchedPayload{
|
||||
{
|
||||
Event: "minute-watched",
|
||||
Properties: minuteWatchedProperties{
|
||||
ChannelID: channelID,
|
||||
BroadcastID: broadcastID,
|
||||
Player: "site",
|
||||
UserID: userID,
|
||||
Live: true,
|
||||
Channel: login,
|
||||
},
|
||||
},
|
||||
}
|
||||
if metadata != nil && metadata.Game != nil && metadata.Game.Name != "" && metadata.Game.ID != "" {
|
||||
payload[0].Properties.Game = metadata.Game.Name
|
||||
payload[0].Properties.GameID = metadata.Game.ID
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// Encode returns the form body Twitch expects for Spade telemetry.
|
||||
func (p MinuteWatchedPayload) Encode() (string, error) {
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(body), nil
|
||||
}
|
||||
|
||||
// SendMinuteWatched posts one minute-watched payload to Twitch's Spade endpoint.
|
||||
func (s *Service) SendMinuteWatched(ctx context.Context, spadeURL string, payload MinuteWatchedPayload) error {
|
||||
encoded, err := payload.Encode()
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode minute watched payload: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, spadeURL, strings.NewReader("data="+encoded))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", s.requestUserAgent())
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post minute watched payload: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("post minute watched payload: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) fetchPlaylistTarget(ctx context.Context, targetURL string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", s.requestUserAgent())
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
lines := bytes.Split(body, []byte{'\n'})
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(string(lines[i]))
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
return "", errors.New("playlist contained no media target")
|
||||
}
|
||||
|
||||
func (s *Service) httpClient() *http.Client {
|
||||
if s.HTTPClient != nil {
|
||||
return s.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func (s *Service) requestUserAgent() string {
|
||||
if s.Session.UserAgent != "" {
|
||||
return s.Session.UserAgent
|
||||
}
|
||||
return browserUserAgent
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@ package twitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"parasocial/internal/gql"
|
||||
@@ -124,3 +129,176 @@ func TestPlaybackAccessTokenMissingFields(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelPointsContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeGQL{data: map[string]string{
|
||||
"ChannelPointsContext": `{"community":{"channel":{"self":{"communityPoints":{"balance":1234,"availableClaim":{"id":"claim-1"}}}}}}`,
|
||||
}}
|
||||
service := &Service{GQL: client}
|
||||
context, err := service.LoadChannelPointsContext(context.Background(), "streamer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if context.Balance != 1234 || context.ClaimID != "claim-1" {
|
||||
t.Fatalf("context = %#v", context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimCommunityPoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeGQL{data: map[string]string{
|
||||
"ClaimCommunityPoints": `{"claimCommunityPoints":{"id":"claim-1"}}`,
|
||||
}}
|
||||
service := &Service{GQL: client}
|
||||
if err := service.ClaimCommunityPoints(context.Background(), "7", "claim-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("requests = %d", len(client.requests))
|
||||
}
|
||||
input := client.requests[0].Variables["input"].(map[string]any)
|
||||
if input["channelID"] != "7" || input["claimID"] != "claim-1" {
|
||||
t.Fatalf("input = %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeGQL{data: map[string]string{
|
||||
"VideoPlayerStreamInfoOverlayChannel": `{"user":{"broadcastSettings":{"title":" Live title ","game":{"id":"99","name":"game-name","displayName":"Game Name"}},"stream":{"id":"broadcast","viewersCount":77,"tags":[{"id":"1","localizedName":"English"}]}}}`,
|
||||
}}
|
||||
service := &Service{GQL: client}
|
||||
metadata, err := service.StreamMetadata(context.Background(), "streamer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.BroadcastID != "broadcast" || metadata.Title != "Live title" {
|
||||
t.Fatalf("metadata = %#v", metadata)
|
||||
}
|
||||
if metadata.Game == nil || metadata.Game.Name != "game-name" {
|
||||
t.Fatalf("game = %#v", metadata.Game)
|
||||
}
|
||||
if len(metadata.Tags) != 1 || metadata.Tags[0].LocalizedName != "English" {
|
||||
t.Fatalf("tags = %#v", metadata.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSpadeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/streamer":
|
||||
_, _ = io.WriteString(w, `<script src="https://assets.twitch.tv/config/settings.abc.js"></script>`)
|
||||
case "/config/settings.abc.js":
|
||||
_, _ = io.WriteString(w, `{"spade_url":"https:\/\/spade.example.test\/collect"}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
originalPageURL := defaultPageURL
|
||||
defaultPageURL = server.URL + "/"
|
||||
defer func() { defaultPageURL = originalPageURL }()
|
||||
|
||||
service := &Service{HTTPClient: rewriteClient(server.URL, server.Client())}
|
||||
spadeURL, err := service.FetchSpadeURL(context.Background(), "streamer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spadeURL != "https://spade.example.test/collect" {
|
||||
t.Fatalf("spadeURL = %q", spadeURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchPlayback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/channel/hls/streamer.m3u8":
|
||||
_, _ = io.WriteString(w, "#EXTM3U\n"+server.URL+"/variant.m3u8\n")
|
||||
case "/variant.m3u8":
|
||||
_, _ = io.WriteString(w, "#EXTM3U\n#EXTINF:1,\n"+server.URL+"/chunk.ts\n")
|
||||
case "/chunk.ts":
|
||||
if r.Method != http.MethodHead {
|
||||
t.Fatalf("method = %s, want HEAD", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &Service{HTTPClient: rewriteClient(server.URL, server.Client())}
|
||||
if err := service.TouchPlayback(context.Background(), "streamer", &PlaybackToken{Signature: "sig", Value: "token"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMinuteWatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s", r.Method)
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded := strings.TrimPrefix(string(body), "data=")
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(decoded), `"event":"minute-watched"`) {
|
||||
t.Fatalf("payload = %s", decoded)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &Service{HTTPClient: server.Client()}
|
||||
payload := BuildMinuteWatchedPayload("viewer", "channel", "streamer", &StreamMetadata{
|
||||
BroadcastID: "broadcast",
|
||||
Game: &Game{ID: "game-id", Name: "game-name"},
|
||||
})
|
||||
if err := service.SendMinuteWatched(context.Background(), server.URL, payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteClient(baseURL string, client *http.Client) *http.Client {
|
||||
copy := *client
|
||||
copy.Transport = rewriteTransport{baseURL: baseURL, next: client.Transport}
|
||||
return ©
|
||||
}
|
||||
|
||||
type rewriteTransport struct {
|
||||
baseURL string
|
||||
next http.RoundTripper
|
||||
}
|
||||
|
||||
func (t rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
if strings.HasPrefix(r.URL.Host, "usher.ttvnw.net") || strings.HasPrefix(r.URL.Host, "assets.twitch.tv") || strings.HasPrefix(r.URL.Host, "static.twitchcdn.net") {
|
||||
target, err := http.NewRequest(http.MethodGet, t.baseURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.URL.Scheme = target.URL.Scheme
|
||||
r.URL.Host = target.URL.Host
|
||||
}
|
||||
if next := t.next; next != nil {
|
||||
return next.RoundTrip(r)
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user