Commit68588277Recorded25 Mar 2026Repositorysigil-youtube

Implement YouTube Data API v3 client library

Message

Five modules covering the full YouTube API surface: - (sigil youtube) — core client, video/channel management, search, thumbnails - (sigil youtube upload) — resumable upload protocol with chunked transfer - (sigil youtube analytics) — Analytics API v2 queries - (sigil youtube live) — broadcast/stream lifecycle management - (sigil youtube playlist) — playlist and playlist item CRUD

All functions document their quota costs. 47 tests with fixture data.

Changed
 .gitignore                      |   1 +
 README.md                       | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/sigil/youtube.sgl           | 420 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/sigil/youtube/analytics.sgl | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/sigil/youtube/live.sgl      | 235 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/sigil/youtube/playlist.sgl  | 261 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/sigil/youtube/upload.sgl    | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/analytics-test.sgl         |  22 +++++++++
 test/live-test.sgl              | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/playlist-test.sgl          | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/upload-test.sgl            |  43 +++++++++++++++++
 test/youtube-test.sgl           | 296 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 12 files changed, 2129 insertions(+), 1 deletion(-)
Diff
.gitignoreadded
@@ -0,0 +1 @@
+1
build/
README.mdmodified
@@ -1,3 +1,184 @@
1
# sigil-youtube
2
3
YouTube Data API v3 client library for Sigil
3
No newline at end of file
+4
YouTube Data API v3 client library for [Sigil](https://codeberg.org/sigil/sigil).
+5
+6
Provides a complete interface to YouTube's video management, upload,
+7
analytics, livestreaming, and playlist APIs.
+8
+9
## Features
+10
+11
- **Video management** — list, update, delete videos; set custom thumbnails
+12
- **Resumable uploads** — chunked upload protocol with resume-after-failure support
+13
- **Analytics** — query views, watch time, subscribers, traffic sources, and top videos
+14
- **Livestreaming** — full broadcast lifecycle: create, bind streams, transition states
+15
- **Playlists** — CRUD for playlists and playlist items with reordering
+16
- **Search** — full-text search across YouTube (quota-expensive; prefer known IDs)
+17
- **Channel info** — retrieve channel metadata, subscriber counts, uploads playlist
+18
- **Quota-aware** — all functions document their quota cost in comments
+19
+20
## Modules
+21
+22
| Module | Description |
+23
|--------|-------------|
+24
| `(sigil youtube)` | Core client, records, HTTP helpers, video/channel/search APIs |
+25
| `(sigil youtube upload)` | Resumable upload protocol with chunked transfer |
+26
| `(sigil youtube analytics)` | YouTube Analytics API v2 queries |
+27
| `(sigil youtube live)` | Live broadcast and stream management |
+28
| `(sigil youtube playlist)` | Playlist and playlist item CRUD |
+29
+30
## Dependencies
+31
+32
- `sigil-stdlib` — core, dict, string, struct
+33
- `sigil-json` — JSON encode/decode
+34
- `sigil-http` — HTTP client
+35
- `sigil-tls` — HTTPS support
+36
- `sigil-log` — logging
+37
+38
All dependencies are from the [sigil](https://codeberg.org/sigil/sigil) mono-repo.
+39
+40
## Building
+41
+42
```bash
+43
# With local sigil checkout (development)
+44
sigil build --redirects dev-redirects.sgl
+45
+46
# Run tests
+47
sigil test --redirects dev-redirects.sgl
+48
```
+49
+50
## Usage
+51
+52
### Create a client
+53
+54
```scheme
+55
(import (sigil youtube))
+56
+57
;; With OAuth2 access token (required for mutations)
+58
(define client (youtube-client access-token: "ya29.your-token"))
+59
+60
;; With API key only (read-only public data)
+61
(define client (youtube-client api-key: "AIza..."))
+62
```
+63
+64
### Get channel info
+65
+66
```scheme
+67
;; Your own channel (requires OAuth)
+68
(define ch (youtube-channel-mine client))
+69
(youtube-channel-title ch) ; => "System Crafters"
+70
(youtube-channel-subscriber-count ch) ; => 50000
+71
(youtube-channel-uploads-playlist-id ch); => "UUxxxxxxxxxxxxxx"
+72
+73
;; Any channel by ID
+74
(define ch (youtube-channel-info client "UCxxxxxxxxxxxxxx"))
+75
```
+76
+77
### List and update videos
+78
+79
```scheme
+80
;; Get videos by ID (1 quota unit, batches multiple IDs)
+81
(define videos (youtube-videos client "id1,id2,id3"))
+82
+83
;; Update video metadata (50 quota units)
+84
(youtube-video-update client "video-id"
+85
#{ title: "New Title"
+86
description: "Updated description"
+87
privacyStatus: "public" })
+88
+89
;; Delete a video (50 quota units)
+90
(youtube-video-delete client "video-id")
+91
```
+92
+93
### Upload a video
+94
+95
```scheme
+96
(import (sigil youtube upload))
+97
+98
;; High-level upload (handles chunking automatically)
+99
;; Quota cost: 1,600 units
+100
(define video
+101
(youtube-upload-video client
+102
#{ snippet: #{ title: "My Video"
+103
description: "A great video"
+104
categoryId: "28" }
+105
status: #{ privacyStatus: "private" } }
+106
file-data
+107
"video/mp4"))
+108
```
+109
+110
### Query analytics
+111
+112
```scheme
+113
(import (sigil youtube analytics))
+114
+115
;; Daily views for a date range
+116
(define report (youtube-views-by-day client "2026-01-01" "2026-03-25"))
+117
+118
;; Top 10 videos by views
+119
(define top (youtube-top-videos client "2026-01-01" "2026-03-25"))
+120
+121
;; Custom query
+122
(define custom
+123
(youtube-analytics-query client "2026-01-01" "2026-03-25"
+124
"views,estimatedMinutesWatched,subscribersGained"
+125
#{ dimensions: "day" sort: "-views" }))
+126
```
+127
+128
### Manage playlists
+129
+130
```scheme
+131
(import (sigil youtube playlist))
+132
+133
;; List your playlists
+134
(define plists (youtube-playlists client))
+135
+136
;; Create a playlist (50 quota units)
+137
(define pl (youtube-create-playlist client "New Series"
+138
#{ description: "Episodes of my new series"
+139
privacy-status: "public" }))
+140
+141
;; Add a video to a playlist (50 quota units)
+142
(youtube-add-to-playlist client (youtube-playlist-id pl) "video-id")
+143
+144
;; List items in a playlist (1 quota unit per page)
+145
(define items (youtube-playlist-items client "PLxxxxxx"))
+146
```
+147
+148
### Livestreaming
+149
+150
```scheme
+151
(import (sigil youtube live))
+152
+153
;; Create broadcast + stream, bind them
+154
(define bc (youtube-create-broadcast client
+155
"Weekly Stream" "2026-03-28T18:00:00Z"))
+156
(define st (youtube-create-stream client "Main Feed"))
+157
(youtube-bind-broadcast client
+158
(youtube-broadcast-id bc) (youtube-stream-id st))
+159
+160
;; Get RTMP credentials
+161
(youtube-stream-rtmp-url st) ; => "rtmp://a.rtmp.youtube.com/live2"
+162
(youtube-stream-stream-key st) ; => "xxxx-xxxx-xxxx-xxxx"
+163
+164
;; Go live, then end
+165
(youtube-transition-broadcast client (youtube-broadcast-id bc) "live")
+166
(youtube-transition-broadcast client (youtube-broadcast-id bc) "complete")
+167
```
+168
+169
## Quota Budget
+170
+171
YouTube's default quota is 10,000 units/day. Key costs:
+172
+173
| Operation | Cost |
+174
|-----------|------|
+175
| Read (list/get) | 1 unit |
+176
| Write (insert/update/delete) | 50 units |
+177
| Video upload | 1,600 units |
+178
| Search | 100 units |
+179
| Thumbnail upload | 50 units |
+180
+181
Tip: batch video IDs in `youtube-videos` calls — multiple IDs still cost only 1 unit.
+182
+183
## License
+184
+185
BSD-3-Clause
src/sigil/youtube.sgladded
@@ -0,0 +1,420 @@
+1
;;; (sigil youtube) - YouTube Data API v3 client library.
+2
;;;
+3
;;; Core module providing authentication, HTTP helpers, record types,
+4
;;; and functions for video management, channel info, search, and thumbnails.
+5
;;;
+6
;;; Quota costs are documented per function:
+7
;;; - Read operations: 1 unit
+8
;;; - Write operations: 50 units
+9
;;; - Search: 100 units
+10
;;; - Video upload: 1,600 units (see (sigil youtube upload))
+11
+12
(define-library (sigil youtube)
+13
(import (sigil core)
+14
(sigil dict)
+15
(sigil string)
+16
(sigil struct)
+17
(sigil json)
+18
(sigil http client))
+19
+20
(export ;; Client
+21
youtube-client
+22
youtube-client?
+23
youtube-client-access-token
+24
youtube-client-api-key
+25
youtube-client-base-url
+26
youtube-client-upload-url
+27
+28
;; Records — video
+29
youtube-video
+30
youtube-video?
+31
youtube-video-id
+32
youtube-video-title
+33
youtube-video-description
+34
youtube-video-tags
+35
youtube-video-category-id
+36
youtube-video-privacy-status
+37
youtube-video-published-at
+38
youtube-video-statistics
+39
+40
;; Records — channel
+41
youtube-channel
+42
youtube-channel?
+43
youtube-channel-id
+44
youtube-channel-title
+45
youtube-channel-subscriber-count
+46
youtube-channel-video-count
+47
youtube-channel-uploads-playlist-id
+48
+49
;; Shared helpers (for sub-modules)
+50
youtube-auth-headers
+51
youtube-api-url
+52
youtube-upload-api-url
+53
youtube-get/json
+54
youtube-post/json
+55
youtube-put/json
+56
youtube-delete/json
+57
url-encode-value
+58
build-query-string
+59
check-youtube-response
+60
+61
;; Parsing
+62
parse-video
+63
parse-channel
+64
parse-page-info
+65
+66
;; API functions
+67
youtube-channel-info
+68
youtube-channel-mine
+69
youtube-videos
+70
youtube-video-update
+71
youtube-video-delete
+72
youtube-search
+73
youtube-set-thumbnail)
+74
+75
(begin
+76
+77
;; ---------------------------------------------------------------
+78
;; Records
+79
;; ---------------------------------------------------------------
+80
+81
(define-struct youtube-client
+82
(access-token default: #f)
+83
(api-key default: #f)
+84
(base-url default: "https://www.googleapis.com/youtube/v3")
+85
(upload-url default: "https://www.googleapis.com/upload/youtube/v3"))
+86
+87
(define-struct youtube-video
+88
(id)
+89
(title default: "")
+90
(description default: "")
+91
(tags default: '())
+92
(category-id default: #f)
+93
(privacy-status default: "private")
+94
(published-at default: #f)
+95
(statistics default: #{}))
+96
+97
(define-struct youtube-channel
+98
(id)
+99
(title default: "")
+100
(subscriber-count default: 0)
+101
(video-count default: 0)
+102
(uploads-playlist-id default: #f))
+103
+104
;; ---------------------------------------------------------------
+105
;; Internal helpers
+106
;; ---------------------------------------------------------------
+107
+108
(define (youtube-auth-headers client)
+109
(let ((token (youtube-client-access-token client)))
+110
(if token
+111
#{ authorization: (string-append "Bearer " token) }
+112
#{})))
+113
+114
(define (youtube-api-url client . parts)
+115
(apply string-append (youtube-client-base-url client)
+116
(map (lambda (p) (string-append "/" p)) parts)))
+117
+118
(define (youtube-upload-api-url client . parts)
+119
(apply string-append (youtube-client-upload-url client)
+120
(map (lambda (p) (string-append "/" p)) parts)))
+121
+122
;; No shared url-encode exists in sigil-http (known gap).
+123
(define (url-encode-value s)
+124
(let ((len (string-length s)))
+125
(let loop ((i 0) (acc '()))
+126
(if (>= i len)
+127
(list->string (reverse acc))
+128
(let ((c (string-ref s i)))
+129
(cond
+130
((or (char-alphabetic? c)
+131
(char-numeric? c)
+132
(char=? c #\-)
+133
(char=? c #\_)
+134
(char=? c #\.)
+135
(char=? c #\~))
+136
(loop (+ i 1) (cons c acc)))
+137
((char=? c #\space)
+138
(loop (+ i 1) (cons #\+ acc)))
+139
(else
+140
(let ((n (char->integer c)))
+141
(loop (+ i 1)
+142
(append (reverse (string->list
+143
(string-append "%"
+144
(if (< n 16) "0" "")
+145
(number->string n 16))))
+146
acc))))))))))
+147
+148
;;; Pairs with #f values are omitted.
+149
(define (build-query-string params)
+150
(let ((parts (filter (lambda (p) (cdr p)) params)))
+151
(if (null? parts)
+152
""
+153
(string-append "?"
+154
(string-join
+155
(map (lambda (p)
+156
(string-append (car p) "=" (url-encode-value (cdr p))))
+157
parts)
+158
"&")))))
+159
+160
;;; Append api-key to query params if client has one and no access-token.
+161
(define (maybe-add-api-key client params)
+162
(let ((key (youtube-client-api-key client))
+163
(token (youtube-client-access-token client)))
+164
(if (and key (not token))
+165
(cons (cons "key" key) params)
+166
params)))
+167
+168
;;; Check an HTTP response and return parsed JSON, or raise a
+169
;;; YouTube-specific error with quota/auth context.
+170
(define (check-youtube-response response)
+171
(if (not (http-response? response))
+172
(error "YouTube API request failed: no response"))
+173
(let ((status (http-response-status response))
+174
(body (http-response-body response)))
+175
(cond
+176
((= status 401)
+177
(error (string-append
+178
"YouTube API 401 Unauthorized. "
+179
"Access token may be expired — refresh and retry. "
+180
"Response: " (or body ""))))
+181
((= status 403)
+182
(error (string-append
+183
"YouTube API 403 Forbidden. "
+184
"Possible quota exceeded or insufficient permissions. "
+185
"Check quota at console.cloud.google.com. "
+186
"Response: " (or body ""))))
+187
((= status 429)
+188
(error (string-append
+189
"YouTube API 429 rate limited. "
+190
"Respect Retry-After header and retry with backoff. "
+191
"Response: " (or body ""))))
+192
((>= status 400)
+193
(error (string-append
+194
"YouTube API error " (number->string status) ": "
+195
(or body ""))))
+196
(else
+197
(if (and body (not (string=? body "")))
+198
(json-decode body)
+199
#t)))))
+200
+201
;;; Authenticated JSON GET request.
+202
(define (youtube-get/json client url)
+203
(check-youtube-response
+204
(http-get url headers: (youtube-auth-headers client))))
+205
+206
;;; Authenticated JSON POST request.
+207
(define (youtube-post/json client url body)
+208
(check-youtube-response
+209
(http-post url (if (string? body) body (json-encode body))
+210
headers: (dict-merge (youtube-auth-headers client)
+211
#{ content-type: "application/json" }))))
+212
+213
;;; Authenticated JSON PUT request.
+214
(define (youtube-put/json client url body)
+215
(check-youtube-response
+216
(http-put url (if (string? body) body (json-encode body))
+217
headers: (dict-merge (youtube-auth-headers client)
+218
#{ content-type: "application/json" }))))
+219
+220
;;; Authenticated DELETE request.
+221
(define (youtube-delete/json client url)
+222
(check-youtube-response
+223
(http-delete url headers: (youtube-auth-headers client))))
+224
+225
;; ---------------------------------------------------------------
+226
;; Response parsing
+227
;; ---------------------------------------------------------------
+228
+229
;;; Extract pagination info from a list response.
+230
;;; Returns a dict with total-results and next-page-token.
+231
(define (parse-page-info data)
+232
(let ((page-info (dict-ref data pageInfo: #{})))
+233
#{ total-results: (dict-ref page-info totalResults: 0)
+234
next-page-token: (dict-ref data nextPageToken: #f) }))
+235
+236
;;; Parse a video resource from the API into a youtube-video record.
+237
(define (parse-video data)
+238
(let ((snippet (dict-ref data snippet: #{}))
+239
(status (dict-ref data status: #{}))
+240
(stats (dict-ref data statistics: #{})))
+241
(youtube-video
+242
id: (dict-ref data id:)
+243
title: (dict-ref snippet title: "")
+244
description: (dict-ref snippet description: "")
+245
tags: (let ((t (dict-ref snippet tags: #f)))
+246
(if (and t (array? t))
+247
(array->list t)
+248
'()))
+249
category-id: (dict-ref snippet categoryId: #f)
+250
privacy-status: (dict-ref status privacyStatus: "private")
+251
published-at: (dict-ref snippet publishedAt: #f)
+252
statistics: stats)))
+253
+254
;;; Parse a channel resource from the API into a youtube-channel record.
+255
(define (parse-channel data)
+256
(let ((snippet (dict-ref data snippet: #{}))
+257
(stats (dict-ref data statistics: #{}))
+258
(content (dict-ref data contentDetails: #{})))
+259
(let ((related (dict-ref content relatedPlaylists: #{})))
+260
(youtube-channel
+261
id: (dict-ref data id:)
+262
title: (dict-ref snippet title: "")
+263
subscriber-count: (let ((v (dict-ref stats subscriberCount: "0")))
+264
(if (string? v) (string->number v) v))
+265
video-count: (let ((v (dict-ref stats videoCount: "0")))
+266
(if (string? v) (string->number v) v))
+267
uploads-playlist-id: (dict-ref related uploads: #f)))))
+268
+269
;; ---------------------------------------------------------------
+270
;; API functions
+271
;; ---------------------------------------------------------------
+272
+273
;;; Get channel info by channel ID.
+274
;;; Quota cost: 1 unit.
+275
(define (youtube-channel-info client channel-id)
+276
(let* ((params (maybe-add-api-key client
+277
(list (cons "part" "snippet,statistics,contentDetails")
+278
(cons "id" channel-id))))
+279
(url (string-append
+280
(youtube-api-url client "channels")
+281
(build-query-string params)))
+282
(data (youtube-get/json client url))
+283
(items (dict-ref data items: #[])))
+284
(if (> (array-length items) 0)
+285
(parse-channel (array-ref items 0))
+286
#f)))
+287
+288
;;; Get the authenticated user's channel info.
+289
;;; Quota cost: 1 unit. Requires OAuth access token.
+290
(define (youtube-channel-mine client)
+291
(if (not (youtube-client-access-token client))
+292
(error "youtube-channel-mine requires an OAuth access token"))
+293
(let* ((url (string-append
+294
(youtube-api-url client "channels")
+295
(build-query-string
+296
(list (cons "part" "snippet,statistics,contentDetails")
+297
(cons "mine" "true")))))
+298
(data (youtube-get/json client url))
+299
(items (dict-ref data items: #[])))
+300
(if (> (array-length items) 0)
+301
(parse-channel (array-ref items 0))
+302
#f)))
+303
+304
;;; Get videos by ID(s). Pass a single ID or comma-separated IDs.
+305
;;; Batching multiple IDs costs only 1 unit total.
+306
;;; Quota cost: 1 unit.
+307
(define (youtube-videos client video-ids)
+308
(let* ((params (maybe-add-api-key client
+309
(list (cons "part" "snippet,statistics,status,contentDetails")
+310
(cons "id" video-ids))))
+311
(url (string-append
+312
(youtube-api-url client "videos")
+313
(build-query-string params)))
+314
(data (youtube-get/json client url))
+315
(items (dict-ref data items: #[])))
+316
(array->list (array-map parse-video items))))
+317
+318
;;; Update video metadata. Takes a video ID and a dict of fields to update.
+319
;;; Only fields present in updates are sent; omitted fields are not touched.
+320
;;; Supported fields: title:, description:, tags:, categoryId:, privacyStatus:.
+321
;;; Quota cost: 50 units.
+322
(define (youtube-video-update client video-id updates)
+323
(let* ((snippet-keys '((title: . title:)
+324
(description: . description:)
+325
(tags: . tags:)
+326
(categoryId: . categoryId:)))
+327
(status-keys '((privacyStatus: . privacyStatus:)))
+328
(collect (lambda (key-map)
+329
(filter cdr
+330
(map (lambda (pair)
+331
(cons (cdr pair)
+332
(dict-ref updates (car pair) #f)))
+333
key-map))))
+334
(snippet-fields (collect snippet-keys))
+335
(status-fields (collect status-keys))
+336
(parts (append (if (null? snippet-fields) '() '("snippet"))
+337
(if (null? status-fields) '() '("status")))))
+338
(if (null? parts)
+339
(error "youtube-video-update: no fields to update"))
+340
(let* ((fields->dict
+341
(lambda (fields)
+342
(apply dict (apply append
+343
(map (lambda (p) (list (car p) (cdr p))) fields)))))
+344
(body (let ((b #{ id: video-id }))
+345
(let ((b (if (null? snippet-fields) b
+346
(dict-set b snippet: (fields->dict snippet-fields)))))
+347
(if (null? status-fields) b
+348
(dict-set b status: (fields->dict status-fields))))))
+349
(url (string-append
+350
(youtube-api-url client "videos")
+351
(build-query-string
+352
(list (cons "part" (string-join parts ","))))))
+353
(result (youtube-put/json client url body)))
+354
(parse-video result))))
+355
+356
;;; Delete a video by ID.
+357
;;; Quota cost: 50 units.
+358
(define (youtube-video-delete client video-id)
+359
(let ((url (string-append
+360
(youtube-api-url client "videos")
+361
(build-query-string
+362
(list (cons "id" video-id))))))
+363
(youtube-delete/json client url)))
+364
+365
;;; Search YouTube. WARNING: costs 100 quota units per call.
+366
;;; Prefer youtube-videos with known IDs or playlist-items when possible.
+367
;;;
+368
;;; query: search term string
+369
;;; Optional keyword args via opts dict:
+370
;;; type: "video", "channel", "playlist" (default: "video")
+371
;;; max-results: number (default: 25, max: 50)
+372
;;; order: "date", "rating", "relevance", "title", "viewCount"
+373
;;; page-token: for pagination
+374
;;; channel-id: restrict to a channel
+375
;;; published-after: ISO 8601 datetime
+376
;;; published-before: ISO 8601 datetime
+377
;;;
+378
;;; Quota cost: 100 units.
+379
(define (youtube-search client query . rest)
+380
(let ((opts (if (null? rest) #{} (car rest))))
+381
(let* ((params (maybe-add-api-key client
+382
(list (cons "part" "snippet")
+383
(cons "q" query)
+384
(cons "type" (dict-ref opts type: "video"))
+385
(cons "maxResults"
+386
(number->string
+387
(dict-ref opts max-results: 25)))
+388
(cons "order"
+389
(dict-ref opts order: #f))
+390
(cons "pageToken"
+391
(dict-ref opts page-token: #f))
+392
(cons "channelId"
+393
(dict-ref opts channel-id: #f))
+394
(cons "publishedAfter"
+395
(dict-ref opts published-after: #f))
+396
(cons "publishedBefore"
+397
(dict-ref opts published-before: #f)))))
+398
(url (string-append
+399
(youtube-api-url client "search")
+400
(build-query-string params)))
+401
(data (youtube-get/json client url)))
+402
;; Return raw results — search returns snippet-only items with
+403
;; id.videoId rather than full video resources
+404
data)))
+405
+406
;;; Upload a custom thumbnail for a video.
+407
;;; image-data should be the raw bytes of a JPEG or PNG image.
+408
;;; Max size: 2 MB. Recommended: 1280x720 (min width 640px).
+409
;;; Quota cost: 50 units.
+410
(define (youtube-set-thumbnail client video-id image-data content-type)
+411
(let ((url (string-append
+412
(youtube-upload-api-url client "thumbnails" "set")
+413
(build-query-string
+414
(list (cons "videoId" video-id))))))
+415
(check-youtube-response
+416
(http-post url image-data
+417
headers: (dict-merge (youtube-auth-headers client)
+418
#{ content-type: content-type })))))
+419
+420
))
src/sigil/youtube/analytics.sgladded
@@ -0,0 +1,137 @@
+1
;;; (sigil youtube analytics) - YouTube Analytics API v2 client.
+2
;;;
+3
;;; Query YouTube Analytics for video performance metrics, audience data,
+4
;;; and revenue information.
+5
;;;
+6
;;; Uses a separate base URL from the Data API:
+7
;;; https://youtubeanalytics.googleapis.com/v2
+8
;;;
+9
;;; Requires OAuth2 scopes:
+10
;;; yt-analytics.readonly — for views, watch time, engagement
+11
;;; yt-analytics-monetary.readonly — for revenue, ad performance
+12
+13
(define-library (sigil youtube analytics)
+14
(import (sigil core)
+15
(sigil dict)
+16
(sigil string)
+17
(sigil json)
+18
(sigil http client)
+19
(sigil youtube))
+20
+21
(export ;; Configuration
+22
youtube-analytics-base-url
+23
+24
;; Core query
+25
youtube-analytics-query
+26
+27
;; Common queries
+28
youtube-views-by-day
+29
youtube-watch-time
+30
youtube-subscriber-changes
+31
youtube-top-videos
+32
youtube-traffic-sources)
+33
+34
(begin
+35
+36
(define youtube-analytics-base-url
+37
"https://youtubeanalytics.googleapis.com/v2")
+38
+39
;; ---------------------------------------------------------------
+40
;; Core analytics query
+41
;; ---------------------------------------------------------------
+42
+43
;;; Query the YouTube Analytics API.
+44
;;;
+45
;;; Required parameters:
+46
;;; start-date: "YYYY-MM-DD" format
+47
;;; end-date: "YYYY-MM-DD" format
+48
;;; metrics: comma-separated string (e.g., "views,estimatedMinutesWatched")
+49
;;;
+50
;;; Optional parameters via opts dict:
+51
;;; ids: channel filter (default: "channel==MINE")
+52
;;; dimensions: comma-separated string (e.g., "day", "video", "country")
+53
;;; filters: filter expression (e.g., "video==VIDEO_ID")
+54
;;; sort: sort order (e.g., "-views" for descending)
+55
;;; max-results: number of results to return
+56
;;; start-index: pagination offset
+57
;;; currency: ISO currency code for monetary metrics
+58
;;;
+59
;;; Returns a dict with:
+60
;;; column-headers: list of #{ name: "..." data-type: "..." column-type: "..." }
+61
;;; rows: list of row arrays
+62
(define (youtube-analytics-query client start-date end-date metrics . rest)
+63
(let ((opts (if (null? rest) #{} (car rest))))
+64
(let* ((params (list
+65
(cons "ids" (dict-ref opts ids: "channel==MINE"))
+66
(cons "startDate" start-date)
+67
(cons "endDate" end-date)
+68
(cons "metrics" metrics)
+69
(cons "dimensions" (dict-ref opts dimensions: #f))
+70
(cons "filters" (dict-ref opts filters: #f))
+71
(cons "sort" (dict-ref opts sort: #f))
+72
(cons "maxResults"
+73
(let ((mr (dict-ref opts max-results: #f)))
+74
(if mr (number->string mr) #f)))
+75
(cons "startIndex"
+76
(let ((si (dict-ref opts start-index: #f)))
+77
(if si (number->string si) #f)))
+78
(cons "currency" (dict-ref opts currency: #f))))
+79
(url (string-append
+80
youtube-analytics-base-url "/reports"
+81
(build-query-string params)))
+82
(data (youtube-get/json client url)))
+83
;; Parse the response into a friendlier format
+84
(let ((headers (dict-ref data columnHeaders: #[]))
+85
(rows (dict-ref data rows: #[])))
+86
#{ column-headers: (if (array? headers)
+87
(array->list headers)
+88
'())
+89
rows: (if (array? rows)
+90
(array->list (array-map array->list rows))
+91
'()) }))))
+92
+93
;; ---------------------------------------------------------------
+94
;; Common analytics queries
+95
;; ---------------------------------------------------------------
+96
+97
;;; Get daily view counts for a date range.
+98
;;; Returns rows of (day views estimated-minutes-watched average-view-duration).
+99
(define (youtube-views-by-day client start-date end-date)
+100
(youtube-analytics-query client start-date end-date
+101
"views,estimatedMinutesWatched,averageViewDuration"
+102
#{ dimensions: "day"
+103
sort: "day" }))
+104
+105
;;; Get total watch time metrics for a date range.
+106
;;; Returns rows of (views estimated-minutes-watched average-view-duration
+107
;;; average-view-percentage).
+108
(define (youtube-watch-time client start-date end-date)
+109
(youtube-analytics-query client start-date end-date
+110
"views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage"))
+111
+112
;;; Get daily subscriber changes for a date range.
+113
;;; Returns rows of (day subscribers-gained subscribers-lost).
+114
(define (youtube-subscriber-changes client start-date end-date)
+115
(youtube-analytics-query client start-date end-date
+116
"subscribersGained,subscribersLost"
+117
#{ dimensions: "day"
+118
sort: "day" }))
+119
+120
;;; Get top videos by views for a date range.
+121
;;; max-results defaults to 10.
+122
(define (youtube-top-videos client start-date end-date . rest)
+123
(let ((max-results (if (null? rest) 10 (car rest))))
+124
(youtube-analytics-query client start-date end-date
+125
"views,estimatedMinutesWatched,likes,comments"
+126
#{ dimensions: "video"
+127
sort: "-views"
+128
max-results: max-results })))
+129
+130
;;; Get traffic source breakdown for a date range.
+131
(define (youtube-traffic-sources client start-date end-date)
+132
(youtube-analytics-query client start-date end-date
+133
"views,estimatedMinutesWatched"
+134
#{ dimensions: "insightTrafficSourceType"
+135
sort: "-views" }))
+136
+137
))
src/sigil/youtube/live.sgladded
@@ -0,0 +1,235 @@
+1
;;; (sigil youtube live) - YouTube Live Streaming API.
+2
;;;
+3
;;; Manages the live broadcast lifecycle: create broadcasts and streams,
+4
;;; bind them together, and transition through states
+5
;;; (testing -> live -> complete).
+6
;;;
+7
;;; Broadcast lifecycle:
+8
;;; created -> ready -> testStarting -> testing -> liveStarting -> live -> complete
+9
+10
(define-library (sigil youtube live)
+11
(import (sigil core)
+12
(sigil dict)
+13
(sigil string)
+14
(sigil struct)
+15
(sigil json)
+16
(sigil http client)
+17
(sigil youtube))
+18
+19
(export ;; Records
+20
youtube-broadcast
+21
youtube-broadcast?
+22
youtube-broadcast-id
+23
youtube-broadcast-title
+24
youtube-broadcast-scheduled-start
+25
youtube-broadcast-lifecycle-status
+26
youtube-broadcast-stream-id
+27
+28
youtube-stream
+29
youtube-stream?
+30
youtube-stream-id
+31
youtube-stream-title
+32
youtube-stream-rtmp-url
+33
youtube-stream-stream-key
+34
+35
;; Parsing
+36
parse-broadcast
+37
parse-stream
+38
+39
;; API functions
+40
youtube-create-broadcast
+41
youtube-create-stream
+42
youtube-bind-broadcast
+43
youtube-transition-broadcast
+44
youtube-list-broadcasts
+45
youtube-list-streams)
+46
+47
(begin
+48
+49
;; ---------------------------------------------------------------
+50
;; Records
+51
;; ---------------------------------------------------------------
+52
+53
(define-struct youtube-broadcast
+54
(id)
+55
(title default: "")
+56
(scheduled-start default: #f)
+57
(lifecycle-status default: "created")
+58
(stream-id default: #f))
+59
+60
(define-struct youtube-stream
+61
(id)
+62
(title default: "")
+63
(rtmp-url default: #f)
+64
(stream-key default: #f))
+65
+66
;; ---------------------------------------------------------------
+67
;; Parsing
+68
;; ---------------------------------------------------------------
+69
+70
;;; Parse a liveBroadcast resource into a youtube-broadcast record.
+71
(define (parse-broadcast data)
+72
(let ((snippet (dict-ref data snippet: #{}))
+73
(status (dict-ref data status: #{}))
+74
(content (dict-ref data contentDetails: #{})))
+75
(youtube-broadcast
+76
id: (dict-ref data id:)
+77
title: (dict-ref snippet title: "")
+78
scheduled-start: (dict-ref snippet scheduledStartTime: #f)
+79
lifecycle-status: (dict-ref status lifeCycleStatus: "created")
+80
stream-id: (dict-ref content boundStreamId: #f))))
+81
+82
;;; Parse a liveStream resource into a youtube-stream record.
+83
(define (parse-stream data)
+84
(let* ((snippet (dict-ref data snippet: #{}))
+85
(cdn (dict-ref data cdn: #{}))
+86
(ingestion (dict-ref cdn ingestionInfo: #{})))
+87
(youtube-stream
+88
id: (dict-ref data id:)
+89
title: (dict-ref snippet title: "")
+90
rtmp-url: (dict-ref ingestion ingestionAddress: #f)
+91
stream-key: (dict-ref ingestion streamName: #f))))
+92
+93
;; ---------------------------------------------------------------
+94
;; API functions
+95
;; ---------------------------------------------------------------
+96
+97
;;; Create a live broadcast (scheduled event).
+98
;;;
+99
;;; title: broadcast title
+100
;;; scheduled-start: ISO 8601 datetime (e.g., "2026-03-28T18:00:00Z")
+101
;;; Optional opts dict:
+102
;;; privacy-status: "public", "private", or "unlisted" (default: "public")
+103
;;; enable-dvr: boolean (default: #t)
+104
;;; record-from-start: boolean (default: #t)
+105
;;; enable-auto-start: boolean (default: #f)
+106
;;; enable-auto-stop: boolean (default: #f)
+107
;;;
+108
;;; Quota cost: 50 units.
+109
(define (youtube-create-broadcast client title scheduled-start . rest)
+110
(let ((opts (if (null? rest) #{} (car rest))))
+111
(let* ((privacy (dict-ref opts privacy-status: "public"))
+112
(body #{ snippet:
+113
#{ title: title
+114
scheduledStartTime: scheduled-start }
+115
contentDetails:
+116
#{ enableDvr: (dict-ref opts enable-dvr: #t)
+117
recordFromStart: (dict-ref opts record-from-start: #t)
+118
enableAutoStart: (dict-ref opts enable-auto-start: #f)
+119
enableAutoStop: (dict-ref opts enable-auto-stop: #f)
+120
monitorStream:
+121
#{ enableMonitorStream: #t } }
+122
status:
+123
#{ privacyStatus: privacy } })
+124
(url (string-append
+125
(youtube-api-url client "liveBroadcasts")
+126
(build-query-string
+127
(list (cons "part" "snippet,contentDetails,status")))))
+128
(result (youtube-post/json client url body)))
+129
(parse-broadcast result))))
+130
+131
;;; Create a live stream (ingest point).
+132
;;;
+133
;;; title: stream title
+134
;;; Optional opts dict:
+135
;;; frame-rate: "30fps", "60fps" (default: "30fps")
+136
;;; resolution: "1080p", "720p", "480p", "360p", "240p" (default: "1080p")
+137
;;; ingestion-type: "rtmp" or "dash" (default: "rtmp")
+138
;;;
+139
;;; Quota cost: 50 units.
+140
(define (youtube-create-stream client title . rest)
+141
(let ((opts (if (null? rest) #{} (car rest))))
+142
(let* ((body #{ snippet:
+143
#{ title: title }
+144
cdn:
+145
#{ frameRate: (dict-ref opts frame-rate: "30fps")
+146
ingestionType: (dict-ref opts ingestion-type: "rtmp")
+147
resolution: (dict-ref opts resolution: "1080p") } })
+148
(url (string-append
+149
(youtube-api-url client "liveStreams")
+150
(build-query-string
+151
(list (cons "part" "snippet,cdn")))))
+152
(result (youtube-post/json client url body)))
+153
(parse-stream result))))
+154
+155
;;; Bind a broadcast to a stream.
+156
;;; After binding, the stream's RTMP feed will be used for the broadcast.
+157
;;; A broadcast binds to exactly one stream; a stream can serve multiple broadcasts.
+158
;;;
+159
;;; Quota cost: 50 units.
+160
(define (youtube-bind-broadcast client broadcast-id stream-id)
+161
(let* ((url (string-append
+162
(youtube-api-url client "liveBroadcasts" "bind")
+163
(build-query-string
+164
(list (cons "id" broadcast-id)
+165
(cons "part" "id,contentDetails")
+166
(cons "streamId" stream-id)))))
+167
(result (youtube-post/json client url #{})))
+168
(parse-broadcast result)))
+169
+170
;;; Transition a broadcast to a new status.
+171
;;; Valid transitions:
+172
;;; "testing" — start preview (stream must be active, monitor stream enabled)
+173
;;; "live" — go live (stream must be active)
+174
;;; "complete" — end broadcast
+175
;;;
+176
;;; Note: stuck intermediate states (testStarting, liveStarting) require
+177
;;; deleting and recreating the broadcast.
+178
;;;
+179
;;; Quota cost: 50 units per transition.
+180
(define (youtube-transition-broadcast client broadcast-id status)
+181
(let* ((url (string-append
+182
(youtube-api-url client "liveBroadcasts" "transition")
+183
(build-query-string
+184
(list (cons "id" broadcast-id)
+185
(cons "broadcastStatus" status)
+186
(cons "part" "status")))))
+187
(result (youtube-post/json client url #{})))
+188
(parse-broadcast result)))
+189
+190
;;; List live broadcasts for the authenticated user.
+191
;;; Optional opts dict:
+192
;;; broadcast-status: "upcoming", "active", "completed", "all" (default: "all")
+193
;;; max-results: number (default: 25)
+194
;;; page-token: pagination token
+195
;;;
+196
;;; Quota cost: 1 unit.
+197
(define (youtube-list-broadcasts client . rest)
+198
(let ((opts (if (null? rest) #{} (car rest))))
+199
(let* ((params (list
+200
(cons "part" "snippet,contentDetails,status")
+201
(cons "mine" "true")
+202
(cons "broadcastStatus"
+203
(dict-ref opts broadcast-status: "all"))
+204
(cons "maxResults"
+205
(number->string
+206
(dict-ref opts max-results: 25)))
+207
(cons "pageToken"
+208
(dict-ref opts page-token: #f))))
+209
(url (string-append
+210
(youtube-api-url client "liveBroadcasts")
+211
(build-query-string params)))
+212
(data (youtube-get/json client url))
+213
(items (dict-ref data items: #[])))
+214
(array->list (array-map parse-broadcast items)))))
+215
+216
;;; List live streams for the authenticated user.
+217
;;; Quota cost: 1 unit.
+218
(define (youtube-list-streams client . rest)
+219
(let ((opts (if (null? rest) #{} (car rest))))
+220
(let* ((params (list
+221
(cons "part" "snippet,cdn,status")
+222
(cons "mine" "true")
+223
(cons "maxResults"
+224
(number->string
+225
(dict-ref opts max-results: 25)))
+226
(cons "pageToken"
+227
(dict-ref opts page-token: #f))))
+228
(url (string-append
+229
(youtube-api-url client "liveStreams")
+230
(build-query-string params)))
+231
(data (youtube-get/json client url))
+232
(items (dict-ref data items: #[])))
+233
(array->list (array-map parse-stream items)))))
+234
+235
))
src/sigil/youtube/playlist.sgladded
@@ -0,0 +1,261 @@
+1
;;; (sigil youtube playlist) - YouTube playlist management.
+2
;;;
+3
;;; CRUD operations for playlists and playlist items.
+4
;;; Write operations cost 50 quota units each; reads cost 1 unit.
+5
;;;
+6
;;; Tip: The uploads playlist for a channel is always
+7
;;; "UU" + channel_id (without the "UC" prefix).
+8
;;; Using playlistItems.list on this is much cheaper than search.list.
+9
+10
(define-library (sigil youtube playlist)
+11
(import (sigil core)
+12
(sigil dict)
+13
(sigil string)
+14
(sigil struct)
+15
(sigil json)
+16
(sigil http client)
+17
(sigil youtube))
+18
+19
(export ;; Records
+20
youtube-playlist
+21
youtube-playlist?
+22
youtube-playlist-id
+23
youtube-playlist-title
+24
youtube-playlist-description
+25
youtube-playlist-item-count
+26
youtube-playlist-privacy-status
+27
+28
youtube-playlist-item
+29
youtube-playlist-item?
+30
youtube-playlist-item-id
+31
youtube-playlist-item-video-id
+32
youtube-playlist-item-title
+33
youtube-playlist-item-position
+34
+35
;; Parsing
+36
parse-playlist
+37
parse-playlist-item
+38
+39
;; Playlist operations
+40
youtube-playlists
+41
youtube-playlists-by-channel
+42
youtube-create-playlist
+43
youtube-update-playlist
+44
youtube-delete-playlist
+45
+46
;; Playlist item operations
+47
youtube-playlist-items
+48
youtube-add-to-playlist
+49
youtube-remove-from-playlist
+50
youtube-reorder-playlist-item)
+51
+52
(begin
+53
+54
;; ---------------------------------------------------------------
+55
;; Records
+56
;; ---------------------------------------------------------------
+57
+58
(define-struct youtube-playlist
+59
(id)
+60
(title default: "")
+61
(description default: "")
+62
(item-count default: 0)
+63
(privacy-status default: "public"))
+64
+65
(define-struct youtube-playlist-item
+66
(id)
+67
(video-id default: "")
+68
(title default: "")
+69
(position default: 0))
+70
+71
;; ---------------------------------------------------------------
+72
;; Parsing
+73
;; ---------------------------------------------------------------
+74
+75
;;; Parse a playlist resource into a youtube-playlist record.
+76
(define (parse-playlist data)
+77
(let ((snippet (dict-ref data snippet: #{}))
+78
(content (dict-ref data contentDetails: #{}))
+79
(status (dict-ref data status: #{})))
+80
(youtube-playlist
+81
id: (dict-ref data id:)
+82
title: (dict-ref snippet title: "")
+83
description: (dict-ref snippet description: "")
+84
item-count: (dict-ref content itemCount: 0)
+85
privacy-status: (dict-ref status privacyStatus: "public"))))
+86
+87
;;; Parse a playlistItem resource into a youtube-playlist-item record.
+88
(define (parse-playlist-item data)
+89
(let ((snippet (dict-ref data snippet: #{})))
+90
(let ((resource-id (dict-ref snippet resourceId: #{})))
+91
(youtube-playlist-item
+92
id: (dict-ref data id:)
+93
video-id: (dict-ref resource-id videoId: "")
+94
title: (dict-ref snippet title: "")
+95
position: (dict-ref snippet position: 0)))))
+96
+97
;; ---------------------------------------------------------------
+98
;; Playlist operations
+99
;; ---------------------------------------------------------------
+100
+101
;; Shared list-playlists implementation.
+102
(define (list-playlists-internal client filter-params . rest)
+103
(let ((opts (if (null? rest) #{} (car rest))))
+104
(let* ((params (maybe-add-api-key client
+105
(append
+106
(list (cons "part" "snippet,contentDetails,status"))
+107
filter-params
+108
(list (cons "maxResults"
+109
(number->string
+110
(dict-ref opts max-results: 25)))
+111
(cons "pageToken"
+112
(dict-ref opts page-token: #f))))))
+113
(url (string-append
+114
(youtube-api-url client "playlists")
+115
(build-query-string params)))
+116
(data (youtube-get/json client url))
+117
(items (dict-ref data items: #[])))
+118
(array->list (array-map parse-playlist items)))))
+119
+120
;;; List playlists for the authenticated user.
+121
;;; Optional opts dict: max-results (default 25), page-token.
+122
;;; Quota cost: 1 unit.
+123
(define (youtube-playlists client . rest)
+124
(apply list-playlists-internal client
+125
(list (cons "mine" "true")) rest))
+126
+127
;;; List playlists for a specific channel.
+128
;;; Quota cost: 1 unit.
+129
(define (youtube-playlists-by-channel client channel-id . rest)
+130
(apply list-playlists-internal client
+131
(list (cons "channelId" channel-id)) rest))
+132
+133
;;; Create a new playlist.
+134
;;; Quota cost: 50 units.
+135
(define (youtube-create-playlist client title . rest)
+136
(let ((opts (if (null? rest) #{} (car rest))))
+137
(let* ((description (dict-ref opts description: ""))
+138
(privacy (dict-ref opts privacy-status: "public"))
+139
(body #{ snippet:
+140
#{ title: title
+141
description: description }
+142
status:
+143
#{ privacyStatus: privacy } })
+144
(url (string-append
+145
(youtube-api-url client "playlists")
+146
(build-query-string
+147
(list (cons "part" "snippet,status")))))
+148
(result (youtube-post/json client url body)))
+149
(parse-playlist result))))
+150
+151
;;; Update a playlist's metadata.
+152
;;; All fields (title:, description:, privacy-status:) are required since
+153
;;; YouTube's PUT replaces the entire resource. Fetch first if you only
+154
;;; want to change one field.
+155
;;; Quota cost: 50 units.
+156
(define (youtube-update-playlist client playlist-id title description
+157
privacy-status)
+158
(let* ((body #{ id: playlist-id
+159
snippet:
+160
#{ title: title
+161
description: description }
+162
status:
+163
#{ privacyStatus: privacy-status } })
+164
(url (string-append
+165
(youtube-api-url client "playlists")
+166
(build-query-string
+167
(list (cons "part" "snippet,status")))))
+168
(result (youtube-put/json client url body)))
+169
(parse-playlist result)))
+170
+171
;;; Delete a playlist.
+172
;;; Quota cost: 50 units.
+173
(define (youtube-delete-playlist client playlist-id)
+174
(let ((url (string-append
+175
(youtube-api-url client "playlists")
+176
(build-query-string
+177
(list (cons "id" playlist-id))))))
+178
(youtube-delete/json client url)))
+179
+180
;; ---------------------------------------------------------------
+181
;; Playlist item operations
+182
;; ---------------------------------------------------------------
+183
+184
;;; List items in a playlist.
+185
;;; Returns a list of youtube-playlist-item records.
+186
;;; Optional opts dict:
+187
;;; max-results: number (default: 50, max: 50)
+188
;;; page-token: pagination token
+189
;;;
+190
;;; Quota cost: 1 unit per page.
+191
(define (youtube-playlist-items client playlist-id . rest)
+192
(let ((opts (if (null? rest) #{} (car rest))))
+193
(let* ((params (maybe-add-api-key client
+194
(list (cons "part" "snippet,contentDetails")
+195
(cons "playlistId" playlist-id)
+196
(cons "maxResults"
+197
(number->string
+198
(dict-ref opts max-results: 50)))
+199
(cons "pageToken"
+200
(dict-ref opts page-token: #f)))))
+201
(url (string-append
+202
(youtube-api-url client "playlistItems")
+203
(build-query-string params)))
+204
(data (youtube-get/json client url))
+205
(items (dict-ref data items: #[])))
+206
(array->list (array-map parse-playlist-item items)))))
+207
+208
;;; Add a video to a playlist.
+209
;;; Optional position parameter specifies the 0-based index.
+210
;;; Quota cost: 50 units.
+211
(define (youtube-add-to-playlist client playlist-id video-id . rest)
+212
(let ((position (if (null? rest) #f (car rest))))
+213
(let* ((snippet #{ playlistId: playlist-id
+214
resourceId:
+215
#{ kind: "youtube#video"
+216
videoId: video-id } })
+217
(snippet (if position
+218
(dict-set snippet position: position)
+219
snippet))
+220
(body #{ snippet: snippet })
+221
(url (string-append
+222
(youtube-api-url client "playlistItems")
+223
(build-query-string
+224
(list (cons "part" "snippet")))))
+225
(result (youtube-post/json client url body)))
+226
(parse-playlist-item result))))
+227
+228
;;; Remove an item from a playlist by playlist item ID.
+229
;;; Note: this requires the playlist *item* ID, not the video ID.
+230
;;; Get item IDs from youtube-playlist-items.
+231
;;; Quota cost: 50 units.
+232
(define (youtube-remove-from-playlist client item-id)
+233
(let ((url (string-append
+234
(youtube-api-url client "playlistItems")
+235
(build-query-string
+236
(list (cons "id" item-id))))))
+237
(youtube-delete/json client url)))
+238
+239
;;; Reorder a playlist item by updating its position.
+240
;;; item-id: the playlist item ID
+241
;;; playlist-id: the playlist this item belongs to
+242
;;; video-id: the video ID of this item
+243
;;; new-position: the new 0-based position
+244
;;; Quota cost: 50 units.
+245
(define (youtube-reorder-playlist-item client item-id playlist-id
+246
video-id new-position)
+247
(let* ((body #{ id: item-id
+248
snippet:
+249
#{ playlistId: playlist-id
+250
resourceId:
+251
#{ kind: "youtube#video"
+252
videoId: video-id }
+253
position: new-position } })
+254
(url (string-append
+255
(youtube-api-url client "playlistItems")
+256
(build-query-string
+257
(list (cons "part" "snippet")))))
+258
(result (youtube-put/json client url body)))
+259
(parse-playlist-item result)))
+260
+261
))
src/sigil/youtube/upload.sgladded
@@ -0,0 +1,219 @@
+1
;;; (sigil youtube upload) - YouTube resumable upload protocol.
+2
;;;
+3
;;; Implements the Google resumable upload protocol for uploading videos
+4
;;; to YouTube. Supports single-request and chunked uploads with resume
+5
;;; capability after failures.
+6
;;;
+7
;;; Upload quota cost: 1,600 units per video.
+8
;;; Chunk size must be a multiple of 256 KB (262144 bytes), except final chunk.
+9
+10
(define-library (sigil youtube upload)
+11
(import (sigil core)
+12
(sigil dict)
+13
(sigil string)
+14
(sigil struct)
+15
(sigil json)
+16
(sigil http client)
+17
(sigil youtube))
+18
+19
(export ;; Low-level upload operations
+20
youtube-upload-init
+21
youtube-upload-send
+22
youtube-upload-resume
+23
youtube-upload-status
+24
+25
;; High-level helper
+26
youtube-upload-video
+27
+28
;; Constants
+29
youtube-chunk-size)
+30
+31
(begin
+32
+33
;; Default chunk size: 8 MB (multiple of 256 KB)
+34
(define youtube-chunk-size (* 8 1024 1024))
+35
+36
;; Minimum chunk alignment: 256 KB
+37
(define chunk-alignment (* 256 1024))
+38
+39
;; ---------------------------------------------------------------
+40
;; Upload initiation
+41
;; ---------------------------------------------------------------
+42
+43
;;; Initiate a resumable upload session.
+44
;;; metadata is a dict with snippet and status fields:
+45
;;; #{ snippet: #{ title: "..." description: "..." tags: #["..."] categoryId: "22" }
+46
;;; status: #{ privacyStatus: "private" } }
+47
;;; file-size is the total size of the video file in bytes.
+48
;;; content-type is the MIME type (e.g., "video/mp4", "video/*").
+49
;;;
+50
;;; Returns the upload URI string for subsequent upload requests.
+51
;;; Quota cost: 1,600 units.
+52
(define (youtube-upload-init client metadata file-size content-type)
+53
(let* ((url (string-append
+54
(youtube-upload-api-url client "videos")
+55
(build-query-string
+56
(list (cons "uploadType" "resumable")
+57
(cons "part" "snippet,status")))))
+58
(response (http-post url (json-encode metadata)
+59
headers: (dict-merge
+60
(youtube-auth-headers client)
+61
#{ content-type: "application/json; charset=UTF-8"
+62
x-upload-content-length: (number->string file-size)
+63
x-upload-content-type: content-type }))))
+64
(if (not (http-response? response))
+65
(error "YouTube upload init failed: no response"))
+66
(let ((status (http-response-status response)))
+67
(if (>= status 400)
+68
(check-youtube-response response))
+69
;; Extract Location header containing the upload URI
+70
(let ((headers (http-response-headers response)))
+71
(let ((location (dict-ref headers location: #f)))
+72
(if (not location)
+73
(error "YouTube upload init: no Location header in response"))
+74
location)))))
+75
+76
;; ---------------------------------------------------------------
+77
;; Upload data transfer
+78
;; ---------------------------------------------------------------
+79
+80
;;; Send file data (or a chunk) to the upload URI.
+81
;;; data is the raw bytes to upload.
+82
;;; start-byte and end-byte define the range within the total file.
+83
;;; total-size is the complete file size.
+84
;;;
+85
;;; For single-request upload, start-byte=0 and end-byte=total-size-1.
+86
;;; For chunked uploads, each chunk must be chunk-alignment-aligned
+87
;;; (except the final chunk).
+88
;;;
+89
;;; Returns:
+90
;;; - A youtube-video record (parsed) on completion (status 200/201)
+91
;;; - 'incomplete if more chunks are needed (status 308)
+92
;;; - Raises error on failure
+93
(define (youtube-upload-send upload-uri data start-byte end-byte total-size)
+94
(let* ((content-range
+95
(string-append "bytes "
+96
(number->string start-byte) "-"
+97
(number->string end-byte) "/"
+98
(number->string total-size)))
+99
(response (http-put upload-uri data
+100
headers: #{ content-type: "video/*"
+101
content-range: content-range })))
+102
(if (not (http-response? response))
+103
(error "YouTube upload send failed: no response"))
+104
(let ((status (http-response-status response)))
+105
(cond
+106
;; Upload complete
+107
((or (= status 200) (= status 201))
+108
(let ((body (http-response-body response)))
+109
(if (and body (not (string=? body "")))
+110
(parse-video (json-decode body))
+111
#t)))
+112
;; More chunks needed
+113
((= status 308)
+114
'incomplete)
+115
;; Error
+116
(else
+117
(check-youtube-response response))))))
+118
+119
;; ---------------------------------------------------------------
+120
;; Upload resume
+121
;; ---------------------------------------------------------------
+122
+123
;;; Check the status of an upload and get the last byte received.
+124
;;; Returns the byte offset to resume from, or 'complete if done.
+125
(define (youtube-upload-status upload-uri total-size)
+126
(let* ((content-range
+127
(string-append "bytes */" (number->string total-size)))
+128
(response (http-put upload-uri ""
+129
headers: #{ content-range: content-range })))
+130
(if (not (http-response? response))
+131
(error "YouTube upload status check failed: no response"))
+132
(let ((status (http-response-status response)))
+133
(cond
+134
;; Upload already complete
+135
((or (= status 200) (= status 201))
+136
'complete)
+137
;; Resume incomplete — parse Range header
+138
((= status 308)
+139
(let* ((headers (http-response-headers response))
+140
(range (dict-ref headers range: #f)))
+141
(if range
+142
;; Range header is "bytes=0-LAST_BYTE"
+143
(let ((dash-pos (string-index range #\-)))
+144
(if dash-pos
+145
(+ 1 (string->number
+146
(substring range (+ dash-pos 1)
+147
(string-length range))))
+148
0))
+149
;; No Range header means no bytes received yet
+150
0)))
+151
;; Upload session expired (404) or other error
+152
((= status 404)
+153
(error "YouTube upload session expired. Must restart upload."))
+154
(else
+155
(check-youtube-response response))))))
+156
+157
;;; Resume an interrupted upload from where it left off.
+158
;;; get-chunk-fn is a function (start-byte end-byte) -> data
+159
;;; that returns the file data for the given byte range.
+160
;;;
+161
;;; Returns the completed youtube-video record.
+162
(define (youtube-upload-resume upload-uri total-size get-chunk-fn)
+163
(let ((resume-from (youtube-upload-status upload-uri total-size)))
+164
(if (eq? resume-from 'complete)
+165
'complete
+166
(upload-chunks upload-uri resume-from total-size get-chunk-fn))))
+167
+168
;; ---------------------------------------------------------------
+169
;; High-level upload helper
+170
;; ---------------------------------------------------------------
+171
+172
;;; Upload a video with full lifecycle management.
+173
;;; metadata: dict with snippet/status (see youtube-upload-init)
+174
;;; file-data: the complete file contents as a string/bytevector
+175
;;; content-type: MIME type (default: "video/*")
+176
;;;
+177
;;; For small files, uploads in a single request.
+178
;;; For large files (> youtube-chunk-size), uses chunked upload.
+179
;;;
+180
;;; Note: file-data is measured with string-length. In Sigil, strings
+181
;;; are byte-strings so this gives the correct byte count for binary data.
+182
;;; For streaming large files, use youtube-upload-init + upload-chunks
+183
;;; with a get-chunk-fn that reads from a port.
+184
;;;
+185
;;; Returns the youtube-video record of the uploaded video.
+186
;;; Quota cost: 1,600 units.
+187
(define (youtube-upload-video client metadata file-data . rest)
+188
(let* ((content-type (if (null? rest) "video/*" (car rest)))
+189
(total-size (string-length file-data))
+190
(upload-uri (youtube-upload-init client metadata
+191
total-size content-type)))
+192
(if (<= total-size youtube-chunk-size)
+193
;; Single-request upload
+194
(youtube-upload-send upload-uri file-data
+195
0 (- total-size 1) total-size)
+196
;; Chunked upload
+197
(upload-chunks upload-uri 0 total-size
+198
(lambda (start end)
+199
(substring file-data start (+ end 1)))))))
+200
+201
;;; Internal: upload file data in chunks starting from offset.
+202
(define (upload-chunks upload-uri start-from total-size get-chunk-fn)
+203
(let loop ((offset start-from))
+204
(if (>= offset total-size)
+205
;; Should not reach here — last chunk returns the video
+206
(error "YouTube upload: unexpected end of chunks"))
+207
(let* ((end (min (- total-size 1)
+208
(- (+ offset youtube-chunk-size) 1)))
+209
(chunk-data (get-chunk-fn offset end))
+210
(result (youtube-upload-send upload-uri chunk-data
+211
offset end total-size)))
+212
(cond
+213
((eq? result 'incomplete)
+214
(loop (+ end 1)))
+215
((youtube-video? result)
+216
result)
+217
(else result)))))
+218
+219
))
test/analytics-test.sgladded
@@ -0,0 +1,22 @@
+1
;;; Tests for (sigil youtube analytics) — analytics module
+2
;;;
+3
;;; Tests the analytics base URL constant. Query function tests
+4
;;; require HTTP mocking which is not available, so we test what we can.
+5
+6
(import (sigil core)
+7
(sigil string)
+8
(sigil test)
+9
(sigil youtube)
+10
(sigil youtube analytics))
+11
+12
;; ---------------------------------------------------------------
+13
;; Constants tests
+14
;; ---------------------------------------------------------------
+15
+16
(test-group "analytics constants"
+17
+18
(test "analytics base URL"
+19
(assert-equal "https://youtubeanalytics.googleapis.com/v2"
+20
youtube-analytics-base-url)))
+21
+22
(run-tests)
test/live-test.sgladded
@@ -0,0 +1,158 @@
+1
;;; Tests for (sigil youtube live) — livestreaming module
+2
;;;
+3
;;; Tests record construction and JSON parsing with fixture data.
+4
+5
(import (sigil core)
+6
(sigil dict)
+7
(sigil string)
+8
(sigil struct)
+9
(sigil json)
+10
(sigil test)
+11
(sigil youtube)
+12
(sigil youtube live))
+13
+14
;; ---------------------------------------------------------------
+15
;; Test fixtures
+16
;; ---------------------------------------------------------------
+17
+18
(define broadcast-resource-json
+19
(json-decode
+20
(string-append
+21
"{\"id\": \"bcast-001\","
+22
" \"snippet\": {"
+23
" \"title\": \"Weekly Livestream\","
+24
" \"scheduledStartTime\": \"2026-03-28T18:00:00Z\""
+25
" },"
+26
" \"status\": {"
+27
" \"lifeCycleStatus\": \"ready\""
+28
" },"
+29
" \"contentDetails\": {"
+30
" \"boundStreamId\": \"stream-001\""
+31
" }}")))
+32
+33
(define broadcast-minimal-json
+34
(json-decode
+35
(string-append
+36
"{\"id\": \"bcast-002\","
+37
" \"snippet\": {},"
+38
" \"status\": {},"
+39
" \"contentDetails\": {}}")))
+40
+41
(define stream-resource-json
+42
(json-decode
+43
(string-append
+44
"{\"id\": \"stream-001\","
+45
" \"snippet\": {"
+46
" \"title\": \"Main Stream Feed\""
+47
" },"
+48
" \"cdn\": {"
+49
" \"ingestionType\": \"rtmp\","
+50
" \"ingestionInfo\": {"
+51
" \"ingestionAddress\": \"rtmp://a.rtmp.youtube.com/live2\","
+52
" \"streamName\": \"xxxx-xxxx-xxxx-xxxx-xxxx\""
+53
" }"
+54
" }}")))
+55
+56
(define stream-minimal-json
+57
(json-decode
+58
(string-append
+59
"{\"id\": \"stream-002\","
+60
" \"snippet\": {},"
+61
" \"cdn\": {}}")))
+62
+63
;; ---------------------------------------------------------------
+64
;; Broadcast record tests
+65
;; ---------------------------------------------------------------
+66
+67
(test-group "broadcast records"
+68
+69
(test "broadcast construction"
+70
(let ((b (youtube-broadcast id: "bc1" title: "Live!"
+71
scheduled-start: "2026-04-01T20:00:00Z"
+72
lifecycle-status: "live"
+73
stream-id: "st1")))
+74
(assert-true (youtube-broadcast? b))
+75
(assert-equal "bc1" (youtube-broadcast-id b))
+76
(assert-equal "Live!" (youtube-broadcast-title b))
+77
(assert-equal "2026-04-01T20:00:00Z"
+78
(youtube-broadcast-scheduled-start b))
+79
(assert-equal "live" (youtube-broadcast-lifecycle-status b))
+80
(assert-equal "st1" (youtube-broadcast-stream-id b))))
+81
+82
(test "broadcast defaults"
+83
(let ((b (youtube-broadcast id: "bc2")))
+84
(assert-equal "" (youtube-broadcast-title b))
+85
(assert-equal #f (youtube-broadcast-scheduled-start b))
+86
(assert-equal "created" (youtube-broadcast-lifecycle-status b))
+87
(assert-equal #f (youtube-broadcast-stream-id b)))))
+88
+89
;; ---------------------------------------------------------------
+90
;; Stream record tests
+91
;; ---------------------------------------------------------------
+92
+93
(test-group "stream records"
+94
+95
(test "stream construction"
+96
(let ((s (youtube-stream id: "st1" title: "Feed"
+97
rtmp-url: "rtmp://example.com/live"
+98
stream-key: "key-123")))
+99
(assert-true (youtube-stream? s))
+100
(assert-equal "st1" (youtube-stream-id s))
+101
(assert-equal "Feed" (youtube-stream-title s))
+102
(assert-equal "rtmp://example.com/live" (youtube-stream-rtmp-url s))
+103
(assert-equal "key-123" (youtube-stream-stream-key s))))
+104
+105
(test "stream defaults"
+106
(let ((s (youtube-stream id: "st2")))
+107
(assert-equal "" (youtube-stream-title s))
+108
(assert-equal #f (youtube-stream-rtmp-url s))
+109
(assert-equal #f (youtube-stream-stream-key s)))))
+110
+111
;; ---------------------------------------------------------------
+112
;; Broadcast parsing tests
+113
;; ---------------------------------------------------------------
+114
+115
(test-group "broadcast parsing"
+116
+117
(test "parse full broadcast"
+118
(let ((b (parse-broadcast broadcast-resource-json)))
+119
(assert-true (youtube-broadcast? b))
+120
(assert-equal "bcast-001" (youtube-broadcast-id b))
+121
(assert-equal "Weekly Livestream" (youtube-broadcast-title b))
+122
(assert-equal "2026-03-28T18:00:00Z"
+123
(youtube-broadcast-scheduled-start b))
+124
(assert-equal "ready" (youtube-broadcast-lifecycle-status b))
+125
(assert-equal "stream-001" (youtube-broadcast-stream-id b))))
+126
+127
(test "parse minimal broadcast"
+128
(let ((b (parse-broadcast broadcast-minimal-json)))
+129
(assert-equal "bcast-002" (youtube-broadcast-id b))
+130
(assert-equal "" (youtube-broadcast-title b))
+131
(assert-equal #f (youtube-broadcast-scheduled-start b))
+132
(assert-equal "created" (youtube-broadcast-lifecycle-status b))
+133
(assert-equal #f (youtube-broadcast-stream-id b)))))
+134
+135
;; ---------------------------------------------------------------
+136
;; Stream parsing tests
+137
;; ---------------------------------------------------------------
+138
+139
(test-group "stream parsing"
+140
+141
(test "parse full stream"
+142
(let ((s (parse-stream stream-resource-json)))
+143
(assert-true (youtube-stream? s))
+144
(assert-equal "stream-001" (youtube-stream-id s))
+145
(assert-equal "Main Stream Feed" (youtube-stream-title s))
+146
(assert-equal "rtmp://a.rtmp.youtube.com/live2"
+147
(youtube-stream-rtmp-url s))
+148
(assert-equal "xxxx-xxxx-xxxx-xxxx-xxxx"
+149
(youtube-stream-stream-key s))))
+150
+151
(test "parse minimal stream"
+152
(let ((s (parse-stream stream-minimal-json)))
+153
(assert-equal "stream-002" (youtube-stream-id s))
+154
(assert-equal "" (youtube-stream-title s))
+155
(assert-equal #f (youtube-stream-rtmp-url s))
+156
(assert-equal #f (youtube-stream-stream-key s)))))
+157
+158
(run-tests)
test/playlist-test.sgladded
@@ -0,0 +1,155 @@
+1
;;; Tests for (sigil youtube playlist) — playlist module
+2
;;;
+3
;;; Tests record construction and JSON parsing with fixture data.
+4
+5
(import (sigil core)
+6
(sigil dict)
+7
(sigil string)
+8
(sigil struct)
+9
(sigil json)
+10
(sigil test)
+11
(sigil youtube)
+12
(sigil youtube playlist))
+13
+14
;; ---------------------------------------------------------------
+15
;; Test fixtures
+16
;; ---------------------------------------------------------------
+17
+18
(define playlist-resource-json
+19
(json-decode
+20
(string-append
+21
"{\"id\": \"PLxxxxxx\","
+22
" \"snippet\": {"
+23
" \"title\": \"Emacs From Scratch\","
+24
" \"description\": \"Building an Emacs config from scratch\""
+25
" },"
+26
" \"contentDetails\": {"
+27
" \"itemCount\": 15"
+28
" },"
+29
" \"status\": {"
+30
" \"privacyStatus\": \"public\""
+31
" }}")))
+32
+33
(define playlist-minimal-json
+34
(json-decode
+35
(string-append
+36
"{\"id\": \"PLyyyyyy\","
+37
" \"snippet\": {},"
+38
" \"contentDetails\": {},"
+39
" \"status\": {}}")))
+40
+41
(define playlist-item-json
+42
(json-decode
+43
(string-append
+44
"{\"id\": \"UExxxxxxxBBBB\","
+45
" \"snippet\": {"
+46
" \"title\": \"Episode 1: Getting Started\","
+47
" \"position\": 0,"
+48
" \"resourceId\": {"
+49
" \"kind\": \"youtube#video\","
+50
" \"videoId\": \"vid001\""
+51
" }"
+52
" }}")))
+53
+54
(define playlist-item-no-position-json
+55
(json-decode
+56
(string-append
+57
"{\"id\": \"UExxxxxxxCCCC\","
+58
" \"snippet\": {"
+59
" \"title\": \"Episode 2\","
+60
" \"resourceId\": {"
+61
" \"kind\": \"youtube#video\","
+62
" \"videoId\": \"vid002\""
+63
" }"
+64
" }}")))
+65
+66
;; ---------------------------------------------------------------
+67
;; Playlist record tests
+68
;; ---------------------------------------------------------------
+69
+70
(test-group "playlist records"
+71
+72
(test "playlist construction"
+73
(let ((p (youtube-playlist id: "PL1" title: "My Playlist"
+74
description: "A playlist"
+75
item-count: 10
+76
privacy-status: "unlisted")))
+77
(assert-true (youtube-playlist? p))
+78
(assert-equal "PL1" (youtube-playlist-id p))
+79
(assert-equal "My Playlist" (youtube-playlist-title p))
+80
(assert-equal "A playlist" (youtube-playlist-description p))
+81
(assert-equal 10 (youtube-playlist-item-count p))
+82
(assert-equal "unlisted" (youtube-playlist-privacy-status p))))
+83
+84
(test "playlist defaults"
+85
(let ((p (youtube-playlist id: "PL2")))
+86
(assert-equal "" (youtube-playlist-title p))
+87
(assert-equal "" (youtube-playlist-description p))
+88
(assert-equal 0 (youtube-playlist-item-count p))
+89
(assert-equal "public" (youtube-playlist-privacy-status p)))))
+90
+91
;; ---------------------------------------------------------------
+92
;; Playlist item record tests
+93
;; ---------------------------------------------------------------
+94
+95
(test-group "playlist item records"
+96
+97
(test "playlist item construction"
+98
(let ((item (youtube-playlist-item id: "ITEM1" video-id: "VID1"
+99
title: "First" position: 0)))
+100
(assert-true (youtube-playlist-item? item))
+101
(assert-equal "ITEM1" (youtube-playlist-item-id item))
+102
(assert-equal "VID1" (youtube-playlist-item-video-id item))
+103
(assert-equal "First" (youtube-playlist-item-title item))
+104
(assert-equal 0 (youtube-playlist-item-position item))))
+105
+106
(test "playlist item defaults"
+107
(let ((item (youtube-playlist-item id: "ITEM2")))
+108
(assert-equal "" (youtube-playlist-item-video-id item))
+109
(assert-equal "" (youtube-playlist-item-title item))
+110
(assert-equal 0 (youtube-playlist-item-position item)))))
+111
+112
;; ---------------------------------------------------------------
+113
;; Playlist parsing tests
+114
;; ---------------------------------------------------------------
+115
+116
(test-group "playlist parsing"
+117
+118
(test "parse full playlist"
+119
(let ((p (parse-playlist playlist-resource-json)))
+120
(assert-true (youtube-playlist? p))
+121
(assert-equal "PLxxxxxx" (youtube-playlist-id p))
+122
(assert-equal "Emacs From Scratch" (youtube-playlist-title p))
+123
(assert-equal "Building an Emacs config from scratch"
+124
(youtube-playlist-description p))
+125
(assert-equal 15 (youtube-playlist-item-count p))
+126
(assert-equal "public" (youtube-playlist-privacy-status p))))
+127
+128
(test "parse minimal playlist"
+129
(let ((p (parse-playlist playlist-minimal-json)))
+130
(assert-equal "PLyyyyyy" (youtube-playlist-id p))
+131
(assert-equal "" (youtube-playlist-title p))
+132
(assert-equal 0 (youtube-playlist-item-count p)))))
+133
+134
;; ---------------------------------------------------------------
+135
;; Playlist item parsing tests
+136
;; ---------------------------------------------------------------
+137
+138
(test-group "playlist item parsing"
+139
+140
(test "parse playlist item"
+141
(let ((item (parse-playlist-item playlist-item-json)))
+142
(assert-true (youtube-playlist-item? item))
+143
(assert-equal "UExxxxxxxBBBB" (youtube-playlist-item-id item))
+144
(assert-equal "vid001" (youtube-playlist-item-video-id item))
+145
(assert-equal "Episode 1: Getting Started"
+146
(youtube-playlist-item-title item))
+147
(assert-equal 0 (youtube-playlist-item-position item))))
+148
+149
(test "parse playlist item without position"
+150
(let ((item (parse-playlist-item playlist-item-no-position-json)))
+151
(assert-equal "UExxxxxxxCCCC" (youtube-playlist-item-id item))
+152
(assert-equal "vid002" (youtube-playlist-item-video-id item))
+153
(assert-equal 0 (youtube-playlist-item-position item)))))
+154
+155
(run-tests)
test/upload-test.sgladded
@@ -0,0 +1,43 @@
+1
;;; Tests for (sigil youtube upload) — upload module
+2
;;;
+3
;;; Tests record construction, chunk size constants, and upload
+4
;;; helper logic. Cannot test actual HTTP upload without mocking.
+5
+6
(import (sigil core)
+7
(sigil dict)
+8
(sigil string)
+9
(sigil struct)
+10
(sigil test)
+11
(sigil youtube)
+12
(sigil youtube upload))
+13
+14
;; ---------------------------------------------------------------
+15
;; Constants tests
+16
;; ---------------------------------------------------------------
+17
+18
(test-group "upload constants"
+19
+20
(test "chunk size is multiple of 256KB"
+21
(assert-equal 0 (modulo youtube-chunk-size (* 256 1024))))
+22
+23
(test "chunk size is 8MB"
+24
(assert-equal (* 8 1024 1024) youtube-chunk-size)))
+25
+26
;; ---------------------------------------------------------------
+27
;; Client construction for uploads
+28
;; ---------------------------------------------------------------
+29
+30
(test-group "upload client"
+31
+32
(test "client has upload URL"
+33
(let ((c (youtube-client access-token: "test-token")))
+34
(assert-equal "https://www.googleapis.com/upload/youtube/v3"
+35
(youtube-client-upload-url c))))
+36
+37
(test "custom upload URL"
+38
(let ((c (youtube-client access-token: "tok"
+39
upload-url: "http://localhost:9090/upload")))
+40
(assert-equal "http://localhost:9090/upload"
+41
(youtube-client-upload-url c)))))
+42
+43
(run-tests)
test/youtube-test.sgladded
@@ -0,0 +1,296 @@
+1
;;; Tests for (sigil youtube) — core module
+2
;;;
+3
;;; Tests record construction, JSON response parsing, URL building,
+4
;;; and query string encoding using fixture data (no real API calls).
+5
+6
(import (sigil core)
+7
(sigil dict)
+8
(sigil string)
+9
(sigil struct)
+10
(sigil json)
+11
(sigil test)
+12
(sigil youtube))
+13
+14
;; ---------------------------------------------------------------
+15
;; Test fixtures — JSON response samples
+16
;; ---------------------------------------------------------------
+17
+18
(define video-resource-json
+19
(json-decode
+20
(string-append
+21
"{\"id\": \"dQw4w9WgXcQ\","
+22
" \"snippet\": {"
+23
" \"title\": \"Test Video\","
+24
" \"description\": \"A test description\","
+25
" \"tags\": [\"test\", \"sigil\", \"youtube\"],"
+26
" \"categoryId\": \"28\","
+27
" \"publishedAt\": \"2026-03-20T12:00:00Z\""
+28
" },"
+29
" \"status\": {"
+30
" \"privacyStatus\": \"public\""
+31
" },"
+32
" \"statistics\": {"
+33
" \"viewCount\": \"1234\","
+34
" \"likeCount\": \"56\","
+35
" \"commentCount\": \"7\""
+36
" }}")))
+37
+38
(define video-no-tags-json
+39
(json-decode
+40
(string-append
+41
"{\"id\": \"abc123\","
+42
" \"snippet\": {"
+43
" \"title\": \"No Tags Video\","
+44
" \"description\": \"\""
+45
" },"
+46
" \"status\": {},"
+47
" \"statistics\": {}}")))
+48
+49
(define channel-resource-json
+50
(json-decode
+51
(string-append
+52
"{\"id\": \"UCxxxxxxxxxxxxxx\","
+53
" \"snippet\": {"
+54
" \"title\": \"System Crafters\""
+55
" },"
+56
" \"statistics\": {"
+57
" \"subscriberCount\": \"50000\","
+58
" \"videoCount\": \"300\""
+59
" },"
+60
" \"contentDetails\": {"
+61
" \"relatedPlaylists\": {"
+62
" \"uploads\": \"UUxxxxxxxxxxxxxx\""
+63
" }"
+64
" }}")))
+65
+66
(define channel-minimal-json
+67
(json-decode
+68
(string-append
+69
"{\"id\": \"UCyyyy\","
+70
" \"snippet\": {},"
+71
" \"statistics\": {},"
+72
" \"contentDetails\": {}}")))
+73
+74
;; ---------------------------------------------------------------
+75
;; Client construction tests
+76
;; ---------------------------------------------------------------
+77
+78
(test-group "client construction"
+79
+80
(test "client with access token"
+81
(let ((c (youtube-client access-token: "ya29.test-token")))
+82
(assert-true (youtube-client? c))
+83
(assert-equal "ya29.test-token" (youtube-client-access-token c))
+84
(assert-equal #f (youtube-client-api-key c))
+85
(assert-equal "https://www.googleapis.com/youtube/v3"
+86
(youtube-client-base-url c))))
+87
+88
(test "client with api key only"
+89
(let ((c (youtube-client api-key: "AIzaTest123")))
+90
(assert-equal #f (youtube-client-access-token c))
+91
(assert-equal "AIzaTest123" (youtube-client-api-key c))))
+92
+93
(test "client with both tokens"
+94
(let ((c (youtube-client access-token: "ya29.tok"
+95
api-key: "AIza.key")))
+96
(assert-equal "ya29.tok" (youtube-client-access-token c))
+97
(assert-equal "AIza.key" (youtube-client-api-key c))))
+98
+99
(test "custom base url"
+100
(let ((c (youtube-client access-token: "tok"
+101
base-url: "http://localhost:8080")))
+102
(assert-equal "http://localhost:8080"
+103
(youtube-client-base-url c)))))
+104
+105
;; ---------------------------------------------------------------
+106
;; Video record tests
+107
;; ---------------------------------------------------------------
+108
+109
(test-group "video records"
+110
+111
(test "video record construction"
+112
(let ((v (youtube-video id: "vid1" title: "My Video"
+113
description: "desc" tags: '("a" "b")
+114
category-id: "22"
+115
privacy-status: "unlisted")))
+116
(assert-true (youtube-video? v))
+117
(assert-equal "vid1" (youtube-video-id v))
+118
(assert-equal "My Video" (youtube-video-title v))
+119
(assert-equal "desc" (youtube-video-description v))
+120
(assert-equal '("a" "b") (youtube-video-tags v))
+121
(assert-equal "22" (youtube-video-category-id v))
+122
(assert-equal "unlisted" (youtube-video-privacy-status v))))
+123
+124
(test "video record defaults"
+125
(let ((v (youtube-video id: "vid2")))
+126
(assert-equal "" (youtube-video-title v))
+127
(assert-equal "" (youtube-video-description v))
+128
(assert-equal '() (youtube-video-tags v))
+129
(assert-equal #f (youtube-video-category-id v))
+130
(assert-equal "private" (youtube-video-privacy-status v))
+131
(assert-equal #f (youtube-video-published-at v)))))
+132
+133
;; ---------------------------------------------------------------
+134
;; Channel record tests
+135
;; ---------------------------------------------------------------
+136
+137
(test-group "channel records"
+138
+139
(test "channel record construction"
+140
(let ((ch (youtube-channel id: "UC123" title: "My Channel"
+141
subscriber-count: 1000
+142
video-count: 50
+143
uploads-playlist-id: "UU123")))
+144
(assert-true (youtube-channel? ch))
+145
(assert-equal "UC123" (youtube-channel-id ch))
+146
(assert-equal "My Channel" (youtube-channel-title ch))
+147
(assert-equal 1000 (youtube-channel-subscriber-count ch))
+148
(assert-equal 50 (youtube-channel-video-count ch))
+149
(assert-equal "UU123" (youtube-channel-uploads-playlist-id ch))))
+150
+151
(test "channel record defaults"
+152
(let ((ch (youtube-channel id: "UC456")))
+153
(assert-equal "" (youtube-channel-title ch))
+154
(assert-equal 0 (youtube-channel-subscriber-count ch))
+155
(assert-equal 0 (youtube-channel-video-count ch))
+156
(assert-equal #f (youtube-channel-uploads-playlist-id ch)))))
+157
+158
;; ---------------------------------------------------------------
+159
;; Video parsing tests
+160
;; ---------------------------------------------------------------
+161
+162
(test-group "video parsing"
+163
+164
(test "parse full video resource"
+165
(let ((v (parse-video video-resource-json)))
+166
(assert-true (youtube-video? v))
+167
(assert-equal "dQw4w9WgXcQ" (youtube-video-id v))
+168
(assert-equal "Test Video" (youtube-video-title v))
+169
(assert-equal "A test description" (youtube-video-description v))
+170
(assert-equal '("test" "sigil" "youtube") (youtube-video-tags v))
+171
(assert-equal "28" (youtube-video-category-id v))
+172
(assert-equal "public" (youtube-video-privacy-status v))
+173
(assert-equal "2026-03-20T12:00:00Z" (youtube-video-published-at v))))
+174
+175
(test "parse video statistics"
+176
(let ((v (parse-video video-resource-json)))
+177
(let ((stats (youtube-video-statistics v)))
+178
(assert-true (dict? stats))
+179
(assert-equal "1234" (dict-ref stats viewCount:))
+180
(assert-equal "56" (dict-ref stats likeCount:)))))
+181
+182
(test "parse video with missing tags"
+183
(let ((v (parse-video video-no-tags-json)))
+184
(assert-equal "abc123" (youtube-video-id v))
+185
(assert-equal "No Tags Video" (youtube-video-title v))
+186
(assert-equal '() (youtube-video-tags v)))))
+187
+188
;; ---------------------------------------------------------------
+189
;; Channel parsing tests
+190
;; ---------------------------------------------------------------
+191
+192
(test-group "channel parsing"
+193
+194
(test "parse full channel resource"
+195
(let ((ch (parse-channel channel-resource-json)))
+196
(assert-true (youtube-channel? ch))
+197
(assert-equal "UCxxxxxxxxxxxxxx" (youtube-channel-id ch))
+198
(assert-equal "System Crafters" (youtube-channel-title ch))
+199
(assert-equal 50000 (youtube-channel-subscriber-count ch))
+200
(assert-equal 300 (youtube-channel-video-count ch))
+201
(assert-equal "UUxxxxxxxxxxxxxx"
+202
(youtube-channel-uploads-playlist-id ch))))
+203
+204
(test "parse minimal channel resource"
+205
(let ((ch (parse-channel channel-minimal-json)))
+206
(assert-equal "UCyyyy" (youtube-channel-id ch))
+207
(assert-equal "" (youtube-channel-title ch))
+208
(assert-equal 0 (youtube-channel-subscriber-count ch))
+209
(assert-equal 0 (youtube-channel-video-count ch))
+210
(assert-equal #f (youtube-channel-uploads-playlist-id ch)))))
+211
+212
;; ---------------------------------------------------------------
+213
;; URL encoding tests
+214
;; ---------------------------------------------------------------
+215
+216
(test-group "URL encoding"
+217
+218
(test "encode simple string"
+219
(assert-equal "hello" (url-encode-value "hello")))
+220
+221
(test "encode spaces"
+222
(assert-equal "hello+world" (url-encode-value "hello world")))
+223
+224
(test "encode special characters"
+225
(assert-equal "foo%26bar" (url-encode-value "foo&bar")))
+226
+227
(test "encode equals sign"
+228
(assert-equal "key%3dvalue" (url-encode-value "key=value")))
+229
+230
(test "preserve unreserved characters"
+231
(assert-equal "a-b_c.d~e" (url-encode-value "a-b_c.d~e"))))
+232
+233
;; ---------------------------------------------------------------
+234
;; Query string building tests
+235
;; ---------------------------------------------------------------
+236
+237
(test-group "query string building"
+238
+239
(test "build simple query string"
+240
(assert-equal "?part=snippet&id=abc123"
+241
(build-query-string
+242
(list (cons "part" "snippet")
+243
(cons "id" "abc123")))))
+244
+245
(test "omit false values"
+246
(assert-equal "?part=snippet"
+247
(build-query-string
+248
(list (cons "part" "snippet")
+249
(cons "pageToken" #f)))))
+250
+251
(test "empty params"
+252
(assert-equal ""
+253
(build-query-string '())))
+254
+255
(test "encode values in query string"
+256
(assert-equal "?q=hello+world"
+257
(build-query-string
+258
(list (cons "q" "hello world"))))))
+259
+260
;; ---------------------------------------------------------------
+261
;; Auth headers tests
+262
;; ---------------------------------------------------------------
+263
+264
(test-group "auth headers"
+265
+266
(test "bearer token header"
+267
(let* ((c (youtube-client access-token: "ya29.test"))
+268
(headers (youtube-auth-headers c)))
+269
(assert-equal "Bearer ya29.test"
+270
(dict-ref headers authorization:))))
+271
+272
(test "no header without token"
+273
(let* ((c (youtube-client api-key: "AIza.key"))
+274
(headers (youtube-auth-headers c)))
+275
(assert-equal #f (dict-ref headers authorization: #f)))))
+276
+277
;; ---------------------------------------------------------------
+278
;; Page info parsing tests
+279
;; ---------------------------------------------------------------
+280
+281
(test-group "page info parsing"
+282
+283
(test "parse page info with next token"
+284
(let ((data #{ pageInfo: #{ totalResults: 42 }
+285
nextPageToken: "CDIQAA" }))
+286
(let ((info (parse-page-info data)))
+287
(assert-equal 42 (dict-ref info total-results:))
+288
(assert-equal "CDIQAA" (dict-ref info next-page-token:)))))
+289
+290
(test "parse page info without next token"
+291
(let ((data #{ pageInfo: #{ totalResults: 5 } }))
+292
(let ((info (parse-page-info data)))
+293
(assert-equal 5 (dict-ref info total-results:))
+294
(assert-equal #f (dict-ref info next-page-token:))))))
+295
+296
(run-tests)