diff --git a/.agents/skills/youtube-direct/scripts/youtube.mjs b/.agents/skills/youtube-direct/scripts/youtube.mjs index dfa0a09..427f78d 100755 --- a/.agents/skills/youtube-direct/scripts/youtube.mjs +++ b/.agents/skills/youtube-direct/scripts/youtube.mjs @@ -778,16 +778,16 @@ var require_util = __commonJS({ function isStream(obj) { return obj && typeof obj === "object" && typeof obj.pipe === "function" && typeof obj.on === "function"; } - function isBlobLike(object2) { - if (object2 === null) { + function isBlobLike(object3) { + if (object3 === null) { return false; - } else if (object2 instanceof Blob2) { + } else if (object3 instanceof Blob2) { return true; - } else if (typeof object2 !== "object") { + } else if (typeof object3 !== "object") { return false; } else { - const sTag = object2[Symbol.toStringTag]; - return (sTag === "Blob" || sTag === "File") && ("stream" in object2 && typeof object2.stream === "function" || "arrayBuffer" in object2 && typeof object2.arrayBuffer === "function"); + const sTag = object3[Symbol.toStringTag]; + return (sTag === "Blob" || sTag === "File") && ("stream" in object3 && typeof object3.stream === "function" || "arrayBuffer" in object3 && typeof object3.arrayBuffer === "function"); } } function buildURL(url, queryParams) { @@ -1069,8 +1069,8 @@ var require_util = __commonJS({ } ); } - function isFormDataLike(object2) { - return object2 && typeof object2 === "object" && typeof object2.append === "function" && typeof object2.delete === "function" && typeof object2.get === "function" && typeof object2.getAll === "function" && typeof object2.has === "function" && typeof object2.set === "function" && object2[Symbol.toStringTag] === "FormData"; + function isFormDataLike(object3) { + return object3 && typeof object3 === "object" && typeof object3.append === "function" && typeof object3.delete === "function" && typeof object3.get === "function" && typeof object3.getAll === "function" && typeof object3.has === "function" && typeof object3.set === "function" && object3[Symbol.toStringTag] === "FormData"; } function addAbortListener(signal, listener) { if ("addEventListener" in signal) { @@ -1861,9 +1861,9 @@ var require_dispatcher_base = __commonJS({ } close(callback) { if (callback === void 0) { - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { this.close((err, data) => { - return err ? reject(err) : resolve(data); + return err ? reject(err) : resolve2(data); }); }); } @@ -1901,12 +1901,12 @@ var require_dispatcher_base = __commonJS({ err = null; } if (callback === void 0) { - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { this.destroy(err, (err2, data) => { return err2 ? ( /* istanbul ignore next: should never error */ reject(err2) - ) : resolve(data); + ) : resolve2(data); }); }); } @@ -3839,8 +3839,8 @@ var require_util2 = __commonJS({ } return "allowed"; } - function isErrorLike(object2) { - return object2 instanceof Error || (object2?.constructor?.name === "Error" || object2?.constructor?.name === "DOMException"); + function isErrorLike(object3) { + return object3 instanceof Error || (object3?.constructor?.name === "Error" || object3?.constructor?.name === "DOMException"); } function isValidReasonPhrase(statusText) { for (let i = 0; i < statusText.length; ++i) { @@ -4076,8 +4076,8 @@ var require_util2 = __commonJS({ return true; } const strongest = getStrongestMetadata(parsedMetadata); - const metadata = filterMetadataListByAlgorithm(parsedMetadata, strongest); - for (const item of metadata) { + const metadata2 = filterMetadataListByAlgorithm(parsedMetadata, strongest); + for (const item of metadata2) { const algorithm = item.algo; const expectedValue = item.hash; let actualValue = crypto.createHash(algorithm).update(bytes).digest("base64"); @@ -4095,10 +4095,10 @@ var require_util2 = __commonJS({ return false; } var parseHashWithOptions = /(?sha256|sha384|sha512)-((?[A-Za-z0-9+/]+|[A-Za-z0-9_-]+)={0,2}(?:\s|$)( +[!-~]*)?)?/i; - function parseMetadata(metadata) { + function parseMetadata(metadata2) { const result = []; let empty = true; - for (const token of metadata.split(" ")) { + for (const token of metadata2.split(" ")) { empty = false; const parsedToken = parseHashWithOptions.exec(token); if (parsedToken === null || parsedToken.groups === void 0 || parsedToken.groups.algo === void 0) { @@ -4120,13 +4120,13 @@ var require_util2 = __commonJS({ return algorithm; } for (let i = 1; i < metadataList.length; ++i) { - const metadata = metadataList[i]; - if (metadata.algo[3] === "5") { + const metadata2 = metadataList[i]; + if (metadata2.algo[3] === "5") { algorithm = "sha512"; break; } else if (algorithm[3] === "3") { continue; - } else if (metadata.algo[3] === "3") { + } else if (metadata2.algo[3] === "3") { algorithm = "sha384"; } } @@ -4173,8 +4173,8 @@ var require_util2 = __commonJS({ function createDeferredPromise() { let res; let rej; - const promise = new Promise((resolve, reject) => { - res = resolve; + const promise = new Promise((resolve2, reject) => { + res = resolve2; rej = reject; }); return { promise, resolve: res, reject: rej }; @@ -4265,7 +4265,7 @@ var require_util2 = __commonJS({ return new FastIterableIterator(target, kind); }; } - function iteratorMixin(name, object2, kInternalIterator, keyIndex = 0, valueIndex = 1) { + function iteratorMixin(name, object3, kInternalIterator, keyIndex = 0, valueIndex = 1) { const makeIterator = createIterator(name, kInternalIterator, keyIndex, valueIndex); const properties = { keys: { @@ -4273,7 +4273,7 @@ var require_util2 = __commonJS({ enumerable: true, configurable: true, value: function keys() { - webidl.brandCheck(this, object2); + webidl.brandCheck(this, object3); return makeIterator(this, "key"); } }, @@ -4282,7 +4282,7 @@ var require_util2 = __commonJS({ enumerable: true, configurable: true, value: function values() { - webidl.brandCheck(this, object2); + webidl.brandCheck(this, object3); return makeIterator(this, "value"); } }, @@ -4291,7 +4291,7 @@ var require_util2 = __commonJS({ enumerable: true, configurable: true, value: function entries() { - webidl.brandCheck(this, object2); + webidl.brandCheck(this, object3); return makeIterator(this, "key+value"); } }, @@ -4300,7 +4300,7 @@ var require_util2 = __commonJS({ enumerable: true, configurable: true, value: function forEach(callbackfn, thisArg = globalThis) { - webidl.brandCheck(this, object2); + webidl.brandCheck(this, object3); webidl.argumentLengthCheck(arguments, 1, `${name}.forEach`); if (typeof callbackfn !== "function") { throw new TypeError( @@ -4313,7 +4313,7 @@ var require_util2 = __commonJS({ } } }; - return Object.defineProperties(object2.prototype, { + return Object.defineProperties(object3.prototype, { ...properties, [Symbol.iterator]: { writable: true, @@ -4713,8 +4713,8 @@ var require_file = __commonJS({ } }; webidl.converters.Blob = webidl.interfaceConverter(Blob2); - function isFileLike(object2) { - return object2 instanceof File || object2 && (typeof object2.stream === "function" || typeof object2.arrayBuffer === "function") && object2[Symbol.toStringTag] === "File"; + function isFileLike(object3) { + return object3 instanceof File || object3 && (typeof object3.stream === "function" || typeof object3.arrayBuffer === "function") && object3[Symbol.toStringTag] === "File"; } module.exports = { FileLike, isFileLike }; } @@ -5162,12 +5162,12 @@ var require_body = __commonJS({ } }); } - function extractBody(object2, keepalive = false) { + function extractBody(object3, keepalive = false) { let stream = null; - if (object2 instanceof ReadableStream) { - stream = object2; - } else if (isBlobLike(object2)) { - stream = object2.stream(); + if (object3 instanceof ReadableStream) { + stream = object3; + } else if (isBlobLike(object3)) { + stream = object3.stream(); } else { stream = new ReadableStream({ async pull(controller) { @@ -5187,17 +5187,17 @@ var require_body = __commonJS({ let source = null; let length = null; let type = null; - if (typeof object2 === "string") { - source = object2; + if (typeof object3 === "string") { + source = object3; type = "text/plain;charset=UTF-8"; - } else if (object2 instanceof URLSearchParams) { - source = object2.toString(); + } else if (object3 instanceof URLSearchParams) { + source = object3.toString(); type = "application/x-www-form-urlencoded;charset=UTF-8"; - } else if (isArrayBuffer(object2)) { - source = new Uint8Array(object2.slice()); - } else if (ArrayBuffer.isView(object2)) { - source = new Uint8Array(object2.buffer.slice(object2.byteOffset, object2.byteOffset + object2.byteLength)); - } else if (util.isFormDataLike(object2)) { + } else if (isArrayBuffer(object3)) { + source = new Uint8Array(object3.slice()); + } else if (ArrayBuffer.isView(object3)) { + source = new Uint8Array(object3.buffer.slice(object3.byteOffset, object3.byteOffset + object3.byteLength)); + } else if (util.isFormDataLike(object3)) { const boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, "0")}`; const prefix = `--${boundary}\r Content-Disposition: form-data`; @@ -5207,7 +5207,7 @@ Content-Disposition: form-data`; const rn = new Uint8Array([13, 10]); length = 0; let hasUnknownSizeValue = false; - for (const [name, value] of object2) { + for (const [name, value] of object3) { if (typeof value === "string") { const chunk2 = textEncoder.encode(prefix + `; name="${escape(normalizeLinefeeds(name))}"\r \r @@ -5235,7 +5235,7 @@ Content-Type: ${value.type || "application/octet-stream"}\r if (hasUnknownSizeValue) { length = null; } - source = object2; + source = object3; action = async function* () { for (const part of blobParts) { if (part.stream) { @@ -5246,22 +5246,22 @@ Content-Type: ${value.type || "application/octet-stream"}\r } }; type = `multipart/form-data; boundary=${boundary}`; - } else if (isBlobLike(object2)) { - source = object2; - length = object2.size; - if (object2.type) { - type = object2.type; + } else if (isBlobLike(object3)) { + source = object3; + length = object3.size; + if (object3.type) { + type = object3.type; } - } else if (typeof object2[Symbol.asyncIterator] === "function") { + } else if (typeof object3[Symbol.asyncIterator] === "function") { if (keepalive) { throw new TypeError("keepalive"); } - if (util.isDisturbed(object2) || object2.locked) { + if (util.isDisturbed(object3) || object3.locked) { throw new TypeError( "Response body object should not be disturbed or locked" ); } - stream = object2 instanceof ReadableStream ? object2 : ReadableStreamFrom(object2); + stream = object3 instanceof ReadableStream ? object3 : ReadableStreamFrom(object3); } if (typeof source === "string" || util.isBuffer(source)) { length = Buffer.byteLength(source); @@ -5270,7 +5270,7 @@ Content-Type: ${value.type || "application/octet-stream"}\r let iterator; stream = new ReadableStream({ async start() { - iterator = action(object2)[Symbol.asyncIterator](); + iterator = action(object3)[Symbol.asyncIterator](); }, async pull(controller) { const { value, done } = await iterator.next(); @@ -5298,12 +5298,12 @@ Content-Type: ${value.type || "application/octet-stream"}\r const body = { stream, source, length }; return [body, type]; } - function safelyExtractBody(object2, keepalive = false) { - if (object2 instanceof ReadableStream) { - assert(!util.isDisturbed(object2), "The body has already been consumed."); - assert(!object2.locked, "The stream is locked."); + function safelyExtractBody(object3, keepalive = false) { + if (object3 instanceof ReadableStream) { + assert(!util.isDisturbed(object3), "The body has already been consumed."); + assert(!object3.locked, "The stream is locked."); } - return extractBody(object2, keepalive); + return extractBody(object3, keepalive); } function cloneBody(instance, body) { const [out1, out2] = body.stream.tee(); @@ -5383,12 +5383,12 @@ Content-Type: ${value.type || "application/octet-stream"}\r function mixinBody(prototype) { Object.assign(prototype.prototype, bodyMixinMethods(prototype)); } - async function consumeBody(object2, convertBytesToJSValue, instance) { - webidl.brandCheck(object2, instance); - if (bodyUnusable(object2)) { + async function consumeBody(object3, convertBytesToJSValue, instance) { + webidl.brandCheck(object3, instance); + if (bodyUnusable(object3)) { throw new TypeError("Body is unusable: Body has already been read"); } - throwIfAborted(object2[kState]); + throwIfAborted(object3[kState]); const promise = createDeferredPromise(); const errorSteps = (error) => promise.reject(error); const successSteps = (data) => { @@ -5398,15 +5398,15 @@ Content-Type: ${value.type || "application/octet-stream"}\r errorSteps(e); } }; - if (object2[kState].body == null) { + if (object3[kState].body == null) { successSteps(Buffer.allocUnsafe(0)); return promise.promise; } - await fullyReadBody(object2[kState].body, successSteps, errorSteps); + await fullyReadBody(object3[kState].body, successSteps, errorSteps); return promise.promise; } - function bodyUnusable(object2) { - const body = object2[kState].body; + function bodyUnusable(object3) { + const body = object3[kState].body; return body != null && (body.stream.locked || util.isDisturbed(body.stream)); } function parseJSONFromBytes(bytes) { @@ -6420,12 +6420,12 @@ upgrade: ${upgrade}\r cb(); } } - const waitForDrain = () => new Promise((resolve, reject) => { + const waitForDrain = () => new Promise((resolve2, reject) => { assert(callback === null); if (socket[kError]) { reject(socket[kError]); } else { - callback = resolve; + callback = resolve2; } }); socket.on("close", onDrain).on("drain", onDrain); @@ -7062,12 +7062,12 @@ var require_client_h2 = __commonJS({ cb(); } } - const waitForDrain = () => new Promise((resolve, reject) => { + const waitForDrain = () => new Promise((resolve2, reject) => { assert(callback === null); if (socket[kError]) { reject(socket[kError]); } else { - callback = resolve; + callback = resolve2; } }); h2stream.on("close", onDrain).on("drain", onDrain); @@ -7545,16 +7545,16 @@ var require_client = __commonJS({ return this[kNeedDrain] < 2; } async [kClose]() { - return new Promise((resolve) => { + return new Promise((resolve2) => { if (this[kSize]) { - this[kClosedResolve] = resolve; + this[kClosedResolve] = resolve2; } else { - resolve(null); + resolve2(null); } }); } async [kDestroy](err) { - return new Promise((resolve) => { + return new Promise((resolve2) => { const requests = this[kQueue].splice(this[kPendingIdx]); for (let i = 0; i < requests.length; i++) { const request = requests[i]; @@ -7565,7 +7565,7 @@ var require_client = __commonJS({ this[kClosedResolve](); this[kClosedResolve] = null; } - resolve(null); + resolve2(null); }; if (this[kHTTPContext]) { this[kHTTPContext].destroy(err, callback); @@ -7616,7 +7616,7 @@ var require_client = __commonJS({ }); } try { - const socket = await new Promise((resolve, reject) => { + const socket = await new Promise((resolve2, reject) => { client[kConnector]({ host, hostname, @@ -7628,7 +7628,7 @@ var require_client = __commonJS({ if (err) { reject(err); } else { - resolve(socket2); + resolve2(socket2); } }); }); @@ -7964,8 +7964,8 @@ var require_pool_base = __commonJS({ if (this[kQueue].isEmpty()) { await Promise.all(this[kClients].map((c) => c.close())); } else { - await new Promise((resolve) => { - this[kClosedResolve] = resolve; + await new Promise((resolve2) => { + this[kClosedResolve] = resolve2; }); } } @@ -9208,7 +9208,7 @@ var require_readable = __commonJS({ if (this._readableState.closeEmitted) { return null; } - return await new Promise((resolve, reject) => { + return await new Promise((resolve2, reject) => { if (this[kContentLength] > limit) { this.destroy(new AbortError()); } @@ -9221,7 +9221,7 @@ var require_readable = __commonJS({ if (signal?.aborted) { reject(signal.reason ?? new AbortError()); } else { - resolve(null); + resolve2(null); } }).on("error", noop).on("data", function(chunk) { limit -= chunk.length; @@ -9240,7 +9240,7 @@ var require_readable = __commonJS({ } async function consume(stream, type) { assert(!stream[kConsume]); - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { if (isUnusable(stream)) { const rState = stream._readableState; if (rState.destroyed && rState.closeEmitted === false) { @@ -9257,7 +9257,7 @@ var require_readable = __commonJS({ stream[kConsume] = { type, stream, - resolve, + resolve: resolve2, reject, length: 0, body: [] @@ -9327,18 +9327,18 @@ var require_readable = __commonJS({ return buffer; } function consumeEnd(consume2) { - const { type, body, resolve, stream, length } = consume2; + const { type, body, resolve: resolve2, stream, length } = consume2; try { if (type === "text") { - resolve(chunksDecode(body, length)); + resolve2(chunksDecode(body, length)); } else if (type === "json") { - resolve(JSON.parse(chunksDecode(body, length))); + resolve2(JSON.parse(chunksDecode(body, length))); } else if (type === "arrayBuffer") { - resolve(chunksConcat(body, length).buffer); + resolve2(chunksConcat(body, length).buffer); } else if (type === "blob") { - resolve(new Blob(body, { type: stream[kContentType] })); + resolve2(new Blob(body, { type: stream[kContentType] })); } else if (type === "bytes") { - resolve(chunksConcat(body, length)); + resolve2(chunksConcat(body, length)); } consumeFinish(consume2); } catch (err) { @@ -9595,9 +9595,9 @@ var require_api_request = __commonJS({ }; function request(opts, callback) { if (callback === void 0) { - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { request.call(this, opts, (err, data) => { - return err ? reject(err) : resolve(data); + return err ? reject(err) : resolve2(data); }); }); } @@ -9820,9 +9820,9 @@ var require_api_stream = __commonJS({ }; function stream(opts, factory, callback) { if (callback === void 0) { - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { stream.call(this, opts, factory, (err, data) => { - return err ? reject(err) : resolve(data); + return err ? reject(err) : resolve2(data); }); }); } @@ -10107,9 +10107,9 @@ var require_api_upgrade = __commonJS({ }; function upgrade(opts, callback) { if (callback === void 0) { - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { upgrade.call(this, opts, (err, data) => { - return err ? reject(err) : resolve(data); + return err ? reject(err) : resolve2(data); }); }); } @@ -10201,9 +10201,9 @@ var require_api_connect = __commonJS({ }; function connect(opts, callback) { if (callback === void 0) { - return new Promise((resolve, reject) => { + return new Promise((resolve2, reject) => { connect.call(this, opts, (err, data) => { - return err ? reject(err) : resolve(data); + return err ? reject(err) : resolve2(data); }); }); } @@ -11587,10 +11587,10 @@ var require_headers = __commonJS({ while (j > i && isHTTPWhiteSpaceCharCode(potentialValue.charCodeAt(i))) ++i; return i === 0 && j === potentialValue.length ? potentialValue : potentialValue.substring(i, j); } - function fill(headers, object2) { - if (Array.isArray(object2)) { - for (let i = 0; i < object2.length; ++i) { - const header = object2[i]; + function fill(headers, object3) { + if (Array.isArray(object3)) { + for (let i = 0; i < object3.length; ++i) { + const header = object3[i]; if (header.length !== 2) { throw webidl.errors.exception({ header: "Headers constructor", @@ -11599,10 +11599,10 @@ var require_headers = __commonJS({ } appendHeader(headers, header[0], header[1]); } - } else if (typeof object2 === "object" && object2 !== null) { - const keys = Object.keys(object2); + } else if (typeof object3 === "object" && object3 !== null) { + const keys = Object.keys(object3); for (let i = 0; i < keys.length; ++i) { - appendHeader(headers, keys[i], object2[keys[i]]); + appendHeader(headers, keys[i], object3[keys[i]]); } } else { throw webidl.errors.conversionFailed({ @@ -14065,7 +14065,7 @@ var require_fetch = __commonJS({ function dispatch({ body }) { const url = requestCurrentURL(request); const agent = fetchParams.controller.dispatcher; - return new Promise((resolve, reject) => agent.dispatch( + return new Promise((resolve2, reject) => agent.dispatch( { path: url.pathname + url.search, origin: url.origin, @@ -14141,7 +14141,7 @@ var require_fetch = __commonJS({ } } const onError = this.onError.bind(this); - resolve({ + resolve2({ status, statusText, headersList, @@ -14187,7 +14187,7 @@ var require_fetch = __commonJS({ for (let i = 0; i < rawHeaders.length; i += 2) { headersList.append(bufferToLowerCasedHeaderName(rawHeaders[i]), rawHeaders[i + 1].toString("latin1"), true); } - resolve({ + resolve2({ status, statusText: STATUS_CODES[status], headersList, @@ -17917,8 +17917,8 @@ var require_util8 = __commonJS({ return true; } function delay(ms) { - return new Promise((resolve) => { - setTimeout(resolve, ms).unref(); + return new Promise((resolve2) => { + setTimeout(resolve2, ms).unref(); }); } module.exports = { @@ -18598,9 +18598,9 @@ var require_undici = __commonJS({ } }); -// node_modules/he/he.js +// ../all-things-youtube/node_modules/he/he.js var require_he = __commonJS({ - "node_modules/he/he.js"(exports, module) { + "../all-things-youtube/node_modules/he/he.js"(exports, module) { (function(root) { var freeExports = typeof exports == "object" && exports; var freeModule = typeof module == "object" && module && module.exports == freeExports && module; @@ -18638,10 +18638,10 @@ var require_he = __commonJS({ var decodeMapNumeric = { "0": "�", "128": "€", "130": "‚", "131": "ƒ", "132": "„", "133": "…", "134": "†", "135": "‡", "136": "ˆ", "137": "‰", "138": "Š", "139": "‹", "140": "Œ", "142": "Ž", "145": "‘", "146": "’", "147": "“", "148": "”", "149": "•", "150": "–", "151": "—", "152": "˜", "153": "™", "154": "š", "155": "›", "156": "œ", "158": "ž", "159": "Ÿ" }; var invalidReferenceCodePoints = [1, 2, 3, 4, 5, 6, 7, 8, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 64976, 64977, 64978, 64979, 64980, 64981, 64982, 64983, 64984, 64985, 64986, 64987, 64988, 64989, 64990, 64991, 64992, 64993, 64994, 64995, 64996, 64997, 64998, 64999, 65e3, 65001, 65002, 65003, 65004, 65005, 65006, 65007, 65534, 65535, 131070, 131071, 196606, 196607, 262142, 262143, 327678, 327679, 393214, 393215, 458750, 458751, 524286, 524287, 589822, 589823, 655358, 655359, 720894, 720895, 786430, 786431, 851966, 851967, 917502, 917503, 983038, 983039, 1048574, 1048575, 1114110, 1114111]; var stringFromCharCode = String.fromCharCode; - var object2 = {}; - var hasOwnProperty = object2.hasOwnProperty; - var has = function(object3, propertyName) { - return hasOwnProperty.call(object3, propertyName); + var object3 = {}; + var hasOwnProperty = object3.hasOwnProperty; + var has = function(object4, propertyName) { + return hasOwnProperty.call(object4, propertyName); }; var contains = function(array2, value) { var index = -1; @@ -18848,9 +18848,9 @@ var require_he = __commonJS({ } }); -// node_modules/striptags/src/striptags.js +// ../all-things-youtube/node_modules/striptags/src/striptags.js var require_striptags = __commonJS({ - "node_modules/striptags/src/striptags.js"(exports, module) { + "../all-things-youtube/node_modules/striptags/src/striptags.js"(exports, module) { "use strict"; (function(global2) { if (typeof Symbol2 !== "function") { @@ -19020,14 +19020,14 @@ var require_striptags = __commonJS({ } }); -// skill/cli.ts -var import_undici = __toESM(require_undici()); +// src/direct/cli.ts +var import_undici = __toESM(require_undici(), 1); -// src/youtube-client.ts +// ../all-things-youtube/src/youtube-client.ts var import_he = __toESM(require_he()); var import_striptags = __toESM(require_striptags()); -// src/youtube-types.ts +// ../all-things-youtube/src/youtube-types.ts var YouTubeClientError = class extends Error { code; status; @@ -19044,7 +19044,7 @@ var YouTubeClientError = class extends Error { } }; -// src/youtube-transport.ts +// ../all-things-youtube/src/youtube-transport.ts var DEFAULT_POLICY = { maxAttempts: 5, attemptTimeoutMs: 1e4, @@ -19083,7 +19083,7 @@ function attemptSignal(request, timeoutMs) { async function runtimeWait(delayMs) { const runtimeScheduler = globalThis.scheduler; if (runtimeScheduler?.wait) return runtimeScheduler.wait(delayMs); - await new Promise((resolve) => setTimeout(resolve, delayMs)); + await new Promise((resolve2) => setTimeout(resolve2, delayMs)); } function retryAfterMs(response, now) { const value = response.headers.get("retry-after")?.trim(); @@ -19158,7 +19158,153 @@ function createYouTubeTransport(options) { }; } -// src/browse-contract.ts +// ../all-things-youtube/src/storyboard.ts +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +var DEFAULT_MAX_SHEETS = 12; +var MAX_SHEETS = 20; +var MAX_SHEET_BYTES = 4 * 1024 * 1024; +function object(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function positiveInteger(value) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0; +} +function parseStoryboardSpec(raw) { + const renderer = object(object(raw.storyboards).playerStoryboardSpecRenderer); + const spec = typeof renderer.spec === "string" ? renderer.spec : void 0; + if (!spec) return void 0; + const [template, ...encodedLevels] = spec.split("|"); + if (!template || !template.startsWith("https://")) return void 0; + const levels = encodedLevels.flatMap((encoded, index) => { + const fields = encoded.split("#"); + const tileWidth = positiveInteger(fields[0]); + const tileHeight = positiveInteger(fields[1]); + const frameCount = positiveInteger(fields[2]); + const columns = positiveInteger(fields[3]); + const rows = positiveInteger(fields[4]); + const intervalMs = positiveInteger(fields[5]); + const namePattern = fields[6]; + const signature = fields[7]; + if (!tileWidth || !tileHeight || !frameCount || !columns || !rows || !intervalMs || !namePattern || !signature) return []; + return [{ + index, + tileWidth, + tileHeight, + frameCount, + columns, + rows, + intervalMs, + namePattern, + signature + }]; + }); + return levels.length ? { template, levels } : void 0; +} +function sheetUrl(spec, level, sheet) { + const name = level.namePattern.replaceAll("$M", String(sheet)); + let value = spec.template.replaceAll("$L", String(level.index)).replaceAll("$N", name); + if (value.includes("$S")) value = value.replaceAll("$S", level.signature); + else { + const url = new URL(value); + url.searchParams.set("sigh", level.signature); + value = url.toString(); + } + return value; +} +async function jpegResponse(response) { + if (!response.ok) { + throw new YouTubeClientError("UPSTREAM_ERROR", `Storyboard request failed with status ${response.status}.`, { + status: response.status, + retryable: response.status === 429 || response.status >= 500 + }); + } + const type = response.headers.get("content-type")?.toLowerCase() ?? ""; + if (!type.startsWith("image/jpeg") && !type.startsWith("image/jpg")) { + throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned a non-JPEG storyboard."); + } + const advertised = Number(response.headers.get("content-length")); + if (Number.isFinite(advertised) && advertised > MAX_SHEET_BYTES) { + throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned an oversized storyboard."); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.length > MAX_SHEET_BYTES || bytes[0] !== 255 || bytes[1] !== 216) { + throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned an invalid storyboard JPEG."); + } + return bytes; +} +function validateOptions(options) { + if (!/^[A-Za-z0-9_-]{11}$/.test(options.videoId)) { + throw new YouTubeClientError("INVALID_INPUT", "videoId must be 11 characters."); + } + if (typeof options.outputDir !== "string" || !options.outputDir.trim()) { + throw new YouTubeClientError("INVALID_INPUT", "outputDir is required."); + } + const maxSheets = options.maxSheets ?? DEFAULT_MAX_SHEETS; + if (!Number.isSafeInteger(maxSheets) || maxSheets < 1 || maxSheets > MAX_SHEETS) { + throw new YouTubeClientError( + "INVALID_INPUT", + `maxSheets must be an integer from 1 to ${MAX_SHEETS}.` + ); + } + return maxSheets; +} +function metadata(warnings) { + return { + source: "allthingsyoutube", + fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), + partial: warnings.length > 0, + warnings + }; +} +async function downloadStoryboard(raw, options, fetchImpl) { + const maxSheets = validateOptions(options); + const spec = parseStoryboardSpec(raw); + if (!spec) { + throw new YouTubeClientError("NOT_FOUND", "No storyboard is available for this video."); + } + const level = [...spec.levels].sort( + (a, b) => b.tileWidth * b.tileHeight - a.tileWidth * a.tileHeight + )[0]; + if (!level) { + throw new YouTubeClientError("NOT_FOUND", "No usable storyboard level is available for this video."); + } + const capacity = level.columns * level.rows; + const availableSheets = Math.ceil(level.frameCount / capacity); + const requestedSheets = Math.min(availableSheets, maxSheets); + const directory = resolve(options.outputDir, "storyboards"); + await mkdir(directory, { recursive: true }); + const sheets = []; + for (let sheet = 0; sheet < requestedSheets; sheet += 1) { + const response = await fetchImpl(sheetUrl(spec, level, sheet)); + const bytes = await jpegResponse(response); + const path = join(directory, `${options.videoId}-level-${level.index}-sheet-${sheet}.jpg`); + await writeFile(path, bytes); + const firstFrameIndex = sheet * capacity; + sheets.push({ + path, + tileWidth: level.tileWidth, + tileHeight: level.tileHeight, + columns: level.columns, + rows: level.rows, + firstFrameIndex, + frameCount: Math.min(capacity, level.frameCount - firstFrameIndex), + intervalMs: level.intervalMs + }); + } + const warnings = requestedSheets < availableSheets ? [`Storyboard: limited to ${requestedSheets} sheets`] : []; + return { + videoId: options.videoId, + level: level.index, + frameCount: level.frameCount, + intervalMs: level.intervalMs, + sheets, + meta: metadata(warnings) + }; +} + +// ../all-things-youtube/src/browse-contract.ts var BROWSE_CATEGORIES = ["music", "news", "sports", "live"]; var BROWSE_REGIONS = ["US", "IN"]; var BROWSE_LANGUAGES = ["en", "hi"]; @@ -19177,7 +19323,7 @@ function normalizeBrowseLanguage(value) { return supportedValue((value ?? "en").trim().toLowerCase(), BROWSE_LANGUAGES, "language"); } -// src/youtube-client.ts +// ../all-things-youtube/src/youtube-client.ts var WEB_PROFILE = { name: "web", clientName: "WEB", @@ -19260,7 +19406,7 @@ function browseLocale(options) { function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -function object(value) { +function object2(value) { return isObject(value) ? value : {}; } function array(value) { @@ -19279,21 +19425,21 @@ function number(value) { } function rendererText(value) { if (typeof value === "string") return import_he.default.decode((0, import_striptags.default)(value)).trim(); - const source = object(value); + const source = object2(value); const content = string(source.content); if (content !== void 0) return import_he.default.decode((0, import_striptags.default)(content)).trim(); const simple = string(source.simpleText); if (simple !== void 0) return import_he.default.decode((0, import_striptags.default)(simple)).trim(); - const runs = array(source.runs).map((run) => string(object(run).text) ?? "").join(""); + const runs = array(source.runs).map((run) => string(object2(run).text) ?? "").join(""); return runs ? import_he.default.decode((0, import_striptags.default)(runs)).trim() : void 0; } function rendererThumbnails(value) { - const source = object(value); + const source = object2(value); const thumbnails = array( - source.thumbnails ?? object(source.thumbnail).thumbnails ?? source.sources ?? object(source.image).sources + source.thumbnails ?? object2(source.thumbnail).thumbnails ?? source.sources ?? object2(source.image).sources ); return thumbnails.flatMap((item) => { - const thumbnail = object(item); + const thumbnail = object2(item); const url = string(thumbnail.url); if (!url) return []; return [{ @@ -19323,17 +19469,17 @@ function continuationToken(root) { let token; walkObjects(root, (candidate) => { if (token) return; - const command = object(object(candidate.continuationEndpoint).continuationCommand); + const command = object2(object2(candidate.continuationEndpoint).continuationCommand); token = string(command.token); - if (!token) token = string(object(candidate.continuationCommand).token); - if (!token) token = string(object(candidate.nextContinuationData).continuation); + if (!token) token = string(object2(candidate.continuationCommand).token); + if (!token) token = string(object2(candidate.nextContinuationData).continuation); }); return token; } function directContinuationToken(value) { - const renderer = object(object(value).continuationItemRenderer); - const command = object(object(renderer.continuationEndpoint).continuationCommand); - return string(command.token) ?? string(object(renderer.nextContinuationData).continuation) ?? continuationToken(renderer); + const renderer = object2(object2(value).continuationItemRenderer); + const command = object2(object2(renderer.continuationEndpoint).continuationCommand); + return string(command.token) ?? string(object2(renderer.nextContinuationData).continuation) ?? continuationToken(renderer); } function commentContinuationTokens(root) { let continuation; @@ -19344,7 +19490,7 @@ function commentContinuationTokens(root) { for (const item of array(command.continuationItems)) { const direct = directContinuationToken(item); if (direct) continuation = direct; - const thread = object(object(item).commentThreadRenderer); + const thread = object2(object2(item).commentThreadRenderer); const reply = continuationToken(thread.replies); if (reply) replyContinuations.add(reply); } @@ -19352,7 +19498,7 @@ function commentContinuationTokens(root) { } for (const menu of findRenderers(root, "sortFilterSubMenuRenderer")) { for (const itemValue of array(menu.subMenuItems)) { - const item = object(itemValue); + const item = object2(itemValue); const title = string(item.title) ?? rendererText(item.title); if (title?.toLowerCase().startsWith("newest")) { newestContinuation = continuationToken(item); @@ -19444,25 +19590,25 @@ function assertPlaylistId(playlistId) { } } function channelFromRuns(value, navigationEndpoint) { - const source = object(value); - const firstRun = object(array(source.runs)[0]); - const commandRun = object(array(source.commandRuns)[0]); + const source = object2(value); + const firstRun = object2(array(source.runs)[0]); + const commandRun = object2(array(source.commandRuns)[0]); const navigationCandidates = [ firstRun.navigationEndpoint, - object(object(commandRun.onTap).innertubeCommand), + object2(object2(commandRun.onTap).innertubeCommand), navigationEndpoint, - object(object(navigationEndpoint).innertubeCommand) + object2(object2(navigationEndpoint).innertubeCommand) ]; - const id = navigationCandidates.map((candidate) => string(object(object(candidate).browseEndpoint).browseId)).find(Boolean) ?? ""; + const id = navigationCandidates.map((candidate) => string(object2(object2(candidate).browseEndpoint).browseId)).find(Boolean) ?? ""; const rawName = string(firstRun.text) ?? rendererText(value) ?? "Unknown channel"; const name = id ? rawName.replace(/^by\s+/i, "") : rawName; return { id, name, url: id ? `https://www.youtube.com/channel/${id}` : "" }; } function channelFromMetadataRows(value) { - for (const metadata of findRenderers(value, "contentMetadataViewModel")) { - for (const row of array(metadata.metadataRows)) { - for (const partValue of array(object(row).metadataParts)) { - const part = object(partValue); + for (const metadata2 of findRenderers(value, "contentMetadataViewModel")) { + for (const row of array(metadata2.metadataRows)) { + for (const partValue of array(object2(row).metadataParts)) { + const part = object2(partValue); const channel = channelFromRuns(part.text); if (channel.id) return channel; } @@ -19488,7 +19634,7 @@ function parseVideoRenderer(renderer) { type: "video", id, title, - description: rendererText(renderer.descriptionSnippet) ?? array(renderer.detailedMetadataSnippets).map((snippet) => rendererText(object(snippet).snippetText)).filter(Boolean).join(" "), + description: rendererText(renderer.descriptionSnippet) ?? array(renderer.detailedMetadataSnippets).map((snippet) => rendererText(object2(snippet).snippetText)).filter(Boolean).join(" "), channel: channelFromRuns( renderer.ownerText ?? renderer.longBylineText ?? renderer.shortBylineText ), @@ -19507,7 +19653,7 @@ function parseChannelRenderer(renderer) { const id = string(renderer.channelId); const name = rendererText(renderer.title); if (!id || !name) return null; - const handle = rendererText(renderer.navigationEndpoint ? object(renderer.navigationEndpoint).commandMetadata : void 0) ?? rendererText(renderer.subscriberCountText); + const handle = rendererText(renderer.navigationEndpoint ? object2(renderer.navigationEndpoint).commandMetadata : void 0) ?? rendererText(renderer.subscriberCountText); return { type: "channel", id, @@ -19533,13 +19679,13 @@ function parsePlaylistRenderer(renderer) { channel: channelFromRuns(renderer.longBylineText ?? renderer.shortBylineText), thumbnails: firstRendererThumbnails( renderer.thumbnail, - object(array(renderer.thumbnails)[0]) + object2(array(renderer.thumbnails)[0]) ), videoCount: parseCompactNumber(videoCountText), videoCountText, isPodcast: false, playUrl: (() => { - const videoId = string(object(object(renderer.navigationEndpoint).watchEndpoint).videoId); + const videoId = string(object2(object2(renderer.navigationEndpoint).watchEndpoint).videoId); return videoId ? `https://www.youtube.com/watch?v=${videoId}&list=${id}` : void 0; })(), url: `https://www.youtube.com/playlist?list=${id}` @@ -19548,12 +19694,12 @@ function parsePlaylistRenderer(renderer) { function parseLockupViewModel(renderer, fallbackChannel) { const id = string(renderer.contentId); const contentType = string(renderer.contentType); - const metadata = object(object(renderer.metadata).lockupMetadataViewModel); - const title = rendererText(metadata.title); + const metadata2 = object2(object2(renderer.metadata).lockupMetadataViewModel); + const title = rendererText(metadata2.title); if (!id || !title) return null; - const contentMetadata = object(object(metadata.metadata).contentMetadataViewModel); + const contentMetadata = object2(object2(metadata2.metadata).contentMetadataViewModel); const metadataRows = array(contentMetadata.metadataRows).map( - (row) => array(object(row).metadataParts).map((part) => rendererText(object(part).text)).filter( + (row) => array(object2(row).metadataParts).map((part) => rendererText(object2(part).text)).filter( (value) => Boolean(value) ) ); @@ -19561,9 +19707,9 @@ function parseLockupViewModel(renderer, fallbackChannel) { const thumbnailRenderer = findRenderers(renderer.contentImage, "thumbnailViewModel")[0] ?? {}; const thumbnails = rendererThumbnails(thumbnailRenderer.image); const badgeTexts = findRenderers(renderer.contentImage, "thumbnailBadgeViewModel").map((badge) => rendererText(badge.text)).filter((value) => Boolean(value)); - const metadataBadgeTexts = findRenderers(metadata, "badgeViewModel").flatMap((badge) => [ + const metadataBadgeTexts = findRenderers(metadata2, "badgeViewModel").flatMap((badge) => [ rendererText(badge.badgeText), - rendererText(object(object(badge.rendererContext).accessibilityContext).label) + rendererText(object2(object2(badge.rendererContext).accessibilityContext).label) ]).filter((value) => Boolean(value)); if (contentType === "LOCKUP_CONTENT_TYPE_VIDEO") { const statsRow = metadataRows.find((row) => row.length >= 2 && row.some((value) => /\d/.test(value))); @@ -19572,7 +19718,7 @@ function parseLockupViewModel(renderer, fallbackChannel) { (value) => /\b(?:ago|streamed|premiered)\b/i.test(value) ) ?? statsRow?.[1]; const durationText = badgeTexts.find((value) => /^\d+(?::\d+)+$/.test(value)); - const channel = channelFromMetadataRows(metadata) ?? fallbackChannel ?? { id: "", name: "Unknown channel", url: "" }; + const channel = channelFromMetadataRows(metadata2) ?? fallbackChannel ?? { id: "", name: "Unknown channel", url: "" }; return { type: "video", id, @@ -19592,8 +19738,8 @@ function parseLockupViewModel(renderer, fallbackChannel) { if (contentType === "LOCKUP_CONTENT_TYPE_PLAYLIST" || contentType === "LOCKUP_CONTENT_TYPE_PODCAST") { const videoCountText = badgeTexts.find((value) => /\b(?:videos?|episodes?)$/i.test(value)); const updatedTimeText = metadataParts.find((value) => /^updated\b/i.test(value)); - const command = object(object(object(renderer.rendererContext).commandContext).onTap); - const firstVideoId = string(object(object(command.innertubeCommand).watchEndpoint).videoId); + const command = object2(object2(object2(renderer.rendererContext).commandContext).onTap); + const firstVideoId = string(object2(object2(command.innertubeCommand).watchEndpoint).videoId); return { type: "playlist", id, @@ -19658,9 +19804,9 @@ function parseSearchResults(root, fallbackChannel) { } function channelTabBrowseOptions(root, suffix) { for (const tab of findRenderers(root, "tabRenderer")) { - const endpoint = object(tab.endpoint); - const commandUrl = string(object(object(endpoint.commandMetadata).webCommandMetadata).url); - const browse = object(endpoint.browseEndpoint); + const endpoint = object2(tab.endpoint); + const commandUrl = string(object2(object2(endpoint.commandMetadata).webCommandMetadata).url); + const browse = object2(endpoint.browseEndpoint); const browseId = string(browse.browseId); const params = string(browse.params); if (commandUrl?.endsWith(suffix) && browseId && params) return { browseId, params }; @@ -19671,8 +19817,8 @@ function channelVideoSortContinuation(root, sort) { const label = sort === "latest" ? "Latest" : sort === "popular" ? "Popular" : "Oldest"; for (const chip of findRenderers(root, "chipViewModel")) { if (rendererText(chip.text) !== label) continue; - const command = object(object(chip.tapCommand).innertubeCommand); - const token = string(object(command.continuationCommand).token); + const command = object2(object2(chip.tapCommand).innertubeCommand); + const token = string(object2(command.continuationCommand).token); if (token) return token; } return void 0; @@ -19681,9 +19827,9 @@ function channelPlaylistSortBrowseOptions(root, sort) { const label = sort === "newest" ? "Date added (newest)" : "Last video added"; for (const menu of findRenderers(root, "sortFilterSubMenuRenderer")) { for (const item of array(menu.subMenuItems)) { - const option = object(item); + const option = object2(item); if (string(option.title) !== label) continue; - const browse = object(object(option.navigationEndpoint).browseEndpoint); + const browse = object2(object2(option.navigationEndpoint).browseEndpoint); const browseId = string(browse.browseId); const params = string(browse.params); if (browseId && params) return { browseId, params }; @@ -19713,9 +19859,9 @@ function catalogContinuationToken(root) { } function channelHeaderCounts(root) { const header = findRenderers(root, "pageHeaderViewModel")[0]; - const metadata = header ? findRenderers(header, "contentMetadataViewModel")[0] : void 0; - const values = array(metadata?.metadataRows).flatMap( - (row) => array(object(row).metadataParts).map((part) => rendererText(object(part).text)).filter( + const metadata2 = header ? findRenderers(header, "contentMetadataViewModel")[0] : void 0; + const values = array(metadata2?.metadataRows).flatMap( + (row) => array(object2(row).metadataParts).map((part) => rendererText(object2(part).text)).filter( (value) => Boolean(value) ) ); @@ -19739,11 +19885,11 @@ function directExternalUrl(value) { function channelExternalLinks(root) { return findRenderers(root, "channelExternalLinkViewModel").flatMap((renderer) => { const title = rendererText(renderer.title); - const link = object(renderer.link); + const link = object2(renderer.link); const displayUrl = rendererText(link); - const commandRun = object(array(link.commandRuns)[0]); - const command = object(object(commandRun.onTap).innertubeCommand); - const commandUrl = string(object(command.urlEndpoint).url) ?? string(object(object(command.commandMetadata).webCommandMetadata).url); + const commandRun = object2(array(link.commandRuns)[0]); + const command = object2(object2(commandRun.onTap).innertubeCommand); + const commandUrl = string(object2(command.urlEndpoint).url) ?? string(object2(object2(command.commandMetadata).webCommandMetadata).url); const url = directExternalUrl(commandUrl); return title && displayUrl && url ? [{ title, displayUrl, url }] : []; }); @@ -19794,13 +19940,13 @@ function playlistHeaderData(root) { const secondary = findRenderers(root, "playlistSidebarSecondaryInfoRenderer")[0] ?? {}; const ownerRenderer = findRenderers(secondary, "videoOwnerRenderer")[0] ?? {}; const avatarStack = findRenderers(pageHeader, "avatarStackViewModel")[0] ?? {}; - const metadata = findRenderers(root, "playlistMetadataRenderer")[0] ?? {}; + const metadata2 = findRenderers(root, "playlistMetadataRenderer")[0] ?? {}; const microformat = findRenderers(root, "microformatDataRenderer")[0] ?? {}; const playlistThumbnail = findRenderers(primary.thumbnailRenderer, "playlistVideoThumbnailRenderer")[0] ?? {}; const metadataValues = [ ...findRenderers(pageHeader, "contentMetadataViewModel").flatMap( (viewModel) => array(viewModel.metadataRows).flatMap( - (row) => array(object(row).metadataParts).map((part) => rendererText(object(part).text)).filter((value) => Boolean(value)) + (row) => array(object2(row).metadataParts).map((part) => rendererText(object2(part).text)).filter((value) => Boolean(value)) ) ), ...array(primary.stats).map((stat) => rendererText(stat)).filter((value) => Boolean(value)) @@ -19813,8 +19959,8 @@ function playlistHeaderData(root) { ]; const channel = channelCandidates.find((candidate) => candidate.id) ?? channelCandidates.find((candidate) => candidate.name !== "Unknown channel") ?? { id: "", name: "Unknown channel", url: "" }; return { - title: rendererText(legacy.title) ?? string(pageHeader.pageTitle) ?? rendererText(object(findRenderers(pageHeader, "dynamicTextViewModel")[0]).text) ?? rendererText(primary.title) ?? string(metadata.title), - description: rendererText(legacy.descriptionText) ?? rendererText(primary.description) ?? string(metadata.description) ?? string(microformat.description), + title: rendererText(legacy.title) ?? string(pageHeader.pageTitle) ?? rendererText(object2(findRenderers(pageHeader, "dynamicTextViewModel")[0]).text) ?? rendererText(primary.title) ?? string(metadata2.title), + description: rendererText(legacy.descriptionText) ?? rendererText(primary.description) ?? string(metadata2.description) ?? string(microformat.description), channel, thumbnails: firstRendererThumbnails( legacy.playlistHeaderBanner, @@ -19871,14 +20017,14 @@ function captionTrackInfo(track, index, defaultIndex = 0) { }; } function parseCaptionTracks(player) { - const renderer = object(object(player.captions).playerCaptionsTracklistRenderer); - const audioTracks = array(renderer.audioTracks).map(object); + const renderer = object2(object2(player.captions).playerCaptionsTracklistRenderer); + const audioTracks = array(renderer.audioTracks).map(object2); const defaultAudioTrackIndex = number(renderer.defaultAudioTrackIndex); const indexedDefaultAudioTrack = defaultAudioTrackIndex !== void 0 ? audioTracks[defaultAudioTrackIndex] : void 0; const defaultAudioTrack = indexedDefaultAudioTrack ?? audioTracks.find((track) => track.hasDefaultTrack === true) ?? audioTracks[0] ?? {}; const defaultCaptionTrackIndex = number(defaultAudioTrack.defaultCaptionTrackIndex) ?? 0; const parsedTracks = array(renderer.captionTracks).flatMap((item, sourceIndex) => { - const track = object(item); + const track = object2(item); const baseUrl = string(track.baseUrl); if (!baseUrl) return []; return [{ @@ -19902,7 +20048,7 @@ function parseCaptionTracks(player) { (track, index) => captionTrackInfo(track, index, defaultIndex) ); const translations = array(renderer.translationLanguages).map((item) => { - const language = object(item); + const language = object2(item); return { languageCode: string(language.languageCode) ?? "und", name: rendererText(language.languageName) ?? string(language.languageCode) ?? "Unknown" @@ -19964,7 +20110,7 @@ function extractAssignedJson(html, markers) { else if (character === "{") depth += 1; else if (character === "}" && --depth === 0) { try { - return object(JSON.parse(html.slice(start, index + 1))); + return object2(JSON.parse(html.slice(start, index + 1))); } catch { break; } @@ -20000,9 +20146,9 @@ function chooseCaptionTrack(internal, language, trackId, defaultTrackId) { } function parseEndscreen(player) { return findRenderers(player.endscreen, "endscreenElementRenderer").map((renderer) => { - const endpoint = object(renderer.endpoint); - const watchEndpoint = object(endpoint.watchEndpoint); - const browseEndpoint = object(endpoint.browseEndpoint); + const endpoint = object2(renderer.endpoint); + const watchEndpoint = object2(endpoint.watchEndpoint); + const browseEndpoint = object2(endpoint.browseEndpoint); const style = string(renderer.style) ?? ""; const type = style.includes("VIDEO") ? "video" : style.includes("PLAYLIST") ? "playlist" : style.includes("CHANNEL") ? "channel" : "unknown"; return { @@ -20011,7 +20157,7 @@ function parseEndscreen(player) { metadata: rendererText(renderer.metadata), videoId: string(watchEndpoint.videoId), playlistId: string(watchEndpoint.playlistId), - channelId: string(browseEndpoint.browseId) ?? string(object(renderer.hovercardButton).channelId), + channelId: string(browseEndpoint.browseId) ?? string(object2(renderer.hovercardButton).channelId), startMs: number(renderer.startMs) ?? 0, endMs: number(renderer.endMs) ?? 0, thumbnails: rendererThumbnails(renderer.image), @@ -20025,12 +20171,12 @@ function parseEndscreen(player) { }); } function parseComment(renderer) { - const commentWrapper = object(renderer.comment); - const comment = object(commentWrapper.commentRenderer ?? renderer.comment ?? renderer); + const commentWrapper = object2(renderer.comment); + const comment = object2(commentWrapper.commentRenderer ?? renderer.comment ?? renderer); const id = string(comment.commentId) ?? string(renderer.commentId); const text = rendererText(comment.contentText ?? comment.content); if (!id || !text) return null; - const authorEndpoint = object(object(comment.authorEndpoint).browseEndpoint); + const authorEndpoint = object2(object2(comment.authorEndpoint).browseEndpoint); return { id, author: { @@ -20049,10 +20195,10 @@ function parseComment(renderer) { }; } function parseCommentEntity(payload) { - const properties = object(payload.properties); - const author = object(payload.author); - const toolbar = object(payload.toolbar); - const content = object(properties.content); + const properties = object2(payload.properties); + const author = object2(payload.author); + const toolbar = object2(payload.toolbar); + const content = object2(properties.content); const id = string(properties.commentId); const text = string(content.content); if (!id || !text) return null; @@ -20165,7 +20311,7 @@ function createYouTubeClient(options = {}) { ); } try { - return object(await response.json()); + return object2(await response.json()); } catch (cause) { throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned invalid JSON.", { cause, @@ -20187,7 +20333,7 @@ function createYouTubeClient(options = {}) { profile ); firstResponse ??= response; - const status = string(object(response.playabilityStatus).status); + const status = string(object2(response.playabilityStatus).status); const tracks = parseCaptionTracks(response).internal; if (status === "OK" && (!requireCaptionTrack || tracks.length)) return response; attempts.push(`${profile.name}: ${status ?? "UNKNOWN"}`); @@ -20443,8 +20589,8 @@ function createYouTubeClient(options = {}) { }, async getVideo(videoId) { const raw = await player(videoId, false); - const details = object(raw.videoDetails); - const status = object(raw.playabilityStatus); + const details = object2(raw.videoDetails); + const status = object2(raw.playabilityStatus); const author = string(details.author) ?? "Unknown channel"; const channelId = string(details.channelId) ?? ""; const playability = string(status.status) ?? "UNKNOWN"; @@ -20497,36 +20643,36 @@ function createYouTubeClient(options = {}) { } const aboutRaw = await desktopChannelAbout(channelId); const raw = aboutRaw ?? await rawBrowse({ browseId: channelId }); - const metadata = findRenderers(raw, "channelMetadataRenderer")[0] ?? {}; + const metadata2 = findRenderers(raw, "channelMetadataRenderer")[0] ?? {}; const about = findRenderers(raw, "aboutChannelViewModel")[0] ?? {}; - if (!string(metadata.externalId) && !string(metadata.title) && !rendererText(metadata.title) && !string(about.channelId)) { + if (!string(metadata2.externalId) && !string(metadata2.title) && !rendererText(metadata2.title) && !string(about.channelId)) { throw new YouTubeClientError("NOT_FOUND", `YouTube channel ${channelId} was not found.`, { status: 404 }); } - const name = string(metadata.title) ?? rendererText(metadata.title) ?? "Unknown channel"; - const resolvedId = string(about.channelId) ?? string(metadata.externalId) ?? channelId; + const name = string(metadata2.title) ?? rendererText(metadata2.title) ?? "Unknown channel"; + const resolvedId = string(about.channelId) ?? string(metadata2.externalId) ?? channelId; const headerCounts = channelHeaderCounts(raw); - const subscriberCountText = string(about.subscriberCountText) ?? headerCounts.subscriberCountText ?? rendererText(metadata.subscriberCountText); - const videoCountText = string(about.videoCountText) ?? headerCounts.videoCountText ?? rendererText(metadata.videoCountText); + const subscriberCountText = string(about.subscriberCountText) ?? headerCounts.subscriberCountText ?? rendererText(metadata2.subscriberCountText); + const videoCountText = string(about.videoCountText) ?? headerCounts.videoCountText ?? rendererText(metadata2.videoCountText); const viewCountText = string(about.viewCountText); const joinedDateText = rendererText(about.joinedDateText); const canonicalChannelUrl = normalizedChannelUrl( - string(about.canonicalChannelUrl) ?? string(metadata.channelUrl), + string(about.canonicalChannelUrl) ?? string(metadata2.channelUrl), resolvedId ); const handleCandidate = canonicalChannelUrl.split("/").pop(); - const metadataHandle = string(metadata.vanityChannelUrl)?.split("/").pop(); + const metadataHandle = string(metadata2.vanityChannelUrl)?.split("/").pop(); const hasAbout = Object.keys(about).length > 0; return { type: "channel", id: resolvedId, name, handle: handleCandidate?.startsWith("@") ? handleCandidate : metadataHandle?.startsWith("@") ? metadataHandle : void 0, - thumbnails: rendererThumbnails(metadata.avatar), + thumbnails: rendererThumbnails(metadata2.avatar), url: canonicalChannelUrl, about: { - description: string(about.description) ?? string(metadata.description), + description: string(about.description) ?? string(metadata2.description), links: channelExternalLinks(raw), moreInfo: { canonicalChannelUrl, @@ -20539,7 +20685,7 @@ function createYouTubeClient(options = {}) { videoCountText, viewCount: parseCompactNumber(viewCountText), viewCountText, - businessEmailAvailable: Object.keys(object(about.signInForBusinessEmail)).length > 0 + businessEmailAvailable: Object.keys(object2(about.signInForBusinessEmail)).length > 0 } }, meta: meta(hasAbout ? [] : ["Channel About details are unavailable."], !hasAbout) @@ -20568,12 +20714,12 @@ function createYouTubeClient(options = {}) { }); const [channelRaw, page] = await Promise.all([channelRawPromise, pagePromise]); const pageRaw = page?.raw; - const metadata = findRenderers(channelRaw, "channelMetadataRenderer")[0] ?? {}; - const resolvedId = string(metadata.externalId) ?? channelId; + const metadata2 = findRenderers(channelRaw, "channelMetadataRenderer")[0] ?? {}; + const resolvedId = string(metadata2.externalId) ?? channelId; const channelSummary = { id: resolvedId, - name: string(metadata.title) ?? rendererText(metadata.title) ?? "Unknown channel", - url: string(metadata.channelUrl) ?? `https://www.youtube.com/channel/${resolvedId}` + name: string(metadata2.title) ?? rendererText(metadata2.title) ?? "Unknown channel", + url: string(metadata2.channelUrl) ?? `https://www.youtube.com/channel/${resolvedId}` }; const videos = pageRaw ? parseSearchResults(pageRaw, channelSummary).filter( (item) => item.type === "video" @@ -20613,12 +20759,12 @@ function createYouTubeClient(options = {}) { }); const [channelRaw, page] = await Promise.all([channelRawPromise, pagePromise]); const pageRaw = page?.raw; - const metadata = findRenderers(channelRaw, "channelMetadataRenderer")[0] ?? {}; - const resolvedId = string(metadata.externalId) ?? channelId; + const metadata2 = findRenderers(channelRaw, "channelMetadataRenderer")[0] ?? {}; + const resolvedId = string(metadata2.externalId) ?? channelId; const channelSummary = { id: resolvedId, - name: string(metadata.title) ?? rendererText(metadata.title) ?? "Unknown channel", - url: string(metadata.channelUrl) ?? `https://www.youtube.com/channel/${resolvedId}` + name: string(metadata2.title) ?? rendererText(metadata2.title) ?? "Unknown channel", + url: string(metadata2.channelUrl) ?? `https://www.youtube.com/channel/${resolvedId}` }; const playlists = pageRaw ? parseSearchResults(pageRaw, channelSummary).filter( (item) => item.type === "playlist" @@ -20723,7 +20869,7 @@ function createYouTubeClient(options = {}) { } let json; try { - json = object(await response.json()); + json = object2(await response.json()); } catch (cause) { throw new YouTubeClientError( "INVALID_RESPONSE", @@ -20733,12 +20879,12 @@ function createYouTubeClient(options = {}) { } const segments = []; for (const value of array(json.events)) { - const event = object(value); + const event = object2(value); if (event.aAppend === 1 || !Array.isArray(event.segs)) continue; const startMs = number(event.tStartMs) ?? 0; const durationMs = number(event.dDurationMs) ?? 0; const words = array(event.segs).map((segmentValue) => { - const segment = object(segmentValue); + const segment = object2(segmentValue); const rawText = string(segment.utf8) ?? ""; const text2 = import_he.default.decode((0, import_striptags.default)(rawText)); const offsetMs = number(segment.tOffsetMs) ?? 0; @@ -20764,6 +20910,13 @@ function createYouTubeClient(options = {}) { meta: meta([], segments.length === 0) }; }, + async getStoryboard(storyboardOptions) { + return downloadStoryboard( + await player(storyboardOptions.videoId, false), + storyboardOptions, + fetchImpl + ); + }, getComments: getCommentsPage, getAllComments, async getEndscreen(videoId) { @@ -20779,7 +20932,7 @@ function decodeContinuation(token) { } } -// src/index.ts +// ../all-things-youtube/src/index.ts function search(options) { const { query, @@ -20883,7 +21036,7 @@ async function getPlaylist(options) { ); } -// skill/cli.ts +// src/direct/cli.ts var HELP = `youtube-direct Call YouTube's internal HTTP endpoints from this machine and print JSON. @@ -21056,8 +21209,8 @@ function optionalEnum(flags, name, values) { } return value; } -function compact(object2) { - return Object.fromEntries(Object.entries(object2).filter(([, value]) => value !== void 0)); +function compact(object3) { + return Object.fromEntries(Object.entries(object3).filter(([, value]) => value !== void 0)); } function operationOptions(operation, flags, requestFetch) { const shared = { @@ -21243,7 +21396,7 @@ async function runSkillCli(argv, io, environment = process.env, dependencies = { } } -// skill/entry.ts +// src/direct/entry.ts process.exitCode = await runSkillCli(process.argv.slice(2), { stdout: (value) => process.stdout.write(value), stderr: (value) => process.stderr.write(value) diff --git a/.agents/skills/youtube-watch/scripts/watch.mjs b/.agents/skills/youtube-watch/scripts/watch.mjs index 27b0b78..802b453 100755 --- a/.agents/skills/youtube-watch/scripts/watch.mjs +++ b/.agents/skills/youtube-watch/scripts/watch.mjs @@ -37,9 +37,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge mod )); -// node_modules/he/he.js +// ../all-things-youtube/node_modules/he/he.js var require_he = __commonJS({ - "node_modules/he/he.js"(exports, module) { + "../all-things-youtube/node_modules/he/he.js"(exports, module) { (function(root) { var freeExports = typeof exports == "object" && exports; var freeModule = typeof module == "object" && module && module.exports == freeExports && module; @@ -287,9 +287,9 @@ var require_he = __commonJS({ } }); -// node_modules/striptags/src/striptags.js +// ../all-things-youtube/node_modules/striptags/src/striptags.js var require_striptags = __commonJS({ - "node_modules/striptags/src/striptags.js"(exports, module) { + "../all-things-youtube/node_modules/striptags/src/striptags.js"(exports, module) { "use strict"; (function(global2) { if (typeof Symbol2 !== "function") { @@ -4498,8 +4498,8 @@ var require_util2 = __commonJS({ return true; } const strongest = getStrongestMetadata(parsedMetadata); - const metadata2 = filterMetadataListByAlgorithm(parsedMetadata, strongest); - for (const item of metadata2) { + const metadata3 = filterMetadataListByAlgorithm(parsedMetadata, strongest); + for (const item of metadata3) { const algorithm = item.algo; const expectedValue = item.hash; let actualValue = crypto.createHash(algorithm).update(bytes).digest("base64"); @@ -4517,10 +4517,10 @@ var require_util2 = __commonJS({ return false; } var parseHashWithOptions = /(?sha256|sha384|sha512)-((?[A-Za-z0-9+/]+|[A-Za-z0-9_-]+)={0,2}(?:\s|$)( +[!-~]*)?)?/i; - function parseMetadata(metadata2) { + function parseMetadata(metadata3) { const result = []; let empty = true; - for (const token of metadata2.split(" ")) { + for (const token of metadata3.split(" ")) { empty = false; const parsedToken = parseHashWithOptions.exec(token); if (parsedToken === null || parsedToken.groups === void 0 || parsedToken.groups.algo === void 0) { @@ -4542,13 +4542,13 @@ var require_util2 = __commonJS({ return algorithm; } for (let i = 1; i < metadataList.length; ++i) { - const metadata2 = metadataList[i]; - if (metadata2.algo[3] === "5") { + const metadata3 = metadataList[i]; + if (metadata3.algo[3] === "5") { algorithm = "sha512"; break; } else if (algorithm[3] === "3") { continue; - } else if (metadata2.algo[3] === "3") { + } else if (metadata3.algo[3] === "3") { algorithm = "sha384"; } } @@ -19020,16 +19020,16 @@ var require_undici = __commonJS({ } }); -// skill/watch-cli.ts +// src/watch/cli.ts import { mkdtemp, readFile as readFile2, realpath, rm as rm2, writeFile as writeFile2 } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve4, sep } from "node:path"; -// src/youtube-client.ts +// ../all-things-youtube/src/youtube-client.ts var import_he = __toESM(require_he()); var import_striptags = __toESM(require_striptags()); -// src/youtube-types.ts +// ../all-things-youtube/src/youtube-types.ts var YouTubeClientError = class extends Error { code; status; @@ -19046,7 +19046,7 @@ var YouTubeClientError = class extends Error { } }; -// src/youtube-transport.ts +// ../all-things-youtube/src/youtube-transport.ts var DEFAULT_POLICY = { maxAttempts: 5, attemptTimeoutMs: 1e4, @@ -19160,7 +19160,153 @@ function createYouTubeTransport(options) { }; } -// src/browse-contract.ts +// ../all-things-youtube/src/storyboard.ts +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +var DEFAULT_MAX_SHEETS = 12; +var MAX_SHEETS = 20; +var MAX_SHEET_BYTES = 4 * 1024 * 1024; +function object(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function positiveInteger(value) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0; +} +function parseStoryboardSpec(raw) { + const renderer = object(object(raw.storyboards).playerStoryboardSpecRenderer); + const spec = typeof renderer.spec === "string" ? renderer.spec : void 0; + if (!spec) return void 0; + const [template, ...encodedLevels] = spec.split("|"); + if (!template || !template.startsWith("https://")) return void 0; + const levels = encodedLevels.flatMap((encoded, index) => { + const fields = encoded.split("#"); + const tileWidth = positiveInteger(fields[0]); + const tileHeight = positiveInteger(fields[1]); + const frameCount = positiveInteger(fields[2]); + const columns = positiveInteger(fields[3]); + const rows = positiveInteger(fields[4]); + const intervalMs = positiveInteger(fields[5]); + const namePattern = fields[6]; + const signature = fields[7]; + if (!tileWidth || !tileHeight || !frameCount || !columns || !rows || !intervalMs || !namePattern || !signature) return []; + return [{ + index, + tileWidth, + tileHeight, + frameCount, + columns, + rows, + intervalMs, + namePattern, + signature + }]; + }); + return levels.length ? { template, levels } : void 0; +} +function sheetUrl(spec, level, sheet) { + const name = level.namePattern.replaceAll("$M", String(sheet)); + let value = spec.template.replaceAll("$L", String(level.index)).replaceAll("$N", name); + if (value.includes("$S")) value = value.replaceAll("$S", level.signature); + else { + const url = new URL(value); + url.searchParams.set("sigh", level.signature); + value = url.toString(); + } + return value; +} +async function jpegResponse(response) { + if (!response.ok) { + throw new YouTubeClientError("UPSTREAM_ERROR", `Storyboard request failed with status ${response.status}.`, { + status: response.status, + retryable: response.status === 429 || response.status >= 500 + }); + } + const type = response.headers.get("content-type")?.toLowerCase() ?? ""; + if (!type.startsWith("image/jpeg") && !type.startsWith("image/jpg")) { + throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned a non-JPEG storyboard."); + } + const advertised = Number(response.headers.get("content-length")); + if (Number.isFinite(advertised) && advertised > MAX_SHEET_BYTES) { + throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned an oversized storyboard."); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.length > MAX_SHEET_BYTES || bytes[0] !== 255 || bytes[1] !== 216) { + throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned an invalid storyboard JPEG."); + } + return bytes; +} +function validateOptions(options) { + if (!/^[A-Za-z0-9_-]{11}$/.test(options.videoId)) { + throw new YouTubeClientError("INVALID_INPUT", "videoId must be 11 characters."); + } + if (typeof options.outputDir !== "string" || !options.outputDir.trim()) { + throw new YouTubeClientError("INVALID_INPUT", "outputDir is required."); + } + const maxSheets = options.maxSheets ?? DEFAULT_MAX_SHEETS; + if (!Number.isSafeInteger(maxSheets) || maxSheets < 1 || maxSheets > MAX_SHEETS) { + throw new YouTubeClientError( + "INVALID_INPUT", + `maxSheets must be an integer from 1 to ${MAX_SHEETS}.` + ); + } + return maxSheets; +} +function metadata(warnings) { + return { + source: "allthingsyoutube", + fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), + partial: warnings.length > 0, + warnings + }; +} +async function downloadStoryboard(raw, options, fetchImpl) { + const maxSheets = validateOptions(options); + const spec = parseStoryboardSpec(raw); + if (!spec) { + throw new YouTubeClientError("NOT_FOUND", "No storyboard is available for this video."); + } + const level = [...spec.levels].sort( + (a, b) => b.tileWidth * b.tileHeight - a.tileWidth * a.tileHeight + )[0]; + if (!level) { + throw new YouTubeClientError("NOT_FOUND", "No usable storyboard level is available for this video."); + } + const capacity = level.columns * level.rows; + const availableSheets = Math.ceil(level.frameCount / capacity); + const requestedSheets = Math.min(availableSheets, maxSheets); + const directory = resolve(options.outputDir, "storyboards"); + await mkdir(directory, { recursive: true }); + const sheets = []; + for (let sheet = 0; sheet < requestedSheets; sheet += 1) { + const response = await fetchImpl(sheetUrl(spec, level, sheet)); + const bytes = await jpegResponse(response); + const path = join(directory, `${options.videoId}-level-${level.index}-sheet-${sheet}.jpg`); + await writeFile(path, bytes); + const firstFrameIndex = sheet * capacity; + sheets.push({ + path, + tileWidth: level.tileWidth, + tileHeight: level.tileHeight, + columns: level.columns, + rows: level.rows, + firstFrameIndex, + frameCount: Math.min(capacity, level.frameCount - firstFrameIndex), + intervalMs: level.intervalMs + }); + } + const warnings = requestedSheets < availableSheets ? [`Storyboard: limited to ${requestedSheets} sheets`] : []; + return { + videoId: options.videoId, + level: level.index, + frameCount: level.frameCount, + intervalMs: level.intervalMs, + sheets, + meta: metadata(warnings) + }; +} + +// ../all-things-youtube/src/browse-contract.ts var BROWSE_CATEGORIES = ["music", "news", "sports", "live"]; var BROWSE_REGIONS = ["US", "IN"]; var BROWSE_LANGUAGES = ["en", "hi"]; @@ -19179,7 +19325,7 @@ function normalizeBrowseLanguage(value) { return supportedValue((value ?? "en").trim().toLowerCase(), BROWSE_LANGUAGES, "language"); } -// src/youtube-client.ts +// ../all-things-youtube/src/youtube-client.ts var WEB_PROFILE = { name: "web", clientName: "WEB", @@ -19262,7 +19408,7 @@ function browseLocale(options) { function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -function object(value) { +function object2(value) { return isObject(value) ? value : {}; } function array(value) { @@ -19281,21 +19427,21 @@ function number(value) { } function rendererText(value) { if (typeof value === "string") return import_he.default.decode((0, import_striptags.default)(value)).trim(); - const source = object(value); + const source = object2(value); const content = string(source.content); if (content !== void 0) return import_he.default.decode((0, import_striptags.default)(content)).trim(); const simple = string(source.simpleText); if (simple !== void 0) return import_he.default.decode((0, import_striptags.default)(simple)).trim(); - const runs = array(source.runs).map((run) => string(object(run).text) ?? "").join(""); + const runs = array(source.runs).map((run) => string(object2(run).text) ?? "").join(""); return runs ? import_he.default.decode((0, import_striptags.default)(runs)).trim() : void 0; } function rendererThumbnails(value) { - const source = object(value); + const source = object2(value); const thumbnails = array( - source.thumbnails ?? object(source.thumbnail).thumbnails ?? source.sources ?? object(source.image).sources + source.thumbnails ?? object2(source.thumbnail).thumbnails ?? source.sources ?? object2(source.image).sources ); return thumbnails.flatMap((item) => { - const thumbnail = object(item); + const thumbnail = object2(item); const url = string(thumbnail.url); if (!url) return []; return [{ @@ -19325,17 +19471,17 @@ function continuationToken(root) { let token; walkObjects(root, (candidate) => { if (token) return; - const command = object(object(candidate.continuationEndpoint).continuationCommand); + const command = object2(object2(candidate.continuationEndpoint).continuationCommand); token = string(command.token); - if (!token) token = string(object(candidate.continuationCommand).token); - if (!token) token = string(object(candidate.nextContinuationData).continuation); + if (!token) token = string(object2(candidate.continuationCommand).token); + if (!token) token = string(object2(candidate.nextContinuationData).continuation); }); return token; } function directContinuationToken(value) { - const renderer = object(object(value).continuationItemRenderer); - const command = object(object(renderer.continuationEndpoint).continuationCommand); - return string(command.token) ?? string(object(renderer.nextContinuationData).continuation) ?? continuationToken(renderer); + const renderer = object2(object2(value).continuationItemRenderer); + const command = object2(object2(renderer.continuationEndpoint).continuationCommand); + return string(command.token) ?? string(object2(renderer.nextContinuationData).continuation) ?? continuationToken(renderer); } function commentContinuationTokens(root) { let continuation; @@ -19346,7 +19492,7 @@ function commentContinuationTokens(root) { for (const item of array(command.continuationItems)) { const direct = directContinuationToken(item); if (direct) continuation = direct; - const thread = object(object(item).commentThreadRenderer); + const thread = object2(object2(item).commentThreadRenderer); const reply = continuationToken(thread.replies); if (reply) replyContinuations.add(reply); } @@ -19354,7 +19500,7 @@ function commentContinuationTokens(root) { } for (const menu of findRenderers(root, "sortFilterSubMenuRenderer")) { for (const itemValue of array(menu.subMenuItems)) { - const item = object(itemValue); + const item = object2(itemValue); const title = string(item.title) ?? rendererText(item.title); if (title?.toLowerCase().startsWith("newest")) { newestContinuation = continuationToken(item); @@ -19446,25 +19592,25 @@ function assertPlaylistId(playlistId) { } } function channelFromRuns(value, navigationEndpoint) { - const source = object(value); - const firstRun = object(array(source.runs)[0]); - const commandRun = object(array(source.commandRuns)[0]); + const source = object2(value); + const firstRun = object2(array(source.runs)[0]); + const commandRun = object2(array(source.commandRuns)[0]); const navigationCandidates = [ firstRun.navigationEndpoint, - object(object(commandRun.onTap).innertubeCommand), + object2(object2(commandRun.onTap).innertubeCommand), navigationEndpoint, - object(object(navigationEndpoint).innertubeCommand) + object2(object2(navigationEndpoint).innertubeCommand) ]; - const id = navigationCandidates.map((candidate) => string(object(object(candidate).browseEndpoint).browseId)).find(Boolean) ?? ""; + const id = navigationCandidates.map((candidate) => string(object2(object2(candidate).browseEndpoint).browseId)).find(Boolean) ?? ""; const rawName = string(firstRun.text) ?? rendererText(value) ?? "Unknown channel"; const name = id ? rawName.replace(/^by\s+/i, "") : rawName; return { id, name, url: id ? `https://www.youtube.com/channel/${id}` : "" }; } function channelFromMetadataRows(value) { - for (const metadata2 of findRenderers(value, "contentMetadataViewModel")) { - for (const row of array(metadata2.metadataRows)) { - for (const partValue of array(object(row).metadataParts)) { - const part = object(partValue); + for (const metadata3 of findRenderers(value, "contentMetadataViewModel")) { + for (const row of array(metadata3.metadataRows)) { + for (const partValue of array(object2(row).metadataParts)) { + const part = object2(partValue); const channel = channelFromRuns(part.text); if (channel.id) return channel; } @@ -19490,7 +19636,7 @@ function parseVideoRenderer(renderer) { type: "video", id, title, - description: rendererText(renderer.descriptionSnippet) ?? array(renderer.detailedMetadataSnippets).map((snippet) => rendererText(object(snippet).snippetText)).filter(Boolean).join(" "), + description: rendererText(renderer.descriptionSnippet) ?? array(renderer.detailedMetadataSnippets).map((snippet) => rendererText(object2(snippet).snippetText)).filter(Boolean).join(" "), channel: channelFromRuns( renderer.ownerText ?? renderer.longBylineText ?? renderer.shortBylineText ), @@ -19509,7 +19655,7 @@ function parseChannelRenderer(renderer) { const id = string(renderer.channelId); const name = rendererText(renderer.title); if (!id || !name) return null; - const handle = rendererText(renderer.navigationEndpoint ? object(renderer.navigationEndpoint).commandMetadata : void 0) ?? rendererText(renderer.subscriberCountText); + const handle = rendererText(renderer.navigationEndpoint ? object2(renderer.navigationEndpoint).commandMetadata : void 0) ?? rendererText(renderer.subscriberCountText); return { type: "channel", id, @@ -19535,13 +19681,13 @@ function parsePlaylistRenderer(renderer) { channel: channelFromRuns(renderer.longBylineText ?? renderer.shortBylineText), thumbnails: firstRendererThumbnails( renderer.thumbnail, - object(array(renderer.thumbnails)[0]) + object2(array(renderer.thumbnails)[0]) ), videoCount: parseCompactNumber(videoCountText), videoCountText, isPodcast: false, playUrl: (() => { - const videoId = string(object(object(renderer.navigationEndpoint).watchEndpoint).videoId); + const videoId = string(object2(object2(renderer.navigationEndpoint).watchEndpoint).videoId); return videoId ? `https://www.youtube.com/watch?v=${videoId}&list=${id}` : void 0; })(), url: `https://www.youtube.com/playlist?list=${id}` @@ -19550,12 +19696,12 @@ function parsePlaylistRenderer(renderer) { function parseLockupViewModel(renderer, fallbackChannel) { const id = string(renderer.contentId); const contentType = string(renderer.contentType); - const metadata2 = object(object(renderer.metadata).lockupMetadataViewModel); - const title = rendererText(metadata2.title); + const metadata3 = object2(object2(renderer.metadata).lockupMetadataViewModel); + const title = rendererText(metadata3.title); if (!id || !title) return null; - const contentMetadata = object(object(metadata2.metadata).contentMetadataViewModel); + const contentMetadata = object2(object2(metadata3.metadata).contentMetadataViewModel); const metadataRows = array(contentMetadata.metadataRows).map( - (row) => array(object(row).metadataParts).map((part) => rendererText(object(part).text)).filter( + (row) => array(object2(row).metadataParts).map((part) => rendererText(object2(part).text)).filter( (value) => Boolean(value) ) ); @@ -19563,9 +19709,9 @@ function parseLockupViewModel(renderer, fallbackChannel) { const thumbnailRenderer = findRenderers(renderer.contentImage, "thumbnailViewModel")[0] ?? {}; const thumbnails = rendererThumbnails(thumbnailRenderer.image); const badgeTexts = findRenderers(renderer.contentImage, "thumbnailBadgeViewModel").map((badge) => rendererText(badge.text)).filter((value) => Boolean(value)); - const metadataBadgeTexts = findRenderers(metadata2, "badgeViewModel").flatMap((badge) => [ + const metadataBadgeTexts = findRenderers(metadata3, "badgeViewModel").flatMap((badge) => [ rendererText(badge.badgeText), - rendererText(object(object(badge.rendererContext).accessibilityContext).label) + rendererText(object2(object2(badge.rendererContext).accessibilityContext).label) ]).filter((value) => Boolean(value)); if (contentType === "LOCKUP_CONTENT_TYPE_VIDEO") { const statsRow = metadataRows.find((row) => row.length >= 2 && row.some((value) => /\d/.test(value))); @@ -19574,7 +19720,7 @@ function parseLockupViewModel(renderer, fallbackChannel) { (value) => /\b(?:ago|streamed|premiered)\b/i.test(value) ) ?? statsRow?.[1]; const durationText = badgeTexts.find((value) => /^\d+(?::\d+)+$/.test(value)); - const channel = channelFromMetadataRows(metadata2) ?? fallbackChannel ?? { id: "", name: "Unknown channel", url: "" }; + const channel = channelFromMetadataRows(metadata3) ?? fallbackChannel ?? { id: "", name: "Unknown channel", url: "" }; return { type: "video", id, @@ -19594,8 +19740,8 @@ function parseLockupViewModel(renderer, fallbackChannel) { if (contentType === "LOCKUP_CONTENT_TYPE_PLAYLIST" || contentType === "LOCKUP_CONTENT_TYPE_PODCAST") { const videoCountText = badgeTexts.find((value) => /\b(?:videos?|episodes?)$/i.test(value)); const updatedTimeText = metadataParts.find((value) => /^updated\b/i.test(value)); - const command = object(object(object(renderer.rendererContext).commandContext).onTap); - const firstVideoId = string(object(object(command.innertubeCommand).watchEndpoint).videoId); + const command = object2(object2(object2(renderer.rendererContext).commandContext).onTap); + const firstVideoId = string(object2(object2(command.innertubeCommand).watchEndpoint).videoId); return { type: "playlist", id, @@ -19660,9 +19806,9 @@ function parseSearchResults(root, fallbackChannel) { } function channelTabBrowseOptions(root, suffix) { for (const tab of findRenderers(root, "tabRenderer")) { - const endpoint = object(tab.endpoint); - const commandUrl = string(object(object(endpoint.commandMetadata).webCommandMetadata).url); - const browse = object(endpoint.browseEndpoint); + const endpoint = object2(tab.endpoint); + const commandUrl = string(object2(object2(endpoint.commandMetadata).webCommandMetadata).url); + const browse = object2(endpoint.browseEndpoint); const browseId = string(browse.browseId); const params = string(browse.params); if (commandUrl?.endsWith(suffix) && browseId && params) return { browseId, params }; @@ -19673,8 +19819,8 @@ function channelVideoSortContinuation(root, sort) { const label = sort === "latest" ? "Latest" : sort === "popular" ? "Popular" : "Oldest"; for (const chip of findRenderers(root, "chipViewModel")) { if (rendererText(chip.text) !== label) continue; - const command = object(object(chip.tapCommand).innertubeCommand); - const token = string(object(command.continuationCommand).token); + const command = object2(object2(chip.tapCommand).innertubeCommand); + const token = string(object2(command.continuationCommand).token); if (token) return token; } return void 0; @@ -19683,9 +19829,9 @@ function channelPlaylistSortBrowseOptions(root, sort) { const label = sort === "newest" ? "Date added (newest)" : "Last video added"; for (const menu of findRenderers(root, "sortFilterSubMenuRenderer")) { for (const item of array(menu.subMenuItems)) { - const option = object(item); + const option = object2(item); if (string(option.title) !== label) continue; - const browse = object(object(option.navigationEndpoint).browseEndpoint); + const browse = object2(object2(option.navigationEndpoint).browseEndpoint); const browseId = string(browse.browseId); const params = string(browse.params); if (browseId && params) return { browseId, params }; @@ -19715,9 +19861,9 @@ function catalogContinuationToken(root) { } function channelHeaderCounts(root) { const header = findRenderers(root, "pageHeaderViewModel")[0]; - const metadata2 = header ? findRenderers(header, "contentMetadataViewModel")[0] : void 0; - const values2 = array(metadata2?.metadataRows).flatMap( - (row) => array(object(row).metadataParts).map((part) => rendererText(object(part).text)).filter( + const metadata3 = header ? findRenderers(header, "contentMetadataViewModel")[0] : void 0; + const values2 = array(metadata3?.metadataRows).flatMap( + (row) => array(object2(row).metadataParts).map((part) => rendererText(object2(part).text)).filter( (value) => Boolean(value) ) ); @@ -19741,11 +19887,11 @@ function directExternalUrl(value) { function channelExternalLinks(root) { return findRenderers(root, "channelExternalLinkViewModel").flatMap((renderer) => { const title = rendererText(renderer.title); - const link = object(renderer.link); + const link = object2(renderer.link); const displayUrl = rendererText(link); - const commandRun = object(array(link.commandRuns)[0]); - const command = object(object(commandRun.onTap).innertubeCommand); - const commandUrl = string(object(command.urlEndpoint).url) ?? string(object(object(command.commandMetadata).webCommandMetadata).url); + const commandRun = object2(array(link.commandRuns)[0]); + const command = object2(object2(commandRun.onTap).innertubeCommand); + const commandUrl = string(object2(command.urlEndpoint).url) ?? string(object2(object2(command.commandMetadata).webCommandMetadata).url); const url = directExternalUrl(commandUrl); return title && displayUrl && url ? [{ title, displayUrl, url }] : []; }); @@ -19796,13 +19942,13 @@ function playlistHeaderData(root) { const secondary = findRenderers(root, "playlistSidebarSecondaryInfoRenderer")[0] ?? {}; const ownerRenderer = findRenderers(secondary, "videoOwnerRenderer")[0] ?? {}; const avatarStack = findRenderers(pageHeader, "avatarStackViewModel")[0] ?? {}; - const metadata2 = findRenderers(root, "playlistMetadataRenderer")[0] ?? {}; + const metadata3 = findRenderers(root, "playlistMetadataRenderer")[0] ?? {}; const microformat = findRenderers(root, "microformatDataRenderer")[0] ?? {}; const playlistThumbnail = findRenderers(primary.thumbnailRenderer, "playlistVideoThumbnailRenderer")[0] ?? {}; const metadataValues = [ ...findRenderers(pageHeader, "contentMetadataViewModel").flatMap( (viewModel) => array(viewModel.metadataRows).flatMap( - (row) => array(object(row).metadataParts).map((part) => rendererText(object(part).text)).filter((value) => Boolean(value)) + (row) => array(object2(row).metadataParts).map((part) => rendererText(object2(part).text)).filter((value) => Boolean(value)) ) ), ...array(primary.stats).map((stat2) => rendererText(stat2)).filter((value) => Boolean(value)) @@ -19815,8 +19961,8 @@ function playlistHeaderData(root) { ]; const channel = channelCandidates.find((candidate) => candidate.id) ?? channelCandidates.find((candidate) => candidate.name !== "Unknown channel") ?? { id: "", name: "Unknown channel", url: "" }; return { - title: rendererText(legacy.title) ?? string(pageHeader.pageTitle) ?? rendererText(object(findRenderers(pageHeader, "dynamicTextViewModel")[0]).text) ?? rendererText(primary.title) ?? string(metadata2.title), - description: rendererText(legacy.descriptionText) ?? rendererText(primary.description) ?? string(metadata2.description) ?? string(microformat.description), + title: rendererText(legacy.title) ?? string(pageHeader.pageTitle) ?? rendererText(object2(findRenderers(pageHeader, "dynamicTextViewModel")[0]).text) ?? rendererText(primary.title) ?? string(metadata3.title), + description: rendererText(legacy.descriptionText) ?? rendererText(primary.description) ?? string(metadata3.description) ?? string(microformat.description), channel, thumbnails: firstRendererThumbnails( legacy.playlistHeaderBanner, @@ -19873,14 +20019,14 @@ function captionTrackInfo(track, index, defaultIndex = 0) { }; } function parseCaptionTracks(player) { - const renderer = object(object(player.captions).playerCaptionsTracklistRenderer); - const audioTracks = array(renderer.audioTracks).map(object); + const renderer = object2(object2(player.captions).playerCaptionsTracklistRenderer); + const audioTracks = array(renderer.audioTracks).map(object2); const defaultAudioTrackIndex = number(renderer.defaultAudioTrackIndex); const indexedDefaultAudioTrack = defaultAudioTrackIndex !== void 0 ? audioTracks[defaultAudioTrackIndex] : void 0; const defaultAudioTrack = indexedDefaultAudioTrack ?? audioTracks.find((track) => track.hasDefaultTrack === true) ?? audioTracks[0] ?? {}; const defaultCaptionTrackIndex = number(defaultAudioTrack.defaultCaptionTrackIndex) ?? 0; const parsedTracks = array(renderer.captionTracks).flatMap((item, sourceIndex) => { - const track = object(item); + const track = object2(item); const baseUrl = string(track.baseUrl); if (!baseUrl) return []; return [{ @@ -19904,7 +20050,7 @@ function parseCaptionTracks(player) { (track, index) => captionTrackInfo(track, index, defaultIndex) ); const translations = array(renderer.translationLanguages).map((item) => { - const language = object(item); + const language = object2(item); return { languageCode: string(language.languageCode) ?? "und", name: rendererText(language.languageName) ?? string(language.languageCode) ?? "Unknown" @@ -19966,7 +20112,7 @@ function extractAssignedJson(html, markers) { else if (character === "{") depth += 1; else if (character === "}" && --depth === 0) { try { - return object(JSON.parse(html.slice(start, index + 1))); + return object2(JSON.parse(html.slice(start, index + 1))); } catch { break; } @@ -20002,9 +20148,9 @@ function chooseCaptionTrack(internal, language, trackId, defaultTrackId) { } function parseEndscreen(player) { return findRenderers(player.endscreen, "endscreenElementRenderer").map((renderer) => { - const endpoint = object(renderer.endpoint); - const watchEndpoint = object(endpoint.watchEndpoint); - const browseEndpoint = object(endpoint.browseEndpoint); + const endpoint = object2(renderer.endpoint); + const watchEndpoint = object2(endpoint.watchEndpoint); + const browseEndpoint = object2(endpoint.browseEndpoint); const style = string(renderer.style) ?? ""; const type = style.includes("VIDEO") ? "video" : style.includes("PLAYLIST") ? "playlist" : style.includes("CHANNEL") ? "channel" : "unknown"; return { @@ -20013,7 +20159,7 @@ function parseEndscreen(player) { metadata: rendererText(renderer.metadata), videoId: string(watchEndpoint.videoId), playlistId: string(watchEndpoint.playlistId), - channelId: string(browseEndpoint.browseId) ?? string(object(renderer.hovercardButton).channelId), + channelId: string(browseEndpoint.browseId) ?? string(object2(renderer.hovercardButton).channelId), startMs: number(renderer.startMs) ?? 0, endMs: number(renderer.endMs) ?? 0, thumbnails: rendererThumbnails(renderer.image), @@ -20027,12 +20173,12 @@ function parseEndscreen(player) { }); } function parseComment(renderer) { - const commentWrapper = object(renderer.comment); - const comment = object(commentWrapper.commentRenderer ?? renderer.comment ?? renderer); + const commentWrapper = object2(renderer.comment); + const comment = object2(commentWrapper.commentRenderer ?? renderer.comment ?? renderer); const id = string(comment.commentId) ?? string(renderer.commentId); const text = rendererText(comment.contentText ?? comment.content); if (!id || !text) return null; - const authorEndpoint = object(object(comment.authorEndpoint).browseEndpoint); + const authorEndpoint = object2(object2(comment.authorEndpoint).browseEndpoint); return { id, author: { @@ -20051,10 +20197,10 @@ function parseComment(renderer) { }; } function parseCommentEntity(payload) { - const properties = object(payload.properties); - const author = object(payload.author); - const toolbar = object(payload.toolbar); - const content = object(properties.content); + const properties = object2(payload.properties); + const author = object2(payload.author); + const toolbar = object2(payload.toolbar); + const content = object2(properties.content); const id = string(properties.commentId); const text = string(content.content); if (!id || !text) return null; @@ -20167,7 +20313,7 @@ function createYouTubeClient(options = {}) { ); } try { - return object(await response.json()); + return object2(await response.json()); } catch (cause) { throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned invalid JSON.", { cause, @@ -20189,7 +20335,7 @@ function createYouTubeClient(options = {}) { profile ); firstResponse ??= response; - const status = string(object(response.playabilityStatus).status); + const status = string(object2(response.playabilityStatus).status); const tracks = parseCaptionTracks(response).internal; if (status === "OK" && (!requireCaptionTrack || tracks.length)) return response; attempts.push(`${profile.name}: ${status ?? "UNKNOWN"}`); @@ -20445,8 +20591,8 @@ function createYouTubeClient(options = {}) { }, async getVideo(videoId) { const raw = await player(videoId, false); - const details = object(raw.videoDetails); - const status = object(raw.playabilityStatus); + const details = object2(raw.videoDetails); + const status = object2(raw.playabilityStatus); const author = string(details.author) ?? "Unknown channel"; const channelId = string(details.channelId) ?? ""; const playability = string(status.status) ?? "UNKNOWN"; @@ -20499,36 +20645,36 @@ function createYouTubeClient(options = {}) { } const aboutRaw = await desktopChannelAbout(channelId); const raw = aboutRaw ?? await rawBrowse({ browseId: channelId }); - const metadata2 = findRenderers(raw, "channelMetadataRenderer")[0] ?? {}; + const metadata3 = findRenderers(raw, "channelMetadataRenderer")[0] ?? {}; const about = findRenderers(raw, "aboutChannelViewModel")[0] ?? {}; - if (!string(metadata2.externalId) && !string(metadata2.title) && !rendererText(metadata2.title) && !string(about.channelId)) { + if (!string(metadata3.externalId) && !string(metadata3.title) && !rendererText(metadata3.title) && !string(about.channelId)) { throw new YouTubeClientError("NOT_FOUND", `YouTube channel ${channelId} was not found.`, { status: 404 }); } - const name = string(metadata2.title) ?? rendererText(metadata2.title) ?? "Unknown channel"; - const resolvedId = string(about.channelId) ?? string(metadata2.externalId) ?? channelId; + const name = string(metadata3.title) ?? rendererText(metadata3.title) ?? "Unknown channel"; + const resolvedId = string(about.channelId) ?? string(metadata3.externalId) ?? channelId; const headerCounts = channelHeaderCounts(raw); - const subscriberCountText = string(about.subscriberCountText) ?? headerCounts.subscriberCountText ?? rendererText(metadata2.subscriberCountText); - const videoCountText = string(about.videoCountText) ?? headerCounts.videoCountText ?? rendererText(metadata2.videoCountText); + const subscriberCountText = string(about.subscriberCountText) ?? headerCounts.subscriberCountText ?? rendererText(metadata3.subscriberCountText); + const videoCountText = string(about.videoCountText) ?? headerCounts.videoCountText ?? rendererText(metadata3.videoCountText); const viewCountText = string(about.viewCountText); const joinedDateText = rendererText(about.joinedDateText); const canonicalChannelUrl = normalizedChannelUrl( - string(about.canonicalChannelUrl) ?? string(metadata2.channelUrl), + string(about.canonicalChannelUrl) ?? string(metadata3.channelUrl), resolvedId ); const handleCandidate = canonicalChannelUrl.split("/").pop(); - const metadataHandle = string(metadata2.vanityChannelUrl)?.split("/").pop(); + const metadataHandle = string(metadata3.vanityChannelUrl)?.split("/").pop(); const hasAbout = Object.keys(about).length > 0; return { type: "channel", id: resolvedId, name, handle: handleCandidate?.startsWith("@") ? handleCandidate : metadataHandle?.startsWith("@") ? metadataHandle : void 0, - thumbnails: rendererThumbnails(metadata2.avatar), + thumbnails: rendererThumbnails(metadata3.avatar), url: canonicalChannelUrl, about: { - description: string(about.description) ?? string(metadata2.description), + description: string(about.description) ?? string(metadata3.description), links: channelExternalLinks(raw), moreInfo: { canonicalChannelUrl, @@ -20541,7 +20687,7 @@ function createYouTubeClient(options = {}) { videoCountText, viewCount: parseCompactNumber(viewCountText), viewCountText, - businessEmailAvailable: Object.keys(object(about.signInForBusinessEmail)).length > 0 + businessEmailAvailable: Object.keys(object2(about.signInForBusinessEmail)).length > 0 } }, meta: meta(hasAbout ? [] : ["Channel About details are unavailable."], !hasAbout) @@ -20570,12 +20716,12 @@ function createYouTubeClient(options = {}) { }); const [channelRaw, page] = await Promise.all([channelRawPromise, pagePromise]); const pageRaw = page?.raw; - const metadata2 = findRenderers(channelRaw, "channelMetadataRenderer")[0] ?? {}; - const resolvedId = string(metadata2.externalId) ?? channelId; + const metadata3 = findRenderers(channelRaw, "channelMetadataRenderer")[0] ?? {}; + const resolvedId = string(metadata3.externalId) ?? channelId; const channelSummary = { id: resolvedId, - name: string(metadata2.title) ?? rendererText(metadata2.title) ?? "Unknown channel", - url: string(metadata2.channelUrl) ?? `https://www.youtube.com/channel/${resolvedId}` + name: string(metadata3.title) ?? rendererText(metadata3.title) ?? "Unknown channel", + url: string(metadata3.channelUrl) ?? `https://www.youtube.com/channel/${resolvedId}` }; const videos = pageRaw ? parseSearchResults(pageRaw, channelSummary).filter( (item) => item.type === "video" @@ -20615,12 +20761,12 @@ function createYouTubeClient(options = {}) { }); const [channelRaw, page] = await Promise.all([channelRawPromise, pagePromise]); const pageRaw = page?.raw; - const metadata2 = findRenderers(channelRaw, "channelMetadataRenderer")[0] ?? {}; - const resolvedId = string(metadata2.externalId) ?? channelId; + const metadata3 = findRenderers(channelRaw, "channelMetadataRenderer")[0] ?? {}; + const resolvedId = string(metadata3.externalId) ?? channelId; const channelSummary = { id: resolvedId, - name: string(metadata2.title) ?? rendererText(metadata2.title) ?? "Unknown channel", - url: string(metadata2.channelUrl) ?? `https://www.youtube.com/channel/${resolvedId}` + name: string(metadata3.title) ?? rendererText(metadata3.title) ?? "Unknown channel", + url: string(metadata3.channelUrl) ?? `https://www.youtube.com/channel/${resolvedId}` }; const playlists = pageRaw ? parseSearchResults(pageRaw, channelSummary).filter( (item) => item.type === "playlist" @@ -20725,7 +20871,7 @@ function createYouTubeClient(options = {}) { } let json; try { - json = object(await response.json()); + json = object2(await response.json()); } catch (cause) { throw new YouTubeClientError( "INVALID_RESPONSE", @@ -20735,12 +20881,12 @@ function createYouTubeClient(options = {}) { } const segments = []; for (const value of array(json.events)) { - const event = object(value); + const event = object2(value); if (event.aAppend === 1 || !Array.isArray(event.segs)) continue; const startMs = number(event.tStartMs) ?? 0; const durationMs = number(event.dDurationMs) ?? 0; const words = array(event.segs).map((segmentValue) => { - const segment = object(segmentValue); + const segment = object2(segmentValue); const rawText = string(segment.utf8) ?? ""; const text2 = import_he.default.decode((0, import_striptags.default)(rawText)); const offsetMs = number(segment.tOffsetMs) ?? 0; @@ -20766,6 +20912,13 @@ function createYouTubeClient(options = {}) { meta: meta([], segments.length === 0) }; }, + async getStoryboard(storyboardOptions) { + return downloadStoryboard( + await player(storyboardOptions.videoId, false), + storyboardOptions, + fetchImpl + ); + }, getComments: getCommentsPage, getAllComments, async getEndscreen(videoId) { @@ -20781,16 +20934,40 @@ function decodeContinuation(token) { } } -// src/watch/index.ts -import { resolve as resolve3 } from "node:path"; +// ../all-things-youtube/src/index.ts +function optionsFrom(options) { + return { + fetch: options.fetch, + language: options.language, + region: options.region, + retry: options.retry + }; +} +function getTranscript(options) { + return createYouTubeClient(optionsFrom(options)).getTranscript({ + videoId: options.videoId, + translateTo: options.lang, + granularity: options.granularity + }); +} +function getStoryboard(options) { + const { videoId, outputDir, maxSheets } = options; + return createYouTubeClient(optionsFrom(options)).getStoryboard({ videoId, outputDir, maxSheets }); +} +function getDetails(options) { + return createYouTubeClient(optionsFrom(options)).getVideo(options.videoId); +} + +// src/watch/workflow.ts import { mkdir as mkdir3 } from "node:fs/promises"; +import { resolve as resolve3 } from "node:path"; // src/watch/ffmpeg.ts import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { constants } from "node:fs"; -import { access, mkdir, readFile, rm, stat } from "node:fs/promises"; -import { delimiter, extname, isAbsolute, join, resolve } from "node:path"; +import { access, mkdir as mkdir2, readFile, rm, stat } from "node:fs/promises"; +import { delimiter, extname, isAbsolute, join as join2, resolve as resolve2 } from "node:path"; // src/watch/jpeg.ts var START_OF_FRAME = /* @__PURE__ */ new Set([ @@ -20859,7 +21036,7 @@ async function resolveFfmpegExecutable(command, environment = process.env, platf const names = executableNames(trimmed, environment, platform); for (const directory of directories) { for (const name of names) { - const candidate = hasPath ? resolve(name) : resolve(directory, name); + const candidate = hasPath ? resolve2(name) : resolve2(directory, name); if (await isExecutable(candidate)) return candidate; } } @@ -20899,9 +21076,9 @@ function conciseError(stderr) { return "FFmpeg could not decode the requested frame."; } async function extractJpeg(ffmpegPath, inputUrl, outputDir, videoId, timestampMs, maxWidth, sourceWidth, sourceHeight) { - const directory = resolve(outputDir, "frames"); - await mkdir(directory, { recursive: true }); - const path = join(directory, `${videoId}-${timestampMs}-${randomUUID()}.jpg`); + const directory = resolve2(outputDir, "frames"); + await mkdir2(directory, { recursive: true }); + const path = join2(directory, `${videoId}-${timestampMs}-${randomUUID()}.jpg`); const seconds = (timestampMs / 1e3).toFixed(3); try { const result = await runProcess(ffmpegPath, [ @@ -20956,6 +21133,109 @@ async function extractJpeg(ffmpegPath, inputUrl, outputDir, videoId, timestampMs } } +// src/transport.ts +var DEFAULT_POLICY2 = { + maxAttempts: 5, + attemptTimeoutMs: 1e4, + baseDelayMs: 200, + maxDelayMs: 2e3, + retryStatuses: [408, 425, 429, 500, 502, 503, 504] +}; +function requestSignal2(request) { + if (request.init?.signal) return request.init.signal; + return typeof Request !== "undefined" && request.input instanceof Request ? request.input.signal : void 0; +} +function attemptSignal2(request, timeoutMs) { + const deadline = AbortSignal.timeout(timeoutMs); + const existing = requestSignal2(request); + if (!existing) return deadline; + const combined = new AbortController(); + const forwardAbort = (signal) => { + if (signal.aborted) combined.abort(signal.reason); + else signal.addEventListener("abort", () => combined.abort(signal.reason), { once: true }); + }; + forwardAbort(existing); + if (!combined.signal.aborted) forwardAbort(deadline); + return combined.signal; +} +async function runtimeWait2(delayMs) { + await new Promise((resolve5) => setTimeout(resolve5, delayMs)); +} +function retryAfterMs2(response, now) { + const value = response.headers.get("retry-after")?.trim(); + if (!value) return void 0; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3); + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? Math.max(0, timestamp - now) : void 0; +} +function retryDelay2(response, attempt, policy, random, now) { + const requestedDelay = response ? retryAfterMs2(response, now()) : void 0; + if (requestedDelay !== void 0) return Math.min(policy.maxDelayMs, requestedDelay); + const ceiling = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1)); + return Math.max(0, Math.round(ceiling * Math.min(1, Math.max(0, random())))); +} +function createSkillTransport(options) { + const wait = options.wait ?? runtimeWait2; + const random = options.random ?? Math.random; + const now = options.now ?? Date.now; + const policy = { + ...DEFAULT_POLICY2, + ...options.policy, + retryStatuses: options.policy?.retryStatuses ?? DEFAULT_POLICY2.retryStatuses + }; + if (!Number.isSafeInteger(policy.attemptTimeoutMs) || policy.attemptTimeoutMs < 1) { + throw new YouTubeClientError( + "INVALID_INPUT", + "retry.policy.attemptTimeoutMs must be a positive integer." + ); + } + return { + async fetch(operation, requestFactory) { + const retryStatuses = new Set(policy.retryStatuses); + let lastNetworkError; + for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) { + const request = await requestFactory(attempt); + let response; + try { + response = await options.fetch(request.input, { + ...request.init, + signal: attemptSignal2(request, policy.attemptTimeoutMs) + }); + } catch (error) { + lastNetworkError = error; + if (attempt === policy.maxAttempts) { + throw new YouTubeClientError( + "UPSTREAM_ERROR", + `YouTube ${operation} network request failed after ${policy.maxAttempts} attempts.`, + { retryable: true, cause: error } + ); + } + } + if (response && (!retryStatuses.has(response.status) || attempt === policy.maxAttempts)) { + return response; + } + const delayMs = retryDelay2(response, attempt, policy, random, now); + options.onRetry?.({ + operation, + attempt, + maxAttempts: policy.maxAttempts, + status: response?.status, + delayMs, + reason: response ? "response" : "network" + }); + if (response?.body) await response.body.cancel().catch(() => void 0); + await wait(delayMs); + } + throw new YouTubeClientError( + "UPSTREAM_ERROR", + `YouTube ${operation} retry loop exhausted.`, + { retryable: true, cause: lastNetworkError } + ); + } + }; +} + // src/watch/innertube.ts var IOS_PROFILE = { name: "ios", @@ -21009,7 +21289,7 @@ var API_ROOT2 = "https://youtubei.googleapis.com/youtubei/v1"; function isObject2(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -function object2(value) { +function object3(value) { return isObject2(value) ? value : {}; } async function callWatchPlayer(videoId, profile, options) { @@ -21018,7 +21298,7 @@ async function callWatchPlayer(videoId, profile, options) { } const fetchImpl = options.fetch ?? globalThis.fetch; if (!fetchImpl) throw new YouTubeClientError("INVALID_INPUT", "A fetch implementation is required."); - const transport = createYouTubeTransport({ fetch: fetchImpl, ...options.retry }); + const transport = createSkillTransport({ fetch: fetchImpl, ...options.retry }); const response = await transport.fetch(`youtube:watch-player:${profile.name}`, () => ({ input: `${API_ROOT2}/player?prettyPrint=false`, init: { @@ -21065,10 +21345,10 @@ async function callWatchPlayer(videoId, profile, options) { retryable: true }); } - const normalized = object2(raw); - const playability = String(object2(normalized.playabilityStatus).status ?? "UNKNOWN"); + const normalized = object3(raw); + const playability = String(object3(normalized.playabilityStatus).status ?? "UNKNOWN"); if (playability !== "OK") { - const reason = object2(normalized.playabilityStatus).reason; + const reason = object3(normalized.playabilityStatus).reason; throw new YouTubeClientError( playability === "LOGIN_REQUIRED" ? "AUTH_REQUIRED" : "UNAVAILABLE", typeof reason === "string" ? reason : `Video is not playable through ${profile.name}.`, @@ -21077,12 +21357,9 @@ async function callWatchPlayer(videoId, profile, options) { } return { profile: profile.name, raw: normalized }; } -function callIosWatchPlayer(videoId, options) { - return callWatchPlayer(videoId, IOS_PROFILE, options); -} // src/watch/media.ts -function object3(value) { +function object4(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } function values(value) { @@ -21094,7 +21371,7 @@ function finiteNumber(value) { } function formats(value, progressive) { return values(value).flatMap((item) => { - const format = object3(item); + const format = object4(item); const url = typeof format.url === "string" ? format.url : void 0; const mimeType = typeof format.mimeType === "string" ? format.mimeType : ""; if (!url || !mimeType.startsWith("video/")) return []; @@ -21115,7 +21392,7 @@ function codecPreference(candidate) { return 3; } function selectCandidates(raw, maxWidth) { - const streaming = object3(raw.streamingData); + const streaming = object4(raw.streamingData); const all = [ ...formats(streaming.formats, true), ...formats(streaming.adaptiveFormats, false) @@ -21147,6 +21424,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; var DEFAULT_PREFIX_CACHE_BYTES = 4 * 1024 * 1024; var DEFAULT_TRANSFER_LIMIT_BYTES = 256 * 1024 * 1024; +var LOOPBACK_HOST = "127.0.0.1"; var TransferBudget = class { constructor(limit = DEFAULT_TRANSFER_LIMIT_BYTES) { this.limit = limit; @@ -21275,7 +21553,7 @@ async function startMediaRangeProxy(candidate, fetchImpl, budget, prefixLimit = }); await new Promise((resolve5, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => { + server.listen(0, LOOPBACK_HOST, () => { server.off("error", reject); resolve5(); }); @@ -21286,7 +21564,7 @@ async function startMediaRangeProxy(candidate, fetchImpl, budget, prefixLimit = throw new Error("Could not bind the media range proxy."); } return { - url: `http://127.0.0.1:${address.port}/${token}`, + url: `http://${LOOPBACK_HOST}:${address.port}/${token}`, close: async () => { for (const controller of controllers) controller.abort(); server.closeAllConnections?.(); @@ -21295,127 +21573,13 @@ async function startMediaRangeProxy(candidate, fetchImpl, budget, prefixLimit = }; } -// src/watch/storyboard.ts -import { mkdir as mkdir2, writeFile } from "node:fs/promises"; -import { join as join2, resolve as resolve2 } from "node:path"; -var MAX_SHEET_BYTES = 4 * 1024 * 1024; -function object4(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function positiveInteger(value) { - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0; -} -function parseStoryboardSpec(raw) { - const storyboards = object4(raw.storyboards); - const renderer = object4(storyboards.playerStoryboardSpecRenderer); - const spec = typeof renderer.spec === "string" ? renderer.spec : void 0; - if (!spec) return void 0; - const [template, ...encodedLevels] = spec.split("|"); - if (!template || !template.startsWith("https://")) return void 0; - const levels = encodedLevels.flatMap((encoded, index) => { - const fields = encoded.split("#"); - const tileWidth = positiveInteger(fields[0]); - const tileHeight = positiveInteger(fields[1]); - const frameCount = positiveInteger(fields[2]); - const columns = positiveInteger(fields[3]); - const rows = positiveInteger(fields[4]); - const intervalMs = positiveInteger(fields[5]); - const namePattern = fields[6]; - const signature = fields[7]; - if (!tileWidth || !tileHeight || !frameCount || !columns || !rows || !intervalMs || !namePattern || !signature) return []; - return [{ - index, - tileWidth, - tileHeight, - frameCount, - columns, - rows, - intervalMs, - namePattern, - signature - }]; - }); - return levels.length ? { template, levels } : void 0; -} -function sheetUrl(spec, level, sheet) { - const name = level.namePattern.replaceAll("$M", String(sheet)); - let value = spec.template.replaceAll("$L", String(level.index)).replaceAll("$N", name); - if (value.includes("$S")) value = value.replaceAll("$S", level.signature); - else { - const url = new URL(value); - url.searchParams.set("sigh", level.signature); - value = url.toString(); - } - return value; -} -async function jpegResponse(response) { - if (!response.ok) { - throw new YouTubeClientError("UPSTREAM_ERROR", `Storyboard request failed with status ${response.status}.`, { - status: response.status, - retryable: response.status === 429 || response.status >= 500 - }); - } - const type = response.headers.get("content-type")?.toLowerCase() ?? ""; - if (!type.startsWith("image/jpeg") && !type.startsWith("image/jpg")) { - throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned a non-JPEG storyboard."); - } - const advertised = Number(response.headers.get("content-length")); - if (Number.isFinite(advertised) && advertised > MAX_SHEET_BYTES) { - throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned an oversized storyboard."); - } - const bytes = new Uint8Array(await response.arrayBuffer()); - if (bytes.length > MAX_SHEET_BYTES || bytes[0] !== 255 || bytes[1] !== 216) { - throw new YouTubeClientError("INVALID_RESPONSE", "YouTube returned an invalid storyboard JPEG."); - } - return bytes; -} -async function downloadStoryboard(raw, videoId, outputDir, maxSheets, options) { - const spec = parseStoryboardSpec(raw); - if (!spec) return void 0; - const level = [...spec.levels].sort( - (a, b) => b.tileWidth * b.tileHeight - a.tileWidth * a.tileHeight - )[0]; - if (!level) return void 0; - const capacity = level.columns * level.rows; - const availableSheets = Math.ceil(level.frameCount / capacity); - const requestedSheets = Math.min(availableSheets, maxSheets); - const directory = resolve2(outputDir, "storyboards"); - await mkdir2(directory, { recursive: true }); - const fetchImpl = options.fetch ?? globalThis.fetch; - const sheets = []; - for (let sheet = 0; sheet < requestedSheets; sheet += 1) { - const response = await fetchImpl(sheetUrl(spec, level, sheet)); - const bytes = await jpegResponse(response); - const path = join2(directory, `${videoId}-level-${level.index}-sheet-${sheet}.jpg`); - await writeFile(path, bytes); - const firstFrameIndex = sheet * capacity; - sheets.push({ - path, - tileWidth: level.tileWidth, - tileHeight: level.tileHeight, - columns: level.columns, - rows: level.rows, - firstFrameIndex, - frameCount: Math.min(capacity, level.frameCount - firstFrameIndex), - intervalMs: level.intervalMs - }); - } - return { - level: level.index, - frameCount: level.frameCount, - intervalMs: level.intervalMs, - sheets - }; -} - -// src/watch/index.ts +// src/watch/workflow.ts var DEFAULT_MAX_STORYBOARD_SHEETS = 12; var MAX_STORYBOARD_SHEETS = 20; var DEFAULT_MAX_WIDTH = 1280; var MAX_WIDTH = 1920; var MAX_TIMESTAMPS = 30; -function optionsFrom(options) { +function optionsFrom2(options) { return { fetch: options.fetch, language: options.language, @@ -21442,7 +21606,7 @@ function validateIndexRequest(options) { } return maxSheets; } -function metadata(warnings, partial) { +function metadata2(warnings, partial) { return { source: "allthingsyoutube", fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), @@ -21456,17 +21620,22 @@ function safeWarning(prefix, error) { } async function getWatchIndex(options) { const maxSheets = validateIndexRequest(options); - const clientOptions = optionsFrom(options); - const client = createYouTubeClient(clientOptions); + const clientOptions = optionsFrom2(options); const outputDir = resolve3(options.outputDir); - const [video, transcriptResult, playerResult] = await Promise.all([ - client.getVideo(options.videoId), - client.getTranscript({ + const [video, transcriptResult, storyboardResult] = await Promise.all([ + getDetails({ videoId: options.videoId, ...clientOptions }), + getTranscript({ videoId: options.videoId, - translateTo: options.lang, - granularity: options.granularity ?? "segment" + lang: options.lang, + granularity: options.granularity ?? "segment", + ...clientOptions }).then((value) => ({ value })).catch((error) => ({ error })), - callIosWatchPlayer(options.videoId, clientOptions).then((value) => ({ value })).catch((error) => ({ error })) + getStoryboard({ + videoId: options.videoId, + outputDir, + maxSheets, + ...clientOptions + }).then((value) => ({ value })).catch((error) => ({ error })) ]); if (video.isLive) { throw new YouTubeClientError("UNAVAILABLE", "Live videos are not supported by watch extraction."); @@ -21476,26 +21645,9 @@ async function getWatchIndex(options) { if ("error" in transcriptResult) { warnings.push(safeWarning("Transcript", transcriptResult.error)); } - let storyboard; - if ("value" in playerResult) { - try { - storyboard = await downloadStoryboard( - playerResult.value.raw, - options.videoId, - outputDir, - maxSheets, - clientOptions - ); - if (!storyboard) warnings.push("Storyboard: unavailable"); - else if (storyboard.sheets.length < Math.ceil( - storyboard.frameCount / (storyboard.sheets[0].columns * storyboard.sheets[0].rows) - )) warnings.push(`Storyboard: limited to ${storyboard.sheets.length} sheets`); - } catch (error) { - warnings.push(safeWarning("Storyboard", error)); - } - } else { - warnings.push(safeWarning("Storyboard", playerResult.error)); - } + const storyboard = "value" in storyboardResult ? storyboardResult.value : void 0; + if ("error" in storyboardResult) warnings.push(safeWarning("Storyboard", storyboardResult.error)); + else if (storyboard?.meta.partial) warnings.push(...storyboard.meta.warnings); if (!transcript && !storyboard?.sheets.length) { throw new YouTubeClientError("UNAVAILABLE", "No transcript or storyboard index is available."); } @@ -21506,7 +21658,7 @@ async function getWatchIndex(options) { strategy, transcript, storyboard, - meta: metadata(warnings, warnings.length > 0) + meta: metadata2(warnings, warnings.length > 0) }; } function validateFrameRequest(options) { @@ -21561,9 +21713,8 @@ async function extractConcurrent(timestamps2, run) { } async function extractFrames(options) { const { timestamps: timestamps2, maxWidth } = validateFrameRequest(options); - const clientOptions = optionsFrom(options); - const client = createYouTubeClient(clientOptions); - const video = await client.getVideo(options.videoId); + const clientOptions = optionsFrom2(options); + const video = await getDetails({ videoId: options.videoId, ...clientOptions }); if (video.isLive) { throw new YouTubeClientError("UNAVAILABLE", "Live videos are not supported by watch extraction."); } @@ -21638,12 +21789,12 @@ async function extractFrames(options) { videoId: options.videoId, frames: orderedFrames, failures, - meta: metadata(warnings, failures.length > 0) + meta: metadata2(warnings, failures.length > 0) }; } -// skill/cli.ts -var import_undici = __toESM(require_undici()); +// src/direct/cli.ts +var import_undici = __toESM(require_undici(), 1); var CliInputError = class extends Error { code = "INVALID_INPUT"; constructor(message) { @@ -21683,7 +21834,7 @@ function createRequestFetch(proxyUrl) { }; } -// skill/watch-cli.ts +// src/watch/cli.ts var HELP = `youtube-watch Inspect a YouTube storyboard and transcript, then extract exact timestamped frames. @@ -21890,7 +22041,7 @@ async function runWatchCli(argv, io, environment = process.env, dependencies = { } } -// skill/watch-entry.ts +// src/watch/entry.ts process.exitCode = await runWatchCli(process.argv.slice(2), { stdout: (value) => process.stdout.write(value), stderr: (value) => process.stderr.write(value) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3666d02 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +.agents/skills/youtube-direct/scripts/youtube.mjs linguist-generated +.agents/skills/youtube-watch/scripts/watch.mjs linguist-generated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1a609b..b54475e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,14 +20,19 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: packages/all-things-youtube/package-lock.json + cache-dependency-path: | + packages/all-things-youtube/package-lock.json + packages/youtube-skills/package-lock.json - run: npm ci + - run: npm run build + - run: npm ci --prefix ../youtube-skills - run: sudo apt-get update && sudo apt-get install --yes ffmpeg - run: npm test + - run: npm test --prefix ../youtube-skills env: WATCH_FFMPEG_TEST: '1' - - run: npm run build - - run: npm run skill:check + - run: npm run typecheck --prefix ../youtube-skills + - run: npm run bundle:check --prefix ../youtube-skills - run: test -x ../../.agents/skills/youtube-direct/scripts/youtube.mjs - run: node ../../.agents/skills/youtube-direct/scripts/youtube.mjs --help - run: test -x ../../.agents/skills/youtube-watch/scripts/watch.mjs diff --git a/README.md b/README.md index 59a0f84..b755828 100644 --- a/README.md +++ b/README.md @@ -126,18 +126,18 @@ const transcript = await getTranscript({ console.log(transcript.text); ``` -Visual context is available through the Node-only watch subpath. FFmpeg is needed only for exact frames: +YouTube's native storyboard contact sheets are available as a package primitive: ```ts -import { getWatchIndex, extractFrames } from 'all-things-youtube/watch'; - -const outputDir = '/tmp/youtube-watch'; -const index = await getWatchIndex({ videoId: '4vItmdk8F_M', outputDir }); -const frames = await extractFrames({ - videoId: index.videoId, - timestampsMs: [30_000, 686_000], - outputDir, +import { getStoryboard } from 'all-things-youtube'; + +const storyboard = await getStoryboard({ + videoId: '4vItmdk8F_M', + outputDir: '/tmp/youtube-storyboard', + maxSheets: 12, }); + +console.log(storyboard.sheets); ``` The package requires Node.js 18 or newer, includes its own types, and does not require a YouTube Data API key. Keep it server-side: browser requests are commonly blocked by CORS and distribute upstream rate-limit pressure across users. @@ -159,6 +159,7 @@ git clone https://github.com/devhims/video2ctx.git cd video2ctx npm ci --prefix packages/all-things-youtube +npm ci --prefix packages/youtube-skills npm ci --prefix packages/video2ctx-cli npm ci --prefix platform npm ci --prefix platform/youtube-processor @@ -257,6 +258,7 @@ For the complete request path and reliability model, see [`reference/engineering | [`platform/`](./platform) | TypeScript/Hono Cloudflare Worker with auth, API keys, billing, D1, R2, KV, AI, queues, workflows, and OpenAPI | | [`platform/youtube-processor/`](./platform/youtube-processor) | Private Node 22/Hono container for outbound YouTube operations and optional proxy egress | | [`packages/all-things-youtube/`](./packages/all-things-youtube) | Publishable normalized YouTube client and public TypeScript data model | +| [`packages/youtube-skills/`](./packages/youtube-skills) | Private source, tests, and bundling for the self-contained direct YouTube skills | | [`packages/video2ctx-cli/`](./packages/video2ctx-cli) | Independently published CLI for device login and authenticated hosted API access | | [`docs/`](./docs) | Public Mintlify documentation site | | [`reference/`](./reference) | Internal architecture, design, deployment, and agent guidance | @@ -294,6 +296,8 @@ Common commands, run from the repository root: | `npm --prefix platform run verify` | Install processor dependencies, type-check the Worker, and run platform and processor tests | | `npm --prefix platform run test:container` | Run only the processor contract tests | | `npm --prefix packages/all-things-youtube run test:watch` | Run the library suite in watch mode | +| `npm run test:skills` | Test, type-check, and verify the private direct-skill source and committed bundles | +| `npm run skill:bundle` | Regenerate the committed self-contained direct-skill executables | | `npm --prefix packages/video2ctx-cli run verify` | Test and build the hosted-service CLI | | `npm pack ./packages/video2ctx-cli --dry-run` | Verify the public CLI tarball contents before release | | `npm run docs:dev` | Preview the Mintlify documentation site locally | @@ -306,6 +310,7 @@ Before opening a pull request, run: ```bash npm run build npm test +npm run test:skills npm --prefix web test npm run docs:check npm run docs:verify diff --git a/docs/open-source/local-development.mdx b/docs/open-source/local-development.mdx index b0de414..f038ba2 100644 --- a/docs/open-source/local-development.mdx +++ b/docs/open-source/local-development.mdx @@ -9,6 +9,7 @@ The repository contains the Next.js web application, Cloudflare Worker platform, git clone https://github.com/devhims/video2ctx.git cd video2ctx npm install --prefix packages/all-things-youtube +npm install --prefix packages/youtube-skills npm install --prefix platform npm install --prefix web npm install --prefix docs diff --git a/package.json b/package.json index 24cd13f..b84242d 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ "build:library": "npm --prefix packages/all-things-youtube run build", "test:library": "npm --prefix packages/all-things-youtube run test", "pack:library": "npm pack ./packages/all-things-youtube", - "skill:bundle": "npm --prefix packages/all-things-youtube run skill:bundle", - "skill:check": "npm --prefix packages/all-things-youtube run skill:check", + "skill:bundle": "npm --prefix packages/youtube-skills run bundle", + "skill:check": "npm --prefix packages/youtube-skills run bundle:check", + "test:skills": "npm run build:library && npm --prefix packages/youtube-skills run verify", "pack:cli": "npm pack ./packages/video2ctx-cli", "test:auth": "npm --prefix packages/video2ctx-cli test && npm --prefix platform run test:auth", "test:auth:integration": "npm --prefix platform run test:auth:integration", diff --git a/packages/all-things-youtube/README.md b/packages/all-things-youtube/README.md index dfe07d4..153fd48 100644 --- a/packages/all-things-youtube/README.md +++ b/packages/all-things-youtube/README.md @@ -19,7 +19,7 @@ Search YouTube and get transcripts/captions, comments, video details, channels, YouTube exposes useful public data across several different page experiences. `all-things-youtube` gives that data one small, task-oriented interface: -- **One import, ten focused functions:** Search or get exactly the resource you need instead of constructing a large client or learning a raw response format. +- **One import, eleven focused functions:** Search or get exactly the resource you need instead of constructing a large client or learning a raw response format. - **Translation made simple:** Call `getTranscript()` with your desired output language. Supports 150+ languages when available. - **Complete channel and playlist data:** Channel About links and statistics, channel sorting, playlist cards, and continuation-based pagination are represented directly. - **Bounded networking by default:** Each attempt has a ten-second deadline; transient failures and `429` responses use bounded exponential backoff with jitter and `Retry-After` support. @@ -35,7 +35,7 @@ npm install all-things-youtube Requires Node.js 18 or newer. The package uses the runtime's standard `fetch`; you can supply your own implementation when needed. -The optional `all-things-youtube/watch` entry point uses a system FFmpeg executable for exact frames. Storyboard and transcript indexing does not require FFmpeg. +Storyboard contact sheets are downloaded as JPEG files beneath a caller-owned output directory. The package does not require FFmpeg. > Keep calls server-side. Direct browser calls are commonly blocked by CORS and make every user's browser responsible for upstream rate limits. @@ -100,6 +100,7 @@ All functions are named exports and accept a single options object. | Search | `search()` | Paginated videos, channels, and playlists | | Caption catalog | `getTracks()` | Source tracks and available translation languages | | Transcript | `getTranscript()` | Full text plus timed segments or words | +| Storyboard | `getStoryboard()` | Contact sheets plus timestamp-ready tile mappings | | Comments | `getComments()` | One page or a bounded complete collection | | Video | `getDetails()` | Core metadata, channel, keywords, and availability | | End screen | `getEndscreen()` | Timed video, playlist, and channel elements | @@ -107,43 +108,29 @@ All functions are named exports and accept a single options object. | Channel videos | `getChannelVideos()` | One sorted, paginated Videos-tab page | | Channel playlists | `getChannelPlaylists()` | One sorted, paginated Playlists-tab page | | Playlist | `getPlaylist()` | Playlist metadata and one page of videos | -| Watch index | `getWatchIndex()` | Timed transcript and storyboard contact sheets | -| Exact frames | `extractFrames()` | Best-effort timestamped JPEG files | -The original task functions remain on the main entry point. The two visual functions are exported from `all-things-youtube/watch`. +Every function is exported from the main `all-things-youtube` entry point. -## Visual context +## Storyboards -Use the storyboard/transcript index to choose focused timestamps before decoding exact frames: +Download YouTube's native storyboard contact sheets when you need a lightweight visual index: ```ts -import { getWatchIndex, extractFrames } from 'all-things-youtube/watch'; +import { getStoryboard } from 'all-things-youtube'; -const outputDir = '/tmp/youtube-watch'; -const index = await getWatchIndex({ +const storyboard = await getStoryboard({ videoId: '4vItmdk8F_M', - outputDir, - granularity: 'segment', + outputDir: '/tmp/youtube-storyboard', + maxSheets: 12, }); -for (const sheet of index.storyboard?.sheets ?? []) { +for (const sheet of storyboard.sheets) { console.log(sheet.path, sheet.firstFrameIndex, sheet.intervalMs); } - -const result = await extractFrames({ - videoId: index.videoId, - timestampsMs: [30_000, 686_000], - outputDir, - maxWidth: 1280, -}); - -console.log(result.frames); -console.log(result.failures); ``` -`getWatchIndex()` returns complete contact-sheet paths and tile mappings without exposing YouTube's storyboard URLs. Transcript timing defaults to segments; pass `granularity: 'word'` only when word timing is necessary. - -`extractFrames()` accepts one to 30 timestamps, uses the best seekable public media format, and may fall back below 720p. It keeps signed media URLs in memory, gives FFmpeg a localhost proxy URL, and returns successful frames alongside per-timestamp failures. Install FFmpeg on `PATH`, set `FFMPEG_PATH`, or pass `ffmpegPath`. +The returned sheet paths are absolute. A tile's timestamp is +`(firstFrameIndex + row * columns + column) * intervalMs`. The package downloads at most 12 sheets by default; `maxSheets` accepts 1–20. Storyboard URLs remain internal and are never returned. ## Working with responses @@ -266,6 +253,33 @@ for (const segment of transcript.segments) { Omit `lang` to return the default source track. Supplying `lang` asks for that output language regardless of whether the source captions are English, Spanish, or another supported language. If translation is unavailable, the call rejects with `INVALID_INPUT`. +### `getStoryboard(options)` + +Downloads YouTube's native storyboard contact sheets and returns the information needed to map every tile to a timestamp. + +```ts +import { getStoryboard } from 'all-things-youtube'; + +const storyboard = await getStoryboard({ + videoId: '4vItmdk8F_M', + outputDir: '/tmp/youtube-storyboard', + maxSheets: 12, +}); + +for (const sheet of storyboard.sheets) { + console.log(sheet.path, sheet.columns, sheet.rows, sheet.intervalMs); +} +``` + +| Option | Type | Required | Default | Description | +| ----------- | -------- | -------- | ------- | ----------------------------------- | +| `videoId` | `string` | Yes | — | YouTube video ID | +| `outputDir` | `string` | Yes | — | Caller-owned directory for JPEGs | +| `maxSheets` | `number` | No | `12` | Sheet budget from 1 through 20 | + +The highest usable storyboard level is selected. A tile at `row` and `column` represents +`(firstFrameIndex + row * columns + column) * intervalMs`. The package creates a `storyboards` child directory but never recursively deletes `outputDir`. + ### `getComments(options)` Fetch one page for interactive pagination, or crawl the available thread and reply pages in one call. diff --git a/packages/all-things-youtube/package-lock.json b/packages/all-things-youtube/package-lock.json index 2122f42..fb329f7 100644 --- a/packages/all-things-youtube/package-lock.json +++ b/packages/all-things-youtube/package-lock.json @@ -1,12 +1,12 @@ { "name": "all-things-youtube", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "all-things-youtube", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "he": "^1.2.0", @@ -15,9 +15,7 @@ "devDependencies": { "@types/he": "^1.2.0", "@types/node": "^25.9.5", - "esbuild": "0.28.2", "typescript": "^6.0.3", - "undici": "6.28.0", "vitest": "^3.2.4" }, "engines": { @@ -1472,16 +1470,6 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", diff --git a/packages/all-things-youtube/package.json b/packages/all-things-youtube/package.json index 235b9e6..8d3bca2 100644 --- a/packages/all-things-youtube/package.json +++ b/packages/all-things-youtube/package.json @@ -1,7 +1,7 @@ { "name": "all-things-youtube", - "version": "0.3.0", - "description": "TypeScript toolkit for YouTube transcripts, metadata, storyboards, and timestamped visual context.", + "version": "0.4.0", + "description": "TypeScript toolkit for YouTube transcripts, metadata, storyboards, comments, channels, and playlists.", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { @@ -9,11 +9,6 @@ "types": "./dist/index.d.ts", "require": "./dist/index.js", "default": "./dist/index.js" - }, - "./watch": { - "types": "./dist/watch/index.d.ts", - "require": "./dist/watch/index.js", - "default": "./dist/watch/index.js" } }, "files": [ @@ -27,8 +22,6 @@ "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "build": "npm run clean && tsc", - "skill:bundle": "node scripts/build-skill-bundle.mjs", - "skill:check": "node scripts/build-skill-bundle.mjs --check", "test": "vitest run", "test:watch": "vitest", "prepack": "npm test && npm run build" @@ -42,7 +35,6 @@ "youtube-comments", "youtube-channel", "youtube-playlist", - "youtube-frames", "youtube-storyboard", "transcript", "captions", @@ -74,9 +66,7 @@ "devDependencies": { "@types/he": "^1.2.0", "@types/node": "^25.9.5", - "esbuild": "0.28.2", "typescript": "^6.0.3", - "undici": "6.28.0", "vitest": "^3.2.4" }, "engines": { diff --git a/packages/all-things-youtube/src/index.test.ts b/packages/all-things-youtube/src/index.test.ts index ab7cec3..783f9d8 100644 --- a/packages/all-things-youtube/src/index.test.ts +++ b/packages/all-things-youtube/src/index.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => { getAllComments: vi.fn(), getVideo: vi.fn(), getEndscreen: vi.fn(), + getStoryboard: vi.fn(), getChannel: vi.fn(), getChannelVideos: vi.fn(), getChannelPlaylists: vi.fn(), @@ -32,6 +33,7 @@ import { getDetails, getEndscreen, getPlaylist, + getStoryboard, search, getTracks, getTranscript, @@ -54,11 +56,14 @@ describe('all-things-youtube public API', () => { getChannelVideos, getChannelPlaylists, getPlaylist, + getStoryboard, search, ]) { expect(typeof exported).toBe('function'); } expect(publicApi).not.toHaveProperty('createYouTubeClient'); + expect(publicApi).not.toHaveProperty('getWatchIndex'); + expect(publicApi).not.toHaveProperty('extractFrames'); }); test('maps stateless search to the normalized client', async () => { @@ -87,11 +92,13 @@ describe('all-things-youtube public API', () => { mocks.client.getTranscript.mockResolvedValue({ segments: [] }); mocks.client.getVideo.mockResolvedValue({ id: 'abcdefghijk' }); mocks.client.getEndscreen.mockResolvedValue([]); + mocks.client.getStoryboard.mockResolvedValue({ sheets: [] }); await getTracks({ videoId: 'abcdefghijk', fetch: fetchMock }); await getTranscript({ videoId: 'abcdefghijk', lang: 'hi', granularity: 'word' }); await getDetails({ videoId: 'abcdefghijk' }); await getEndscreen({ videoId: 'abcdefghijk' }); + await getStoryboard({ videoId: 'abcdefghijk', outputDir: '/tmp/storyboards', maxSheets: 3 }); expect(mocks.createYouTubeClient).toHaveBeenCalledWith(expect.objectContaining({ fetch: fetchMock })); expect(mocks.client.getCaptionTracks).toHaveBeenCalledWith('abcdefghijk'); @@ -100,6 +107,9 @@ describe('all-things-youtube public API', () => { }); expect(mocks.client.getVideo).toHaveBeenCalledWith('abcdefghijk'); expect(mocks.client.getEndscreen).toHaveBeenCalledWith('abcdefghijk'); + expect(mocks.client.getStoryboard).toHaveBeenCalledWith({ + videoId: 'abcdefghijk', outputDir: '/tmp/storyboards', maxSheets: 3, + }); }); test('keeps comment continuation internals out of the public result', async () => { diff --git a/packages/all-things-youtube/src/index.ts b/packages/all-things-youtube/src/index.ts index 3912ef0..0f77220 100644 --- a/packages/all-things-youtube/src/index.ts +++ b/packages/all-things-youtube/src/index.ts @@ -33,6 +33,11 @@ export interface TranscriptRequest extends VideoRequest { granularity?: 'segment' | 'word'; } +export interface StoryboardRequest extends VideoRequest { + outputDir: string; + maxSheets?: number; +} + export interface CommentsRequest extends VideoRequest { continuation?: string; all?: false; @@ -157,6 +162,12 @@ export function getTranscript(options: TranscriptRequest): Promise { }); } +/** Download normalized YouTube storyboard contact sheets and timestamp mappings. */ +export function getStoryboard(options: StoryboardRequest) { + const { videoId, outputDir, maxSheets } = options; + return createYouTubeClient(optionsFrom(options)).getStoryboard({ videoId, outputDir, maxSheets }); +} + export function getComments(options: AllCommentsRequest): Promise; export function getComments(options: CommentsRequest): Promise; export async function getComments( diff --git a/packages/all-things-youtube/src/live.test.ts b/packages/all-things-youtube/src/live.test.ts index 3f904ae..1c1a082 100644 --- a/packages/all-things-youtube/src/live.test.ts +++ b/packages/all-things-youtube/src/live.test.ts @@ -9,11 +9,11 @@ import { getDetails, getEndscreen, getPlaylist, + getStoryboard, search, getTracks, getTranscript, } from './index'; -import { extractFrames, getWatchIndex } from './watch'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -54,21 +54,16 @@ describeLive('live public API', () => { expect(playlist.id).toBe(playlistId); }, 120_000); - test('loads a storyboard index and exact best-effort frames', async () => { - const outputDir = await mkdtemp(join(tmpdir(), 'all-things-youtube-live-watch-')); + test('loads storyboard contact sheets through the public primitive', async () => { + const outputDir = await mkdtemp(join(tmpdir(), 'all-things-youtube-live-storyboard-')); try { - const index = await getWatchIndex({ - videoId: '4vItmdk8F_M', outputDir, maxStoryboardSheets: 1, + const storyboard = await getStoryboard({ + videoId: '4vItmdk8F_M', outputDir, maxSheets: 1, }); - const frames = await extractFrames({ - videoId: '4vItmdk8F_M', outputDir, timestampsMs: [30_000, 686_000, 1_000_000], - }); - expect(index.storyboard?.sheets.length).toBe(1); - expect(index.transcript?.segments.length).toBeGreaterThan(0); - expect(frames.frames).toHaveLength(3); - expect(JSON.stringify({ index, frames })).not.toContain('googlevideo.com'); + expect(storyboard.sheets).toHaveLength(1); + expect(JSON.stringify(storyboard)).not.toContain('i.ytimg.com'); } finally { await rm(outputDir, { recursive: true, force: true }); } - }, 180_000); + }, 120_000); }); diff --git a/packages/all-things-youtube/src/watch/storyboard.test.ts b/packages/all-things-youtube/src/storyboard.test.ts similarity index 61% rename from packages/all-things-youtube/src/watch/storyboard.test.ts rename to packages/all-things-youtube/src/storyboard.test.ts index f66013d..4eee6e7 100644 --- a/packages/all-things-youtube/src/watch/storyboard.test.ts +++ b/packages/all-things-youtube/src/storyboard.test.ts @@ -47,16 +47,15 @@ describe('storyboard contact sheets', () => { { headers: { 'content-type': 'image/jpeg' } }, )); - const result = await downloadStoryboard( - rawSpec(), - 'abcdefghijk', - directory, - 1, - { fetch: fetchMock as unknown as typeof fetch }, - ); + const result = await downloadStoryboard(rawSpec(), { + videoId: 'abcdefghijk', outputDir: directory, maxSheets: 1, + }, fetchMock as unknown as typeof fetch); - expect(result).toMatchObject({ level: 2, frameCount: 30, intervalMs: 10_000 }); - expect(result?.sheets).toEqual([expect.objectContaining({ + expect(result).toMatchObject({ + videoId: 'abcdefghijk', level: 2, frameCount: 30, intervalMs: 10_000, + meta: { partial: true, warnings: ['Storyboard: limited to 1 sheets'] }, + }); + expect(result.sheets).toEqual([expect.objectContaining({ tileWidth: 160, tileHeight: 90, columns: 5, @@ -65,11 +64,25 @@ describe('storyboard contact sheets', () => { frameCount: 25, intervalMs: 10_000, })]); - const requestedUrl = String(fetchMock.mock.calls[0]?.[0]); + const requestedUrl = String(fetchMock.mock.calls.find((call) => + String(call[0]).includes('i.ytimg.test') + )?.[0]); expect(requestedUrl).toContain('storyboard3_L2/M0.jpg'); expect(requestedUrl).toContain('sigh=signature-two'); - expect(await readFile(result!.sheets[0]!.path)).toEqual( + expect(await readFile(result.sheets[0]!.path)).toEqual( Buffer.from([0xff, 0xd8, 0xff, 0xd9]), ); }); + + test('rejects malformed requests before contacting YouTube', async () => { + const fetchMock = vi.fn(); + + await expect(downloadStoryboard(rawSpec(), { + videoId: 'short', outputDir: '/tmp/storyboard-test', + }, fetchMock as unknown as typeof fetch)).rejects.toMatchObject({ code: 'INVALID_INPUT' }); + await expect(downloadStoryboard(rawSpec(), { + videoId: 'abcdefghijk', outputDir: '/tmp/storyboard-test', maxSheets: 21, + }, fetchMock as unknown as typeof fetch)).rejects.toMatchObject({ code: 'INVALID_INPUT' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/all-things-youtube/src/watch/storyboard.ts b/packages/all-things-youtube/src/storyboard.ts similarity index 70% rename from packages/all-things-youtube/src/watch/storyboard.ts rename to packages/all-things-youtube/src/storyboard.ts index 3d2ea40..0496735 100644 --- a/packages/all-things-youtube/src/watch/storyboard.ts +++ b/packages/all-things-youtube/src/storyboard.ts @@ -1,12 +1,20 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; -import { YouTubeClientError, type YouTubeClientOptions } from '../youtube-types'; -import type { StoryboardContactSheet, StoryboardIndex } from './types'; -import type { JsonObject } from './innertube'; +import { + YouTubeClientError, + type SourceMetadata, + type StoryboardContactSheet, + type StoryboardIndex, + type StoryboardOptions, +} from './youtube-types'; +const DEFAULT_MAX_SHEETS = 12; +const MAX_SHEETS = 20; const MAX_SHEET_BYTES = 4 * 1024 * 1024; +type JsonObject = Record; + interface StoryboardLevel { index: number; tileWidth: number; @@ -36,8 +44,7 @@ function positiveInteger(value: string | undefined): number | undefined { } export function parseStoryboardSpec(raw: JsonObject): StoryboardSpec | undefined { - const storyboards = object(raw.storyboards); - const renderer = object(storyboards.playerStoryboardSpecRenderer); + const renderer = object(object(raw.storyboards).playerStoryboardSpecRenderer); const spec = typeof renderer.spec === 'string' ? renderer.spec : undefined; if (!spec) return undefined; const [template, ...encodedLevels] = spec.split('|'); @@ -95,30 +102,58 @@ async function jpegResponse(response: Response): Promise { return bytes; } +function validateOptions(options: StoryboardOptions): number { + if (!/^[A-Za-z0-9_-]{11}$/.test(options.videoId)) { + throw new YouTubeClientError('INVALID_INPUT', 'videoId must be 11 characters.'); + } + if (typeof options.outputDir !== 'string' || !options.outputDir.trim()) { + throw new YouTubeClientError('INVALID_INPUT', 'outputDir is required.'); + } + const maxSheets = options.maxSheets ?? DEFAULT_MAX_SHEETS; + if (!Number.isSafeInteger(maxSheets) || maxSheets < 1 || maxSheets > MAX_SHEETS) { + throw new YouTubeClientError( + 'INVALID_INPUT', + `maxSheets must be an integer from 1 to ${MAX_SHEETS}.`, + ); + } + return maxSheets; +} + +function metadata(warnings: string[]): SourceMetadata { + return { + source: 'allthingsyoutube', + fetchedAt: new Date().toISOString(), + partial: warnings.length > 0, + warnings, + }; +} + export async function downloadStoryboard( raw: JsonObject, - videoId: string, - outputDir: string, - maxSheets: number, - options: YouTubeClientOptions, -): Promise { + options: StoryboardOptions, + fetchImpl: typeof fetch, +): Promise { + const maxSheets = validateOptions(options); const spec = parseStoryboardSpec(raw); - if (!spec) return undefined; + if (!spec) { + throw new YouTubeClientError('NOT_FOUND', 'No storyboard is available for this video.'); + } const level = [...spec.levels].sort((a, b) => (b.tileWidth * b.tileHeight) - (a.tileWidth * a.tileHeight) )[0]; - if (!level) return undefined; + if (!level) { + throw new YouTubeClientError('NOT_FOUND', 'No usable storyboard level is available for this video.'); + } const capacity = level.columns * level.rows; const availableSheets = Math.ceil(level.frameCount / capacity); const requestedSheets = Math.min(availableSheets, maxSheets); - const directory = resolve(outputDir, 'storyboards'); + const directory = resolve(options.outputDir, 'storyboards'); await mkdir(directory, { recursive: true }); - const fetchImpl = options.fetch ?? globalThis.fetch; const sheets: StoryboardContactSheet[] = []; for (let sheet = 0; sheet < requestedSheets; sheet += 1) { const response = await fetchImpl(sheetUrl(spec, level, sheet)); const bytes = await jpegResponse(response); - const path = join(directory, `${videoId}-level-${level.index}-sheet-${sheet}.jpg`); + const path = join(directory, `${options.videoId}-level-${level.index}-sheet-${sheet}.jpg`); await writeFile(path, bytes); const firstFrameIndex = sheet * capacity; sheets.push({ @@ -132,10 +167,15 @@ export async function downloadStoryboard( intervalMs: level.intervalMs, }); } + const warnings = requestedSheets < availableSheets + ? [`Storyboard: limited to ${requestedSheets} sheets`] + : []; return { + videoId: options.videoId, level: level.index, frameCount: level.frameCount, intervalMs: level.intervalMs, sheets, + meta: metadata(warnings), }; } diff --git a/packages/all-things-youtube/src/youtube-client.ts b/packages/all-things-youtube/src/youtube-client.ts index 829a9ee..7267b23 100644 --- a/packages/all-things-youtube/src/youtube-client.ts +++ b/packages/all-things-youtube/src/youtube-client.ts @@ -1,6 +1,7 @@ import he from 'he'; import striptags from 'striptags'; import { createYouTubeTransport } from './youtube-transport'; +import { downloadStoryboard } from './storyboard'; import { normalizeBrowseCategory, normalizeBrowseLanguage, @@ -31,6 +32,8 @@ import { SearchResponse, SearchResult, SourceMetadata, + StoryboardIndex, + StoryboardOptions, Thumbnail, Transcript, TranscriptOptions, @@ -1224,6 +1227,7 @@ export interface YouTubeClient { getPlaylist(playlistId: string, continuation?: string): Promise; getCaptionTracks(videoId: string): Promise; getTranscript(options: TranscriptOptions): Promise; + getStoryboard(options: StoryboardOptions): Promise; getComments(options: CommentOptions): Promise; getAllComments(options: AllCommentOptions): Promise; getEndscreen(videoId: string): Promise; @@ -1953,6 +1957,14 @@ export function createYouTubeClient(options: YouTubeClientOptions = {}): YouTube }; }, + async getStoryboard(storyboardOptions) { + return downloadStoryboard( + await player(storyboardOptions.videoId, false), + storyboardOptions, + fetchImpl, + ); + }, + getComments: getCommentsPage, getAllComments, diff --git a/packages/all-things-youtube/src/youtube-types.ts b/packages/all-things-youtube/src/youtube-types.ts index 68fc9f7..897f980 100644 --- a/packages/all-things-youtube/src/youtube-types.ts +++ b/packages/all-things-youtube/src/youtube-types.ts @@ -170,6 +170,32 @@ export interface Transcript { meta: SourceMetadata; } +export interface StoryboardContactSheet { + path: string; + tileWidth: number; + tileHeight: number; + columns: number; + rows: number; + firstFrameIndex: number; + frameCount: number; + intervalMs: number; +} + +export interface StoryboardIndex { + videoId: string; + level: number; + frameCount: number; + intervalMs: number; + sheets: StoryboardContactSheet[]; + meta: SourceMetadata; +} + +export interface StoryboardOptions { + videoId: string; + outputDir: string; + maxSheets?: number; +} + export interface TranscriptOptions { videoId: string; language?: string; diff --git a/packages/all-things-youtube/vitest.config.ts b/packages/all-things-youtube/vitest.config.ts index 81830da..7dd1325 100644 --- a/packages/all-things-youtube/vitest.config.ts +++ b/packages/all-things-youtube/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.test.ts', 'skill/**/*.test.ts'], + include: ['src/**/*.test.ts'], }, }); diff --git a/packages/youtube-skills/README.md b/packages/youtube-skills/README.md new file mode 100644 index 0000000..bf59a4c --- /dev/null +++ b/packages/youtube-skills/README.md @@ -0,0 +1,28 @@ +# youtube-skills + +Private source, tests, and bundling for the repository's self-contained YouTube agent skills. + +This package is not published. It consumes `all-things-youtube` through that package's public interface and owns agent-specific orchestration, workspace handling, media resolution, range proxying, and FFmpeg extraction. The distributable skill instructions and generated executables remain under `.agents/skills/`. + +Install the public library first because this package uses it as a local dependency: + +```bash +npm ci --prefix packages/all-things-youtube +npm --prefix packages/all-things-youtube run build +npm ci --prefix packages/youtube-skills +``` + +From the repository root: + +```bash +npm run test:skills +npm run skill:bundle +npm run skill:check +``` + +`skill:bundle` regenerates: + +- `.agents/skills/youtube-direct/scripts/youtube.mjs` +- `.agents/skills/youtube-watch/scripts/watch.mjs` + +Commit generated executable changes together with their source changes. CI verifies that both bundles are current and executable. diff --git a/packages/youtube-skills/package-lock.json b/packages/youtube-skills/package-lock.json new file mode 100644 index 0000000..776d896 --- /dev/null +++ b/packages/youtube-skills/package-lock.json @@ -0,0 +1,1694 @@ +{ + "name": "@video2ctx/youtube-skills", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@video2ctx/youtube-skills", + "version": "0.1.0", + "dependencies": { + "all-things-youtube": "file:../all-things-youtube", + "he": "^1.2.0", + "striptags": "^3.2.0", + "undici": "6.28.0" + }, + "devDependencies": { + "@types/node": "^25.9.5", + "esbuild": "0.28.2", + "typescript": "^6.0.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=18.17.0" + } + }, + "../all-things-youtube": { + "version": "0.4.0", + "license": "MIT", + "dependencies": { + "he": "^1.2.0", + "striptags": "^3.2.0" + }, + "devDependencies": { + "@types/he": "^1.2.0", + "@types/node": "^25.9.5", + "typescript": "^6.0.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/all-things-youtube": { + "resolved": "../all-things-youtube", + "link": true + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/striptags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.2.0.tgz", + "integrity": "sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/packages/youtube-skills/package.json b/packages/youtube-skills/package.json new file mode 100644 index 0000000..6d598a2 --- /dev/null +++ b/packages/youtube-skills/package.json @@ -0,0 +1,30 @@ +{ + "name": "@video2ctx/youtube-skills", + "version": "0.1.0", + "private": true, + "description": "Private source, tests, and bundling for the video2ctx YouTube agent skills.", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "bundle": "node scripts/build-bundles.mjs", + "bundle:check": "node scripts/build-bundles.mjs --check", + "verify": "npm test && npm run typecheck && npm run bundle:check" + }, + "dependencies": { + "all-things-youtube": "file:../all-things-youtube", + "he": "^1.2.0", + "striptags": "^3.2.0", + "undici": "6.28.0" + }, + "devDependencies": { + "@types/node": "^25.9.5", + "esbuild": "0.28.2", + "typescript": "^6.0.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=18.17.0" + } +} diff --git a/packages/all-things-youtube/scripts/build-skill-bundle.mjs b/packages/youtube-skills/scripts/build-bundles.mjs similarity index 73% rename from packages/all-things-youtube/scripts/build-skill-bundle.mjs rename to packages/youtube-skills/scripts/build-bundles.mjs index 38524e8..1f86dbe 100644 --- a/packages/all-things-youtube/scripts/build-skill-bundle.mjs +++ b/packages/youtube-skills/scripts/build-bundles.mjs @@ -5,24 +5,25 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'esbuild'; -const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repositoryRoot = resolve(skillRoot, '../..'); const bundles = [ { - entryPoint: resolve(packageRoot, 'skill/entry.ts'), - committedBundle: resolve(packageRoot, '../../.agents/skills/youtube-direct/scripts/youtube.mjs'), + entryPoint: resolve(skillRoot, 'src/direct/entry.ts'), + committedBundle: resolve(repositoryRoot, '.agents/skills/youtube-direct/scripts/youtube.mjs'), }, { - entryPoint: resolve(packageRoot, 'skill/watch-entry.ts'), - committedBundle: resolve(packageRoot, '../../.agents/skills/youtube-watch/scripts/watch.mjs'), + entryPoint: resolve(skillRoot, 'src/watch/entry.ts'), + committedBundle: resolve(repositoryRoot, '.agents/skills/youtube-watch/scripts/watch.mjs'), }, ]; async function bundle(entryPoint, outfile) { const licenses = await Promise.all([ - ['all-things-youtube', resolve(packageRoot, 'LICENSE')], - ['he', resolve(packageRoot, 'node_modules/he/LICENSE-MIT.txt')], - ['striptags', resolve(packageRoot, 'node_modules/striptags/LICENSE')], - ['undici', resolve(packageRoot, 'node_modules/undici/LICENSE')], + ['all-things-youtube', resolve(skillRoot, '../all-things-youtube/LICENSE')], + ['he', resolve(skillRoot, 'node_modules/he/LICENSE-MIT.txt')], + ['striptags', resolve(skillRoot, 'node_modules/striptags/LICENSE')], + ['undici', resolve(skillRoot, 'node_modules/undici/LICENSE')], ].map(async ([name, path]) => { const notice = `${name}\n${await readFile(path, 'utf8')}`; return notice.replaceAll('*/', '* /'); @@ -35,10 +36,13 @@ async function bundle(entryPoint, outfile) { ].join('\n'); await build({ - absWorkingDir: packageRoot, + absWorkingDir: skillRoot, entryPoints: [entryPoint], outfile, bundle: true, + alias: { + 'all-things-youtube': resolve(skillRoot, '../all-things-youtube/src/index.ts'), + }, platform: 'node', target: 'node18.17', format: 'esm', @@ -75,7 +79,7 @@ async function check() { console.error(`The ${definition.committedBundle.split('/').at(-3)} skill bundle is stale.`); console.error(`Generated: ${digest(actual)}`); console.error(`Committed: ${expected ? digest(expected) : 'missing'}`); - console.error('Run: npm --prefix packages/all-things-youtube run skill:bundle'); + console.error('Run: npm --prefix packages/youtube-skills run bundle'); process.exitCode = 1; } } diff --git a/packages/all-things-youtube/skill/cli.test.ts b/packages/youtube-skills/src/direct/cli.test.ts similarity index 98% rename from packages/all-things-youtube/skill/cli.test.ts rename to packages/youtube-skills/src/direct/cli.test.ts index 7e39d81..05cb71c 100644 --- a/packages/all-things-youtube/skill/cli.test.ts +++ b/packages/youtube-skills/src/direct/cli.test.ts @@ -2,7 +2,7 @@ import { createServer, request as httpRequest, type Server } from 'node:http'; import { connect } from 'node:net'; import type { AddressInfo } from 'node:net'; import { describe, expect, test, vi } from 'vitest'; -import { YouTubeClientError } from '../src'; +import { YouTubeClientError } from 'all-things-youtube'; import { createRequestFetch, runSkillCli, type CliDependencies, type CliIo } from './cli'; function captureIo(): CliIo & { output: string[]; errors: string[] } { @@ -45,7 +45,7 @@ async function closeServer(server: Server): Promise { }); } -describe('standalone skill CLI', () => { +describe('youtube-direct CLI', () => { test('maps search flags to the shared library operation and emits JSON', async () => { const io = captureIo(); const search = vi.fn(async () => ({ query: 'agent skills', videos: [] })); diff --git a/packages/all-things-youtube/skill/cli.ts b/packages/youtube-skills/src/direct/cli.ts similarity index 96% rename from packages/all-things-youtube/skill/cli.ts rename to packages/youtube-skills/src/direct/cli.ts index 33efd7f..52f8f04 100644 --- a/packages/all-things-youtube/skill/cli.ts +++ b/packages/youtube-skills/src/direct/cli.ts @@ -11,7 +11,7 @@ import { getTracks, getTranscript, search, -} from '../src/index'; +} from 'all-things-youtube'; const HELP = `youtube-direct @@ -109,16 +109,16 @@ class CliInputError extends Error { } const defaultOperations: SkillOperations = { - search: search as SkillOperation, - tracks: getTracks as SkillOperation, - transcript: getTranscript as SkillOperation, - comments: getComments as SkillOperation, - details: getDetails as SkillOperation, - endscreen: getEndscreen as SkillOperation, - 'channel-info': getChannelInfo as SkillOperation, - 'channel-videos': getChannelVideos as SkillOperation, - 'channel-playlists': getChannelPlaylists as SkillOperation, - playlist: getPlaylist as SkillOperation, + search: search as unknown as SkillOperation, + tracks: getTracks as unknown as SkillOperation, + transcript: getTranscript as unknown as SkillOperation, + comments: getComments as unknown as SkillOperation, + details: getDetails as unknown as SkillOperation, + endscreen: getEndscreen as unknown as SkillOperation, + 'channel-info': getChannelInfo as unknown as SkillOperation, + 'channel-videos': getChannelVideos as unknown as SkillOperation, + 'channel-playlists': getChannelPlaylists as unknown as SkillOperation, + playlist: getPlaylist as unknown as SkillOperation, }; const sharedFlags = ['language', 'region', 'proxy', 'pretty'] as const; diff --git a/packages/all-things-youtube/skill/entry.ts b/packages/youtube-skills/src/direct/entry.ts similarity index 100% rename from packages/all-things-youtube/skill/entry.ts rename to packages/youtube-skills/src/direct/entry.ts diff --git a/packages/youtube-skills/src/transport.test.ts b/packages/youtube-skills/src/transport.test.ts new file mode 100644 index 0000000..bfa5c4a --- /dev/null +++ b/packages/youtube-skills/src/transport.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { createSkillTransport } from './transport'; + +describe('youtube-skills transport', () => { + test('retries configured response statuses with a bounded delay', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce(new Response('{}', { status: 200 })); + const wait = vi.fn(async () => {}); + const transport = createSkillTransport({ + fetch: fetchMock as unknown as typeof fetch, + wait, + random: () => 1, + policy: { maxAttempts: 2, baseDelayMs: 25, maxDelayMs: 25 }, + }); + + await expect(transport.fetch('player', () => ({ + input: 'https://youtube.test/player', + }))).resolves.toMatchObject({ status: 200 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledWith(25); + }); + + test('classifies exhausted network failures without leaking request details', async () => { + const transport = createSkillTransport({ + fetch: vi.fn(async () => { throw new Error('signed-url-secret'); }) as unknown as typeof fetch, + wait: async () => {}, + policy: { maxAttempts: 2 }, + }); + + await expect(transport.fetch('player', () => ({ + input: 'https://youtube.test/player', + }))).rejects.toMatchObject({ + code: 'UPSTREAM_ERROR', + message: 'YouTube player network request failed after 2 attempts.', + retryable: true, + }); + }); +}); diff --git a/packages/youtube-skills/src/transport.ts b/packages/youtube-skills/src/transport.ts new file mode 100644 index 0000000..350d71f --- /dev/null +++ b/packages/youtube-skills/src/transport.ts @@ -0,0 +1,136 @@ +import { + YouTubeClientError, + type YouTubeRetryOptions, + type YouTubeRetryPolicy, +} from 'all-things-youtube'; + +interface RequestAttempt { + input: RequestInfo | URL; + init?: RequestInit; +} + +interface SkillTransport { + fetch( + operation: string, + requestFactory: (attempt: number) => RequestAttempt | Promise, + ): Promise; +} + +const DEFAULT_POLICY: YouTubeRetryPolicy = { + maxAttempts: 5, + attemptTimeoutMs: 10_000, + baseDelayMs: 200, + maxDelayMs: 2_000, + retryStatuses: [408, 425, 429, 500, 502, 503, 504], +}; + +function requestSignal(request: RequestAttempt): AbortSignal | null | undefined { + if (request.init?.signal) return request.init.signal; + return typeof Request !== 'undefined' && request.input instanceof Request + ? request.input.signal + : undefined; +} + +function attemptSignal(request: RequestAttempt, timeoutMs: number): AbortSignal { + const deadline = AbortSignal.timeout(timeoutMs); + const existing = requestSignal(request); + if (!existing) return deadline; + const combined = new AbortController(); + const forwardAbort = (signal: AbortSignal) => { + if (signal.aborted) combined.abort(signal.reason); + else signal.addEventListener('abort', () => combined.abort(signal.reason), { once: true }); + }; + forwardAbort(existing); + if (!combined.signal.aborted) forwardAbort(deadline); + return combined.signal; +} + +async function runtimeWait(delayMs: number): Promise { + await new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function retryAfterMs(response: Response, now: number): number | undefined { + const value = response.headers.get('retry-after')?.trim(); + if (!value) return undefined; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1_000); + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? Math.max(0, timestamp - now) : undefined; +} + +function retryDelay( + response: Response | undefined, + attempt: number, + policy: YouTubeRetryPolicy, + random: () => number, + now: () => number, +): number { + const requestedDelay = response ? retryAfterMs(response, now()) : undefined; + if (requestedDelay !== undefined) return Math.min(policy.maxDelayMs, requestedDelay); + const ceiling = Math.min(policy.maxDelayMs, policy.baseDelayMs * (2 ** (attempt - 1))); + return Math.max(0, Math.round(ceiling * Math.min(1, Math.max(0, random())))); +} + +export function createSkillTransport( + options: YouTubeRetryOptions & { fetch: typeof fetch }, +): SkillTransport { + const wait = options.wait ?? runtimeWait; + const random = options.random ?? Math.random; + const now = options.now ?? Date.now; + const policy: YouTubeRetryPolicy = { + ...DEFAULT_POLICY, + ...options.policy, + retryStatuses: options.policy?.retryStatuses ?? DEFAULT_POLICY.retryStatuses, + }; + if (!Number.isSafeInteger(policy.attemptTimeoutMs) || policy.attemptTimeoutMs < 1) { + throw new YouTubeClientError( + 'INVALID_INPUT', + 'retry.policy.attemptTimeoutMs must be a positive integer.', + ); + } + + return { + async fetch(operation, requestFactory) { + const retryStatuses = new Set(policy.retryStatuses); + let lastNetworkError: unknown; + for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) { + const request = await requestFactory(attempt); + let response: Response | undefined; + try { + response = await options.fetch(request.input, { + ...request.init, + signal: attemptSignal(request, policy.attemptTimeoutMs), + }); + } catch (error) { + lastNetworkError = error; + if (attempt === policy.maxAttempts) { + throw new YouTubeClientError( + 'UPSTREAM_ERROR', + `YouTube ${operation} network request failed after ${policy.maxAttempts} attempts.`, + { retryable: true, cause: error }, + ); + } + } + if (response && (!retryStatuses.has(response.status) || attempt === policy.maxAttempts)) { + return response; + } + const delayMs = retryDelay(response, attempt, policy, random, now); + options.onRetry?.({ + operation, + attempt, + maxAttempts: policy.maxAttempts, + status: response?.status, + delayMs, + reason: response ? 'response' : 'network', + }); + if (response?.body) await response.body.cancel().catch(() => undefined); + await wait(delayMs); + } + throw new YouTubeClientError( + 'UPSTREAM_ERROR', + `YouTube ${operation} retry loop exhausted.`, + { retryable: true, cause: lastNetworkError }, + ); + }, + }; +} diff --git a/packages/all-things-youtube/skill/watch-cli.test.ts b/packages/youtube-skills/src/watch/cli.test.ts similarity index 98% rename from packages/all-things-youtube/skill/watch-cli.test.ts rename to packages/youtube-skills/src/watch/cli.test.ts index 35a4093..d56ec18 100644 --- a/packages/all-things-youtube/skill/watch-cli.test.ts +++ b/packages/youtube-skills/src/watch/cli.test.ts @@ -3,12 +3,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import { YouTubeClientError } from '../src'; +import { YouTubeClientError } from 'all-things-youtube'; import { runWatchCli, type WatchCliDependencies, type WatchCliIo, -} from './watch-cli'; +} from './cli'; const directories: string[] = []; diff --git a/packages/all-things-youtube/skill/watch-cli.ts b/packages/youtube-skills/src/watch/cli.ts similarity index 97% rename from packages/all-things-youtube/skill/watch-cli.ts rename to packages/youtube-skills/src/watch/cli.ts index dcbc2ea..52b8838 100644 --- a/packages/all-things-youtube/skill/watch-cli.ts +++ b/packages/youtube-skills/src/watch/cli.ts @@ -2,14 +2,16 @@ import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { YouTubeClientError } from '../src'; +import { YouTubeClientError } from 'all-things-youtube'; import { extractFrames, getWatchIndex, - type ExtractFramesRequest, - type WatchIndexRequest, -} from '../src/watch'; -import { createRequestFetch, type FetchResource } from './cli'; +} from './workflow'; +import type { + ExtractFramesRequest, + WatchIndexRequest, +} from './types'; +import { createRequestFetch, type FetchResource } from '../direct/cli'; const HELP = `youtube-watch diff --git a/packages/all-things-youtube/skill/watch-entry.ts b/packages/youtube-skills/src/watch/entry.ts similarity index 79% rename from packages/all-things-youtube/skill/watch-entry.ts rename to packages/youtube-skills/src/watch/entry.ts index 39a8159..f117266 100644 --- a/packages/all-things-youtube/skill/watch-entry.ts +++ b/packages/youtube-skills/src/watch/entry.ts @@ -1,4 +1,4 @@ -import { runWatchCli } from './watch-cli'; +import { runWatchCli } from './cli'; process.exitCode = await runWatchCli(process.argv.slice(2), { stdout: (value) => process.stdout.write(value), diff --git a/packages/all-things-youtube/src/watch/ffmpeg.integration.test.ts b/packages/youtube-skills/src/watch/ffmpeg.integration.test.ts similarity index 97% rename from packages/all-things-youtube/src/watch/ffmpeg.integration.test.ts rename to packages/youtube-skills/src/watch/ffmpeg.integration.test.ts index 61eb8ec..0847c53 100644 --- a/packages/all-things-youtube/src/watch/ffmpeg.integration.test.ts +++ b/packages/youtube-skills/src/watch/ffmpeg.integration.test.ts @@ -21,7 +21,7 @@ afterAll(async () => { }))); }); -describeFfmpeg('FFmpeg range integration', () => { +describeFfmpeg('youtube-watch private FFmpeg range integration', () => { test('decodes a remote timestamp through the localhost proxy', async () => { const directory = await mkdtemp(join(tmpdir(), 'watch-ffmpeg-test-')); directories.push(directory); diff --git a/packages/all-things-youtube/src/watch/ffmpeg.test.ts b/packages/youtube-skills/src/watch/ffmpeg.test.ts similarity index 96% rename from packages/all-things-youtube/src/watch/ffmpeg.test.ts rename to packages/youtube-skills/src/watch/ffmpeg.test.ts index e9dbbaa..25b9fca 100644 --- a/packages/all-things-youtube/src/watch/ffmpeg.test.ts +++ b/packages/youtube-skills/src/watch/ffmpeg.test.ts @@ -27,7 +27,7 @@ async function executable(name = 'ffmpeg'): Promise<{ directory: string; path: s return { directory, path }; } -describe('FFmpeg executable resolution', () => { +describe('youtube-watch private FFmpeg executable resolution', () => { test('resolves a bare executable name from PATH without launching it', async () => { const fake = await executable(); diff --git a/packages/all-things-youtube/src/watch/ffmpeg.ts b/packages/youtube-skills/src/watch/ffmpeg.ts similarity index 98% rename from packages/all-things-youtube/src/watch/ffmpeg.ts rename to packages/youtube-skills/src/watch/ffmpeg.ts index 726e172..b3c7507 100644 --- a/packages/all-things-youtube/src/watch/ffmpeg.ts +++ b/packages/youtube-skills/src/watch/ffmpeg.ts @@ -4,7 +4,7 @@ import { constants } from 'node:fs'; import { access, mkdir, readFile, rm, stat } from 'node:fs/promises'; import { delimiter, extname, isAbsolute, join, resolve } from 'node:path'; -import { YouTubeClientError } from '../youtube-types'; +import { YouTubeClientError } from 'all-things-youtube'; import { jpegDimensions } from './jpeg'; import type { ExtractedFrame } from './types'; diff --git a/packages/all-things-youtube/src/watch/innertube.ts b/packages/youtube-skills/src/watch/innertube.ts similarity index 95% rename from packages/all-things-youtube/src/watch/innertube.ts rename to packages/youtube-skills/src/watch/innertube.ts index 5e0e60a..93b1927 100644 --- a/packages/all-things-youtube/src/watch/innertube.ts +++ b/packages/youtube-skills/src/watch/innertube.ts @@ -1,5 +1,5 @@ -import { createYouTubeTransport } from '../youtube-transport'; -import { YouTubeClientError, type YouTubeClientOptions } from '../youtube-types'; +import { YouTubeClientError, type YouTubeClientOptions } from 'all-things-youtube'; +import { createSkillTransport } from '../transport'; export type JsonObject = Record; @@ -80,7 +80,7 @@ export async function callWatchPlayer( } const fetchImpl = options.fetch ?? globalThis.fetch; if (!fetchImpl) throw new YouTubeClientError('INVALID_INPUT', 'A fetch implementation is required.'); - const transport = createYouTubeTransport({ fetch: fetchImpl, ...options.retry }); + const transport = createSkillTransport({ fetch: fetchImpl, ...options.retry }); const response = await transport.fetch(`youtube:watch-player:${profile.name}`, () => ({ input: `${API_ROOT}/player?prettyPrint=false`, init: { diff --git a/packages/all-things-youtube/src/watch/jpeg.ts b/packages/youtube-skills/src/watch/jpeg.ts similarity index 96% rename from packages/all-things-youtube/src/watch/jpeg.ts rename to packages/youtube-skills/src/watch/jpeg.ts index a09b723..3c7837b 100644 --- a/packages/all-things-youtube/src/watch/jpeg.ts +++ b/packages/youtube-skills/src/watch/jpeg.ts @@ -1,4 +1,4 @@ -export interface JpegDimensions { +interface JpegDimensions { width: number; height: number; } diff --git a/packages/youtube-skills/src/watch/live.test.ts b/packages/youtube-skills/src/watch/live.test.ts new file mode 100644 index 0000000..067b11e --- /dev/null +++ b/packages/youtube-skills/src/watch/live.test.ts @@ -0,0 +1,29 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; +import { describe, expect, test } from 'vitest'; + +import { extractFrames, getWatchIndex } from './workflow'; + +const describeLive = process.env.YOUTUBE_LIVE === '1' ? describe : describe.skip; + +describeLive('youtube-watch private live workflow', () => { + test('loads a storyboard/transcript index and exact best-effort frames', async () => { + const outputDir = await mkdtemp(join(tmpdir(), 'youtube-watch-live-')); + try { + const index = await getWatchIndex({ + videoId: '4vItmdk8F_M', outputDir, maxStoryboardSheets: 1, + }); + const frames = await extractFrames({ + videoId: '4vItmdk8F_M', outputDir, timestampsMs: [30_000, 686_000, 1_000_000], + }); + expect(index.storyboard?.sheets).toHaveLength(1); + expect(index.transcript?.segments.length).toBeGreaterThan(0); + expect(frames.frames).toHaveLength(3); + expect(JSON.stringify({ index, frames })).not.toContain('googlevideo.com'); + } finally { + await rm(outputDir, { recursive: true, force: true }); + } + }, 180_000); +}); diff --git a/packages/all-things-youtube/src/watch/media.ts b/packages/youtube-skills/src/watch/media.ts similarity index 98% rename from packages/all-things-youtube/src/watch/media.ts rename to packages/youtube-skills/src/watch/media.ts index 10790b1..ec765bc 100644 --- a/packages/all-things-youtube/src/watch/media.ts +++ b/packages/youtube-skills/src/watch/media.ts @@ -1,4 +1,4 @@ -import type { YouTubeClientOptions } from '../youtube-types'; +import type { YouTubeClientOptions } from 'all-things-youtube'; import { callWatchPlayer, type JsonObject, WATCH_MEDIA_PROFILES } from './innertube'; function object(value: unknown): JsonObject { diff --git a/packages/all-things-youtube/src/watch/range-proxy.test.ts b/packages/youtube-skills/src/watch/range-proxy.test.ts similarity index 97% rename from packages/all-things-youtube/src/watch/range-proxy.test.ts rename to packages/youtube-skills/src/watch/range-proxy.test.ts index 9ddb014..052416b 100644 --- a/packages/all-things-youtube/src/watch/range-proxy.test.ts +++ b/packages/youtube-skills/src/watch/range-proxy.test.ts @@ -36,7 +36,7 @@ async function upstream(payload: Buffer): Promise<{ url: string; ranges: Array { +describe('youtube-watch private media range proxy', () => { test('replays a cached stream prefix without another upstream request', async () => { const source = await upstream(Buffer.from('abcdefghijklmnopqrstuvwxyz')); const budget = new TransferBudget(1_024); diff --git a/packages/all-things-youtube/src/watch/range-proxy.ts b/packages/youtube-skills/src/watch/range-proxy.ts similarity index 97% rename from packages/all-things-youtube/src/watch/range-proxy.ts rename to packages/youtube-skills/src/watch/range-proxy.ts index ff29413..da5b1e5 100644 --- a/packages/all-things-youtube/src/watch/range-proxy.ts +++ b/packages/youtube-skills/src/watch/range-proxy.ts @@ -5,6 +5,7 @@ import type { MediaCandidate } from './media'; const DEFAULT_PREFIX_CACHE_BYTES = 4 * 1024 * 1024; const DEFAULT_TRANSFER_LIMIT_BYTES = 256 * 1024 * 1024; +const LOOPBACK_HOST = '127.0.0.1' as const; export class TransferBudget { used = 0; @@ -154,7 +155,7 @@ export async function startMediaRangeProxy( await new Promise((resolve, reject) => { server.once('error', reject); - server.listen(0, '127.0.0.1', () => { + server.listen(0, LOOPBACK_HOST, () => { server.off('error', reject); resolve(); }); @@ -165,7 +166,7 @@ export async function startMediaRangeProxy( throw new Error('Could not bind the media range proxy.'); } return { - url: `http://127.0.0.1:${address.port}/${token}`, + url: `http://${LOOPBACK_HOST}:${address.port}/${token}`, close: async () => { for (const controller of controllers) controller.abort(); server.closeAllConnections?.(); diff --git a/packages/all-things-youtube/src/watch/types.ts b/packages/youtube-skills/src/watch/types.ts similarity index 75% rename from packages/all-things-youtube/src/watch/types.ts rename to packages/youtube-skills/src/watch/types.ts index 3be0fc4..40257a1 100644 --- a/packages/all-things-youtube/src/watch/types.ts +++ b/packages/youtube-skills/src/watch/types.ts @@ -1,10 +1,11 @@ import type { SourceMetadata, + StoryboardIndex, Transcript, Video, YouTubeClientOptions, YouTubeErrorCode, -} from '../youtube-types'; +} from 'all-things-youtube'; export interface WatchIndexRequest extends YouTubeClientOptions { videoId: string; @@ -14,24 +15,6 @@ export interface WatchIndexRequest extends YouTubeClientOptions { maxStoryboardSheets?: number; } -export interface StoryboardContactSheet { - path: string; - tileWidth: number; - tileHeight: number; - columns: number; - rows: number; - firstFrameIndex: number; - frameCount: number; - intervalMs: number; -} - -export interface StoryboardIndex { - level: number; - frameCount: number; - intervalMs: number; - sheets: StoryboardContactSheet[]; -} - export interface WatchIndex { videoId: string; video: Video; diff --git a/packages/all-things-youtube/src/watch/index.test.ts b/packages/youtube-skills/src/watch/workflow.test.ts similarity index 80% rename from packages/all-things-youtube/src/watch/index.test.ts rename to packages/youtube-skills/src/watch/workflow.test.ts index 069938d..a342512 100644 --- a/packages/all-things-youtube/src/watch/index.test.ts +++ b/packages/youtube-skills/src/watch/workflow.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { YouTubeClientError } from '../youtube-types'; +import { YouTubeClientError } from 'all-things-youtube'; const mocks = vi.hoisted(() => ({ - client: { getVideo: vi.fn(), getTranscript: vi.fn() }, - callIosWatchPlayer: vi.fn(), - downloadStoryboard: vi.fn(), + getDetails: vi.fn(), + getTranscript: vi.fn(), + getStoryboard: vi.fn(), resolveFfmpegExecutable: vi.fn(), extractJpeg: vi.fn(), loadMediaCandidateGroup: vi.fn(), @@ -13,9 +13,12 @@ const mocks = vi.hoisted(() => ({ proxyClose: vi.fn(), })); -vi.mock('../youtube-client', () => ({ createYouTubeClient: () => mocks.client })); -vi.mock('./innertube', () => ({ callIosWatchPlayer: mocks.callIosWatchPlayer })); -vi.mock('./storyboard', () => ({ downloadStoryboard: mocks.downloadStoryboard })); +vi.mock('all-things-youtube', async (importOriginal) => ({ + ...await importOriginal(), + getDetails: mocks.getDetails, + getTranscript: mocks.getTranscript, + getStoryboard: mocks.getStoryboard, +})); vi.mock('./ffmpeg', () => ({ resolveFfmpegExecutable: mocks.resolveFfmpegExecutable, extractJpeg: mocks.extractJpeg, @@ -26,7 +29,7 @@ vi.mock('./range-proxy', () => ({ startMediaRangeProxy: mocks.startMediaRangeProxy, })); -import { extractFrames, getWatchIndex } from './index'; +import { extractFrames, getWatchIndex } from './workflow'; const video = { id: 'abcdefghijk', @@ -55,6 +58,7 @@ const transcript = { }; const storyboard = { + videoId: 'abcdefghijk', level: 2, frameCount: 25, intervalMs: 10_000, @@ -62,15 +66,15 @@ const storyboard = { path: '/tmp/sheet.jpg', tileWidth: 160, tileHeight: 90, columns: 5, rows: 5, firstFrameIndex: 0, frameCount: 25, intervalMs: 10_000, }], + meta: { source: 'allthingsyoutube', fetchedAt: 'now', partial: false, warnings: [] }, }; -describe('watch module interface', () => { +describe('youtube-watch workflow', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.client.getVideo.mockResolvedValue(video); - mocks.client.getTranscript.mockResolvedValue(transcript); - mocks.callIosWatchPlayer.mockResolvedValue({ profile: 'ios', raw: {} }); - mocks.downloadStoryboard.mockResolvedValue(storyboard); + mocks.getDetails.mockResolvedValue(video); + mocks.getTranscript.mockResolvedValue(transcript); + mocks.getStoryboard.mockResolvedValue(storyboard); mocks.resolveFfmpegExecutable.mockResolvedValue('/usr/local/bin/ffmpeg'); mocks.loadMediaCandidateGroup.mockImplementation(async (profileIndex: number) => profileIndex === 0 ? { @@ -89,14 +93,14 @@ describe('watch module interface', () => { test('uses segment timing by default and returns contact-sheet evidence', async () => { const result = await getWatchIndex({ videoId: 'abcdefghijk', outputDir: '/tmp/watch-test' }); - expect(mocks.client.getTranscript).toHaveBeenCalledWith({ - videoId: 'abcdefghijk', translateTo: undefined, granularity: 'segment', + expect(mocks.getTranscript).toHaveBeenCalledWith({ + videoId: 'abcdefghijk', lang: undefined, granularity: 'segment', }); expect(result).toMatchObject({ strategy: 'storyboard-transcript', transcript, storyboard }); }); test('keeps a storyboard-only index usable when captions fail', async () => { - mocks.client.getTranscript.mockRejectedValue(new YouTubeClientError('NOT_FOUND', 'No captions.')); + mocks.getTranscript.mockRejectedValue(new YouTubeClientError('NOT_FOUND', 'No captions.')); const result = await getWatchIndex({ videoId: 'abcdefghijk', outputDir: '/tmp/watch-test' }); @@ -106,8 +110,8 @@ describe('watch module interface', () => { }); test('rejects only when neither transcript nor storyboard is usable', async () => { - mocks.client.getTranscript.mockRejectedValue(new YouTubeClientError('NOT_FOUND', 'No captions.')); - mocks.downloadStoryboard.mockResolvedValue(undefined); + mocks.getTranscript.mockRejectedValue(new YouTubeClientError('NOT_FOUND', 'No captions.')); + mocks.getStoryboard.mockRejectedValue(new YouTubeClientError('NOT_FOUND', 'No storyboard.')); await expect(getWatchIndex({ videoId: 'abcdefghijk', outputDir: '/tmp/watch-test', diff --git a/packages/all-things-youtube/src/watch/index.ts b/packages/youtube-skills/src/watch/workflow.ts similarity index 87% rename from packages/all-things-youtube/src/watch/index.ts rename to packages/youtube-skills/src/watch/workflow.ts index 1006f60..a594a86 100644 --- a/packages/all-things-youtube/src/watch/index.ts +++ b/packages/youtube-skills/src/watch/workflow.ts @@ -1,13 +1,17 @@ -import { resolve } from 'node:path'; import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; -import { createYouTubeClient } from '../youtube-client'; -import { YouTubeClientError, type SourceMetadata, type YouTubeClientOptions } from '../youtube-types'; +import { + getDetails, + getStoryboard, + getTranscript, + YouTubeClientError, + type SourceMetadata, + type YouTubeClientOptions, +} from 'all-things-youtube'; import { extractJpeg, resolveFfmpegExecutable } from './ffmpeg'; -import { callIosWatchPlayer } from './innertube'; import { loadMediaCandidateGroup } from './media'; import { startMediaRangeProxy, TransferBudget } from './range-proxy'; -import { downloadStoryboard } from './storyboard'; import type { ExtractedFrame, ExtractFramesRequest, @@ -71,16 +75,21 @@ function safeWarning(prefix: string, error: unknown): string { export async function getWatchIndex(options: WatchIndexRequest): Promise { const maxSheets = validateIndexRequest(options); const clientOptions = optionsFrom(options); - const client = createYouTubeClient(clientOptions); const outputDir = resolve(options.outputDir); - const [video, transcriptResult, playerResult] = await Promise.all([ - client.getVideo(options.videoId), - client.getTranscript({ + const [video, transcriptResult, storyboardResult] = await Promise.all([ + getDetails({ videoId: options.videoId, ...clientOptions }), + getTranscript({ videoId: options.videoId, - translateTo: options.lang, + lang: options.lang, granularity: options.granularity ?? 'segment', + ...clientOptions, }).then((value) => ({ value })).catch((error: unknown) => ({ error })), - callIosWatchPlayer(options.videoId, clientOptions) + getStoryboard({ + videoId: options.videoId, + outputDir, + maxSheets, + ...clientOptions, + }) .then((value) => ({ value })) .catch((error: unknown) => ({ error })), ]); @@ -94,26 +103,9 @@ export async function getWatchIndex(options: WatchIndexRequest): Promise { const { timestamps, maxWidth } = validateFrameRequest(options); const clientOptions = optionsFrom(options); - const client = createYouTubeClient(clientOptions); - const video = await client.getVideo(options.videoId); + const video = await getDetails({ videoId: options.videoId, ...clientOptions }); if (video.isLive) { throw new YouTubeClientError('UNAVAILABLE', 'Live videos are not supported by watch extraction.'); } diff --git a/packages/youtube-skills/tsconfig.json b/packages/youtube-skills/tsconfig.json new file mode 100644 index 0000000..cf6b5e9 --- /dev/null +++ b/packages/youtube-skills/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/youtube-skills/vitest.config.ts b/packages/youtube-skills/vitest.config.ts new file mode 100644 index 0000000..7dd1325 --- /dev/null +++ b/packages/youtube-skills/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/reference/engineering/IMPLEMENTATION.md b/reference/engineering/IMPLEMENTATION.md index 1cc33d0..fb4e684 100644 --- a/reference/engineering/IMPLEMENTATION.md +++ b/reference/engineering/IMPLEMENTATION.md @@ -1,11 +1,12 @@ # video2ctx Workspace -This repository implements the beta with a standalone library, two Worker applications, and one containerized processing boundary: +This repository implements the beta with a standalone library, private agent-skill source, two Worker applications, and one containerized processing boundary: 1. `packages/all-things-youtube/` contains the publishable normalized YouTube client, public helpers, retry transport, and data model. -2. `platform/` is the typed Hono platform Worker and owns auth, private data, ingestion, retrieval, AI metering, monitoring, billing, notifications, and deletion. -3. `web/` is the Next.js 16 application deployed on Vercel. Its same-origin BFF reaches the platform through `PLATFORM_API_BASE_URL`, defaulting to the public API domain in production and the local Worker in development. -4. `platform/youtube-processor/` is a private Node/Hono Cloudflare Container that owns outbound YouTube calls, parsing, retries, and optional proxy egress. +2. `packages/youtube-skills/` contains the private source, tests, and bundling for the self-contained `youtube-direct` and `youtube-watch` skills; generated executables remain under `.agents/skills/`. +3. `platform/` is the typed Hono platform Worker and owns auth, private data, ingestion, retrieval, AI metering, monitoring, billing, notifications, and deletion. +4. `web/` is the Next.js 16 application deployed on Vercel. Its same-origin BFF reaches the platform through `PLATFORM_API_BASE_URL`, defaulting to the public API domain in production and the local Worker in development. +5. `platform/youtube-processor/` is a private Node/Hono Cloudflare Container that owns outbound YouTube calls, parsing, retries, and optional proxy egress. The platform's core YouTube routes call `platform/src/lib/youtube.ts`. That adapter checks Workers KV first and sends misses through a cache-key-specific `YOUTUBE_REQUEST_COORDINATOR` Durable Object. The coordinator rechecks KV, coalesces identical concurrent misses, and invokes a randomly selected `YOUTUBE_PROCESSOR` container. The container executes both public package helpers and the internal search, browse, and trend-signal client. No platform route calls YouTube directly from the Worker runtime.