AtlatestRepositorysigil-youtube

sigil-youtube / tree / srcyoutube.sgl

1;;; (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 (youtube upload))
12(define-library (youtube)
13 (import (sigil core)
14 (sigil dict)
15 (sigil string)
16 (sigil struct)
17 (sigil json)
18 (only (sigil http) url-encode-value build-query-string)
19 (sigil http client))
21 (export ;; Client
22 youtube-client
23 youtube-client?
24 youtube-client-access-token
25 youtube-client-api-key
26 youtube-client-base-url
27 youtube-client-upload-url
29 ;; Records — video
30 youtube-video
31 youtube-video?
32 youtube-video-id
33 youtube-video-title
34 youtube-video-description
35 youtube-video-tags
36 youtube-video-category-id
37 youtube-video-privacy-status
38 youtube-video-published-at
39 youtube-video-statistics
41 ;; Records — channel
42 youtube-channel
43 youtube-channel?
44 youtube-channel-id
45 youtube-channel-title
46 youtube-channel-subscriber-count
47 youtube-channel-video-count
48 youtube-channel-uploads-playlist-id
50 ;; Shared helpers (for sub-modules)
51 youtube-auth-headers
52 youtube-api-url
53 youtube-upload-api-url
54 youtube-get/json
55 youtube-post/json
56 youtube-put/json
57 youtube-delete/json
58 check-youtube-response
60 ;; Parsing
61 parse-video
62 parse-channel
63 parse-page-info
65 ;; API functions
66 youtube-channel-info
67 youtube-channel-mine
68 youtube-videos
69 youtube-video-update
70 youtube-video-delete
71 youtube-search
72 youtube-set-thumbnail)
74 (begin
76 ;; ---------------------------------------------------------------
77 ;; Records
78 ;; ---------------------------------------------------------------
80 (define-struct youtube-client
81 (access-token default: #f)
82 (api-key default: #f)
83 (base-url default: "https://www.googleapis.com/youtube/v3")
84 (upload-url default: "https://www.googleapis.com/upload/youtube/v3"))
86 (define-struct youtube-video
87 (id)
88 (title default: "")
89 (description default: "")
90 (tags default: '())
91 (category-id default: #f)
92 (privacy-status default: "private")
93 (published-at default: #f)
94 (statistics default: #{}))
96 (define-struct youtube-channel
97 (id)
98 (title default: "")
99 (subscriber-count default: 0)
100 (video-count default: 0)
101 (uploads-playlist-id default: #f))
103 ;; ---------------------------------------------------------------
104 ;; Internal helpers
105 ;; ---------------------------------------------------------------
107 (define (youtube-auth-headers client)
108 (let ((token (youtube-client-access-token client)))
109 (if token
110 #{ authorization: (string-append "Bearer " token) }
111 #{})))
113 (define (youtube-api-url client . parts)
114 (apply build-api-url (youtube-client-base-url client) parts))
116 (define (youtube-upload-api-url client . parts)
117 (apply build-api-url (youtube-client-upload-url client) parts))
120 ;;; Append api-key to query params if client has one and no access-token.
121 (define (maybe-add-api-key client params)
122 (let ((key (youtube-client-api-key client))
123 (token (youtube-client-access-token client)))
124 (if (and key (not token))
125 (cons (cons "key" key) params)
126 params)))
128 ;;; Response checker with YouTube-specific quota/auth context.
129 (define check-youtube-response
130 (make-response-checker
131 name: "YouTube API"
132 handlers: (list
133 (cons 401 "Access token may be expired — refresh and retry.")
134 (cons 403 "Possible quota exceeded or insufficient permissions. Check quota at console.cloud.google.com.")
135 (cons 429 "Respect Retry-After header and retry with backoff."))))
137 ;;; Authenticated JSON GET request.
138 (define (youtube-get/json client url)
139 (check-youtube-response
140 (http-get url headers: (youtube-auth-headers client))))
142 ;;; Authenticated JSON POST request.
143 (define (youtube-post/json client url body)
144 (check-youtube-response
145 (http-post url (if (string? body) body (json-encode body))
146 headers: (dict-merge (youtube-auth-headers client)
147 #{ content-type: "application/json" }))))
149 ;;; Authenticated JSON PUT request.
150 (define (youtube-put/json client url body)
151 (check-youtube-response
152 (http-put url (if (string? body) body (json-encode body))
153 headers: (dict-merge (youtube-auth-headers client)
154 #{ content-type: "application/json" }))))
156 ;;; Authenticated DELETE request.
157 (define (youtube-delete/json client url)
158 (check-youtube-response
159 (http-delete url headers: (youtube-auth-headers client))))
161 ;; ---------------------------------------------------------------
162 ;; Response parsing
163 ;; ---------------------------------------------------------------
165 ;;; Extract pagination info from a list response.
166 ;;; Returns a dict with total-results and next-page-token.
167 (define (parse-page-info data)
168 (let ((page-info (dict-ref data pageInfo: #{})))
169 #{ total-results: (dict-ref page-info totalResults: 0)
170 next-page-token: (dict-ref data nextPageToken: #f) }))
172 ;;; Parse a video resource from the API into a youtube-video record.
173 (define (parse-video data)
174 (let ((snippet (dict-ref data snippet: #{}))
175 (status (dict-ref data status: #{}))
176 (stats (dict-ref data statistics: #{})))
177 (youtube-video
178 id: (dict-ref data id:)
179 title: (dict-ref snippet title: "")
180 description: (dict-ref snippet description: "")
181 tags: (let ((t (dict-ref snippet tags: #f)))
182 (if (and t (array? t))
183 (array->list t)
184 '()))
185 category-id: (dict-ref snippet categoryId: #f)
186 privacy-status: (dict-ref status privacyStatus: "private")
187 published-at: (dict-ref snippet publishedAt: #f)
188 statistics: stats)))
190 ;;; Parse a channel resource from the API into a youtube-channel record.
191 (define (parse-channel data)
192 (let ((snippet (dict-ref data snippet: #{}))
193 (stats (dict-ref data statistics: #{}))
194 (content (dict-ref data contentDetails: #{})))
195 (let ((related (dict-ref content relatedPlaylists: #{})))
196 (youtube-channel
197 id: (dict-ref data id:)
198 title: (dict-ref snippet title: "")
199 subscriber-count: (let ((v (dict-ref stats subscriberCount: "0")))
200 (if (string? v) (string->number v) v))
201 video-count: (let ((v (dict-ref stats videoCount: "0")))
202 (if (string? v) (string->number v) v))
203 uploads-playlist-id: (dict-ref related uploads: #f)))))
205 ;; ---------------------------------------------------------------
206 ;; API functions
207 ;; ---------------------------------------------------------------
209 ;;; Get channel info by channel ID.
210 ;;; Quota cost: 1 unit.
211 (define (youtube-channel-info client channel-id)
212 (let* ((params (maybe-add-api-key client
213 (list (cons "part" "snippet,statistics,contentDetails")
214 (cons "id" channel-id))))
215 (url (string-append
216 (youtube-api-url client "channels")
217 (build-query-string params)))
218 (data (youtube-get/json client url))
219 (items (dict-ref data items: #[])))
220 (if (> (array-length items) 0)
221 (parse-channel (array-ref items 0))
222 #f)))
224 ;;; Get the authenticated user's channel info.
225 ;;; Quota cost: 1 unit. Requires OAuth access token.
226 (define (youtube-channel-mine client)
227 (if (not (youtube-client-access-token client))
228 (error "youtube-channel-mine requires an OAuth access token"))
229 (let* ((url (string-append
230 (youtube-api-url client "channels")
231 (build-query-string
232 (list (cons "part" "snippet,statistics,contentDetails")
233 (cons "mine" "true")))))
234 (data (youtube-get/json client url))
235 (items (dict-ref data items: #[])))
236 (if (> (array-length items) 0)
237 (parse-channel (array-ref items 0))
238 #f)))
240 ;;; Get videos by ID(s). Pass a single ID or comma-separated IDs.
241 ;;; Batching multiple IDs costs only 1 unit total.
242 ;;; Quota cost: 1 unit.
243 (define (youtube-videos client video-ids)
244 (let* ((params (maybe-add-api-key client
245 (list (cons "part" "snippet,statistics,status,contentDetails")
246 (cons "id" video-ids))))
247 (url (string-append
248 (youtube-api-url client "videos")
249 (build-query-string params)))
250 (data (youtube-get/json client url))
251 (items (dict-ref data items: #[])))
252 (array->list (array-map parse-video items))))
254 ;;; Update video metadata. Takes a video ID and a dict of fields to update.
255 ;;; Only fields present in updates are sent; omitted fields are not touched.
256 ;;; Supported fields: title:, description:, tags:, categoryId:, privacyStatus:.
257 ;;;
258 ;;; IMPORTANT: YouTube requires both title: and categoryId: when updating
259 ;;; any snippet field. If you update description: or tags:, you must also
260 ;;; include title: and categoryId: in the updates dict. This function
261 ;;; raises an error if snippet fields are being updated without both
262 ;;; title: and categoryId: present.
263 ;;;
264 ;;; Quota cost: 50 units.
265 (define (youtube-video-update client video-id updates)
266 (let* ((snippet-keys '((title: . title:)
267 (description: . description:)
268 (tags: . tags:)
269 (categoryId: . categoryId:)))
270 (status-keys '((privacyStatus: . privacyStatus:)))
271 (collect (lambda (key-map)
272 (filter cdr
273 (map (lambda (pair)
274 (cons (cdr pair)
275 (dict-ref updates (car pair) #f)))
276 key-map))))
277 (snippet-fields (collect snippet-keys))
278 (status-fields (collect status-keys))
279 (parts (append (if (null? snippet-fields) '() '("snippet"))
280 (if (null? status-fields) '() '("status")))))
281 (if (null? parts)
282 (error "youtube-video-update: no fields to update"))
283 ;; YouTube requires both title and categoryId when snippet is in part
284 (if (not (null? snippet-fields))
285 (begin
286 (if (not (dict-ref updates title: #f))
287 (error (string-append
288 "youtube-video-update: title: is required when updating "
289 "snippet fields (YouTube API requirement)")))
290 (if (not (dict-ref updates categoryId: #f))
291 (error (string-append
292 "youtube-video-update: categoryId: is required when updating "
293 "snippet fields (YouTube API requirement)")))))
294 (let* ((fields->dict
295 (lambda (fields)
296 (apply dict (apply append
297 (map (lambda (p) (list (car p) (cdr p))) fields)))))
298 (body (let ((b #{ id: video-id }))
299 (let ((b (if (null? snippet-fields) b
300 (dict-set b snippet: (fields->dict snippet-fields)))))
301 (if (null? status-fields) b
302 (dict-set b status: (fields->dict status-fields))))))
303 (url (string-append
304 (youtube-api-url client "videos")
305 (build-query-string
306 (list (cons "part" (string-join parts ","))))))
307 (result (youtube-put/json client url body)))
308 (parse-video result))))
310 ;;; Delete a video by ID.
311 ;;; Quota cost: 50 units.
312 (define (youtube-video-delete client video-id)
313 (let ((url (string-append
314 (youtube-api-url client "videos")
315 (build-query-string
316 (list (cons "id" video-id))))))
317 (youtube-delete/json client url)))
319 ;;; Search YouTube. WARNING: costs 100 quota units per call.
320 ;;; Prefer youtube-videos with known IDs or playlist-items when possible.
321 ;;;
322 ;;; query: search term string
323 ;;; Optional keyword args via opts dict:
324 ;;; type: "video", "channel", "playlist" (default: "video")
325 ;;; max-results: number (default: 25, max: 50)
326 ;;; order: "date", "rating", "relevance", "title", "viewCount"
327 ;;; page-token: for pagination
328 ;;; channel-id: restrict to a channel
329 ;;; published-after: ISO 8601 datetime
330 ;;; published-before: ISO 8601 datetime
331 ;;;
332 ;;; Quota cost: 100 units.
333 (define (youtube-search client query . rest)
334 (let ((opts (if (null? rest) #{} (car rest))))
335 (let* ((params (maybe-add-api-key client
336 (list (cons "part" "snippet")
337 (cons "q" query)
338 (cons "type" (dict-ref opts type: "video"))
339 (cons "maxResults"
340 (number->string
341 (dict-ref opts max-results: 25)))
342 (cons "order"
343 (dict-ref opts order: #f))
344 (cons "pageToken"
345 (dict-ref opts page-token: #f))
346 (cons "channelId"
347 (dict-ref opts channel-id: #f))
348 (cons "publishedAfter"
349 (dict-ref opts published-after: #f))
350 (cons "publishedBefore"
351 (dict-ref opts published-before: #f)))))
352 (url (string-append
353 (youtube-api-url client "search")
354 (build-query-string params)))
355 (data (youtube-get/json client url)))
356 ;; Return raw results — search returns snippet-only items with
357 ;; id.videoId rather than full video resources
358 data)))
360 ;;; Upload a custom thumbnail for a video.
361 ;;; image-data should be the raw bytes of a JPEG or PNG image.
362 ;;; Max size: 2 MB. Recommended: 1280x720 (min width 640px).
363 ;;; Quota cost: 50 units.
364 (define (youtube-set-thumbnail client video-id image-data content-type)
365 (let ((url (string-append
366 (youtube-upload-api-url client "thumbnails" "set")
367 (build-query-string
368 (list (cons "videoId" video-id))))))
369 (check-youtube-response
370 (http-post url image-data
371 headers: (dict-merge (youtube-auth-headers client)
372 #{ content-type: content-type })))))
374 ))