nx_yt_captions_lib.nx source
↩ module page · 482 lines · 29357 B
1// nx_yt_captions_lib.nx -- the YouTube TIMED-TEXT (caption/transcript) path, composed on the estate's
2// proven InnerTube + sovereign-TLS stack. It does NOT duplicate nx_yt_innertube: it COMPOSES yt_innertube
3// (the ANDROID_VR /youtubei/v1/player POST that self-heals against key rotation) to obtain the player
4// response, reads the captionTracks[] baseUrl out of THAT SAME response, and fetches the timedtext body
5// with nx_https_fetch_follow (the shared gzip-decoding, redirect-following GET). Parsers turn json3 and
6// srv1/XML timedtext into rows `startMs \t durMs \t text`, entities decoded through nx_html_entities.
7//
8// WHY A SIBLING LIB, NOT AN EDIT TO nx_yt_innertube: that lib's header declares its subject as the stream
9// path (streamingData.formats[].url); captions are a distinct output read from the same response, so this
10// stays a NEW file that imports it (blast radius: zero -- nothing that imports innertube is touched).
11//
12// WHY THE WEB baseUrl DOES NOT WORK (measured 2026-09-18 from the NAS): the WEB captionTracks baseUrl
13// scraped from a watch page answers status=200 with an EMPTY body -- it is IP-locked (ip=0.0.0.0) and
14// requires a proof-of-origin (pot) token the WEB client mints via BotGuard. The ANDROID_VR client's
15// baseUrl carries no such requirement, which is the whole reason this composes yt_innertube.
16// license_tier: ORIGINAL No hw writes (Rule 26). LIB (no main).
17import "nx_syscalls.nx"
18import "nx_yt_innertube.nx" // yt_innertube (ANDROID_VR player POST); brings TrustStore + TLS closure
19import "nx_media_state.nx" // ms_find, ms_extract_json_value
20import "nx_https_fetch_follow.nx" // nx_https_fetch_follow: gzip-decoding, redirect-following GET; nx_csprng_fill
21import "nx_html_entities.nx" // nx_html_entity_lookup: HTML5 named-entity decode for srv1/XML text
22
23// ---- caps (named for their purpose) ------------------------------------------------------------
24// Mozilla certdata.txt measures ~1MB; this is ITS reserve alone (matches nx_research_fetch/nx_yt_probe).
25const YTC_CERTDATA_CAP: i64 = 4194304
26// THE PLAYER-RESPONSE RESERVE: an ANDROID_VR /player JSON is ~100-300 KB for a normal video; 8 MiB is
27// generous headroom. A network body's exact size is unknowable in advance, so this is a NAMED bound for
28// this one purpose (buffer-cap law); mmap commits pages lazily so headroom costs address space only.
29const YTC_PLAYER_CAP: i64 = 8388608
30// THE CAPTION-BODY RESERVE: a timedtext response for a multi-hour lecture is at most a few hundred KB of
31// text; 64 MiB is far past any real transcript. Same named-network-body bound; the caller ANNOUNCES and
32// REFUSES if a body ever fills it rather than saving a maybe-truncated transcript.
33const YTC_BODY_CAP: i64 = 67108864
34// A signed timedtext baseUrl measured at ~350 B (WEB) / ~600 B (translation params); 8 KiB is generous.
35const YTC_URL_CAP: i64 = 8192
36
37// ---- small byte helpers --------------------------------------------------------------------------
38func ytc_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
39func ytc_catn(dst: *u8, o: i64, v: i64, cap: i64) -> i64 {
40 if v == 0 { if o < cap - 1 { dst[o] = 48 as u8; o = o + 1 } return o }
41 var m: i64 = v
42 if m < 0 { if o < cap - 1 { dst[o] = 45 as u8; o = o + 1 } m = 0 - m }
43 let t: *u8 = sys_mmap(24); var k: i64 = 0
44 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
45 var j: i64 = 0
46 while j < k { if o < cap - 1 { dst[o] = t[k - 1 - j]; o = o + 1 } j = j + 1 }
47 return o
48}
49func ytc_hexval(c: i64) -> i64 {
50 if c >= 48 { if c <= 57 { return c - 48 } }
51 if c >= 97 { if c <= 102 { return c - 87 } }
52 if c >= 65 { if c <= 70 { return c - 55 } }
53 return 0
54}
55// emit codepoint cp as UTF-8 at dst[o..]; strip=1 maps ASCII control (<32) to space so a row stays 1 line.
56func ytc_utf8_emit(dst: *u8, o: i64, cp0: i64, cap: i64, strip: i64) -> i64 {
57 var cp: i64 = cp0
58 if strip == 1 { if cp < 32 { cp = 32 } }
59 if cp < 128 { if o < cap - 1 { dst[o] = cp as u8; o = o + 1 } return o }
60 if cp < 2048 { if o < cap - 2 { dst[o] = (192 + (cp >> 6)) as u8; dst[o+1] = (128 + (cp & 63)) as u8; o = o + 2 } return o }
61 if cp < 65536 { if o < cap - 3 { dst[o] = (224 + (cp >> 12)) as u8; dst[o+1] = (128 + ((cp >> 6) & 63)) as u8; dst[o+2] = (128 + (cp & 63)) as u8; o = o + 3 } return o }
62 if o < cap - 4 { dst[o] = (240 + (cp >> 18)) as u8; dst[o+1] = (128 + ((cp >> 12) & 63)) as u8; dst[o+2] = (128 + ((cp >> 6) & 63)) as u8; dst[o+3] = (128 + (cp & 63)) as u8; o = o + 4 }
63 return o
64}
65
66// Copy a JSON string value (src[start..] is the FIRST content byte AFTER the opening quote) into dst[o..],
67// decoding \/ \" \\ \n \t \r and \uXXXX (incl surrogate pairs). Stops at the unescaped closing quote.
68// strip=1 sanitises control bytes to space. Returns new o; out_end[0] = index of the closing quote.
69func ytc_copy_jsonstr(src: *u8, start: i64, slen: i64, dst: *u8, o0: i64, cap: i64, strip: i64, out_end: *i64) -> i64 {
70 var i: i64 = start
71 var o: i64 = o0
72 var go: i64 = 1
73 while go == 1 {
74 if i >= slen { go = 0 }
75 else {
76 let c: i64 = src[i] & 0xff
77 if c == 34 { out_end[0] = i; return o } // unescaped closing quote
78 if c == 92 { // backslash
79 if i + 1 >= slen { out_end[0] = i; return o }
80 let n: i64 = src[i+1] & 0xff
81 if n == 117 { // \u
82 if i + 5 < slen {
83 var cp: i64 = (ytc_hexval(src[i+2]&0xff) << 12) | (ytc_hexval(src[i+3]&0xff) << 8) | (ytc_hexval(src[i+4]&0xff) << 4) | ytc_hexval(src[i+5]&0xff)
84 i = i + 6
85 if cp >= 55296 { if cp <= 56319 { // high surrogate: look for the low half
86 if i + 5 < slen { if (src[i]&0xff) == 92 { if (src[i+1]&0xff) == 117 {
87 let lo: i64 = (ytc_hexval(src[i+2]&0xff) << 12) | (ytc_hexval(src[i+3]&0xff) << 8) | (ytc_hexval(src[i+4]&0xff) << 4) | ytc_hexval(src[i+5]&0xff)
88 if lo >= 56320 { if lo <= 57343 { cp = 65536 + ((cp - 55296) << 10) + (lo - 56320); i = i + 6 } }
89 } } }
90 } }
91 o = ytc_utf8_emit(dst, o, cp, cap, strip)
92 } else { i = i + 2 }
93 } else {
94 var m: i64 = n
95 if n == 110 { m = 10 } // \n
96 if n == 116 { m = 9 } // \t
97 if n == 114 { m = 13 } // \r
98 if n == 47 { m = 47 } // \/
99 if n == 92 { m = 92 } // \\
100 if n == 34 { m = 34 } // \"
101 o = ytc_utf8_emit(dst, o, m, cap, strip)
102 i = i + 2
103 }
104 } else {
105 o = ytc_utf8_emit(dst, o, c, cap, strip)
106 i = i + 1
107 }
108 }
109 }
110 out_end[0] = slen
111 return o
112}
113
114// exact compare of src[s..s+len) against a NUL-terminated literal.
115func ytc_named_eq(src: *u8, s: i64, len: i64, lit: *u8) -> i64 {
116 var i: i64 = 0
117 while i < len { if lit[i] == (0 as u8) { return 0 } if (src[s+i]&0xff) != (lit[i]&0xff) { return 0 } i = i + 1 }
118 if lit[i] != (0 as u8) { return 0 }
119 return 1
120}
121// Decode an HTML/XML text run src[s..e) (entities & < > " &#NN; &#xHH; and any HTML5 named
122// reference) into dst[o..]; strip=1 -> control bytes become space. Returns new o.
123func ytc_html_decode(src: *u8, s: i64, e: i64, dst: *u8, o0: i64, cap: i64, strip: i64) -> i64 {
124 var i: i64 = s
125 var o: i64 = o0
126 while i < e {
127 let c: i64 = src[i] & 0xff
128 if c == 38 { // '&'
129 var j: i64 = i + 1
130 var semi: i64 = 0 - 1
131 while j < e { if j - i > 33 { break } if (src[j]&0xff) == 59 { semi = j; break } j = j + 1 }
132 if semi < 0 { o = ytc_utf8_emit(dst, o, c, cap, strip); i = i + 1 }
133 else {
134 if (src[i+1]&0xff) == 35 { // '#': numeric
135 var cp: i64 = 0
136 if (src[i+2]&0xff) == 120 { var k: i64 = i + 3; while k < semi { cp = cp * 16 + ytc_hexval(src[k]&0xff); k = k + 1 } }
137 else { var k: i64 = i + 2; while k < semi { let d: i64 = src[k]&0xff; if d >= 48 { if d <= 57 { cp = cp * 10 + (d - 48) } } k = k + 1 } }
138 o = ytc_utf8_emit(dst, o, cp, cap, strip)
139 } else {
140 let elen: i64 = semi - (i + 1) // entity name WITHOUT the ';'
141 var done: i64 = 0
142 if ytc_named_eq(src, i+1, elen, "amp" as *u8) == 1 { o = ytc_utf8_emit(dst, o, 38, cap, strip); done = 1 }
143 if done == 0 { if ytc_named_eq(src, i+1, elen, "lt" as *u8) == 1 { o = ytc_utf8_emit(dst, o, 60, cap, strip); done = 1 } }
144 if done == 0 { if ytc_named_eq(src, i+1, elen, "gt" as *u8) == 1 { o = ytc_utf8_emit(dst, o, 62, cap, strip); done = 1 } }
145 if done == 0 { if ytc_named_eq(src, i+1, elen, "quot" as *u8) == 1 { o = ytc_utf8_emit(dst, o, 34, cap, strip); done = 1 } }
146 if done == 0 { if ytc_named_eq(src, i+1, elen, "apos" as *u8) == 1 { o = ytc_utf8_emit(dst, o, 39, cap, strip); done = 1 } }
147 if done == 0 { if ytc_named_eq(src, i+1, elen, "nbsp" as *u8) == 1 { o = ytc_utf8_emit(dst, o, 160, cap, strip); done = 1 } }
148 if done == 0 {
149 let namelen: i64 = elen + 1 // include the ';' the table expects
150 let cpbuf: *i64 = sys_mmap(16) as *i64
151 let r: i64 = nx_html_entity_lookup(((src as i64) + (i + 1)) as *u8, namelen, cpbuf)
152 if r >= 1 { var q: i64 = 0; while q < r { o = ytc_utf8_emit(dst, o, cpbuf[q], cap, strip); q = q + 1 } done = 1 }
153 }
154 if done == 0 { var k: i64 = i; while k <= semi { o = ytc_utf8_emit(dst, o, src[k]&0xff, cap, strip); k = k + 1 } }
155 }
156 i = semi + 1
157 }
158 } else { o = ytc_utf8_emit(dst, o, c, cap, strip); i = i + 1 }
159 }
160 return o
161}
162
163// ---- caption-track selection from the player response --------------------------------------------
164// Extract the balanced captionTracks[...] array into arr. Returns array bytes, or -1 if the response has
165// no caption tracks at all (a video with captions disabled).
166func ytc_ct_array(pj: *u8, jlen: i64, arr: *u8, cap: i64) -> i64 {
167 let ct: i64 = ms_find(pj, jlen, "\"captionTracks\":[" as *u8, 17, 0)
168 if ct < 0 { return 0 - 1 }
169 return ms_extract_json_value(pj, ct + 16, jlen, arr, cap) // ct+16 is the '['
170}
171// Pick a track by languageCode (wlen==0 -> first track). Writes the JSON-unescaped baseUrl (NUL-terminated)
172// and the kind ("asr" or "manual"). Returns 1 ok, 0 no-tracks, -2 requested-lang-absent.
173func ytc_pick(arr: *u8, alen: i64, want: *u8, wlen: i64, out_url: *u8, ucap: i64, out_kind: *u8, kcap: i64) -> i64 {
174 if alen <= 2 { return 0 }
175 let eidx: *i64 = sys_mmap(16) as *i64
176 var pos: i64 = 0
177 var go: i64 = 1
178 while go == 1 {
179 let bu: i64 = ms_find(arr, alen, "\"baseUrl\":\"" as *u8, 11, pos)
180 if bu < 0 { go = 0 }
181 else {
182 let nextbu: i64 = ms_find(arr, alen, "\"baseUrl\":\"" as *u8, 11, bu + 11)
183 var objend: i64 = alen
184 if nextbu >= 0 { objend = nextbu }
185 var mtch: i64 = 0
186 if wlen == 0 { mtch = 1 }
187 else {
188 let lc: i64 = ms_find(arr, objend, "\"languageCode\":\"" as *u8, 16, bu)
189 if lc >= 0 {
190 var i: i64 = lc + 16; var j: i64 = 0; var eq: i64 = 1
191 while j < wlen { if (arr[i+j]&0xff) != (want[j]&0xff) { eq = 0; break } j = j + 1 }
192 if eq == 1 { if (arr[i+wlen]&0xff) == 34 { mtch = 1 } }
193 }
194 }
195 if mtch == 1 {
196 let ulen: i64 = ytc_copy_jsonstr(arr, bu + 11, objend, out_url, 0, ucap, 0, eidx)
197 out_url[ulen] = 0 as u8
198 let kd: i64 = ms_find(arr, objend, "\"kind\":\"" as *u8, 8, bu)
199 if kd >= 0 {
200 var i2: i64 = kd + 8; var o2: i64 = 0
201 while i2 < objend { let c: i64 = arr[i2]&0xff; if c == 34 { break } if o2 < kcap - 1 { out_kind[o2] = c as u8; o2 = o2 + 1 } i2 = i2 + 1 }
202 out_kind[o2] = 0 as u8
203 } else {
204 out_kind[0]=109 as u8; out_kind[1]=97 as u8; out_kind[2]=110 as u8; out_kind[3]=117 as u8; out_kind[4]=97 as u8; out_kind[5]=108 as u8; out_kind[6]=0 as u8 // "manual"
205 }
206 return 1
207 }
208 pos = bu + 11
209 }
210 }
211 if wlen == 0 { return 0 }
212 return 0 - 2
213}
214// List the languageCodes present (comma-separated), for the LANG-ABSENT refusal. Returns count.
215func ytc_langs(arr: *u8, alen: i64, out: *u8, cap: i64) -> i64 {
216 var pos: i64 = 0; var o: i64 = 0; var n: i64 = 0
217 var go: i64 = 1
218 while go == 1 {
219 let lc: i64 = ms_find(arr, alen, "\"languageCode\":\"" as *u8, 16, pos)
220 if lc < 0 { go = 0 }
221 else {
222 var i: i64 = lc + 16
223 if n > 0 { if o < cap - 1 { out[o] = 44 as u8; o = o + 1 } }
224 while i < alen { let c: i64 = arr[i]&0xff; if c == 34 { break } if o < cap - 1 { out[o] = c as u8; o = o + 1 } i = i + 1 }
225 n = n + 1
226 pos = lc + 16
227 }
228 }
229 out[o] = 0 as u8
230 return n
231}
232
233// ---- transports (compose the shipped stack; no new TLS) ------------------------------------------
234func ytc_load_store() -> i64 {
235 return nx_trust_store_load_from_certdata("data/mozilla_certdata.txt" as *u8, 512, YTC_CERTDATA_CAP)
236}
237func ytc_cat(dst: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; while s[i] != (0 as u8) { dst[o] = s[i]; o = o + 1; i = i + 1 } return o }
238// THE visionOS InnerTube client (X-Youtube-Client-Name 101), captured VERBATIM from yt-dlp's own /youtubei
239// traffic 2026-09-18: ANDROID_VR now answers playabilityStatus=LOGIN_REQUIRED ("Sign in to confirm you're
240// not a bot"), but the visionOS player response returns a captionTracks baseUrl that serves a body with NO
241// proof-of-origin token -- the one client context measured to work today. Facts, not memory.
242const YTC_VOS_UA: *u8 = "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15"
243func ytc_vos_body(vid: *u8, vidlen: i64, vd: *u8, vdlen: i64, out: *u8, cap: i64) -> i64 {
244 var o: i64 = ytc_cat(out, 0, "{\"context\":{\"client\":{\"clientName\":\"VISIONOS\",\"clientVersion\":\"1.02\",\"deviceMake\":\"Apple\",\"deviceModel\":\"RealityDevice17,1\",\"userAgent\":\"" as *u8)
245 o = ytc_cat(out, o, YTC_VOS_UA)
246 o = ytc_cat(out, o, "\",\"osName\":\"visionOS\",\"osVersion\":\"26.5.23O471\",\"hl\":\"en\",\"timeZone\":\"UTC\",\"utcOffsetMinutes\":0" as *u8)
247 if vdlen > 0 { o = ytc_cat(out, o, ",\"visitorData\":\"" as *u8); var d: i64 = 0; while d < vdlen { out[o] = vd[d]; o = o + 1; d = d + 1 } o = ytc_cat(out, o, "\"" as *u8) }
248 o = ytc_cat(out, o, "}},\"videoId\":\"" as *u8)
249 var i: i64 = 0; while i < vidlen { out[o] = vid[i]; o = o + 1; i = i + 1 }
250 o = ytc_cat(out, o, "\",\"playbackContext\":{\"contentPlaybackContext\":{\"html5Preference\":\"HTML5_PREF_WANTS\"}},\"contentCheckOk\":true,\"racyCheckOk\":true}" as *u8)
251 return o
252}
253func ytc_vos_req(vd: *u8, vdlen: i64, body: *u8, bl: i64, out: *u8) -> i64 {
254 var o: i64 = ytc_cat(out, 0, "POST /youtubei/v1/player?prettyPrint=false HTTP/1.1\r\nHost: www.youtube.com\r\nUser-Agent: " as *u8)
255 o = ytc_cat(out, o, YTC_VOS_UA)
256 o = ytc_cat(out, o, "\r\nContent-Type: application/json\r\nX-Youtube-Client-Name: 101\r\nX-Youtube-Client-Version: 1.02\r\nOrigin: https://www.youtube.com\r\n" as *u8)
257 if vdlen > 0 { o = ytc_cat(out, o, "X-Goog-Visitor-Id: " as *u8); var d: i64 = 0; while d < vdlen { out[o] = vd[d]; o = o + 1; d = d + 1 } o = ytc_cat(out, o, "\r\n" as *u8) }
258 o = ytc_cat(out, o, "Accept: */*\r\nAccept-Encoding: identity\r\nContent-Length: " as *u8)
259 o = ytc_catn(out, o, bl, 16384)
260 o = ytc_cat(out, o, "\r\nConnection: close\r\n\r\n" as *u8)
261 var i: i64 = 0; while i < bl { out[o] = body[i]; o = o + 1; i = i + 1 }
262 return o
263}
264// Strip HTTP headers and de-chunk a raw response into out (identity encoding requested, so no gzip).
265func ytc_dechunk(raw: *u8, n: i64, out: *u8, cap: i64) -> i64 {
266 let he: i64 = ms_find(raw, n, "\r\n\r\n" as *u8, 4, 0)
267 if he < 0 { var i: i64 = 0; while i < n { if i < cap - 1 { out[i] = raw[i] } i = i + 1 } out[n] = 0 as u8; return n }
268 let bodystart: i64 = he + 4
269 let chunked: i64 = ms_find(raw, bodystart, "chunked" as *u8, 7, 0)
270 if chunked < 0 { var o: i64 = 0; var i: i64 = bodystart; while i < n { if o < cap - 1 { out[o] = raw[i]; o = o + 1 } i = i + 1 } out[o] = 0 as u8; return o }
271 var i: i64 = bodystart; var o: i64 = 0; var go: i64 = 1
272 while go == 1 {
273 var sz: i64 = 0; var got: i64 = 0
274 while i < n { let c: i64 = raw[i]&0xff; if c == 13 { break } sz = sz * 16 + ytc_hexval(c); got = 1; i = i + 1 }
275 if i + 1 < n { if (raw[i]&0xff) == 13 { i = i + 2 } }
276 if got == 0 { go = 0 } else { if sz == 0 { go = 0 } else {
277 var k: i64 = 0
278 while k < sz { if i < n { if o < cap - 1 { out[o] = raw[i]; o = o + 1 } i = i + 1 } else { k = sz } k = k + 1 }
279 if i + 1 < n { if (raw[i]&0xff) == 13 { i = i + 2 } }
280 } }
281 }
282 out[o] = 0 as u8; return o
283}
284// POST the visionOS player request to www.youtube.com; returns RAW response bytes (headers + chunked body).
285func ytc_vos_post(vid: *u8, vidlen: i64, vd: *u8, vdlen: i64, store: *TrustStore, cr: *u8, pk: *u8, now: i64, out: *u8, cap: i64) -> i64 {
286 let host_url: *u8 = "https://www.youtube.com/youtubei/v1/player?prettyPrint=false" as *u8
287 let url_p: *NxUrl = nx_url_new()
288 let target_raw: *u8 = sys_mmap(32); let target: *NxHttpsTarget = target_raw as *NxHttpsTarget
289 target.url = url_p; target.port = 0
290 if nx_https_url_for_fetch(host_url, target) != NX_HTTPS_URL_OK { return 0 - 1 }
291 let fd_p: *i64 = sys_mmap(16) as *i64
292 if nx_https_url_connect(target, host_url, now, fd_p) != NX_HTTPS_CONNECT_OK { return 0 - 2 }
293 let fd: i64 = *fd_p
294 let val_ctx_raw: *u8 = sys_mmap(64); let val_ctx: *TlsValidationContext = val_ctx_raw as *TlsValidationContext
295 val_ctx.store = store; val_ctx.sni_host = host_url + target.url.host_off; val_ctx.sni_host_len = target.url.host_len; val_ctx.now_epoch = now
296 let session_r: i64 = nx_tls13_client_session_run(fd, host_url + target.url.host_off, target.url.host_len, cr, pk, val_ctx)
297 if session_r < 0 { sys_close(fd); return 0 - 3 }
298 let session: *Tls13ClientSession = session_r as *Tls13ClientSession
299 let body: *u8 = sys_mmap(8192); let bl: i64 = ytc_vos_body(vid, vidlen, vd, vdlen, body, 8192)
300 let req: *u8 = sys_mmap(16384); let req_len: i64 = ytc_vos_req(vd, vdlen, body, bl, req)
301 let nresp: i64 = nx_https_req_complete(session, fd, req, req_len, out, cap)
302 sys_close(fd)
303 return nresp
304}
305// Player response via the visionOS client: fetch the watch page for visitorData, POST visionOS, de-chunk.
306func ytc_player(url: *u8, ulen: i64, store: *TrustStore, out: *u8, cap: i64) -> i64 {
307 let vid: *u8 = sys_mmap(64); let idl: i64 = yt_video_id(url, ulen, vid, 64)
308 if idl == 0 { return 0 - 11 }
309 let cr: *u8 = sys_mmap(32); nx_csprng_fill(cr, 32)
310 let pk: *u8 = sys_mmap(32); nx_csprng_fill(pk, 32)
311 let now: i64 = sys_now_realtime_sec()
312 let html: *u8 = sys_mmap(YTC_PLAYER_CAP); let status: *i64 = sys_mmap(8) as *i64
313 let hn: i64 = nx_https_fetch_follow(url, store, html, YTC_PLAYER_CAP, 6, status)
314 let vd: *u8 = sys_mmap(4096); var vdlen: i64 = 0
315 if hn > 0 { vdlen = yt_scrape(html, hn, "\"visitorData\":\"" as *u8, vd, 4096) }
316 let raw: *u8 = sys_mmap(YTC_PLAYER_CAP)
317 let rn: i64 = ytc_vos_post(vid, idl, vd, vdlen, store, cr, pk, now, raw, YTC_PLAYER_CAP)
318 if rn <= 0 { return rn }
319 return ytc_dechunk(raw, rn, out, cap)
320}
321// GET the (NUL-terminated) timedtext baseUrl. status[0] = final HTTP status. Returns body bytes (dechunked
322// and gunzipped by the shared chokepoint).
323func ytc_fetch(url: *u8, store: *TrustStore, out: *u8, cap: i64, status: *i64) -> i64 {
324 return nx_https_fetch_follow(url, store, out, cap, 6, status)
325}
326
327// ---- body classification + parsers ---------------------------------------------------------------
328// 0 json3 | 1 srv1/xml | 2 EMPTY (the pot-token defect: status 200, no bytes) | 3 present-but-not-caption.
329func ytc_classify_body(body: *u8, blen: i64) -> i64 {
330 if blen <= 0 { return 2 }
331 if ms_find(body, blen, "\"wireMagic\"" as *u8, 11, 0) >= 0 { return 0 } // json3 signature (ws-tolerant)
332 if ms_find(body, blen, "\"events\"" as *u8, 8, 0) >= 0 { return 0 }
333 if ms_find(body, blen, "<text " as *u8, 6, 0) >= 0 { return 1 }
334 if ms_find(body, blen, "<transcript" as *u8, 11, 0) >= 0 { return 1 }
335 return 3
336}
337// skip a ':' and any following JSON whitespace, returning the index of the value's first byte.
338func ytc_after_colon(b: *u8, i0: i64, end: i64) -> i64 {
339 var i: i64 = i0
340 while i < end { let c: i64 = b[i]&0xff; if c == 58 { i = i + 1 } else { if c == 32 { i = i + 1 } else { if c == 10 { i = i + 1 } else { if c == 9 { i = i + 1 } else { if c == 13 { i = i + 1 } else { return i } } } } } }
341 return end
342}
343// json3 -> rows `startMs \t durMs \t text \n`. WHITESPACE-TOLERANT: the timedtext json3 is pretty-printed
344// ("tStartMs": 0, "utf8": "..."), so keys are matched alone and the value is read after colon+whitespace.
345// Events with no utf8 seg (window/format-only events) are SKIPPED so the count matches the visible lines.
346func ytc_json3_to_tsv(body: *u8, blen: i64, out: *u8, cap: i64, out_nseg: *i64) -> i64 {
347 var o: i64 = 0; var nseg: i64 = 0
348 var pos: i64 = ms_find(body, blen, "\"events\"" as *u8, 8, 0)
349 if pos < 0 { out[0] = 0 as u8; out_nseg[0] = 0; return 0 }
350 pos = pos + 8
351 let eidx: *i64 = sys_mmap(16) as *i64
352 var go: i64 = 1
353 while go == 1 {
354 let ts: i64 = ms_find(body, blen, "\"tStartMs\"" as *u8, 10, pos)
355 if ts < 0 { go = 0 }
356 else {
357 var i: i64 = ytc_after_colon(body, ts + 10, blen); var startv: i64 = 0
358 while i < blen { let c: i64 = body[i]&0xff; if c < 48 { break } if c > 57 { break } startv = startv * 10 + (c - 48); i = i + 1 }
359 let nextev: i64 = ms_find(body, blen, "\"tStartMs\"" as *u8, 10, i)
360 var evend: i64 = blen
361 if nextev >= 0 { evend = nextev }
362 let firstseg: i64 = ms_find(body, evend, "\"utf8\"" as *u8, 6, ts)
363 if firstseg < 0 { pos = evend } // no text in this event -> skip
364 else {
365 var durv: i64 = 0
366 let dp: i64 = ms_find(body, evend, "\"dDurationMs\"" as *u8, 13, ts)
367 if dp >= 0 { var di: i64 = ytc_after_colon(body, dp + 13, evend); while di < evend { let c: i64 = body[di]&0xff; if c < 48 { break } if c > 57 { break } durv = durv * 10 + (c - 48); di = di + 1 } }
368 o = ytc_catn(out, o, startv, cap)
369 if o < cap - 1 { out[o] = 9 as u8; o = o + 1 }
370 o = ytc_catn(out, o, durv, cap)
371 if o < cap - 1 { out[o] = 9 as u8; o = o + 1 }
372 var seg: i64 = firstseg
373 while seg >= 0 {
374 let q: i64 = ytc_after_colon(body, seg + 6, evend)
375 var nxt: i64 = q + 1
376 if q < evend { if (body[q]&0xff) == 34 { o = ytc_copy_jsonstr(body, q + 1, evend, out, o, cap, 1, eidx); nxt = eidx[0] + 1 } }
377 seg = ms_find(body, evend, "\"utf8\"" as *u8, 6, nxt)
378 }
379 if o < cap - 1 { out[o] = 10 as u8; o = o + 1 }
380 nseg = nseg + 1
381 pos = evend
382 }
383 }
384 }
385 out[o] = 0 as u8; out_nseg[0] = nseg; return o
386}
387// seconds float "12.34" -> milliseconds integer (3 decimal places, padded).
388func ytc_secs_to_ms(src: *u8, s: i64, e: i64) -> i64 {
389 var i: i64 = s; var whole: i64 = 0
390 while i < e { let c: i64 = src[i]&0xff; if c == 46 { break } if c >= 48 { if c <= 57 { whole = whole * 10 + (c - 48) } } i = i + 1 }
391 var frac: i64 = 0; var digits: i64 = 0
392 if i < e { if (src[i]&0xff) == 46 { i = i + 1; while i < e { if digits >= 3 { break } let c: i64 = src[i]&0xff; if c < 48 { break } if c > 57 { break } frac = frac * 10 + (c - 48); digits = digits + 1; i = i + 1 } } }
393 while digits < 3 { frac = frac * 10; digits = digits + 1 }
394 return whole * 1000 + frac
395}
396// srv1/XML `<text start="s" dur="s">TEXT</text>` -> rows. out_nseg[0] = rows. Returns bytes.
397func ytc_xml_to_tsv(body: *u8, blen: i64, out: *u8, cap: i64, out_nseg: *i64) -> i64 {
398 var o: i64 = 0; var nseg: i64 = 0; var pos: i64 = 0
399 var go: i64 = 1
400 while go == 1 {
401 let tp: i64 = ms_find(body, blen, "<text " as *u8, 6, pos)
402 if tp < 0 { go = 0 }
403 else {
404 var tg: i64 = tp
405 while tg < blen { if (body[tg]&0xff) == 62 { break } tg = tg + 1 } // '>' ends the open tag
406 var startv: i64 = 0 - 1
407 let sp: i64 = ms_find(body, blen, "start=\"" as *u8, 7, tp)
408 if sp >= 0 { if sp < tg { var se: i64 = sp + 7; var e2: i64 = se; while e2 < tg { if (body[e2]&0xff) == 34 { break } e2 = e2 + 1 } startv = ytc_secs_to_ms(body, se, e2) } }
409 var durv: i64 = 0
410 let dpp: i64 = ms_find(body, blen, "dur=\"" as *u8, 5, tp)
411 if dpp >= 0 { if dpp < tg { var de: i64 = dpp + 5; var e3: i64 = de; while e3 < tg { if (body[e3]&0xff) == 34 { break } e3 = e3 + 1 } durv = ytc_secs_to_ms(body, de, e3) } }
412 if startv < 0 { startv = 0 }
413 let te: i64 = ms_find(body, blen, "</text>" as *u8, 7, tg)
414 var txend: i64 = blen
415 if te >= 0 { txend = te }
416 o = ytc_catn(out, o, startv, cap)
417 if o < cap - 1 { out[o] = 9 as u8; o = o + 1 }
418 o = ytc_catn(out, o, durv, cap)
419 if o < cap - 1 { out[o] = 9 as u8; o = o + 1 }
420 o = ytc_html_decode(body, tg + 1, txend, out, o, cap, 1)
421 if o < cap - 1 { out[o] = 10 as u8; o = o + 1 }
422 nseg = nseg + 1
423 if te >= 0 { pos = te + 7 } else { pos = tg + 1 }
424 }
425 }
426 out[o] = 0 as u8; out_nseg[0] = nseg; return o
427}
428
429// ---- gate + referee helpers ----------------------------------------------------------------------
430// count rows (newline-terminated) in a tsv buffer.
431func ytc_tsv_nseg(tsv: *u8, len: i64) -> i64 { var n: i64 = 0; var i: i64 = 0; while i < len { if (tsv[i]&0xff) == 10 { n = n + 1 } i = i + 1 } return n }
432// non-decreasing startMs across rows? 1 yes, 0 no. Each row: startMs is the leading integer.
433func ytc_tsv_monotonic(tsv: *u8, len: i64) -> i64 {
434 var i: i64 = 0; var prev: i64 = 0 - 1
435 while i < len {
436 var v: i64 = 0
437 while i < len { let c: i64 = tsv[i]&0xff; if c < 48 { break } if c > 57 { break } v = v * 10 + (c - 48); i = i + 1 }
438 if v < prev { return 0 }
439 prev = v
440 while i < len { if (tsv[i]&0xff) == 10 { break } i = i + 1 } // skip to end of row
441 i = i + 1
442 }
443 return 1
444}
445// normalise whitespace: collapse any run of space/tab/CR/LF to a single space, trim ends. Returns len.
446func ytc_norm_ws(src: *u8, slen: i64, dst: *u8, cap: i64) -> i64 {
447 var i: i64 = 0; var o: i64 = 0; var pend: i64 = 0; var seen: i64 = 0
448 while i < slen {
449 let c: i64 = src[i]&0xff
450 if c == 32 { pend = 1 } else { if c == 9 { pend = 1 } else { if c == 10 { pend = 1 } else { if c == 13 { pend = 1 } else {
451 if seen == 1 { if pend == 1 { if o < cap - 1 { dst[o] = 32 as u8; o = o + 1 } } }
452 pend = 0; seen = 1
453 if o < cap - 1 { dst[o] = c as u8; o = o + 1 }
454 } } } }
455 i = i + 1
456 }
457 dst[o] = 0 as u8; return o
458}
459// text of the Nth data row (0-based; a data row starts with a digit) into dst; returns len, -1 if absent.
460func ytc_row_text(tsv: *u8, len: i64, idx: i64, dst: *u8, cap: i64) -> i64 {
461 var i: i64 = 0; var row: i64 = 0
462 while i < len {
463 let ls: i64 = i
464 var le: i64 = i
465 while le < len { if (tsv[le]&0xff) == 10 { break } le = le + 1 }
466 let c0: i64 = tsv[ls]&0xff
467 var isdata: i64 = 0
468 if c0 >= 48 { if c0 <= 57 { isdata = 1 } }
469 if isdata == 1 {
470 if row == idx {
471 var t: i64 = ls; var tabs: i64 = 0
472 while t < le { if (tsv[t]&0xff) == 9 { tabs = tabs + 1; if tabs == 2 { t = t + 1; break } } t = t + 1 }
473 var o: i64 = 0
474 while t < le { if o < cap - 1 { dst[o] = tsv[t]; o = o + 1 } t = t + 1 }
475 dst[o] = 0 as u8; return o
476 }
477 row = row + 1
478 }
479 i = le + 1
480 }
481 return 0 - 1
482}