Commitfc06f4a9Recorded25 Mar 2026Repositorytube

Implement tube CLI and MCP server

Message

Dual CLI/MCP server for unified YouTube + Twitch management. Five modules: config (env credentials), youtube (upload, videos, analytics, livestream, playlists), twitch (channel, schedule, analytics, chat), tools (7 MCP tools with cross-platform support), and main (CLI dispatch + MCP server entry point).

24 tests covering config, YouTube formatters, and Twitch formatters.

Changed
 .gitignore            |   2 ++
 README.md             | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/tube/config.sgl   |  46 +++++++++++++++++++++++++++
 src/tube/main.sgl     | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/tube/tools.sgl    | 293 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/tube/twitch.sgl   | 178 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/tube/youtube.sgl  | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/test-config.sgl  |  46 +++++++++++++++++++++++++++
 test/test-twitch.sgl  | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/test-youtube.sgl | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 10 files changed, 1256 insertions(+), 1 deletion(-)
Diff
.gitignoreadded
@@ -0,0 +1,2 @@
+1
build/
+2
lib
README.mdmodified
@@ -1,3 +1,107 @@
1
# tube
2
3
Video platform management CLI and MCP server — YouTube + Twitch
3
No newline at end of file
+4
Video platform management CLI and MCP server — YouTube + Twitch unified in one interface.
+5
+6
Upload videos, edit metadata, check analytics, schedule streams, manage chat, and get a cross-platform overview of your content channels.
+7
+8
## Installation
+9
+10
Requires a C toolchain. On Guix:
+11
+12
```bash
+13
guix shell -m ../sigil/manifest.scm
+14
```
+15
+16
Build:
+17
+18
```bash
+19
sigil build --redirects dev-redirects.sgl
+20
```
+21
+22
Run tests:
+23
+24
```bash
+25
sigil test
+26
```
+27
+28
## Usage
+29
+30
### CLI Mode
+31
+32
```bash
+33
# Platform overview (subscribers, followers, live status)
+34
tube status
+35
+36
# List recent YouTube videos with stats
+37
tube videos --limit 20
+38
+39
# Upload a video to YouTube
+40
tube upload video.mp4 --title "My Video" --description "About this video" --privacy unlisted
+41
+42
# Analytics summary (YouTube + Twitch combined)
+43
tube analytics --days 30
+44
+45
# View stream schedule across platforms
+46
tube stream-schedule --platform both
+47
+48
# Start a broadcast
+49
tube go-live --title "Weekly Livestream" --platform both
+50
+51
# Send a chat message
+52
tube chat "Hello everyone!" --platform twitch
+53
```
+54
+55
### MCP Server Mode
+56
+57
```bash
+58
tube serve
+59
```
+60
+61
This starts an MCP server exposing the following tools:
+62
+63
| Tool | Description |
+64
|------|-------------|
+65
| `tube/upload` | Upload video to YouTube |
+66
| `tube/videos` | List recent videos with stats |
+67
| `tube/analytics` | Cross-platform analytics summary |
+68
| `tube/schedule` | View stream schedule (YouTube + Twitch) |
+69
| `tube/go-live` | Start broadcast on one or both platforms |
+70
| `tube/chat` | Send message to live chat |
+71
| `tube/status` | Platform overview (subs, followers, live status) |
+72
+73
## Configuration
+74
+75
Set these environment variables:
+76
+77
| Variable | Required | Description |
+78
|----------|----------|-------------|
+79
| `YOUTUBE_ACCESS_TOKEN` | For YouTube write ops | YouTube OAuth2 access token |
+80
| `YOUTUBE_API_KEY` | For YouTube read ops | YouTube Data API key |
+81
| `TWITCH_CLIENT_ID` | For Twitch | Twitch application client ID |
+82
| `TWITCH_ACCESS_TOKEN` | For Twitch | Twitch OAuth2 access token |
+83
| `TUBE_DEFAULT_PLATFORM` | No | Default platform: `youtube`, `twitch`, or `both` (default: `both`) |
+84
+85
You only need credentials for the platforms you want to use. If you only have YouTube credentials, Twitch operations will gracefully report that credentials are missing, and vice versa.
+86
+87
## Architecture
+88
+89
```
+90
src/tube/
+91
config.sgl — Environment-based configuration
+92
youtube.sgl — YouTube operations (wraps sigil-youtube)
+93
twitch.sgl — Twitch operations (wraps sigil-twitch)
+94
tools.sgl — MCP tool definitions + shared helpers
+95
main.sgl — Dual CLI/MCP entry point
+96
```
+97
+98
tube follows the dual CLI/MCP server pattern used by [fjo](https://codeberg.org/sigil/fjo) and [tally](https://codeberg.org/sigil/tally). The same business logic serves both the command-line interface and MCP tool handlers.
+99
+100
## Dependencies
+101
+102
- [sigil-youtube](https://codeberg.org/sigil/sigil-youtube) — YouTube Data API v3 client
+103
- [sigil-twitch](https://codeberg.org/sigil/sigil-twitch) — Twitch Helix API client
+104
- [sigil-mcp](https://codeberg.org/sigil/sigil) — MCP server framework
+105
+106
## License
+107
+108
BSD-3-Clause
src/tube/config.sgladded
@@ -0,0 +1,46 @@
+1
;;; (tube config) - Configuration from environment variables.
+2
;;;
+3
;;; Reads YouTube and Twitch credentials from environment.
+4
;;; YOUTUBE_ACCESS_TOKEN / YOUTUBE_API_KEY for YouTube,
+5
;;; TWITCH_CLIENT_ID / TWITCH_ACCESS_TOKEN for Twitch,
+6
;;; TUBE_DEFAULT_PLATFORM for default platform preference.
+7
+8
(define-library (tube config)
+9
(import (sigil core)
+10
(sigil struct)
+11
(sigil process)
+12
(sigil env))
+13
(export tube-config
+14
tube-config?
+15
tube-config-youtube-access-token
+16
tube-config-youtube-api-key
+17
tube-config-twitch-client-id
+18
tube-config-twitch-access-token
+19
tube-config-default-platform
+20
+21
load-tube-config)
+22
(begin
+23
+24
(define-struct tube-config
+25
(youtube-access-token default: #f)
+26
(youtube-api-key default: #f)
+27
(twitch-client-id default: #f)
+28
(twitch-access-token default: #f)
+29
(default-platform default: "both"))
+30
+31
;;; Load configuration from environment variables.
+32
;;;
+33
;;; - YOUTUBE_ACCESS_TOKEN — OAuth2 token for YouTube (required for mutations)
+34
;;; - YOUTUBE_API_KEY — API key for YouTube read-only operations
+35
;;; - TWITCH_CLIENT_ID — Twitch application client ID
+36
;;; - TWITCH_ACCESS_TOKEN — Twitch OAuth2 token
+37
;;; - TUBE_DEFAULT_PLATFORM — default platform: "youtube", "twitch", or "both"
+38
(define (load-tube-config)
+39
(tube-config
+40
youtube-access-token: (getenv "YOUTUBE_ACCESS_TOKEN")
+41
youtube-api-key: (getenv "YOUTUBE_API_KEY")
+42
twitch-client-id: (getenv "TWITCH_CLIENT_ID")
+43
twitch-access-token: (getenv "TWITCH_ACCESS_TOKEN")
+44
default-platform: (getenv-default "TUBE_DEFAULT_PLATFORM" "both")))
+45
+46
))
src/tube/main.sgladded
@@ -0,0 +1,185 @@
+1
;;; (tube main) - Entry point for tube CLI and MCP server.
+2
;;;
+3
;;; Dispatches between MCP server mode (`tube serve`) and CLI mode
+4
;;; (`tube <command> <args>`). Follows the fjo/tally dual-mode pattern.
+5
+6
(define-library (tube main)
+7
(import (sigil core)
+8
(sigil string)
+9
(sigil process)
+10
(sigil mcp server)
+11
(tube config)
+12
(tube youtube)
+13
(tube twitch)
+14
(tube tools))
+15
(export main)
+16
(begin
+17
+18
(define (run-server config)
+19
(let ((server (mcp-server name: "tube" version: "0.1.0")))
+20
(register-tube-tools! server config)
+21
(mcp-server-run server)))
+22
+23
(define (print-usage)
+24
(display "tube - Video platform management tool (YouTube + Twitch)\n\n")
+25
(display "Usage:\n")
+26
(display " tube serve Start MCP server\n")
+27
(display " tube upload <file> [--title T] [--description D] [--privacy P]\n")
+28
(display " Upload video to YouTube\n")
+29
(display " tube videos [--limit N] List recent YouTube videos\n")
+30
(display " tube analytics [--days N] [--platform P] Analytics summary\n")
+31
(display " tube stream-schedule [--platform P] Show upcoming streams\n")
+32
(display " tube go-live [--title T] [--platform P] Start a broadcast\n")
+33
(display " tube chat <message> [--platform P] Send chat message\n")
+34
(display " tube status [--platform P] Platform overview\n\n")
+35
(display "Platforms: youtube, twitch, both (default: both)\n\n")
+36
(display "Environment:\n")
+37
(display " YOUTUBE_ACCESS_TOKEN YouTube OAuth2 token\n")
+38
(display " YOUTUBE_API_KEY YouTube API key (read-only)\n")
+39
(display " TWITCH_CLIENT_ID Twitch application client ID\n")
+40
(display " TWITCH_ACCESS_TOKEN Twitch OAuth2 token\n")
+41
(display " TUBE_DEFAULT_PLATFORM Default platform (youtube/twitch/both)\n"))
+42
+43
;;; Find a --flag value in an argument list.
+44
(define (find-flag args flag default)
+45
(let loop ((rest args))
+46
(cond
+47
((null? rest) default)
+48
((and (string=? (car rest) flag)
+49
(not (null? (cdr rest))))
+50
(cadr rest))
+51
(else (loop (cdr rest))))))
+52
+53
;;; Collect non-flag arguments (positional args).
+54
(define (positional-args args)
+55
(let loop ((rest args) (acc '()))
+56
(cond
+57
((null? rest) (reverse acc))
+58
((and (> (string-length (car rest)) 1)
+59
(char=? (string-ref (car rest) 0) #\-)
+60
(char=? (string-ref (car rest) 1) #\-))
+61
;; Skip --flag and its value
+62
(if (null? (cdr rest))
+63
(reverse acc)
+64
(loop (cddr rest) acc)))
+65
(else
+66
(loop (cdr rest) (cons (car rest) acc))))))
+67
+68
;; ============================================================
+69
;; CLI Command Handlers
+70
;; ============================================================
+71
+72
(define (cli-upload config args)
+73
(let* ((positionals (positional-args args))
+74
(file (if (pair? positionals) (car positionals) #f))
+75
(title (find-flag args "--title" "Untitled"))
+76
(description (find-flag args "--description" ""))
+77
(privacy (find-flag args "--privacy" "private")))
+78
(unless file
+79
(display "Error: file path is required.\n")
+80
(exit 1))
+81
;; Read file data
+82
(let* ((port (open-binary-input-file file))
+83
(data (read-bytevector-all port)))
+84
(close-input-port port)
+85
(let ((result (tube-upload-video config data title description privacy)))
+86
(display (string-append
+87
"Uploaded: " (youtube-video-title result) "\n"
+88
"Video ID: " (youtube-video-id result) "\n"
+89
"Privacy: " privacy "\n"))))))
+90
+91
(define (cli-videos config args)
+92
(let* ((limit (string->number (find-flag args "--limit" "10")))
+93
(videos (tube-list-videos config limit)))
+94
(if (null? videos)
+95
(display "No videos found.\n")
+96
(begin
+97
(display (string-append
+98
"Recent videos (" (number->string (length videos)) "):\n\n"))
+99
(for-each (lambda (v)
+100
(display (format-video v))
+101
(display "\n\n"))
+102
videos)))))
+103
+104
(define (cli-analytics config args start-default end-default)
+105
(let* ((days (find-flag args "--days" "30"))
+106
(platform (find-flag args "--platform"
+107
(tube-config-default-platform config)))
+108
(params #{ days: (string->number days)
+109
platform: platform
+110
start_date: start-default
+111
end_date: end-default }))
+112
;; Reuse the tool handler
+113
(display (tool-analytics config params start-default end-default))
+114
(newline)))
+115
+116
(define (cli-schedule config args)
+117
(let* ((platform (find-flag args "--platform"
+118
(tube-config-default-platform config)))
+119
(params #{ platform: platform }))
+120
(display (tool-schedule config params))
+121
(newline)))
+122
+123
(define (cli-go-live config args)
+124
(let* ((title (find-flag args "--title" "Live Stream"))
+125
(platform (find-flag args "--platform"
+126
(tube-config-default-platform config)))
+127
(params #{ title: title platform: platform }))
+128
(display (tool-go-live config params))
+129
(newline)))
+130
+131
(define (cli-chat config args)
+132
(let* ((positionals (positional-args args))
+133
(message (if (pair? positionals)
+134
(string-join positionals " ")
+135
#f))
+136
(platform (find-flag args "--platform" "twitch")))
+137
(unless message
+138
(display "Error: chat message is required.\n")
+139
(exit 1))
+140
(let ((params #{ message: message platform: platform }))
+141
(display (tool-chat config params))
+142
(newline))))
+143
+144
(define (cli-status config args)
+145
(let* ((platform (find-flag args "--platform"
+146
(tube-config-default-platform config)))
+147
(params #{ platform: platform }))
+148
(display (tool-status config params))
+149
(newline)))
+150
+151
;; ============================================================
+152
;; Entry Point
+153
;; ============================================================
+154
+155
(define (main)
+156
(let ((config (load-tube-config))
+157
(args (cdr (command-line)))
+158
(start-default (default-start-date))
+159
(end-default (default-end-date)))
+160
(cond
+161
((null? args)
+162
(print-usage)
+163
(exit 1))
+164
((string=? (car args) "serve")
+165
(run-server config))
+166
((string=? (car args) "upload")
+167
(cli-upload config (cdr args)))
+168
((string=? (car args) "videos")
+169
(cli-videos config (cdr args)))
+170
((string=? (car args) "analytics")
+171
(cli-analytics config (cdr args) start-default end-default))
+172
((string=? (car args) "stream-schedule")
+173
(cli-schedule config (cdr args)))
+174
((string=? (car args) "go-live")
+175
(cli-go-live config (cdr args)))
+176
((string=? (car args) "chat")
+177
(cli-chat config (cdr args)))
+178
((string=? (car args) "status")
+179
(cli-status config (cdr args)))
+180
(else
+181
(display (string-append "Unknown command: " (car args) "\n\n"))
+182
(print-usage)
+183
(exit 1)))))
+184
+185
))
src/tube/tools.sgladded
@@ -0,0 +1,293 @@
+1
;;; (tube tools) - MCP tool definitions and shared helpers for tube.
+2
;;;
+3
;;; Provides MCP tool registration for YouTube and Twitch operations.
+4
;;; Tools present unified cross-platform data where appropriate.
+5
+6
(define-library (tube tools)
+7
(import (sigil core)
+8
(sigil string)
+9
(sigil dict)
+10
(sigil struct)
+11
(sigil json)
+12
(sigil time)
+13
(sigil mcp server)
+14
(sigil youtube)
+15
(sigil youtube analytics)
+16
(sigil youtube live)
+17
(sigil youtube playlist)
+18
(sigil twitch)
+19
(sigil twitch schedule)
+20
(sigil twitch analytics)
+21
(tube config)
+22
(tube youtube)
+23
(tube twitch))
+24
(export register-tube-tools!
+25
default-start-date
+26
default-end-date)
+27
(begin
+28
+29
;;; Compute default start date (30 days ago).
+30
;;; Must be called outside async contexts due to VM yield bug
+31
;;; with current-second.
+32
(define (default-start-date)
+33
(format-date (- (current-second) (* 30 86400))))
+34
+35
;;; Compute default end date (today).
+36
;;; Must be called outside async contexts due to VM yield bug
+37
;;; with current-second.
+38
(define (default-end-date)
+39
(format-date (current-second)))
+40
+41
;;; Check if a platform is enabled based on config and requested platform.
+42
(define (platform-enabled? config platform requested)
+43
(let ((req (or requested (tube-config-default-platform config))))
+44
(or (string=? req "both")
+45
(string=? req platform))))
+46
+47
;;; Collect results from enabled platforms. Each provider is a pair
+48
;;; of (platform-name . thunk). Returns results from enabled platforms
+49
;;; that succeed, or the fallback message if none produced data.
+50
(define (collect-platform-results config requested providers fallback)
+51
(let ((results
+52
(filter identity
+53
(map (lambda (provider)
+54
(let ((platform (car provider))
+55
(thunk (cdr provider)))
+56
(if (platform-enabled? config platform requested)
+57
(guard (exn (else #f))
+58
(thunk))
+59
#f)))
+60
providers))))
+61
(if (null? results)
+62
fallback
+63
(string-join results "\n\n"))))
+64
+65
;; ============================================================
+66
;; Tool Handlers
+67
;; ============================================================
+68
+69
(define (tool-upload config params)
+70
(let* ((file (dict-ref params file:))
+71
(title (dict-ref params title: "Untitled"))
+72
(description (dict-ref params description: ""))
+73
(privacy (dict-ref params privacy: "private")))
+74
(unless file
+75
(error "file parameter is required"))
+76
(let ((result (tube-upload-video config file title description privacy)))
+77
(string-append "Video uploaded successfully.\n"
+78
"Video ID: " (youtube-video-id result) "\n"
+79
"Title: " (youtube-video-title result) "\n"
+80
"Privacy: " privacy))))
+81
+82
(define (tool-videos config params)
+83
(let* ((limit (dict-ref params limit: 10))
+84
(videos (tube-list-videos config limit)))
+85
(if (null? videos)
+86
"No videos found."
+87
(string-append
+88
"Recent videos (" (number->string (length videos)) "):\n\n"
+89
(string-join (map format-video videos) "\n\n")))))
+90
+91
(define (tool-analytics config params start-default end-default)
+92
(let* ((platform (dict-ref params platform:
+93
(tube-config-default-platform config)))
+94
(start-date (dict-ref params start_date: start-default))
+95
(end-date (dict-ref params end_date: end-default)))
+96
(collect-platform-results config platform
+97
(list
+98
(cons "youtube"
+99
(lambda ()
+100
(let* ((yt-data (tube-video-analytics config start-date end-date))
+101
(rows (dict-ref yt-data rows: '())))
+102
(if (null? rows)
+103
"No YouTube data for this period."
+104
(string-append
+105
"YouTube Analytics (" start-date " to " end-date "):\n"
+106
"Day\tViews\tWatch Min\tAvg Duration\tNew Subs\n"
+107
(string-join (map format-analytics-row rows) "\n"))))))
+108
(cons "twitch"
+109
(lambda ()
+110
(let* ((followers (tube-twitch-followers config 1))
+111
(total (dict-ref followers total: 0))
+112
(clips (tube-twitch-clips config 5)))
+113
(string-append
+114
"Twitch Analytics:\n"
+115
"Total Followers: " (number->string total) "\n"
+116
(if (null? clips)
+117
"No recent clips."
+118
(string-append
+119
"Recent Clips:\n"
+120
(string-join (map format-twitch-clip clips) "\n"))))))))
+121
"No analytics data available. Check platform credentials.")))
+122
+123
(define (tool-schedule config params)
+124
(let ((platform (dict-ref params platform:
+125
(tube-config-default-platform config))))
+126
(collect-platform-results config platform
+127
(list
+128
(cons "youtube"
+129
(lambda ()
+130
(let ((broadcasts (tube-list-broadcasts config "upcoming")))
+131
(if (null? broadcasts)
+132
"YouTube: No upcoming broadcasts."
+133
(string-append
+134
"YouTube Upcoming Broadcasts:\n"
+135
(string-join (map format-broadcast broadcasts) "\n"))))))
+136
(cons "twitch"
+137
(lambda ()
+138
(let ((segments (tube-twitch-schedule config)))
+139
(if (null? segments)
+140
"Twitch: No scheduled streams."
+141
(string-append
+142
"Twitch Schedule:\n"
+143
(string-join (map format-schedule-segment segments) "\n")))))))
+144
"No schedule data available. Check platform credentials.")))
+145
+146
(define (tool-go-live config params)
+147
(let* ((platform (dict-ref params platform:
+148
(tube-config-default-platform config)))
+149
(title (dict-ref params title: "Live Stream"))
+150
;; Pre-compute date outside platform lambdas to avoid
+151
;; calling format-date inside async context
+152
(today (default-end-date)))
+153
(collect-platform-results config platform
+154
(list
+155
(cons "youtube"
+156
(lambda ()
+157
(let* ((scheduled (string-append today "T00:00:00Z"))
+158
(broadcast (tube-create-broadcast config title scheduled)))
+159
(string-append
+160
"YouTube broadcast created: "
+161
(youtube-broadcast-id broadcast)))))
+162
(cons "twitch"
+163
(lambda ()
+164
(let ((client (require-twitch-client config))
+165
(channel (tube-twitch-channel-info config)))
+166
(twitch-modify-channel client
+167
(twitch-channel-id channel)
+168
#{ title: title })
+169
(string-append "Twitch channel title set to: " title)))))
+170
"Failed to start broadcast. Check platform credentials.")))
+171
+172
(define (tool-chat config params)
+173
(let* ((message (dict-ref params message:))
+174
(platform (dict-ref params platform: "twitch")))
+175
(unless message
+176
(error "message parameter is required"))
+177
(cond
+178
((string=? platform "twitch")
+179
(tube-twitch-send-chat config message)
+180
(string-append "Chat message sent to Twitch: " message))
+181
((string=? platform "youtube")
+182
"YouTube live chat is not yet supported via this tool.")
+183
(else
+184
(error (string-append "Unknown platform: " platform))))))
+185
+186
(define (tool-status config params)
+187
(let ((platform (dict-ref params platform:
+188
(tube-config-default-platform config))))
+189
(collect-platform-results config platform
+190
(list
+191
(cons "youtube" (lambda () (tube-youtube-status config)))
+192
(cons "twitch" (lambda () (tube-twitch-status config))))
+193
"No platform data available. Check credentials.")))
+194
+195
;; ============================================================
+196
;; MCP Registration
+197
;; ============================================================
+198
+199
;;; Register all tube MCP tools on the given server.
+200
;;; Precomputes default dates here (before async context) to avoid
+201
;;; the VM yield bug with current-second in async goroutines.
+202
(define (register-tube-tools! server config)
+203
(let ((start-default (default-start-date))
+204
(end-default (default-end-date)))
+205
+206
(mcp-server-register-tool! server
+207
name: "tube/upload"
+208
description: "Upload a video to YouTube. Provide the file path, title, optional description, and privacy level."
+209
schema: #{ type: "object"
+210
properties: #{
+211
file: #{ type: "string"
+212
description: "Path to the video file to upload" }
+213
title: #{ type: "string"
+214
description: "Video title" }
+215
description: #{ type: "string"
+216
description: "Video description" }
+217
privacy: #{ type: "string"
+218
description: "Privacy status: public, private, or unlisted (default: private)"
+219
enum: #["public" "private" "unlisted"] } }
+220
required: #["file"] }
+221
handler: (lambda (params) (tool-upload config params)))
+222
+223
(mcp-server-register-tool! server
+224
name: "tube/videos"
+225
description: "List recent YouTube videos with view counts, likes, and comment counts."
+226
schema: #{ type: "object"
+227
properties: #{
+228
limit: #{ type: "integer"
+229
description: "Number of videos to return (default: 10)" } } }
+230
handler: (lambda (params) (tool-videos config params)))
+231
+232
(mcp-server-register-tool! server
+233
name: "tube/analytics"
+234
description: "Get analytics summary combining YouTube and Twitch data. Shows views, watch time, subscribers/followers, and engagement."
+235
schema: #{ type: "object"
+236
properties: #{
+237
days: #{ type: "integer"
+238
description: "Number of days to look back (default: 30)" }
+239
platform: #{ type: "string"
+240
description: "Platform: youtube, twitch, or both (default: both)"
+241
enum: #["youtube" "twitch" "both"] }
+242
start_date: #{ type: "string"
+243
description: "Start date in YYYY-MM-DD format" }
+244
end_date: #{ type: "string"
+245
description: "End date in YYYY-MM-DD format" } } }
+246
handler: (lambda (params) (tool-analytics config params start-default end-default)))
+247
+248
(mcp-server-register-tool! server
+249
name: "tube/schedule"
+250
description: "View stream schedule across YouTube (upcoming broadcasts) and Twitch (schedule segments)."
+251
schema: #{ type: "object"
+252
properties: #{
+253
platform: #{ type: "string"
+254
description: "Platform: youtube, twitch, or both (default: both)"
+255
enum: #["youtube" "twitch" "both"] } } }
+256
handler: (lambda (params) (tool-schedule config params)))
+257
+258
(mcp-server-register-tool! server
+259
name: "tube/go-live"
+260
description: "Start a broadcast on one or both platforms. Creates a YouTube broadcast and/or sets the Twitch channel title."
+261
schema: #{ type: "object"
+262
properties: #{
+263
title: #{ type: "string"
+264
description: "Stream title" }
+265
platform: #{ type: "string"
+266
description: "Platform: youtube, twitch, or both (default: both)"
+267
enum: #["youtube" "twitch" "both"] } } }
+268
handler: (lambda (params) (tool-go-live config params)))
+269
+270
(mcp-server-register-tool! server
+271
name: "tube/chat"
+272
description: "Send a message to live chat on the specified platform."
+273
schema: #{ type: "object"
+274
properties: #{
+275
message: #{ type: "string"
+276
description: "Chat message to send" }
+277
platform: #{ type: "string"
+278
description: "Platform: youtube or twitch (default: twitch)"
+279
enum: #["youtube" "twitch"] } }
+280
required: #["message"] }
+281
handler: (lambda (params) (tool-chat config params)))
+282
+283
(mcp-server-register-tool! server
+284
name: "tube/status"
+285
description: "Overview of both platforms — subscriber/follower counts, recent activity, live status, and channel info."
+286
schema: #{ type: "object"
+287
properties: #{
+288
platform: #{ type: "string"
+289
description: "Platform: youtube, twitch, or both (default: both)"
+290
enum: #["youtube" "twitch" "both"] } } }
+291
handler: (lambda (params) (tool-status config params)))))
+292
+293
))
src/tube/twitch.sgladded
@@ -0,0 +1,178 @@
+1
;;; (tube twitch) - Twitch-specific operations wrapping sigil-twitch.
+2
;;;
+3
;;; Channel info, stream scheduling, analytics (followers, subs, clips),
+4
;;; and chat operations. All operations that need the broadcaster ID
+5
;;; resolve it once via with-twitch-context to avoid repeated API calls.
+6
+7
(define-library (tube twitch)
+8
(import (sigil core)
+9
(sigil string)
+10
(sigil dict)
+11
(sigil struct)
+12
(sigil json)
+13
(sigil twitch)
+14
(sigil twitch schedule)
+15
(sigil twitch analytics)
+16
(sigil twitch chat)
+17
(tube config))
+18
(export require-twitch-client
+19
tube-twitch-channel-info
+20
tube-twitch-status
+21
tube-twitch-schedule
+22
tube-twitch-create-segment
+23
tube-twitch-followers
+24
tube-twitch-subscribers
+25
tube-twitch-clips
+26
tube-twitch-videos
+27
tube-twitch-send-chat
+28
format-twitch-channel
+29
format-schedule-segment
+30
format-twitch-clip
+31
format-twitch-video)
+32
(begin
+33
+34
;;; Build a twitch-client from config, raising if no credentials.
+35
(define (require-twitch-client config)
+36
(let ((client-id (tube-config-twitch-client-id config))
+37
(token (tube-config-twitch-access-token config)))
+38
(unless (and client-id token)
+39
(error "Twitch credentials not set. Set TWITCH_CLIENT_ID and TWITCH_ACCESS_TOKEN."))
+40
(twitch-client client-id: client-id
+41
access-token: token)))
+42
+43
;;; Resolve client + broadcaster ID together. Caches the broadcaster ID
+44
;;; lookup so multiple operations in the same tool handler share one call.
+45
(define (with-twitch-context config proc)
+46
(let* ((client (require-twitch-client config))
+47
(users (twitch-users client))
+48
(broadcaster-id
+49
(if (and (pair? users) (car users))
+50
(twitch-user-id (car users))
+51
(error "Could not determine Twitch user ID"))))
+52
(proc client broadcaster-id)))
+53
+54
;;; Format a twitch-channel record.
+55
(define (format-twitch-channel channel)
+56
(string-append
+57
(twitch-channel-name channel)
+58
(let ((title (twitch-channel-title channel)))
+59
(if (and title (not (string=? title "")))
+60
(string-append " - " title)
+61
""))
+62
(let ((game (twitch-channel-game-name channel)))
+63
(if (and game (not (string=? game "")))
+64
(string-append " [" game "]")
+65
""))))
+66
+67
;;; Get channel info for the authenticated user.
+68
(define (tube-twitch-channel-info config)
+69
(with-twitch-context config
+70
(lambda (client broadcaster-id)
+71
(let ((channels (twitch-channel-info client broadcaster-id)))
+72
(if (pair? channels)
+73
(car channels)
+74
(error "Could not fetch Twitch channel info"))))))
+75
+76
;;; Get a Twitch channel status overview.
+77
(define (tube-twitch-status config)
+78
(with-twitch-context config
+79
(lambda (client broadcaster-id)
+80
(let* ((channels (twitch-channel-info client broadcaster-id))
+81
(channel (if (pair? channels) (car channels) #f))
+82
(followers (twitch-followers client broadcaster-id))
+83
(follower-count (if followers (dict-ref followers total: 0) 0))
+84
(streams (twitch-streams client user-id: broadcaster-id))
+85
(is-live (and (pair? streams) (car streams))))
+86
(string-append
+87
"Twitch Channel: " (if channel (twitch-channel-name channel) "unknown") "\n"
+88
"Followers: " (number->string follower-count) "\n"
+89
"Status: " (if is-live
+90
(string-append "LIVE - "
+91
(twitch-stream-title (car streams))
+92
" (" (number->string (twitch-stream-viewer-count (car streams)))
+93
" viewers)")
+94
"Offline"))))))
+95
+96
;;; Format a schedule segment.
+97
(define (format-schedule-segment seg)
+98
(string-append
+99
(twitch-schedule-segment-start-time seg) " - "
+100
(twitch-schedule-segment-end-time seg) " "
+101
(or (twitch-schedule-segment-title seg) "(no title)")
+102
(let ((cat (twitch-schedule-segment-category seg)))
+103
(if cat
+104
(string-append " [" (dict-ref cat name: "unknown") "]")
+105
""))
+106
(if (twitch-schedule-segment-is-recurring seg)
+107
" (recurring)"
+108
"")))
+109
+110
;;; Get the stream schedule.
+111
(define (tube-twitch-schedule config)
+112
(with-twitch-context config
+113
(lambda (client broadcaster-id)
+114
(let ((result (twitch-schedule client broadcaster-id)))
+115
(dict-ref result segments: '())))))
+116
+117
;;; Create a new schedule segment.
+118
(define (tube-twitch-create-segment config start-time timezone
+119
. rest)
+120
(with-twitch-context config
+121
(lambda (client broadcaster-id)
+122
(let ((title (if (pair? rest) (car rest) #f))
+123
(duration (if (and (pair? rest) (pair? (cdr rest)))
+124
(cadr rest) 60)))
+125
(twitch-create-segment client broadcaster-id start-time timezone
+126
duration: duration
+127
title: title)))))
+128
+129
;;; Get follower data.
+130
(define (tube-twitch-followers config . rest)
+131
(with-twitch-context config
+132
(lambda (client broadcaster-id)
+133
(let ((limit (if (pair? rest) (car rest) 20)))
+134
(twitch-followers client broadcaster-id first: limit)))))
+135
+136
;;; Get subscriber data.
+137
(define (tube-twitch-subscribers config)
+138
(with-twitch-context config
+139
(lambda (client broadcaster-id)
+140
(twitch-subscribers client broadcaster-id))))
+141
+142
;;; Format a twitch-clip record.
+143
(define (format-twitch-clip clip)
+144
(string-append
+145
(twitch-clip-title clip)
+146
" (" (number->string (twitch-clip-view-count clip)) " views)"
+147
" - " (twitch-clip-url clip)))
+148
+149
;;; Get recent clips.
+150
(define (tube-twitch-clips config . rest)
+151
(with-twitch-context config
+152
(lambda (client broadcaster-id)
+153
(let ((limit (if (pair? rest) (car rest) 10)))
+154
(twitch-clips client broadcaster-id: broadcaster-id first: limit)))))
+155
+156
;;; Format a twitch-video record.
+157
(define (format-twitch-video video)
+158
(string-append
+159
(twitch-video-id video) " "
+160
(twitch-video-title video)
+161
" [" (twitch-video-type video) "]"
+162
" (" (number->string (twitch-video-view-count video)) " views)"))
+163
+164
;;; Get recent VODs/videos.
+165
(define (tube-twitch-videos config . rest)
+166
(with-twitch-context config
+167
(lambda (client broadcaster-id)
+168
(let ((limit (if (pair? rest) (car rest) 10)))
+169
(twitch-videos client user-id: broadcaster-id first: limit)))))
+170
+171
;;; Send a chat message to the channel.
+172
(define (tube-twitch-send-chat config message)
+173
(with-twitch-context config
+174
(lambda (client broadcaster-id)
+175
(twitch-send-chat-message client broadcaster-id broadcaster-id
+176
message))))
+177
+178
))
src/tube/youtube.sgladded
@@ -0,0 +1,181 @@
+1
;;; (tube youtube) - YouTube-specific operations wrapping sigil-youtube.
+2
;;;
+3
;;; Upload with metadata, video listing with stats, analytics dashboard,
+4
;;; livestream management, and playlist management.
+5
+6
(define-library (tube youtube)
+7
(import (sigil core)
+8
(sigil string)
+9
(sigil dict)
+10
(sigil struct)
+11
(sigil json)
+12
(sigil youtube)
+13
(sigil youtube upload)
+14
(sigil youtube analytics)
+15
(sigil youtube live)
+16
(sigil youtube playlist)
+17
(tube config))
+18
(export require-youtube-client
+19
tube-upload-video
+20
tube-list-videos
+21
tube-video-analytics
+22
tube-youtube-status
+23
tube-list-broadcasts
+24
tube-create-broadcast
+25
tube-go-live-youtube
+26
tube-list-playlists
+27
format-video
+28
format-analytics-row
+29
format-broadcast
+30
format-playlist)
+31
(begin
+32
+33
;;; Build a youtube-client from config, raising if no credentials.
+34
(define (require-youtube-client config)
+35
(let ((token (tube-config-youtube-access-token config))
+36
(key (tube-config-youtube-api-key config)))
+37
(unless (or token key)
+38
(error "No YouTube credentials set. Set YOUTUBE_ACCESS_TOKEN or YOUTUBE_API_KEY."))
+39
(youtube-client access-token: token
+40
api-key: key)))
+41
+42
;; ============================================================
+43
;; Upload
+44
;; ============================================================
+45
+46
;;; Upload a video to YouTube with metadata.
+47
;;; Returns the video resource dict on success.
+48
(define (tube-upload-video config file-data title
+49
. rest)
+50
(let* ((client (require-youtube-client config))
+51
(description (if (and (pair? rest) (car rest)) (car rest) ""))
+52
(privacy (if (and (pair? rest) (pair? (cdr rest)) (cadr rest))
+53
(cadr rest) "private"))
+54
(metadata #{ snippet: #{ title: title
+55
description: description
+56
categoryId: "28" }
+57
status: #{ privacyStatus: privacy } }))
+58
(youtube-upload-video client metadata file-data)))
+59
+60
;; ============================================================
+61
;; Video Listing
+62
;; ============================================================
+63
+64
;;; Format a youtube-video record as a display string.
+65
(define (format-video video)
+66
(let ((stats (youtube-video-statistics video)))
+67
(string-append
+68
(youtube-video-id video) " "
+69
(youtube-video-title video)
+70
(if stats
+71
(string-append
+72
"\n Views: " (dict-ref stats viewCount: "0")
+73
" Likes: " (dict-ref stats likeCount: "0")
+74
" Comments: " (dict-ref stats commentCount: "0"))
+75
""))))
+76
+77
;;; List recent videos from the authenticated channel.
+78
;;; Returns a list of youtube-video records.
+79
(define (tube-list-videos config . rest)
+80
(let* ((client (require-youtube-client config))
+81
(limit (if (pair? rest) (car rest) 10))
+82
(channel (youtube-channel-mine client))
+83
(uploads-id (youtube-channel-uploads-playlist-id channel))
+84
(items (youtube-playlist-items client uploads-id
+85
max-results: limit))
+86
(video-ids (map youtube-playlist-item-video-id items)))
+87
(if (null? video-ids)
+88
'()
+89
(youtube-videos client video-ids))))
+90
+91
;; ============================================================
+92
;; Analytics
+93
;; ============================================================
+94
+95
;;; Format an analytics row as a display string.
+96
(define (format-analytics-row row)
+97
(string-join (map (lambda (v)
+98
(if (string? v) v (number->string v)))
+99
row)
+100
"\t"))
+101
+102
;;; Get a YouTube analytics summary for the given date range.
+103
;;; Returns a dict with column-headers and rows.
+104
(define (tube-video-analytics config start-date end-date)
+105
(let ((client (require-youtube-client config)))
+106
(youtube-analytics-query client start-date end-date
+107
"views,estimatedMinutesWatched,averageViewDuration,subscribersGained"
+108
dimensions: "day"
+109
sort: "-day")))
+110
+111
;; ============================================================
+112
;; YouTube Status
+113
;; ============================================================
+114
+115
;;; Get YouTube channel status overview.
+116
;;; Returns a formatted string with subscriber count, video count,
+117
;;; and recent video info.
+118
(define (tube-youtube-status config)
+119
(let* ((client (require-youtube-client config))
+120
(channel (youtube-channel-mine client)))
+121
(string-append
+122
"YouTube Channel: " (youtube-channel-title channel) "\n"
+123
"Subscribers: " (number->string (youtube-channel-subscriber-count channel)) "\n"
+124
"Total Videos: " (number->string (youtube-channel-video-count channel)))))
+125
+126
;; ============================================================
+127
;; Livestream
+128
;; ============================================================
+129
+130
;;; Format a youtube-broadcast record.
+131
(define (format-broadcast broadcast)
+132
(string-append
+133
(youtube-broadcast-id broadcast) " "
+134
(youtube-broadcast-title broadcast) " "
+135
"[" (youtube-broadcast-lifecycle-status broadcast) "]"
+136
(let ((start (youtube-broadcast-scheduled-start broadcast)))
+137
(if start (string-append " Scheduled: " start) ""))))
+138
+139
;;; List upcoming/active broadcasts.
+140
(define (tube-list-broadcasts config . rest)
+141
(let* ((client (require-youtube-client config))
+142
(status (if (pair? rest) (car rest) "upcoming")))
+143
(youtube-list-broadcasts client broadcast-status: status)))
+144
+145
;;; Create a new broadcast and stream, bind them.
+146
;;; Returns the broadcast record.
+147
(define (tube-create-broadcast config title scheduled-start)
+148
(let* ((client (require-youtube-client config))
+149
(broadcast (youtube-create-broadcast client title scheduled-start
+150
enable-auto-start: #t
+151
enable-auto-stop: #t))
+152
(stream (youtube-create-stream client
+153
(string-append title " stream"))))
+154
(youtube-bind-broadcast client
+155
(youtube-broadcast-id broadcast)
+156
(youtube-stream-id stream))
+157
broadcast))
+158
+159
;;; Transition a broadcast to live.
+160
(define (tube-go-live-youtube config broadcast-id)
+161
(let ((client (require-youtube-client config)))
+162
(youtube-transition-broadcast client broadcast-id "live")))
+163
+164
;; ============================================================
+165
;; Playlists
+166
;; ============================================================
+167
+168
;;; Format a youtube-playlist record.
+169
(define (format-playlist playlist)
+170
(string-append
+171
(youtube-playlist-id playlist) " "
+172
(youtube-playlist-title playlist)
+173
" (" (number->string (youtube-playlist-item-count playlist)) " items)"
+174
" [" (youtube-playlist-privacy-status playlist) "]"))
+175
+176
;;; List playlists for the authenticated channel.
+177
(define (tube-list-playlists config)
+178
(let ((client (require-youtube-client config)))
+179
(youtube-playlists client)))
+180
+181
))
test/test-config.sgladded
@@ -0,0 +1,46 @@
+1
;;; Test suite for (tube config)
+2
;;;
+3
;;; Tests configuration loading from environment variables.
+4
+5
(import (sigil test)
+6
(sigil string)
+7
(sigil struct)
+8
(sigil process)
+9
(tube config))
+10
+11
;; ========== Config Defaults ==========
+12
+13
(test-group "tube-config defaults"
+14
(test "default platform is both"
+15
(let ((config (tube-config)))
+16
(assert-equal "both" (tube-config-default-platform config))))
+17
+18
(test "default tokens are #f"
+19
(let ((config (tube-config)))
+20
(assert-false (tube-config-youtube-access-token config))
+21
(assert-false (tube-config-youtube-api-key config))
+22
(assert-false (tube-config-twitch-client-id config))
+23
(assert-false (tube-config-twitch-access-token config)))))
+24
+25
;; ========== Config Construction ==========
+26
+27
(test-group "tube-config construction"
+28
(test "creates config with all fields"
+29
(let ((config (tube-config
+30
youtube-access-token: "yt-token"
+31
youtube-api-key: "yt-key"
+32
twitch-client-id: "tw-id"
+33
twitch-access-token: "tw-token"
+34
default-platform: "youtube")))
+35
(assert-equal "yt-token" (tube-config-youtube-access-token config))
+36
(assert-equal "yt-key" (tube-config-youtube-api-key config))
+37
(assert-equal "tw-id" (tube-config-twitch-client-id config))
+38
(assert-equal "tw-token" (tube-config-twitch-access-token config))
+39
(assert-equal "youtube" (tube-config-default-platform config))))
+40
+41
(test "tube-config? predicate works"
+42
(assert-true (tube-config? (tube-config)))
+43
(assert-false (tube-config? "not a config"))
+44
(assert-false (tube-config? 42))))
+45
+46
(run-tests)
test/test-twitch.sgladded
@@ -0,0 +1,114 @@
+1
;;; Test suite for (tube twitch)
+2
;;;
+3
;;; Tests Twitch helper functions using fixture data.
+4
;;; Does not require a Twitch API connection.
+5
+6
(import (sigil test)
+7
(sigil string)
+8
(sigil struct)
+9
(sigil dict)
+10
(sigil twitch)
+11
(sigil twitch schedule)
+12
(sigil twitch analytics)
+13
(tube config)
+14
(tube twitch))
+15
+16
;; ========== Client Construction ==========
+17
+18
(test-group "require-twitch-client"
+19
(test "raises when no credentials set"
+20
(let ((config (tube-config)))
+21
(assert-error (require-twitch-client config))))
+22
+23
(test "raises when only client-id set"
+24
(let ((config (tube-config twitch-client-id: "test-id")))
+25
(assert-error (require-twitch-client config))))
+26
+27
(test "raises when only access-token set"
+28
(let ((config (tube-config twitch-access-token: "test-token")))
+29
(assert-error (require-twitch-client config))))
+30
+31
(test "creates client with both credentials"
+32
(let* ((config (tube-config
+33
twitch-client-id: "test-id"
+34
twitch-access-token: "test-token"))
+35
(client (require-twitch-client config)))
+36
(assert-true (twitch-client? client))
+37
(assert-equal "test-id" (twitch-client-client-id client))
+38
(assert-equal "test-token" (twitch-client-access-token client)))))
+39
+40
;; ========== Formatting ==========
+41
+42
(test-group "format-twitch-channel"
+43
(test "formats channel with title and game"
+44
(let* ((channel (twitch-channel
+45
id: "123"
+46
name: "systemcrafters"
+47
title: "Building with Sigil"
+48
game-name: "Science & Technology"))
+49
(result (format-twitch-channel channel)))
+50
(assert-true (string-contains? result "systemcrafters"))
+51
(assert-true (string-contains? result "Building with Sigil"))
+52
(assert-true (string-contains? result "Science & Technology"))))
+53
+54
(test "formats channel without title"
+55
(let* ((channel (twitch-channel
+56
id: "123"
+57
name: "systemcrafters"
+58
title: ""))
+59
(result (format-twitch-channel channel)))
+60
(assert-true (string-contains? result "systemcrafters"))
+61
(assert-false (string-contains? result " - ")))))
+62
+63
(test-group "format-schedule-segment"
+64
(test "formats recurring segment"
+65
(let* ((seg (twitch-schedule-segment
+66
id: "seg1"
+67
start-time: "2026-03-28T18:00:00Z"
+68
end-time: "2026-03-28T20:00:00Z"
+69
title: "Weekly Stream"
+70
category: #{ name: "Just Chatting" }
+71
is-recurring: #t))
+72
(result (format-schedule-segment seg)))
+73
(assert-true (string-contains? result "2026-03-28T18:00:00Z"))
+74
(assert-true (string-contains? result "Weekly Stream"))
+75
(assert-true (string-contains? result "Just Chatting"))
+76
(assert-true (string-contains? result "recurring"))))
+77
+78
(test "formats non-recurring segment without category"
+79
(let* ((seg (twitch-schedule-segment
+80
id: "seg2"
+81
start-time: "2026-04-01T20:00:00Z"
+82
end-time: "2026-04-01T22:00:00Z"
+83
title: "Special Event"
+84
is-recurring: #f))
+85
(result (format-schedule-segment seg)))
+86
(assert-true (string-contains? result "Special Event"))
+87
(assert-false (string-contains? result "recurring")))))
+88
+89
(test-group "format-twitch-clip"
+90
(test "formats clip"
+91
(let* ((clip (twitch-clip
+92
id: "clip1"
+93
url: "https://clips.twitch.tv/test"
+94
title: "Amazing Moment"
+95
view-count: 500))
+96
(result (format-twitch-clip clip)))
+97
(assert-true (string-contains? result "Amazing Moment"))
+98
(assert-true (string-contains? result "500"))
+99
(assert-true (string-contains? result "https://clips.twitch.tv/test")))))
+100
+101
(test-group "format-twitch-video"
+102
(test "formats video"
+103
(let* ((video (twitch-video
+104
id: "v123"
+105
title: "Past Stream"
+106
type: "archive"
+107
view-count: 200))
+108
(result (format-twitch-video video)))
+109
(assert-true (string-contains? result "v123"))
+110
(assert-true (string-contains? result "Past Stream"))
+111
(assert-true (string-contains? result "archive"))
+112
(assert-true (string-contains? result "200")))))
+113
+114
(run-tests)
test/test-youtube.sgladded
@@ -0,0 +1,106 @@
+1
;;; Test suite for (tube youtube)
+2
;;;
+3
;;; Tests YouTube helper functions using fixture data.
+4
;;; Does not require a YouTube API connection.
+5
+6
(import (sigil test)
+7
(sigil string)
+8
(sigil struct)
+9
(sigil dict)
+10
(sigil youtube)
+11
(sigil youtube live)
+12
(sigil youtube playlist)
+13
(tube config)
+14
(tube youtube))
+15
+16
;; ========== Client Construction ==========
+17
+18
(test-group "require-youtube-client"
+19
(test "raises when no credentials set"
+20
(let ((config (tube-config)))
+21
(assert-error (require-youtube-client config))))
+22
+23
(test "creates client with access token"
+24
(let* ((config (tube-config youtube-access-token: "test-token"))
+25
(client (require-youtube-client config)))
+26
(assert-true (youtube-client? client))
+27
(assert-equal "test-token" (youtube-client-access-token client))))
+28
+29
(test "creates client with api key only"
+30
(let* ((config (tube-config youtube-api-key: "test-key"))
+31
(client (require-youtube-client config)))
+32
(assert-true (youtube-client? client))
+33
(assert-equal "test-key" (youtube-client-api-key client)))))
+34
+35
;; ========== Formatting ==========
+36
+37
(test-group "format-video"
+38
(test "formats video with stats"
+39
(let* ((video (youtube-video
+40
id: "abc123"
+41
title: "Test Video"
+42
description: "A test"
+43
statistics: #{ viewCount: "1000"
+44
likeCount: "50"
+45
commentCount: "10" }))
+46
(result (format-video video)))
+47
(assert-true (string-contains? result "abc123"))
+48
(assert-true (string-contains? result "Test Video"))
+49
(assert-true (string-contains? result "1000"))
+50
(assert-true (string-contains? result "50"))
+51
(assert-true (string-contains? result "10"))))
+52
+53
(test "formats video without stats"
+54
(let* ((video (youtube-video
+55
id: "def456"
+56
title: "No Stats Video"))
+57
(result (format-video video)))
+58
(assert-true (string-contains? result "def456"))
+59
(assert-true (string-contains? result "No Stats Video")))))
+60
+61
(test-group "format-broadcast"
+62
(test "formats broadcast with schedule"
+63
(let* ((broadcast (youtube-broadcast
+64
id: "bc123"
+65
title: "Weekly Stream"
+66
lifecycle-status: "upcoming"
+67
scheduled-start: "2026-03-28T18:00:00Z"))
+68
(result (format-broadcast broadcast)))
+69
(assert-true (string-contains? result "bc123"))
+70
(assert-true (string-contains? result "Weekly Stream"))
+71
(assert-true (string-contains? result "upcoming"))
+72
(assert-true (string-contains? result "2026-03-28"))))
+73
+74
(test "formats broadcast without schedule"
+75
(let* ((broadcast (youtube-broadcast
+76
id: "bc456"
+77
title: "Ad Hoc Stream"
+78
lifecycle-status: "live"))
+79
(result (format-broadcast broadcast)))
+80
(assert-true (string-contains? result "live")))))
+81
+82
(test-group "format-playlist"
+83
(test "formats playlist"
+84
(let* ((playlist (youtube-playlist
+85
id: "pl123"
+86
title: "My Playlist"
+87
item-count: 42
+88
privacy-status: "public"))
+89
(result (format-playlist playlist)))
+90
(assert-true (string-contains? result "pl123"))
+91
(assert-true (string-contains? result "My Playlist"))
+92
(assert-true (string-contains? result "42"))
+93
(assert-true (string-contains? result "public")))))
+94
+95
;; ========== Analytics Formatting ==========
+96
+97
(test-group "format-analytics-row"
+98
(test "formats mixed string and number row"
+99
(let ((result (format-analytics-row '("2026-03-20" 150 320 45 5))))
+100
(assert-equal "2026-03-20\t150\t320\t45\t5" result)))
+101
+102
(test "formats all-string row"
+103
(let ((result (format-analytics-row '("a" "b" "c"))))
+104
(assert-equal "a\tb\tc" result))))
+105
+106
(run-tests)