// service.go defines the Twitch domain lookups the rewritten app currently needs. // It wraps the lower-level GraphQL client with small typed methods for the viewer // identity and configured streamer resolution used to populate the terminal UI. 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 } // Service exposes the Twitch lookups the current app needs. type Service struct { GQL GQLClient HTTPClient *http.Client Session gql.Session } // Viewer is the authenticated Twitch account. type Viewer struct { ID string Login string } // Channel is the resolved Twitch channel identity for one streamer login. type Channel struct { ID string Login string } // StreamInfo describes whether a channel is live. type StreamInfo struct { Online bool } // PlaybackToken carries the Twitch playback token/signature pair for live HLS access. type PlaybackToken struct { Signature string 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 const ( StreamerLoading StreamerStatus = "loading" StreamerReady StreamerStatus = "ready" StreamerError StreamerStatus = "error" ) // StreamerEntry is the UI-facing state for one configured streamer. type StreamerEntry struct { ConfigLogin string Login string ChannelID string Live bool Status StreamerStatus Error string } // ErrStreamerNotFound is returned when Twitch has no channel for the requested login. var ErrStreamerNotFound = errors.New("streamer does not exist") // LoadingStreamerEntries seeds UI state from the normalized config logins. func LoadingStreamerEntries(logins []string) []StreamerEntry { entries := make([]StreamerEntry, 0, len(logins)) for _, login := range logins { entries = append(entries, StreamerEntry{ ConfigLogin: login, Status: StreamerLoading, }) } return entries } // CurrentUser resolves the authenticated viewer through Twitch GraphQL. func (s *Service) CurrentUser(ctx context.Context) (*Viewer, error) { var data struct { CurrentUser *struct { ID string `json:"id"` Login string `json:"login"` } `json:"currentUser"` } if err := s.GQL.Do(ctx, gql.CurrentUser(), &data); err != nil { return nil, err } if data.CurrentUser == nil || data.CurrentUser.ID == "" || data.CurrentUser.Login == "" { return nil, fmt.Errorf("current user response missing id or login") } return &Viewer{ ID: data.CurrentUser.ID, Login: data.CurrentUser.Login, }, nil } // ResolveStreamer resolves a configured streamer login into canonical Twitch identity. func (s *Service) ResolveStreamer(ctx context.Context, login string) (*Channel, error) { var data struct { User *struct { ID string `json:"id"` Login string `json:"login"` } `json:"user"` } if err := s.GQL.Do(ctx, gql.GetIDFromLogin(login), &data); err != nil { return nil, err } if data.User == nil || data.User.ID == "" { return nil, fmt.Errorf("%w: %s", ErrStreamerNotFound, login) } resolvedLogin := data.User.Login if resolvedLogin == "" { resolvedLogin = login } return &Channel{ID: data.User.ID, Login: resolvedLogin}, nil } // StreamInfo resolves whether the given channel ID is currently live. func (s *Service) StreamInfo(ctx context.Context, channelID string) (*StreamInfo, error) { var data struct { User *struct { Stream *struct { ID string `json:"id"` } `json:"stream"` } `json:"user"` } if err := s.GQL.Do(ctx, gql.WithIsStreamLiveQuery(channelID), &data); err != nil { return nil, err } if data.User == nil { return nil, fmt.Errorf("stream info missing user for channel %s", channelID) } if data.User.Stream == nil { return &StreamInfo{Online: false}, nil } return &StreamInfo{Online: true}, nil } // PlaybackAccessToken fetches the Twitch playback token needed to access a live stream. func (s *Service) PlaybackAccessToken(ctx context.Context, login string) (*PlaybackToken, error) { var data struct { StreamPlaybackAccessToken *struct { Signature string `json:"signature"` Value string `json:"value"` } `json:"streamPlaybackAccessToken"` } if err := s.GQL.Do(ctx, gql.PlaybackAccessToken(login), &data); err != nil { return nil, err } if data.StreamPlaybackAccessToken == nil || data.StreamPlaybackAccessToken.Signature == "" || data.StreamPlaybackAccessToken.Value == "" { return nil, fmt.Errorf("playback access token missing signature or value for %s", login) } return &PlaybackToken{ Signature: data.StreamPlaybackAccessToken.Signature, 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 }