all repos

rss-tools @ 58cc1bd492af31e6c9acf65e690474c8c1edf164

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
twitch: add channel link as feed site url, 4 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
		WithLink("alternate", "https://twitch.tv/"+name)
97
	if stream != nil {
98
		feed.Add(entryFromStream(stream))
99
	}
100
	if err := feed.Render(w); err != nil {
101
		http.Error(w, err.Error(), http.StatusInternalServerError)
102
	}
103
}
104
105
func (tf *twitchfeed) handleTokenStatus(w http.ResponseWriter, r *http.Request) {
106
	tf.mu.Lock()
107
	tok := tf.token
108
	exp := tf.tokenExp
109
	tf.mu.Unlock()
110
111
	info := map[string]any{
112
		"client_id_set":     tf.clientID != "",
113
		"secret_configured": tf.clientSecret != "",
114
		"token_cached":      tok != "",
115
	}
116
	if tok != "" {
117
		info["token_length"] = len(tok)
118
		info["expires_at"] = exp.Format(time.RFC3339)
119
		info["expired"] = time.Now().After(exp)
120
		if len(tok) > 8 {
121
			info["preview"] = tok[:4] + "..." + tok[len(tok)-4:]
122
		}
123
	}
124
	w.Header().Set("Content-Type", "application/json")
125
	json.NewEncoder(w).Encode(info)
126
}
127
128
func (tf *twitchfeed) do(ctx context.Context, login string) (*twitchStream, error) {
129
	tok, err := tf.getToken(ctx)
130
	if err != nil {
131
		return nil, fmt.Errorf("auth: %w", err)
132
	}
133
134
	stream, err := tf.fetchStream(ctx, tok, login)
135
	if err != nil {
136
		return nil, err
137
	}
138
	return stream, nil
139
}
140
141
func (tf *twitchfeed) getToken(ctx context.Context) (string, error) {
142
	tf.mu.Lock()
143
	hasToken := tf.token != ""
144
	expired := time.Now().After(tf.tokenExp)
145
	tf.mu.Unlock()
146
147
	if hasToken && !expired {
148
		return tf.token, nil
149
	}
150
151
	return tf.authenticate(ctx)
152
}
153
154
func (tf *twitchfeed) authenticate(ctx context.Context) (string, error) {
155
	v := url.Values{
156
		"client_id":     {tf.clientID},
157
		"client_secret": {tf.clientSecret},
158
		"grant_type":    {"client_credentials"},
159
	}
160
161
	resp, err := tf.client.PostForm(twitchAuthBase+"/oauth2/token", v)
162
	if err != nil {
163
		return "", fmt.Errorf("token request: %w", err)
164
	}
165
	defer resp.Body.Close()
166
167
	if resp.StatusCode != http.StatusOK {
168
		body, _ := io.ReadAll(resp.Body)
169
		return "", fmt.Errorf("token endpoint: %s %s", resp.Status, body)
170
	}
171
172
	var auth twitchAuthResponse
173
	if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil {
174
		return "", fmt.Errorf("token decode: %w", err)
175
	}
176
177
	tf.mu.Lock()
178
	tf.token = auth.AccessToken
179
	tf.tokenExp = time.Now().Add(time.Duration(auth.ExpiresIn)*time.Second - tokenExpiryBuffer)
180
	tf.mu.Unlock()
181
182
	slog.Info("twitch: new token obtained", "expires_in", auth.ExpiresIn)
183
	return auth.AccessToken, nil
184
}
185
186
func (tf *twitchfeed) fetchStream(ctx context.Context, token, login string) (*twitchStream, error) {
187
	u := tf.baseURL + "/helix/streams?user_login=" + login
188
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
189
	if err != nil {
190
		return nil, fmt.Errorf("creating request: %w", err)
191
	}
192
	req.Header.Set("Authorization", "Bearer "+token)
193
	req.Header.Set("Client-Id", tf.clientID)
194
195
	resp, err := tf.client.Do(req)
196
	if err != nil {
197
		return nil, fmt.Errorf("fetching streams: %w", err)
198
	}
199
	defer resp.Body.Close()
200
201
	if resp.StatusCode == http.StatusUnauthorized {
202
		tf.mu.Lock()
203
		tf.token = ""
204
		tf.mu.Unlock()
205
		return nil, fmt.Errorf("twitch API: %s", resp.Status)
206
	}
207
	if resp.StatusCode != http.StatusOK {
208
		return nil, fmt.Errorf("twitch API: %s", resp.Status)
209
	}
210
211
	var result twitchStreamsResponse
212
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
213
		return nil, fmt.Errorf("decoding response: %w", err)
214
	}
215
	if len(result.Data) == 0 {
216
		return nil, nil
217
	}
218
	return &result.Data[0], nil
219
}
220
221
func entryFromStream(s *twitchStream) *atom.Entry {
222
	title := fmt.Sprintf("%s — %s", s.UserName, s.Title)
223
	if len(title) > 80 {
224
		title = title[:80] + "…"
225
	}
226
227
	thumbnail := strings.NewReplacer("{width}", "640", "{height}", "360").Replace(s.ThumbnailURL)
228
229
	startedAt, _ := time.Parse(time.RFC3339, s.StartedAt)
230
231
	content := fmt.Sprintf(
232
		"<p>Playing <b>%s</b> · %d viewers</p>",
233
		html.EscapeString(s.GameName), s.ViewerCount,
234
	)
235
	if thumbnail != "" {
236
		content += fmt.Sprintf(`<p><img src="%s" alt="%s stream"/></p>`,
237
			html.EscapeString(thumbnail), html.EscapeString(s.UserName))
238
	}
239
240
	login := s.UserLogin
241
	if login == "" {
242
		login = s.UserName
243
	}
244
245
	return &atom.Entry{
246
		ID:      "twitch-stream-" + s.ID,
247
		Title:   title,
248
		Content: atom.NewText(content, "html"),
249
		Updated: atom.Time(startedAt),
250
		Link: []atom.Link{
251
			{Rel: "alternate", Href: "https://twitch.tv/" + login},
252
		},
253
	}
254
}