all repos

rss-tools @ 70f8cb6

get rss feed from sources that(i need and) dont provide one

rss-tools/sources/twitch/twitch.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
add twtich souce, 10 days ago
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
}