mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-19 04:21:35 +00:00
* refactor: remove dependancies removes node-forge and uuid in favor of Web APIs * refactor!: commonjs to es6 To aid with #93 I will make all my changes in TypeScript instead. This is the first step into making that happen. Used: https://github.com/wessberg/cjstoesm * refactor!: NToken and Signature TS files Bring this PR up to speed with #93 * feat: cross platform cache (WIP) this is untested! should remove idb as dependecy. * feat: EventEmitter polyfill * refactor: remove events * feat: HTTPClient based on Fetch API (WIP) * refactor!: parsers refactor (WIP) Initial TS support for parsers as per #93 This adds several type safety checks to the parser which'll help to ensure valid data is returned by the parser. * refactor!: parsers refactor (WIP) Bring more in line with the existing implementations & make less verbose * refactor!: parser refactor I was overcomplicating things, this is much simpler and compatible with the existing JS API * fix: some missed parsers while refactoring * fix: better type inferance for parseResponse * feat(TS): typesafe YTNode casts * feat: more type safety in YTNode and Parser * refactor: VideoInfo download with fetch & TS (WIP) Again, this also does some work for #93 * fix: LiveChat in VideoInfo * refactor!: more typesafety in parser * refactor!: VideoInfo almost completed * refactor!: player and session refactors - Remove the Player class' dependance on Session. - Add additional context to the Session. * refactor!: move auth logic to Session (WIP) * refactor: TS port for Actions and Innertube My fingers hurt from typing out all those types :-P * refactor: NavigationEndpoint TS this is still a WIP and should be improved. NavigationEndpoint should probably be refactored further. * refactor!: VideoInfo compiles without errors * chore: delete old player * fix: import errors It compiles and runs!! * fix: Utils import fixes * fix: several runtime errors * fix: video streaming * chore: remove console.log debugging Whoops, forgot to remove these before I pushed the previous commit * chore: remove old unused dependencies * fix: typescript errors Now emitting declarations and source maps * refactor: TS feed * chore: delete old Feed * refactor: move streamToIterable into Utils * refactor: AccountManager TS * refactor: FilterableFeed to TS * refactor: InteractionManager to TS * refactor: PlaylistManager to TS * refactor: TabbedFeed to TS * refactor: Music to TS (WIP) more work to be done, see TODO comments * fix: getting the tests to pass (6/12) YouTube.js Tests Search ✓ Should search on YouTube (1152 ms) ✕ Should search on YouTube Music (705 ms) ✕ Should retrieve YouTube search suggestions (722 ms) ✓ Should retrieve YouTube Music search suggestions (233 ms) Comments ✓ Should retrieve comments (585 ms) ✕ Should retrieve next batch of comments (221 ms) ✕ Should retrieve comment replies (1 ms) General ✕ Should retrieve playlist with YouTube (732 ms) ✓ Should retrieve home feed (838 ms) ✓ Should retrieve trending content (543 ms) ✓ Should retrieve video info (639 ms) ✕ Should download video (5 ms) * fix: tests (7/12) YouTube.js Tests Search ✓ Should search on YouTube (1984 ms) ✕ Should search on YouTube Music (1139 ms) ✕ Should retrieve YouTube search suggestions (1433 ms) ✓ Should retrieve YouTube Music search suggestions (529 ms) Comments ✓ Should retrieve comments (324 ms) ✓ Should retrieve next batch of comments (395 ms) ✕ Should retrieve comment replies General ✕ Should retrieve playlist with YouTube (653 ms) ✓ Should retrieve home feed (1085 ms) ✓ Should retrieve trending content (513 ms) ✓ Should retrieve video info (921 ms) ✕ Should download video (3 ms) * fix: download tests (8/12) YouTube.js Tests Search ✓ Should search on YouTube (1293 ms) ✕ Should search on YouTube Music (927 ms) ✕ Should retrieve YouTube search suggestions (1250 ms) ✓ Should retrieve YouTube Music search suggestions (258 ms) Comments ✓ Should retrieve comments (803 ms) ✓ Should retrieve next batch of comments (511 ms) ✕ Should retrieve comment replies General ✕ Should retrieve playlist with YouTube (528 ms) ✓ Should retrieve home feed (1047 ms) ✓ Should retrieve trending content (548 ms) ✓ Should retrieve video info (825 ms) ✓ Should download video (1779 ms) * fix: tests (9/12) YouTube.js Tests Search ✓ Should search on YouTube (1276 ms) ✕ Should search on YouTube Music (955 ms) ✓ Should retrieve YouTube search suggestions (661 ms) ✓ Should retrieve YouTube Music search suggestions (491 ms) Comments ✓ Should retrieve comments (624 ms) ✓ Should retrieve next batch of comments (353 ms) ✕ Should retrieve comment replies General ✕ Should retrieve playlist with YouTube (672 ms) ✓ Should retrieve home feed (1277 ms) ✓ Should retrieve trending content (999 ms) ✓ Should retrieve video info (1106 ms) ✓ Should download video (2514 ms) * feat: key based type validation for parsers * fix: comments tests pass (10/12) YouTube.js Tests Search ✓ Should search on YouTube (938 ms) ✕ Should search on YouTube Music (850 ms) ✓ Should retrieve YouTube search suggestions (528 ms) ✓ Should retrieve YouTube Music search suggestions (224 ms) Comments ✓ Should retrieve comments (518 ms) ✓ Should retrieve next batch of comments (337 ms) ✓ Should retrieve comment replies (358 ms) General ✕ Should retrieve playlist with YouTube (466 ms) ✓ Should retrieve home feed (1051 ms) ✓ Should retrieve trending content (623 ms) ✓ Should retrieve video info (863 ms) ✓ Should download video (2656 ms) * refactor: type safety checks removing @ts-ignore * fix: playlist tests pass (11/12) YouTube.js Tests Search ✓ Should search on YouTube (991 ms) ✕ Should search on YouTube Music (924 ms) ✓ Should retrieve YouTube search suggestions (606 ms) ✓ Should retrieve YouTube Music search suggestions (225 ms) Comments ✓ Should retrieve comments (393 ms) ✓ Should retrieve next batch of comments (284 ms) ✓ Should retrieve comment replies (252 ms) General ✓ Should retrieve playlist with YouTube (578 ms) ✓ Should retrieve home feed (1148 ms) ✓ Should retrieve trending content (541 ms) ✓ Should retrieve video info (799 ms) ✓ Should download video (1419 ms) * fix: all tests pass for node 🎉 YouTube.js Tests Search ✓ Should search on YouTube (1053 ms) ✓ Should search on YouTube Music (761 ms) ✓ Should retrieve YouTube search suggestions (453 ms) ✓ Should retrieve YouTube Music search suggestions (221 ms) Comments ✓ Should retrieve comments (627 ms) ✓ Should retrieve next batch of comments (412 ms) ✓ Should retrieve comment replies (268 ms) General ✓ Should retrieve playlist with YouTube (565 ms) ✓ Should retrieve home feed (775 ms) ✓ Should retrieve trending content (498 ms) ✓ Should retrieve video info (875 ms) ✓ Should download video (1364 ms) * build: working Deno bundle Still need to test whether this bundle works in the browser * docs: update deno example to download video * refactor: MusicResponsiveListItem to TS * docs: TSDoc for Parser helpers * docs: Parser documentation for TS * docs: add note about parseItem and parseArray * test: remove browser tests since they're identical * feat: browser support and proxy example * fix: PlaylistManager TS after merge * feat: in-browser video streaming * refactor: cleanup the Dash example * feat: allow custom fetch implementations * feat: fetch debugger * fix: OAuth login * refactor: remove file extensions from imports * refactor: build scripts * fix: CustomEvent on node * fix: LiveChat * fix: linting * fix: liniting in build-parser-json * chore: update test workflow * fix: NToken errors after lint fixes * fix: codacy complaints * docs: update to reflect changes Definitly needs more work but its a start * refactor: cleanup imports/exports * fix: browser example - Remove user-agent before making request. - Fix cache on browsers * fix: cache on node * fix: stupid mistake * refactor: Session#signIn to wait untill success This also splits the 'auth' event up into 3 distinct events: - 'auth' -> fired on success - 'auth-pending' -> fired when pending authentication - 'auth-error' -> fired when an error occurred * refactor: freeze Constants * refactor: cleanup HTTPClient Request * refactor: debugFetch readability * chore: lint * refactor: replace jsdoc with tsdoc eslint plugin remove @param annotations without descriptions * fix: bunch of liniting warnings * refactor: better inference on YTNode#is As suggested by @MasterOfBob777 * fix: linting warnings * revert: undici import * refactor: rename `list_type` to `item_type`
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
import UserAgent from 'user-agents';
|
|
import Flatten from 'flat';
|
|
import package_json from '../../package.json';
|
|
import { FetchFunction } from './HTTPClient';
|
|
|
|
const VALID_CLIENTS = new Set([ 'YOUTUBE', 'YTMUSIC' ]);
|
|
|
|
export class InnertubeError extends Error {
|
|
date: Date;
|
|
version: string;
|
|
info?: any;
|
|
|
|
constructor(message: string, info?: any) {
|
|
super(message);
|
|
if (info) {
|
|
this.info = info;
|
|
}
|
|
this.date = new Date();
|
|
this.version = package_json.version;
|
|
}
|
|
}
|
|
export class ParsingError extends InnertubeError { }
|
|
|
|
export class DownloadError extends InnertubeError { }
|
|
|
|
export class MissingParamError extends InnertubeError { }
|
|
|
|
export class UnavailableContentError extends InnertubeError { }
|
|
|
|
export class NoStreamingDataError extends InnertubeError { }
|
|
|
|
export class OAuthError extends InnertubeError { }
|
|
|
|
export class PlayerError extends Error { }
|
|
|
|
export class SessionError extends Error { }
|
|
|
|
/**
|
|
* Utility to help access deep properties of an object.
|
|
*
|
|
* @param obj - the object.
|
|
* @param key - key of the property being accessed.
|
|
* @param target - anything that might be inside of the property.
|
|
* @param depth - maximum number of nested objects to flatten.
|
|
* @param safe - if set to true arrays will be preserved.
|
|
*/
|
|
export function findNode(obj: any, key: string, target: string, depth: number, safe = true): any | any[] {
|
|
const flat_obj = Flatten(obj, { safe, maxDepth: depth || 2 }) as any;
|
|
const result = Object.keys(flat_obj).find((entry) => entry.includes(key) && JSON.stringify(flat_obj[entry] || '{}').includes(target));
|
|
if (!result)
|
|
throw new ParsingError(`Expected to find "${key}" with content "${target}" but got ${result}`, {
|
|
key, target, data_snippet: `${JSON.stringify(flat_obj, null, 4).slice(0, 300)}..`
|
|
});
|
|
return flat_obj[result];
|
|
}
|
|
|
|
/**
|
|
* Compares given objects. May not work correctly for
|
|
* objects with methods.
|
|
*
|
|
*/
|
|
export function deepCompare(obj1: any, obj2: any) {
|
|
const keys = Reflect.ownKeys(obj1);
|
|
return keys.some((key) => {
|
|
const is_text = obj2[key]?.constructor.name === 'Text';
|
|
if (!is_text && typeof obj2[key] === 'object') {
|
|
return JSON.stringify(obj1[key]) === JSON.stringify(obj2[key]);
|
|
}
|
|
return obj1[key] === (is_text ? obj2[key].toString() : obj2[key]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Finds a string between two delimiters.
|
|
*
|
|
* @param data - the data.
|
|
* @param start_string - start string.
|
|
* @param end_string - end string.
|
|
*/
|
|
export function getStringBetweenStrings(data: string, start_string: string, end_string: string) {
|
|
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, 's');
|
|
const match = data.match(regex);
|
|
return match ? match[1] : undefined;
|
|
}
|
|
|
|
/**
|
|
*/
|
|
export function escapeStringRegexp(input: string): string {
|
|
return input.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d');
|
|
}
|
|
|
|
export type DeviceCategory = 'mobile' | 'desktop';
|
|
|
|
/**
|
|
* Returns a random user agent.
|
|
*
|
|
* @param type - mobile | desktop
|
|
*/
|
|
export function getRandomUserAgent(type: DeviceCategory): UserAgent['data'] {
|
|
switch (type) {
|
|
case 'mobile':
|
|
return new UserAgent(/Android/).data;
|
|
case 'desktop':
|
|
return new UserAgent({
|
|
deviceCategory: 'desktop'
|
|
}).data;
|
|
default:
|
|
throw new TypeError('Invalid user agent type specified');
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
export async function sha1Hash(str: string) {
|
|
const SubtleCrypto = getRuntime() === 'node' ? (Reflect.get(module, 'require')('crypto').webcrypto as unknown as Crypto).subtle : window.crypto.subtle;
|
|
const byteToHex = [
|
|
'00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '0a', '0b', '0c', '0d', '0e', '0f',
|
|
'10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '1a', '1b', '1c', '1d', '1e', '1f',
|
|
'20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '2a', '2b', '2c', '2d', '2e', '2f',
|
|
'30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '3a', '3b', '3c', '3d', '3e', '3f',
|
|
'40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '4a', '4b', '4c', '4d', '4e', '4f',
|
|
'50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '5a', '5b', '5c', '5d', '5e', '5f',
|
|
'60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '6a', '6b', '6c', '6d', '6e', '6f',
|
|
'70', '71', '72', '73', '74', '75', '76', '77', '78', '79', '7a', '7b', '7c', '7d', '7e', '7f',
|
|
'80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '8a', '8b', '8c', '8d', '8e', '8f',
|
|
'90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '9a', '9b', '9c', '9d', '9e', '9f',
|
|
'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'aa', 'ab', 'ac', 'ad', 'ae', 'af',
|
|
'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'b7', 'b8', 'b9', 'ba', 'bb', 'bc', 'bd', 'be', 'bf',
|
|
'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9', 'ca', 'cb', 'cc', 'cd', 'ce', 'cf',
|
|
'd0', 'd1', 'd2', 'd3', 'd4', 'd5', 'd6', 'd7', 'd8', 'd9', 'da', 'db', 'dc', 'dd', 'de', 'df',
|
|
'e0', 'e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7', 'e8', 'e9', 'ea', 'eb', 'ec', 'ed', 'ee', 'ef',
|
|
'f0', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'fa', 'fb', 'fc', 'fd', 'fe', 'ff'
|
|
];
|
|
/**
|
|
*
|
|
*/
|
|
function hex(arrayBuffer: ArrayBuffer) {
|
|
const buff = new Uint8Array(arrayBuffer);
|
|
const hexOctets = [];
|
|
for (let i = 0; i < buff.length; ++i)
|
|
hexOctets.push(byteToHex[buff[i]]);
|
|
return hexOctets.join('');
|
|
}
|
|
return hex(await SubtleCrypto.digest('SHA-1', new TextEncoder().encode(str)));
|
|
}
|
|
/**
|
|
* Generates an authentication token from a cookies' sid.
|
|
*
|
|
* @param sid - Sid extracted from cookies
|
|
*/
|
|
export async function generateSidAuth(sid: string): Promise<string> {
|
|
const youtube = 'https://www.youtube.com';
|
|
const timestamp = Math.floor(new Date().getTime() / 1000);
|
|
const input = [ timestamp, sid, youtube ].join(' ');
|
|
const gen_hash = await sha1Hash(input);
|
|
return [ 'SAPISIDHASH', [ timestamp, gen_hash ].join('_') ].join(' ');
|
|
}
|
|
|
|
/**
|
|
* Generates a random string with the given length.
|
|
*
|
|
*/
|
|
export function generateRandomString(length: number): string {
|
|
const result = [];
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
for (let i = 0; i < length; i++) {
|
|
result.push(alphabet.charAt(Math.floor(Math.random() * alphabet.length)));
|
|
}
|
|
return result.join('');
|
|
}
|
|
|
|
/**
|
|
* Converts time (h:m:s) to seconds.
|
|
*
|
|
* @returns seconds
|
|
*/
|
|
export function timeToSeconds(time: string) {
|
|
const params = time.split(':').map((param) => parseInt(param));
|
|
switch (params.length) {
|
|
case 1:
|
|
return params[0];
|
|
case 2:
|
|
return params[0] * 60 + params[1];
|
|
case 3:
|
|
return params[0] * 3600 + params[1] * 60 + params[2];
|
|
default:
|
|
throw new Error('Invalid time string');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts strings in camelCase to snake_case.
|
|
*
|
|
* @param string - The string in camelCase.
|
|
*/
|
|
export function camelToSnake(string: string) {
|
|
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
}
|
|
|
|
/**
|
|
* Checks if a given client is valid.
|
|
*
|
|
* @returns
|
|
*/
|
|
export function isValidClient(client: string) {
|
|
return VALID_CLIENTS.has(client);
|
|
}
|
|
|
|
/**
|
|
* Throws an error if given parameters are undefined.
|
|
*/
|
|
export function throwIfMissing(params: object) {
|
|
for (const [ key, value ] of Object.entries(params)) {
|
|
if (!value)
|
|
throw new MissingParamError(`${key} is missing`);
|
|
}
|
|
}
|
|
|
|
export function hasKeys<T extends object, R extends (keyof T)[]>(params: T, ...keys: R): params is Exclude<T, R[number]> & Required<Pick<T, R[number]>> {
|
|
for (const key of keys) {
|
|
if (!Reflect.has(params, key) || (params[key] === undefined))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Turns the ntoken transform data into a valid json array
|
|
*/
|
|
export function refineNTokenData(data: string) {
|
|
return data
|
|
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
|
|
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
|
|
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)').replace(/,b,/g, ',"b",')
|
|
.replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
|
|
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",')
|
|
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
export function uuidv4() {
|
|
if (getRuntime() === 'node') {
|
|
return Reflect.get(module, 'require')('crypto').webcrypto.randomUUID();
|
|
}
|
|
|
|
if (globalThis.crypto?.randomUUID()) {
|
|
return globalThis.crypto.randomUUID();
|
|
}
|
|
|
|
// See https://stackoverflow.com/a/2117523
|
|
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (cc) => {
|
|
const c = parseInt(cc);
|
|
return (c ^ window.crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16);
|
|
});
|
|
|
|
|
|
}
|
|
|
|
export type Runtime = 'node' | 'deno' | 'browser';
|
|
|
|
/**
|
|
*
|
|
*/
|
|
export function getRuntime(): Runtime {
|
|
if ((typeof process !== 'undefined') && (process?.versions?.node))
|
|
return 'node';
|
|
if (Reflect.has(globalThis, 'Deno'))
|
|
return 'deno';
|
|
return 'browser';
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
export function isServer() {
|
|
return [ 'node', 'deno' ].includes(getRuntime());
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
export async function* streamToIterable(stream: ReadableStream<Uint8Array>) {
|
|
const reader = stream.getReader();
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
return;
|
|
}
|
|
yield value;
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
export const debugFetch: FetchFunction = (input, init) => {
|
|
const url =
|
|
typeof input === 'string' ?
|
|
new URL(input) :
|
|
input instanceof URL ?
|
|
input :
|
|
new URL(input.url);
|
|
|
|
|
|
const headers =
|
|
init?.headers ?
|
|
new Headers(init.headers) :
|
|
input instanceof Request ?
|
|
input.headers :
|
|
new Headers();
|
|
|
|
const arr_headers = [ ...headers ];
|
|
|
|
const body_contents =
|
|
init?.body ?
|
|
typeof init.body === 'string' ?
|
|
headers.get('content-type') === 'application/json' ?
|
|
JSON.stringify(JSON.parse(init.body), null, 2) : // Body is string and json
|
|
init.body : // Body is string
|
|
' <binary>' : // Body is not string
|
|
' (none)'; // No body provided
|
|
|
|
const headers_serialized =
|
|
arr_headers.length > 0 ?
|
|
`${arr_headers.map(([ key, value ]) => ` ${key}: ${value}`).join('\n')}` :
|
|
' (none)';
|
|
|
|
console.log(
|
|
'YouTube.js Fetch:\n' +
|
|
` url: ${url.toString()}\n` +
|
|
` method: ${init?.method || 'GET'}\n` +
|
|
` headers:\n${headers_serialized}\n' +
|
|
' body:\n${body_contents}`
|
|
);
|
|
|
|
return globalThis.fetch(input, init);
|
|
};
|