AtlatestRepositorysigil-twitch

sigil-twitch / tree / srctwitch.sgl

1;;; (twitch) - Twitch Helix API client library.
2;;;
3;;; Core module providing authentication, HTTP helpers, record types,
4;;; and functions for channel management, stream info, user lookup,
5;;; search, and cursor-based pagination.
6;;;
7;;; All Helix API requests require both a Client-Id header and a
8;;; Bearer access token.
9
10(define-library (twitch)
11 (import (sigil core)
12 (sigil dict)
13 (sigil string)
14 (sigil struct)
15 (sigil json)
16 (only (sigil http) url-encode-value build-query-string
17 build-repeated-params ensure-list)
18 (sigil http client))
20 (export ;; Client
21 twitch-client
22 twitch-client?
23 twitch-client-client-id
24 twitch-client-access-token
25 twitch-client-base-url
27 ;; Records — channel
28 twitch-channel
29 twitch-channel?
30 twitch-channel-id
31 twitch-channel-name
32 twitch-channel-game-name
33 twitch-channel-game-id
34 twitch-channel-title
35 twitch-channel-tags
37 ;; Records — stream
38 twitch-stream
39 twitch-stream?
40 twitch-stream-id
41 twitch-stream-user-id
42 twitch-stream-user-name
43 twitch-stream-game-name
44 twitch-stream-title
45 twitch-stream-viewer-count
46 twitch-stream-started-at
48 ;; Records — user
49 twitch-user
50 twitch-user?
51 twitch-user-id
52 twitch-user-login
53 twitch-user-display-name
54 twitch-user-type
55 twitch-user-broadcaster-type
56 twitch-user-description
57 twitch-user-profile-image-url
58 twitch-user-created-at
60 ;; Shared helpers (for sub-modules)
61 twitch-auth-headers
62 twitch-api-url
63 twitch-get/json
64 twitch-post/json
65 twitch-put/json
66 twitch-patch/json
67 twitch-delete/json
68 build-repeated-params
69 ensure-list
70 check-twitch-response
71 check-twitch-response/raw
73 ;; Parsing
74 parse-channel
75 parse-stream
76 parse-user
77 parse-pagination
79 ;; Pagination
80 twitch-paginate
82 ;; API functions
83 twitch-channel-info
84 twitch-modify-channel
85 twitch-streams
86 twitch-stream-key
87 twitch-search-categories
88 twitch-search-channels
89 twitch-users)
91 (begin
93 ;; ---------------------------------------------------------------
94 ;; Records
95 ;; ---------------------------------------------------------------
97 (define-struct twitch-client
98 (client-id)
99 (access-token)
100 (base-url default: "https://api.twitch.tv/helix"))
102 (define-struct twitch-channel
103 (id)
104 (name default: "")
105 (game-name default: "")
106 (game-id default: "")
107 (title default: "")
108 (tags default: '()))
110 (define-struct twitch-stream
111 (id)
112 (user-id default: "")
113 (user-name default: "")
114 (game-name default: "")
115 (title default: "")
116 (viewer-count default: 0)
117 (started-at default: #f))
119 (define-struct twitch-user
120 (id)
121 (login default: "")
122 (display-name default: "")
123 (type default: "")
124 (broadcaster-type default: "")
125 (description default: "")
126 (profile-image-url default: "")
127 (created-at default: #f))
129 ;; ---------------------------------------------------------------
130 ;; Internal helpers
131 ;; ---------------------------------------------------------------
133 ;;; Build auth headers with both Client-Id and Bearer token.
134 (define (twitch-auth-headers client)
135 #{ authorization: (string-append "Bearer " (twitch-client-access-token client))
136 client-id: (twitch-client-client-id client) })
138 (define (twitch-api-url client . parts)
139 (apply build-api-url (twitch-client-base-url client) parts))
142 ;;; Response checker with Twitch-specific error context.
143 (define check-twitch-response
144 (make-response-checker
145 name: "Twitch API"
146 handlers: (list
147 (cons 401 "Access token may be expired or invalid.")
148 (cons 403 "Insufficient permissions or missing scope.")
149 (cons 429 "Check Ratelimit-Reset header and retry."))))
151 ;;; Check an HTTP response for errors and return the raw body string.
152 ;;; Unlike check-twitch-response, returns unparsed body on success.
153 (define (check-twitch-response/raw response)
154 (if (or (not (http-response? response))
155 (>= (http-response-status response) 400))
156 (check-twitch-response response)
157 (http-response-body response)))
159 ;;; Authenticated JSON GET request.
160 (define (twitch-get/json client url)
161 (check-twitch-response
162 (http-get url headers: (twitch-auth-headers client))))
164 ;;; Authenticated JSON POST request.
165 (define (twitch-post/json client url body)
166 (check-twitch-response
167 (http-post url (if (string? body) body (json-encode body))
168 headers: (dict-merge (twitch-auth-headers client)
169 #{ content-type: "application/json" }))))
171 ;;; Authenticated JSON PUT request.
172 (define (twitch-put/json client url body)
173 (check-twitch-response
174 (http-put url (if (string? body) body (json-encode body))
175 headers: (dict-merge (twitch-auth-headers client)
176 #{ content-type: "application/json" }))))
178 ;;; Authenticated JSON PATCH request.
179 (define (twitch-patch/json client url body)
180 (check-twitch-response
181 (http-patch url (if (string? body) body (json-encode body))
182 headers: (dict-merge (twitch-auth-headers client)
183 #{ content-type: "application/json" }))))
185 ;;; Authenticated DELETE request.
186 (define (twitch-delete/json client url)
187 (check-twitch-response
188 (http-delete url headers: (twitch-auth-headers client))))
190 ;; ---------------------------------------------------------------
191 ;; Response parsing
192 ;; ---------------------------------------------------------------
194 ;;; Extract pagination cursor from a Twitch list response.
195 ;;; Returns the cursor string or #f if no more pages.
196 (define (parse-pagination data)
197 (let ((pag (dict-ref data pagination: #{})))
198 (dict-ref pag cursor: #f)))
200 ;;; Parse a channel resource into a twitch-channel record.
201 (define (parse-channel data)
202 (twitch-channel
203 id: (dict-ref data broadcaster_id: "")
204 name: (dict-ref data broadcaster_name:
205 (dict-ref data broadcaster_login: ""))
206 game-name: (dict-ref data game_name: "")
207 game-id: (dict-ref data game_id: "")
208 title: (dict-ref data title: "")
209 tags: (let ((t (dict-ref data tags: #f)))
210 (if (and t (array? t))
211 (array->list t)
212 '()))))
214 ;;; Parse a stream resource into a twitch-stream record.
215 (define (parse-stream data)
216 (twitch-stream
217 id: (dict-ref data id:)
218 user-id: (dict-ref data user_id: "")
219 user-name: (dict-ref data user_name: "")
220 game-name: (dict-ref data game_name: "")
221 title: (dict-ref data title: "")
222 viewer-count: (dict-ref data viewer_count: 0)
223 started-at: (dict-ref data started_at: #f)))
225 ;;; Parse a user resource into a twitch-user record.
226 (define (parse-user data)
227 (twitch-user
228 id: (dict-ref data id:)
229 login: (dict-ref data login: "")
230 display-name: (dict-ref data display_name: "")
231 type: (dict-ref data type: "")
232 broadcaster-type: (dict-ref data broadcaster_type: "")
233 description: (dict-ref data description: "")
234 profile-image-url: (dict-ref data profile_image_url: "")
235 created-at: (dict-ref data created_at: #f)))
237 ;; ---------------------------------------------------------------
238 ;; Pagination helper
239 ;; ---------------------------------------------------------------
241 ;;; Generic cursor-based paginator.
242 ;;; Fetches all pages from a Twitch endpoint and returns a flat
243 ;;; list of parsed items.
244 ;;;
245 ;;; fetch-fn: (lambda (cursor) ...) — returns raw API response dict
246 ;;; parse-fn: (lambda (item) ...) — parses a single item from the
247 ;;; data array
248 ;;; Optional max-pages: limit the number of pages fetched (default: 100)
249 (define (twitch-paginate fetch-fn parse-fn . rest)
250 (let ((max-pages (if (null? rest) 100 (car rest))))
251 (let loop ((cursor #f) (acc '()) (page 0))
252 (if (>= page max-pages)
253 (apply append (reverse acc))
254 (let* ((data (fetch-fn cursor))
255 (items (dict-ref data data: #[]))
256 (parsed (map parse-fn (array->list items)))
257 (next-cursor (parse-pagination data))
258 (new-acc (cons parsed acc)))
259 (if (or (not next-cursor)
260 (string=? next-cursor ""))
261 (apply append (reverse new-acc))
262 (loop next-cursor new-acc (+ page 1))))))))
264 ;; ---------------------------------------------------------------
265 ;; API functions
266 ;; ---------------------------------------------------------------
268 ;;; Get channel information by broadcaster ID(s).
269 ;;; broadcaster-id: a single ID string or list of ID strings.
270 ;;; Returns a list of twitch-channel records.
271 (define (twitch-channel-info client broadcaster-id)
272 (let* ((ids (ensure-list broadcaster-id))
273 (id-params (build-repeated-params "broadcaster_id" ids))
274 (url (string-append
275 (twitch-api-url client "channels")
276 "?" id-params))
277 (data (twitch-get/json client url))
278 (items (dict-ref data data: #[])))
279 (map parse-channel (array->list items))))
281 ;;; Modify channel information (title, game, tags, etc.).
282 ;;; Requires channel:manage:broadcast scope.
283 ;;; updates is a dict with optional keys: title:, game-id:, tags:,
284 ;;; broadcaster-language:, is-branded-content:,
285 ;;; content-classification-labels:.
286 ;;;
287 ;;; content-classification-labels: is a list of dicts, each with
288 ;;; id: (string) and is-enabled: (boolean).
289 ;;; Example: (list #{ id: "MatureGame" is-enabled: #t })
290 (define (twitch-modify-channel client broadcaster-id updates)
291 (let* ((body (let ((b #{}))
292 (let* ((b (if (dict-ref updates title: #f)
293 (dict-set b title: (dict-ref updates title:))
294 b))
295 (b (if (dict-ref updates game-id: #f)
296 (dict-set b game_id: (dict-ref updates game-id:))
297 b))
298 (b (if (dict-ref updates tags: #f)
299 (dict-set b tags: (list->array (dict-ref updates tags:)))
300 b))
301 (b (if (dict-ref updates broadcaster-language: #f)
302 (dict-set b broadcaster_language:
303 (dict-ref updates broadcaster-language:))
304 b))
305 (b (if (not (eq? (dict-ref updates is-branded-content: 'unset)
306 'unset))
307 (dict-set b is_branded_content:
308 (dict-ref updates is-branded-content:))
309 b))
310 (b (if (dict-ref updates content-classification-labels: #f)
311 (dict-set b content_classification_labels:
312 (list->array
313 (map (lambda (label)
314 #{ id: (dict-ref label id:)
315 is_enabled: (dict-ref label is-enabled:) })
316 (dict-ref updates content-classification-labels:))))
317 b)))
318 b)))
319 (url (string-append
320 (twitch-api-url client "channels")
321 (build-query-string
322 (list (cons "broadcaster_id" broadcaster-id))))))
323 (twitch-patch/json client url body)))
325 ;;; Get live streams. Filter by user IDs, user logins, game IDs, or language.
326 ;;; Optional opts dict:
327 ;;; user-id: string or list of strings
328 ;;; user-login: string or list of strings
329 ;;; game-id: string or list of strings
330 ;;; first: number (1-100, default: 20)
331 ;;; after: pagination cursor
332 (define (twitch-streams client . rest)
333 (let ((opts (if (null? rest) #{} (car rest))))
334 (let* ((params (list
335 (cons "first"
336 (if (dict-ref opts first: #f)
337 (number->string (dict-ref opts first:))
338 #f))
339 (cons "after" (dict-ref opts after: #f))))
340 (query-str (build-query-string params))
341 (base-url (string-append
342 (twitch-api-url client "streams") query-str))
343 (has-params (not (string=? query-str "")))
344 ;; Collect repeated params for multi-value fields
345 (repeated '())
346 (repeated (let ((uid (dict-ref opts user-id: #f)))
347 (if uid
348 (cons (build-repeated-params
349 "user_id" (ensure-list uid))
350 repeated)
351 repeated)))
352 (repeated (let ((login (dict-ref opts user-login: #f)))
353 (if login
354 (cons (build-repeated-params
355 "user_login" (ensure-list login))
356 repeated)
357 repeated)))
358 (repeated (let ((gid (dict-ref opts game-id: #f)))
359 (if gid
360 (cons (build-repeated-params
361 "game_id" (ensure-list gid))
362 repeated)
363 repeated)))
364 (url (if (null? repeated)
365 base-url
366 (string-append base-url
367 (if has-params "&" "?")
368 (string-join (reverse repeated) "&"))))
369 (data (twitch-get/json client url))
370 (items (dict-ref data data: #[])))
371 (map parse-stream (array->list items)))))
373 ;;; Get the stream key for a channel.
374 ;;; Requires channel:read:stream_key scope.
375 (define (twitch-stream-key client broadcaster-id)
376 (let* ((url (string-append
377 (twitch-api-url client "streams" "key")
378 (build-query-string
379 (list (cons "broadcaster_id" broadcaster-id)))))
380 (data (twitch-get/json client url))
381 (items (dict-ref data data: #[])))
382 (if (> (array-length items) 0)
383 (dict-ref (array-ref items 0) stream_key: #f)
384 #f)))
386 ;;; Search for categories/games.
387 ;;; Returns raw data array items with id, name, box_art_url.
388 (define (twitch-search-categories client query . rest)
389 (let ((opts (if (null? rest) #{} (car rest))))
390 (let* ((params (list
391 (cons "query" query)
392 (cons "first"
393 (number->string (dict-ref opts first: 20)))
394 (cons "after"
395 (dict-ref opts after: #f))))
396 (url (string-append
397 (twitch-api-url client "search" "categories")
398 (build-query-string params)))
399 (data (twitch-get/json client url)))
400 data)))
402 ;;; Search for channels.
403 ;;; Returns raw data with broadcaster info.
404 (define (twitch-search-channels client query . rest)
405 (let ((opts (if (null? rest) #{} (car rest))))
406 (let* ((params (list
407 (cons "query" query)
408 (cons "first"
409 (number->string (dict-ref opts first: 20)))
410 (cons "live_only"
411 (if (dict-ref opts live-only: #f) "true" #f))
412 (cons "after"
413 (dict-ref opts after: #f))))
414 (url (string-append
415 (twitch-api-url client "search" "channels")
416 (build-query-string params)))
417 (data (twitch-get/json client url)))
418 data)))
420 ;;; Get users by ID or login.
421 ;;; Pass id: (string or list) and/or login: (string or list).
422 (define (twitch-users client . rest)
423 (let ((opts (if (null? rest) #{} (car rest))))
424 (let* ((base-url (twitch-api-url client "users"))
425 (parts '())
426 (parts (let ((ids (dict-ref opts id: #f)))
427 (if ids
428 (cons (build-repeated-params "id" (ensure-list ids))
429 parts)
430 parts)))
431 (parts (let ((logins (dict-ref opts login: #f)))
432 (if logins
433 (cons (build-repeated-params "login"
434 (ensure-list logins))
435 parts)
436 parts)))
437 (query-str (if (null? parts) ""
438 (string-append "?"
439 (string-join (reverse parts) "&"))))
440 (url (string-append base-url query-str))
441 (data (twitch-get/json client url))
442 (items (dict-ref data data: #[])))
443 (map parse-user (array->list items)))))
445 ))