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-Clausesrc/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)