rss-tools/sources/twitch/twitch.go (view raw)
| 1 | package twitch |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "html" |
| 8 | "io" |
| 9 | "log/slog" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "strings" |
| 13 | "sync" |
| 14 | "time" |
| 15 | |
| 16 | "olexsmir.xyz/rss-tools/app" |
| 17 | "olexsmir.xyz/rss-tools/app/atom" |
| 18 | ) |
| 19 | |
| 20 | var defaultHTTPClient = &http.Client{Timeout: 10 * time.Second} |
| 21 | |
| 22 | const ( |
| 23 | twitchAPIBase = "https://api.twitch.tv" |
| 24 | tokenExpiryBuffer = 5 * time.Minute |
| 25 | ) |
| 26 | |
| 27 | var twitchAuthBase = "https://id.twitch.tv" |
| 28 | |
| 29 | type twitchStream struct { |
| 30 | ID string `json:"id"` |
| 31 | UserLogin string `json:"user_login"` |
| 32 | UserName string `json:"user_name"` |
| 33 | GameName string `json:"game_name"` |
| 34 | Title string `json:"title"` |
| 35 | ViewerCount int `json:"viewer_count"` |
| 36 | StartedAt string `json:"started_at"` |
| 37 | ThumbnailURL string `json:"thumbnail_url"` |
| 38 | } |
| 39 | |
| 40 | type twitchStreamsResponse struct { |
| 41 | Data []twitchStream `json:"data"` |
| 42 | } |
| 43 | |
| 44 | type twitchAuthResponse struct { |
| 45 | AccessToken string `json:"access_token"` |
| 46 | ExpiresIn int `json:"expires_in"` |
| 47 | TokenType string `json:"token_type"` |
| 48 | } |
| 49 | |
| 50 | type twitchfeed struct { |
| 51 | clientID string |
| 52 | clientSecret string |
| 53 | baseURL string |
| 54 | client *http.Client |
| 55 | |
| 56 | mu sync.Mutex |
| 57 | token string |
| 58 | tokenExp time.Time |
| 59 | } |
| 60 | |
| 61 | func Register(a *app.App) error { |
| 62 | if a.Config.TwitchClientID == "" || a.Config.TwitchClientSecret == "" { |
| 63 | a.Logger.Info("twitch: client_id or client_secret not set, skipping") |
| 64 | return nil |
| 65 | } |
| 66 | |
| 67 | tf := &twitchfeed{ |
| 68 | clientID: a.Config.TwitchClientID, |
| 69 | clientSecret: a.Config.TwitchClientSecret, |
| 70 | baseURL: twitchAPIBase, |
| 71 | client: defaultHTTPClient, |
| 72 | } |
| 73 | |
| 74 | a.Route("GET /twitch/__oauth2", tf.handleTokenStatus) |
| 75 | a.Route("GET /twitch/{name}", tf.handleStreams) |
| 76 | |
| 77 | a.Logger.Info("twitch source registered") |
| 78 | return nil |
| 79 | } |
| 80 | |
| 81 | func (tf *twitchfeed) handleStreams(w http.ResponseWriter, r *http.Request) { |
| 82 | name := r.PathValue("name") |
| 83 | if name == "" || name == "__oauth2" { |
| 84 | http.Error(w, "missing streamer name", http.StatusBadRequest) |
| 85 | return |
| 86 | } |
| 87 | |
| 88 | stream, err := tf.do(r.Context(), name) |
| 89 | if err != nil { |
| 90 | slog.Error("twitch: fetch failed", "user", name, "err", err) |
| 91 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 92 | return |
| 93 | } |
| 94 | |
| 95 | feed := atom.NewFeed("Twitch: "+name, "twitch:"+name) |
| 96 | if stream != nil { |
| 97 | feed.Add(entryFromStream(stream)) |
| 98 | } |
| 99 | if err := feed.Render(w); err != nil { |
| 100 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func (tf *twitchfeed) handleTokenStatus(w http.ResponseWriter, r *http.Request) { |
| 105 | tf.mu.Lock() |
| 106 | tok := tf.token |
| 107 | exp := tf.tokenExp |
| 108 | tf.mu.Unlock() |
| 109 | |
| 110 | info := map[string]any{ |
| 111 | "client_id_set": tf.clientID != "", |
| 112 | "secret_configured": tf.clientSecret != "", |
| 113 | "token_cached": tok != "", |
| 114 | } |
| 115 | if tok != "" { |
| 116 | info["token_length"] = len(tok) |
| 117 | info["expires_at"] = exp.Format(time.RFC3339) |
| 118 | info["expired"] = time.Now().After(exp) |
| 119 | if len(tok) > 8 { |
| 120 | info["preview"] = tok[:4] + "..." + tok[len(tok)-4:] |
| 121 | } |
| 122 | } |
| 123 | w.Header().Set("Content-Type", "application/json") |
| 124 | json.NewEncoder(w).Encode(info) |
| 125 | } |
| 126 | |
| 127 | func (tf *twitchfeed) do(ctx context.Context, login string) (*twitchStream, error) { |
| 128 | tok, err := tf.getToken(ctx) |
| 129 | if err != nil { |
| 130 | return nil, fmt.Errorf("auth: %w", err) |
| 131 | } |
| 132 | |
| 133 | stream, err := tf.fetchStream(ctx, tok, login) |
| 134 | if err != nil { |
| 135 | return nil, err |
| 136 | } |
| 137 | return stream, nil |
| 138 | } |
| 139 | |
| 140 | func (tf *twitchfeed) getToken(ctx context.Context) (string, error) { |
| 141 | tf.mu.Lock() |
| 142 | hasToken := tf.token != "" |
| 143 | expired := time.Now().After(tf.tokenExp) |
| 144 | tf.mu.Unlock() |
| 145 | |
| 146 | if hasToken && !expired { |
| 147 | return tf.token, nil |
| 148 | } |
| 149 | |
| 150 | return tf.authenticate(ctx) |
| 151 | } |
| 152 | |
| 153 | func (tf *twitchfeed) authenticate(ctx context.Context) (string, error) { |
| 154 | v := url.Values{ |
| 155 | "client_id": {tf.clientID}, |
| 156 | "client_secret": {tf.clientSecret}, |
| 157 | "grant_type": {"client_credentials"}, |
| 158 | } |
| 159 | |
| 160 | resp, err := tf.client.PostForm(twitchAuthBase+"/oauth2/token", v) |
| 161 | if err != nil { |
| 162 | return "", fmt.Errorf("token request: %w", err) |
| 163 | } |
| 164 | defer resp.Body.Close() |
| 165 | |
| 166 | if resp.StatusCode != http.StatusOK { |
| 167 | body, _ := io.ReadAll(resp.Body) |
| 168 | return "", fmt.Errorf("token endpoint: %s %s", resp.Status, body) |
| 169 | } |
| 170 | |
| 171 | var auth twitchAuthResponse |
| 172 | if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil { |
| 173 | return "", fmt.Errorf("token decode: %w", err) |
| 174 | } |
| 175 | |
| 176 | tf.mu.Lock() |
| 177 | tf.token = auth.AccessToken |
| 178 | tf.tokenExp = time.Now().Add(time.Duration(auth.ExpiresIn)*time.Second - tokenExpiryBuffer) |
| 179 | tf.mu.Unlock() |
| 180 | |
| 181 | slog.Info("twitch: new token obtained", "expires_in", auth.ExpiresIn) |
| 182 | return auth.AccessToken, nil |
| 183 | } |
| 184 | |
| 185 | func (tf *twitchfeed) fetchStream(ctx context.Context, token, login string) (*twitchStream, error) { |
| 186 | u := tf.baseURL + "/helix/streams?user_login=" + login |
| 187 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) |
| 188 | if err != nil { |
| 189 | return nil, fmt.Errorf("creating request: %w", err) |
| 190 | } |
| 191 | req.Header.Set("Authorization", "Bearer "+token) |
| 192 | req.Header.Set("Client-Id", tf.clientID) |
| 193 | |
| 194 | resp, err := tf.client.Do(req) |
| 195 | if err != nil { |
| 196 | return nil, fmt.Errorf("fetching streams: %w", err) |
| 197 | } |
| 198 | defer resp.Body.Close() |
| 199 | |
| 200 | if resp.StatusCode == http.StatusUnauthorized { |
| 201 | tf.mu.Lock() |
| 202 | tf.token = "" |
| 203 | tf.mu.Unlock() |
| 204 | return nil, fmt.Errorf("twitch API: %s", resp.Status) |
| 205 | } |
| 206 | if resp.StatusCode != http.StatusOK { |
| 207 | return nil, fmt.Errorf("twitch API: %s", resp.Status) |
| 208 | } |
| 209 | |
| 210 | var result twitchStreamsResponse |
| 211 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { |
| 212 | return nil, fmt.Errorf("decoding response: %w", err) |
| 213 | } |
| 214 | if len(result.Data) == 0 { |
| 215 | return nil, nil |
| 216 | } |
| 217 | return &result.Data[0], nil |
| 218 | } |
| 219 | |
| 220 | func entryFromStream(s *twitchStream) *atom.Entry { |
| 221 | title := fmt.Sprintf("%s — %s", s.UserName, s.Title) |
| 222 | if len(title) > 80 { |
| 223 | title = title[:80] + "…" |
| 224 | } |
| 225 | |
| 226 | thumbnail := strings.NewReplacer("{width}", "640", "{height}", "360").Replace(s.ThumbnailURL) |
| 227 | |
| 228 | startedAt, _ := time.Parse(time.RFC3339, s.StartedAt) |
| 229 | |
| 230 | content := fmt.Sprintf( |
| 231 | "<p>Playing <b>%s</b> · %d viewers</p>", |
| 232 | html.EscapeString(s.GameName), s.ViewerCount, |
| 233 | ) |
| 234 | if thumbnail != "" { |
| 235 | content += fmt.Sprintf(`<p><img src="%s" alt="%s stream"/></p>`, |
| 236 | html.EscapeString(thumbnail), html.EscapeString(s.UserName)) |
| 237 | } |
| 238 | |
| 239 | login := s.UserLogin |
| 240 | if login == "" { |
| 241 | login = s.UserName |
| 242 | } |
| 243 | |
| 244 | return &atom.Entry{ |
| 245 | ID: "twitch-stream-" + s.ID, |
| 246 | Title: title, |
| 247 | Content: atom.NewText(content, "html"), |
| 248 | Updated: atom.Time(startedAt), |
| 249 | Link: []atom.Link{ |
| 250 | {Rel: "alternate", Href: "https://twitch.tv/" + login}, |
| 251 | }, |
| 252 | } |
| 253 | } |