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 a8
;;; Bearer access token.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-string17
build-repeated-params ensure-list)18
(sigil http client))20
(export ;; Client21
twitch-client22
twitch-client?23
twitch-client-client-id24
twitch-client-access-token25
twitch-client-base-url27
;; Records — channel28
twitch-channel29
twitch-channel?30
twitch-channel-id31
twitch-channel-name32
twitch-channel-game-name33
twitch-channel-game-id34
twitch-channel-title35
twitch-channel-tags37
;; Records — stream38
twitch-stream39
twitch-stream?40
twitch-stream-id41
twitch-stream-user-id42
twitch-stream-user-name43
twitch-stream-game-name44
twitch-stream-title45
twitch-stream-viewer-count46
twitch-stream-started-at48
;; Records — user49
twitch-user50
twitch-user?51
twitch-user-id52
twitch-user-login53
twitch-user-display-name54
twitch-user-type55
twitch-user-broadcaster-type56
twitch-user-description57
twitch-user-profile-image-url58
twitch-user-created-at60
;; Shared helpers (for sub-modules)61
twitch-auth-headers62
twitch-api-url63
twitch-get/json64
twitch-post/json65
twitch-put/json66
twitch-patch/json67
twitch-delete/json68
build-repeated-params69
ensure-list70
check-twitch-response71
check-twitch-response/raw73
;; Parsing74
parse-channel75
parse-stream76
parse-user77
parse-pagination79
;; Pagination80
twitch-paginate82
;; API functions83
twitch-channel-info84
twitch-modify-channel85
twitch-streams86
twitch-stream-key87
twitch-search-categories88
twitch-search-channels89
twitch-users)91
(begin93
;; ---------------------------------------------------------------94
;; Records95
;; ---------------------------------------------------------------97
(define-struct twitch-client98
(client-id)99
(access-token)100
(base-url default: "https://api.twitch.tv/helix"))102
(define-struct twitch-channel103
(id)104
(name default: "")105
(game-name default: "")106
(game-id default: "")107
(title default: "")108
(tags default: '()))110
(define-struct twitch-stream111
(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-user120
(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 helpers131
;; ---------------------------------------------------------------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-response144
(make-response-checker145
name: "Twitch API"146
handlers: (list147
(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-response162
(http-get url headers: (twitch-auth-headers client))))164
;;; Authenticated JSON POST request.165
(define (twitch-post/json client url body)166
(check-twitch-response167
(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-response174
(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-response181
(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-response188
(http-delete url headers: (twitch-auth-headers client))))190
;; ---------------------------------------------------------------191
;; Response parsing192
;; ---------------------------------------------------------------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-channel203
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-stream217
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-user228
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 helper239
;; ---------------------------------------------------------------241
;;; Generic cursor-based paginator.242
;;; Fetches all pages from a Twitch endpoint and returns a flat243
;;; list of parsed items.244
;;;245
;;; fetch-fn: (lambda (cursor) ...) — returns raw API response dict246
;;; parse-fn: (lambda (item) ...) — parses a single item from the247
;;; data array248
;;; 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 functions266
;; ---------------------------------------------------------------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-append275
(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 with288
;;; 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->array313
(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-append320
(twitch-api-url client "channels")321
(build-query-string322
(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 strings328
;;; user-login: string or list of strings329
;;; game-id: string or list of strings330
;;; first: number (1-100, default: 20)331
;;; after: pagination cursor332
(define (twitch-streams client . rest)333
(let ((opts (if (null? rest) #{} (car rest))))334
(let* ((params (list335
(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-append342
(twitch-api-url client "streams") query-str))343
(has-params (not (string=? query-str "")))344
;; Collect repeated params for multi-value fields345
(repeated '())346
(repeated (let ((uid (dict-ref opts user-id: #f)))347
(if uid348
(cons (build-repeated-params349
"user_id" (ensure-list uid))350
repeated)351
repeated)))352
(repeated (let ((login (dict-ref opts user-login: #f)))353
(if login354
(cons (build-repeated-params355
"user_login" (ensure-list login))356
repeated)357
repeated)))358
(repeated (let ((gid (dict-ref opts game-id: #f)))359
(if gid360
(cons (build-repeated-params361
"game_id" (ensure-list gid))362
repeated)363
repeated)))364
(url (if (null? repeated)365
base-url366
(string-append base-url367
(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-append377
(twitch-api-url client "streams" "key")378
(build-query-string379
(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 (list391
(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-append397
(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 (list407
(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-append415
(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 ids428
(cons (build-repeated-params "id" (ensure-list ids))429
parts)430
parts)))431
(parts (let ((logins (dict-ref opts login: #f)))432
(if logins433
(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
))