diff --git a/deno/package.json b/deno/package.json index afb9e61d..a9c01061 100644 --- a/deno/package.json +++ b/deno/package.json @@ -1,6 +1,6 @@ { "name": "youtubei.js", - "version": "15.1.1", + "version": "16.0.0", "description": "A JavaScript client for YouTube's private API, known as InnerTube.", "type": "module", "types": "./dist/src/platform/lib.d.ts", @@ -103,30 +103,27 @@ "license": "MIT", "dependencies": { "@bufbuild/protobuf": "^2.0.0", - "jintr": "^3.3.1", - "undici": "^6.21.3" - }, - "overrides": { - "typescript": "^5.0.0" + "meriyah": "^6.1.4" }, "devDependencies": { - "@eslint/js": "^9.9.0", + "@eslint/js": "^9.37.0", "@types/estree": "^1.0.6", "@types/glob": "^8.1.0", "@types/node": "^24.0.14", + "@typescript-eslint/eslint-plugin": "^8.46.0", + "@typescript-eslint/parser": "^8.46.0", "cpy-cli": "^4.2.0", "esbuild": "^0.25.6", - "eslint": "^9.9.0", - "glob": "^8.0.3", - "globals": "^15.9.0", + "eslint": "^9.37.0", + "globals": "^16.4.0", "replace": "^1.2.2", "rimraf": "^6.0.1", "ts-patch": "^3.0.2", "ts-proto": "^2.2.0", - "typedoc": "^0.26.7", - "typedoc-plugin-markdown": "^4.2.7", - "typescript": "^5.0.0", - "typescript-eslint": "^8.2.0", + "typedoc": "^0.28.14", + "typedoc-plugin-markdown": "^4.9.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.46.0", "vitest": "^3.2.4" }, "bugs": { @@ -144,4 +141,4 @@ "downloader", "ytmusic" ] -} +} \ No newline at end of file diff --git a/deno/protos/generated/misc/params.ts b/deno/protos/generated/misc/params.ts index a09e298c..30a7d1a5 100644 --- a/deno/protos/generated/misc/params.ts +++ b/deno/protos/generated/misc/params.ts @@ -234,6 +234,7 @@ export interface ShortsParam_Field1 { export interface NextParams { videoId: string[]; + playlistTitle?: string | undefined; } export interface CommunityPostParams { @@ -2082,7 +2083,7 @@ export const ShortsParam_Field1: MessageFns = { }; function createBaseNextParams(): NextParams { - return { videoId: [] }; + return { videoId: [], playlistTitle: undefined }; } export const NextParams: MessageFns = { @@ -2090,6 +2091,9 @@ export const NextParams: MessageFns = { for (const v of message.videoId) { writer.uint32(42).string(v!); } + if (message.playlistTitle !== undefined) { + writer.uint32(50).string(message.playlistTitle); + } return writer; }, @@ -2107,6 +2111,13 @@ export const NextParams: MessageFns = { message.videoId.push(reader.string()); continue; + case 6: + if (tag !== 50) { + break; + } + + message.playlistTitle = reader.string(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; diff --git a/deno/protos/misc/params.proto b/deno/protos/misc/params.proto index 010cc3ff..415ed78c 100644 --- a/deno/protos/misc/params.proto +++ b/deno/protos/misc/params.proto @@ -228,6 +228,7 @@ message ShortsParam { message NextParams { repeated string video_id = 5; + optional string playlist_title = 6; } message CommunityPostParams { diff --git a/deno/src/Innertube.ts b/deno/src/Innertube.ts index 95c24883..80c20c55 100644 --- a/deno/src/Innertube.ts +++ b/deno/src/Innertube.ts @@ -95,7 +95,7 @@ export default class Innertube { vis: 0, splay: false, lactMilliseconds: '-1', - signatureTimestamp: session.player?.sts + signatureTimestamp: session.player?.signature_timestamp } }, client: options?.client @@ -140,7 +140,7 @@ export default class Innertube { vis: 0, splay: false, lactMilliseconds: '-1', - signatureTimestamp: session.player?.sts + signatureTimestamp: session.player?.signature_timestamp } }, client: options?.client @@ -463,7 +463,7 @@ export default class Innertube { const info = await this.getBasicInfo(video_id, options); const format = info.chooseFormat(options); - format.url = format.decipher(this.#session.player); + format.url = await format.decipher(this.#session.player); return format; } diff --git a/deno/src/core/Player.ts b/deno/src/core/Player.ts index 0a554919..a8a15e00 100644 --- a/deno/src/core/Player.ts +++ b/deno/src/core/Player.ts @@ -1,45 +1,49 @@ -import { Jinter } from 'jsr:@luanrt/jintr'; import type { FetchFunction, ICache } from '../types/index.ts'; import { Constants, BinarySerializer, Log } from '../utils/index.ts'; + import { - type ASTLookupResult, - findFunction, - findVariable, getRandomUserAgent, getStringBetweenStrings, Platform, PlayerError } from '../utils/Utils.ts'; + +import { JsExtractor, JsAnalyzer } from '../utils/index.ts'; +import { nMatcher, sigMatcher, timestampMatcher } from '../utils/javascript/matchers.ts'; + +import type { ExtractionConfig } from '../utils/javascript/JsAnalyzer.ts'; +import type { BuildScriptResult } from '../utils/javascript/JsExtractor.ts'; + import packageInfo from '../../package.json' with { type: 'json' }; const TAG = 'Player'; interface SerializablePlayer { - player_id: string; - sts: number; - sig_sc?: string; - nsig_sc?: string; - library_version: string; + playerId: string; + signatureTimestamp: number; + libraryVersion: string; + data?: BuildScriptResult; +} + +interface PlayerInitializationOptions { + cache?: ICache; + signature_timestamp: number; + data: BuildScriptResult; } /** * Represents YouTube's player script. This is required to decipher signatures. */ export default class Player { - public player_id: string; - public sts: number; - public nsig_sc?: string; - public sig_sc?: string; public po_token?: string; - constructor(player_id: string, signature_timestamp: number, sig_sc?: string, nsig_sc?: string) { - this.player_id = player_id; - this.sts = signature_timestamp; - this.nsig_sc = nsig_sc; - this.sig_sc = sig_sc; - } + constructor(public player_id: string, public signature_timestamp: number, public data?: BuildScriptResult) { /** no-op */ } - static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch, po_token?: string, player_id?: string): Promise { + public static async create( + cache: ICache | undefined, + fetch: FetchFunction = Platform.shim.fetch, + po_token?: string, player_id?: string + ): Promise { if (!player_id) { const url = new URL('/iframe_api', Constants.URLS.YT_BASE); const res = await fetch(url); @@ -83,22 +87,51 @@ export default class Player { const player_js = await player_res.text(); - const ast = Jinter.parseScript(player_js, { ecmaVersion: 'latest', ranges: true }); + const sigFunctionName = 'sigFunction'; + const nFunctionName = 'nFunction'; + const timestampVarName = 'signatureTimestampVar'; - const sig_timestamp = this.extractSigTimestamp(player_js); - const global_variable = this.extractGlobalVariable(player_js, ast); - const sig_sc = this.extractSigSourceCode(player_js, global_variable); - const nsig_sc = this.extractNSigSourceCode(player_js, ast, global_variable); + const extractions: ExtractionConfig[] = [ + { friendlyName: sigFunctionName, match: sigMatcher }, + { friendlyName: nFunctionName, match: nMatcher }, + { friendlyName: timestampVarName, match: timestampMatcher, collectDependencies: false } + ]; - Log.info(TAG, `Got signature timestamp (${sig_timestamp}) and algorithms needed to decipher signatures.`); + const jsAnalyzer = new JsAnalyzer(player_js, { extractions }); + const jsExtractor = new JsExtractor(jsAnalyzer); + + const result = jsExtractor.buildScript({ + disallowSideEffectInitializers: true, + exportRawValues: true, + rawValueOnly: [ timestampVarName ] + }); + + if (result.exportedRawValues && !(timestampVarName in result.exportedRawValues)) { + Log.warn(TAG, 'Failed to extract signature timestamp.'); + } + + if (!result.exported.includes(sigFunctionName)) { + Log.warn(TAG, 'Failed to extract signature decipher function.'); + } + + if (!result.exported.includes(nFunctionName)) { + Log.warn(TAG, 'Failed to extract n decipher function.'); + } + + const signatureTimestamp = result.exportedRawValues?.[timestampVarName]; + + const player = await Player.fromSource(player_id, { + cache, + signature_timestamp: parseInt(signatureTimestamp) || 0, + data: result + }); - const player = await Player.fromSource(player_id, sig_timestamp, cache, sig_sc, nsig_sc); player.po_token = po_token; return player; } - decipher(url?: string, signature_cipher?: string, cipher?: string, this_response_nsig_cache?: Map): string { + async decipher(url?: string, signature_cipher?: string, cipher?: string, this_response_nsig_cache?: Map): Promise { url = url || signature_cipher || cipher; if (!url) @@ -107,50 +140,64 @@ export default class Player { const args = new URLSearchParams(url); const url_components = new URL(args.get('url') || url); - if (this.sig_sc && (signature_cipher || cipher)) { - const signature = Platform.shim.eval(this.sig_sc, { - sig: args.get('s') - }); - - Log.info(TAG, `Transformed signature from ${args.get('s')} to ${signature}.`); - - if (typeof signature !== 'string') - throw new PlayerError('Failed to decipher signature'); - - const sp = args.get('sp'); - - if (sp) { - url_components.searchParams.set(sp, signature); - } else { - url_components.searchParams.set('signature', signature); - } - } - const n = url_components.searchParams.get('n'); + const s = args.get('s'); + const sp = args.get('sp'); - if (this.nsig_sc && n) { - let nsig; + if (this.data && ((signature_cipher || cipher) || n)) { + const eval_args: { sig?: string | null; n?: string | null } = {}; - if (this_response_nsig_cache && this_response_nsig_cache.has(n)) { - nsig = this_response_nsig_cache.get(n) as string; - } else { - nsig = Platform.shim.eval(this.nsig_sc, { - nsig: n - }); + if (signature_cipher || cipher) { + eval_args.sig = s; + } - Log.info(TAG, `Transformed n signature from ${n} to ${nsig}.`); - - if (typeof nsig !== 'string') - throw new PlayerError('Failed to decipher nsig'); - - if (nsig.startsWith('enhanced_except_')) { - Log.warn(TAG, 'Something went wrong while deciphering nsig.'); - } else if (this_response_nsig_cache) { - this_response_nsig_cache.set(n, nsig); + if (n) { + if (this_response_nsig_cache?.has(n)) { + const nsig = this_response_nsig_cache.get(n) as string; + url_components.searchParams.set('n', nsig); + } else { + eval_args.n = n; } } - url_components.searchParams.set('n', nsig); + if (Object.keys(eval_args).length > 0) { + const result = await Platform.shim.eval(this.data, eval_args) as Record; + + if (typeof result !== 'object' || result === null) { + throw new PlayerError('Got invalid result from player script evaluation.'); + } + + if (typeof eval_args.sig === 'string') { + const signatureResult = result.sig; + + Log.info(TAG, `Transformed signature from ${s} to ${signatureResult}.`); + + if (typeof signatureResult !== 'string') + throw new PlayerError('Got invalid signature from player script evaluation.'); + + if (sp) { + url_components.searchParams.set(sp, signatureResult); + } else { + url_components.searchParams.set('signature', signatureResult); + } + } + + if (typeof eval_args.n === 'string') { + const nResult = result.n; + Log.info(TAG, `Transformed n from ${n} to ${nResult}.`); + + if (typeof nResult !== 'string') + throw new PlayerError('Failed to decipher nsig'); + + if (nResult.startsWith('enhanced_except_')) { + Log.warn(TAG, `Decipher script returned an error (n=${n}):`, nResult); + } else if (this_response_nsig_cache) { + this_response_nsig_cache.set(n as string, nResult); + } + + url_components.searchParams.set('n', nResult); + } + } } // @NOTE: SABR requests should include the PoToken (not base64d, but as bytes!) in the payload. @@ -200,125 +247,40 @@ export default class Player { return null; try { - const player_data = BinarySerializer.deserialize(new Uint8Array(buffer)); + const deserializedCache = BinarySerializer.deserialize(new Uint8Array(buffer)); - if (player_data.library_version !== packageInfo.version) { - Log.warn(TAG, `Cached player data is from a different library version (${player_data.library_version}). Ignoring it.`); + if (deserializedCache.libraryVersion !== packageInfo.version) { + Log.warn(TAG, `Cached player data is from a different library version (${deserializedCache.libraryVersion}). Ignoring it.`); return null; } - return new Player(player_data.player_id, player_data.sts, player_data.sig_sc, player_data.nsig_sc); + return new Player(deserializedCache.playerId, deserializedCache.signatureTimestamp, deserializedCache.data); } catch (e) { Log.error(TAG, 'Failed to deserialize player data from cache:', e); return null; } } - static async fromSource(player_id: string, sig_timestamp: number, cache?: ICache, sig_sc?: string, nsig_sc?: string): Promise { - const player = new Player(player_id, sig_timestamp, sig_sc, nsig_sc); - await player.cache(cache); + static async fromSource(player_id: string, options: PlayerInitializationOptions): Promise { + const player = new Player(player_id, options.signature_timestamp, options.data); + await player.cache(options.cache); return player; } async cache(cache?: ICache): Promise { - if (!cache || !this.sig_sc || !this.nsig_sc) + if (!cache || !this.data) return; const buffer = BinarySerializer.serialize({ - player_id: this.player_id, - sts: this.sts, - sig_sc: this.sig_sc, - nsig_sc: this.nsig_sc, - library_version: packageInfo.version + playerId: this.player_id, + signatureTimestamp: this.signature_timestamp, + libraryVersion: packageInfo.version, + data: this.data }); await cache.set(this.player_id, buffer); } - static extractSigTimestamp(data: string): number { - return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0'); - } - - static extractGlobalVariable(data: string, ast: ReturnType): ASTLookupResult | undefined { - let variable = findVariable(data, { includes: '-_w8_', ast }); - - // For redundancy/the above fails: - if (!variable) - variable = findVariable(data, { includes: 'Untrusted URL{', ast }); - - if (!variable) - variable = findVariable(data, { includes: '1969', ast }); - - if (!variable) - variable = findVariable(data, { includes: '1970', ast }); - - if (!variable) - variable = findVariable(data, { includes: 'playerfallback', ast }); - - return variable; - } - - static extractSigSourceCode(data: string, global_variable?: ASTLookupResult): string | undefined { - // Classic static split/join. - const split_join_regex = /function\(([A-Za-z_0-9]+)\)\{([A-Za-z_0-9]+=[A-Za-z_0-9]+\.split\((?:[^)]+)\)(.+?)\.join\((?:[^)]+)\))\}/; - - // Using the global lookup variable. - const lookup_var = global_variable?.name?.replace(/[$^\\.*+?()[\]{}|]/g, '\\$&'); - const lookup_regex = lookup_var - ? new RegExp( - `function\\(([A-Za-z_0-9]+)\\)\\{([A-Za-z_0-9]+=[A-Za-z_0-9]+\\[${lookup_var}\\[\\d+\\]\\]\\([^)]*\\)([\\s\\S]+?)\\[${lookup_var}\\[\\d+\\]\\]\\([^)]*\\))\\}` - ) - : null; - - const match = data.match(split_join_regex) || (lookup_regex ? data.match(lookup_regex) : null); - - if (!match) { - Log.warn(TAG, 'Failed to extract signature decipher algorithm.'); - return; - } - - const var_name = match[1]; - const obj_name = match[3].split(/\.|\[/)[0]?.replace(';', '').trim(); - const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};'); - - if (!functions || !var_name) - Log.warn(TAG, 'Failed to extract signature decipher algorithm.'); - - return `${global_variable?.result ? `var ${global_variable.result};` : ''} function descramble_sig(${var_name}) { let ${obj_name}={${functions}}; ${match[2]} } descramble_sig(sig);`; - } - - static extractNSigSourceCode(data: string, ast?: ReturnType, global_variable?: ASTLookupResult): string | undefined { - let nsig_function; - - if (global_variable) { - nsig_function = findFunction(data, { includes: `new Date(${global_variable.name}`, ast }); - - // For redundancy/the above fails: - if (!nsig_function) - nsig_function = findFunction(data, { includes: '.push(String.fromCharCode(', ast }); - - if (!nsig_function) - nsig_function = findFunction(data, { includes: '.reverse().forEach(function', ast }); - - if (nsig_function) - return `var ${global_variable.result}; var ${nsig_function.result} ${nsig_function.name}(nsig);`; - } - - // This is the suffix of the error tag. - nsig_function = findFunction(data, { includes: '-_w8_', ast }); - - // Usually, only this function uses these dates in the entire script. - if (!nsig_function) - nsig_function = findFunction(data, { includes: '1969', ast }); - - // This used to be the prefix of the error tag (leaving it here for reference). - if (!nsig_function) - nsig_function = findFunction(data, { includes: 'enhanced_except', ast }); - - if (nsig_function) - return `let ${nsig_function.result} ${nsig_function.name}(nsig);`; - } - get url(): string { return new URL(`/s/player/${this.player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString(); } diff --git a/deno/src/core/Session.ts b/deno/src/core/Session.ts index a7e5d646..33f7e5c6 100644 --- a/deno/src/core/Session.ts +++ b/deno/src/core/Session.ts @@ -169,6 +169,11 @@ export type SessionOptions = { * If you want to force a new session to be generated, you must clear the cache or disable session caching. */ generate_session_locally?: boolean; + /** + * If set to `true`, session creation will fail if it's not possible to retrieve session data from YouTube. + * If `false`, a local fallback will be used. + */ + fail_fast?: boolean; /** * Specifies whether the session data should be cached. */ @@ -301,6 +306,7 @@ export default class Session extends EventEmitter { options.user_agent, options.enable_safety_mode, options.generate_session_locally, + options.fail_fast, options.device_category, options.client_type, options.timezone, @@ -381,6 +387,7 @@ export default class Session extends EventEmitter { user_agent: string = getRandomUserAgent('desktop'), enable_safety_mode = false, generate_session_locally = false, + fail_fast = false, device_category: DeviceCategory = 'desktop', client_name: ClientType = ClientType.WEB, tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone, @@ -446,6 +453,8 @@ export default class Session extends EventEmitter { api_version = sw_session_data.api_version; context_data = sw_session_data.context_data; } catch (error) { + if (fail_fast) + throw error; Log.error(TAG, 'Failed to retrieve session data from server. Session data generated locally will be used instead.', error); } } diff --git a/deno/src/core/clients/Kids.ts b/deno/src/core/clients/Kids.ts index 1a5ace57..5b31e373 100644 --- a/deno/src/core/clients/Kids.ts +++ b/deno/src/core/clients/Kids.ts @@ -32,7 +32,7 @@ export default class Kids { vis: 0, splay: false, lactMilliseconds: '-1', - signatureTimestamp: session.player?.sts + signatureTimestamp: session.player?.signature_timestamp } }, client: 'YTKIDS' diff --git a/deno/src/core/clients/Music.ts b/deno/src/core/clients/Music.ts index c1974394..2215c233 100644 --- a/deno/src/core/clients/Music.ts +++ b/deno/src/core/clients/Music.ts @@ -66,7 +66,7 @@ export default class Music { vis: 0, splay: false, lactMilliseconds: '-1', - signatureTimestamp: this.#session.player?.sts + signatureTimestamp: this.#session.player?.signature_timestamp } }, client: 'YTMUSIC' @@ -102,7 +102,7 @@ export default class Music { vis: 0, splay: false, lactMilliseconds: '-1', - signatureTimestamp: this.#session.player?.sts + signatureTimestamp: this.#session.player?.signature_timestamp } }, client: 'YTMUSIC' diff --git a/deno/src/parser/classes/CompositeVideoPrimaryInfo.ts b/deno/src/parser/classes/CompositeVideoPrimaryInfo.ts index 0fc806af..52767928 100644 --- a/deno/src/parser/classes/CompositeVideoPrimaryInfo.ts +++ b/deno/src/parser/classes/CompositeVideoPrimaryInfo.ts @@ -4,8 +4,7 @@ import type { RawNode } from '../types/index.ts'; export default class CompositeVideoPrimaryInfo extends YTNode { static type = 'CompositeVideoPrimaryInfo'; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - constructor(data: RawNode) { + constructor(_data: RawNode) { super(); } } diff --git a/deno/src/parser/classes/Form.ts b/deno/src/parser/classes/Form.ts new file mode 100644 index 00000000..c530c1a4 --- /dev/null +++ b/deno/src/parser/classes/Form.ts @@ -0,0 +1,15 @@ +import { YTNode } from '../helpers.ts'; +import { Parser, type RawNode } from '../index.ts'; +import { type ObservedArray } from '../helpers.ts'; +import ToggleFormField from './ToggleFormField.ts'; + +export default class Form extends YTNode { + static type = 'Form'; + + fields: ObservedArray; + + constructor(data: RawNode) { + super(); + this.fields = Parser.parseArray(data.fields, ToggleFormField); + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/FormPopup.ts b/deno/src/parser/classes/FormPopup.ts new file mode 100644 index 00000000..6446f00b --- /dev/null +++ b/deno/src/parser/classes/FormPopup.ts @@ -0,0 +1,21 @@ +import { YTNode } from '../helpers.ts'; +import { Parser, type RawNode } from '../index.ts'; +import { type ObservedArray } from '../helpers.ts'; +import Text from './misc/Text.ts'; +import Form from './Form.ts'; +import Button from './Button.ts'; + +export default class FormPopup extends YTNode { + static type = 'FormPopup'; + + title: Text; + form: Form | null; + buttons: ObservedArray