// Package twitch polls the Twitch Helix API to track whether a channel is live. package twitch import ( "encoding/json" "fmt" "log" "net/http" "net/url" "sync" "sync/atomic" "time" ) // Checker polls Twitch and exposes whether the channel is currently live. type Checker struct { clientID string clientSecret string channel string live atomic.Bool token string tokenExpiry time.Time mu sync.Mutex http *http.Client } // New creates a Checker for the given channel. func New(clientID, clientSecret, channel string) *Checker { return &Checker{ clientID: clientID, clientSecret: clientSecret, channel: channel, http: &http.Client{Timeout: 10 * time.Second}, } } // Start begins background polling at the given interval. func (c *Checker) Start(interval time.Duration) { go func() { for { if err := c.check(); err != nil { log.Printf("twitch: %v", err) } time.Sleep(interval) } }() } // IsLive reports whether the channel is currently streaming. func (c *Checker) IsLive() bool { return c.live.Load() } // Channel returns the configured channel name. func (c *Checker) Channel() string { return c.channel } func (c *Checker) check() error { token, err := c.accessToken() if err != nil { return fmt.Errorf("access token: %w", err) } req, err := http.NewRequest("GET", "https://api.twitch.tv/helix/streams?user_login="+url.QueryEscape(c.channel), nil) if err != nil { return err } req.Header.Set("Client-Id", c.clientID) req.Header.Set("Authorization", "Bearer "+token) resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("streams request: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { // Token may have been revoked; clear it and retry next tick. c.mu.Lock() c.token = "" c.mu.Unlock() return fmt.Errorf("streams: 401 unauthorized, will refresh token") } if resp.StatusCode != http.StatusOK { return fmt.Errorf("streams: %s", resp.Status) } var body struct { Data []struct { Type string `json:"type"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return fmt.Errorf("decode: %w", err) } wasLive := c.live.Load() isLive := len(body.Data) > 0 && body.Data[0].Type == "live" c.live.Store(isLive) if isLive != wasLive { if isLive { log.Printf("twitch: %s went live", c.channel) } else { log.Printf("twitch: %s went offline", c.channel) } } return nil } // accessToken returns a cached app access token, refreshing if expired. func (c *Checker) accessToken() (string, error) { c.mu.Lock() defer c.mu.Unlock() if c.token != "" && time.Now().Before(c.tokenExpiry) { return c.token, nil } resp, err := c.http.PostForm("https://id.twitch.tv/oauth2/token", url.Values{ "client_id": {c.clientID}, "client_secret": {c.clientSecret}, "grant_type": {"client_credentials"}, }) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("token: %s", resp.Status) } var body struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return "", err } c.token = body.AccessToken // Refresh 5 minutes before actual expiry. c.tokenExpiry = time.Now().Add(time.Duration(body.ExpiresIn)*time.Second - 5*time.Minute) return c.token, nil }