chore: clean up build steps

This commit is contained in:
LuanRT
2022-07-20 16:28:51 -03:00
parent fb68e6bcfe
commit 6a5ebeb8ee
287 changed files with 40 additions and 40 deletions

241
src/utils/Cache.ts Normal file
View File

@@ -0,0 +1,241 @@
import { getRuntime } from './Utils';
// Browser Cache is based off:
// https://github.com/elias551/simple-kvs/blob/master/src/index.ts
export default class UniversalCache {
#persistent_directory: string;
#persistent: boolean;
constructor(persistent = false, persistent_directory?: string) {
this.#persistent_directory = persistent_directory || UniversalCache.default_persistent_directory;
this.#persistent = persistent;
}
static get temp_directory() {
switch (getRuntime()) {
case 'deno':
const Deno: any = Reflect.get(globalThis, 'Deno');
return `${Deno.env.get('TMPDIR') || Deno.env.get('TMP') || Deno.env.get('TEMP') || '/tmp'}/youtubei.js`;
case 'node':
return `${Reflect.get(module, 'require')('os').tmpdir()}/youtubei.js`;
default:
return '';
}
}
static get default_persistent_directory() {
switch (getRuntime()) {
case 'deno':
const Deno: any = Reflect.get(globalThis, 'Deno');
return `${Deno.cwd()}/.cache/youtubei.js`;
case 'node':
return Reflect.get(module, 'require')('path').resolve(__dirname, '..', '..', '.cache', 'youtubei.js');
default:
return '';
}
}
get cache_dir() {
return this.#persistent ? this.#persistent_directory : UniversalCache.temp_directory;
}
async #createCache() {
const dir = this.cache_dir;
switch (getRuntime()) {
case 'deno':
const Deno: any = Reflect.get(globalThis, 'Deno');
try {
const cwd = await Deno.stat(dir);
if (!cwd.isDirectory)
throw new Error('An unexpected file was found in place of the cache directory');
} catch (e) {
if (e instanceof Deno.errors.NotFound)
await Deno.mkdir(dir, { recursive: true });
else
throw e;
}
break;
case 'node':
const fs = Reflect.get(module, 'require')('fs/promises');
try {
const cwd = await fs.stat(dir);
if (!cwd.isDirectory())
throw new Error('An unexpected file was found in place of the cache directory');
} catch (e: any) {
if (e?.code === 'ENOENT')
await fs.mkdir(dir, { recursive: true });
else
throw e;
}
break;
}
}
#getBrowserDB() {
const indexedDB: IDBFactory = Reflect.get(globalThis, 'indexedDB') || Reflect.get(globalThis, 'webkitIndexedDB') || Reflect.get(globalThis, 'mozIndexedDB') || Reflect.get(globalThis, 'msIndexedDB');
if (!indexedDB) return console.log('IndexedDB is not supported. No cache will be used.');
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('youtubei.js', 1);
request.onsuccess = function () {
resolve(this.result);
};
request.onerror = function (event) {
reject('indexedDB request error');
console.error(event);
};
request.onupgradeneeded = function () {
const store = this.result.createObjectStore('kv-store', {
keyPath: 'k'
});
store.transaction.oncomplete = function () {
resolve(this.db);
};
};
});
}
async get(key: string) {
await this.#createCache();
switch (getRuntime()) {
case 'deno':
{
const file = `${this.cache_dir}/${key}`;
const Deno: any = Reflect.get(globalThis, 'Deno');
try {
const stat = await Deno.stat(file);
if (stat.isFile) {
const data: Uint8Array = await Deno.readFile(file);
return data.buffer;
}
throw new Error('An unexpected file was found in place of the cache key');
} catch (e) {
if (e instanceof Deno.errors.NotFound)
return undefined;
throw e;
}
}
case 'node':
{
const fs = Reflect.get(module, 'require')('fs/promises');
const file = Reflect.get(module, 'require')('path').resolve(this.cache_dir, key);
try {
const stat = await fs.stat(file);
if (stat.isFile()) {
const data: Buffer = await fs.readFile(file);
return data.buffer;
}
throw new Error('An unexpected file was found in place of the cache key');
} catch (e: any) {
if (e?.code === 'ENOENT')
return undefined;
throw e;
}
}
case 'browser':
{
const db = await this.#getBrowserDB();
if (!db) return;
return new Promise<ArrayBuffer | undefined>((resolve, reject) => {
const request = db.transaction('kv-store', 'readonly').objectStore('kv-store').get(key);
request.onerror = reject;
request.onsuccess = function () {
const result: Uint8Array | undefined = this.result?.v;
resolve(result ? result.buffer : undefined);
};
});
}
}
}
async set(key: string, value: ArrayBuffer) {
await this.#createCache();
switch (getRuntime()) {
case 'deno':
{
const Deno: any = Reflect.get(globalThis, 'Deno');
const file = `${this.cache_dir}/${key}`;
await Deno.writeFile(file, new Uint8Array(value));
}
break;
case 'node':
{
const fs = Reflect.get(module, 'require')('fs/promises');
const file = Reflect.get(module, 'require')('path').resolve(this.cache_dir, key);
await fs.writeFile(file, new Uint8Array(value));
}
break;
case 'browser':
{
const db = await this.#getBrowserDB();
if (!db) return;
return new Promise<void>((resolve, reject) => {
const request = db.transaction('kv-store', 'readwrite').objectStore('kv-store').put({ k: key, v: value });
request.onerror = reject;
request.onsuccess = () => resolve();
});
}
break;
}
}
async remove(key: string) {
await this.#createCache();
switch (getRuntime()) {
case 'deno':
{
const file = `${this.cache_dir}/${key}`;
const Deno: any = Reflect.get(globalThis, 'Deno');
try {
await Deno.remove(file);
} catch (e) {
if (e instanceof Deno.errors.NotFound) return undefined;
throw e;
}
}
break;
case 'node':
{
const fs = Reflect.get(module, 'require')('fs/promises');
const file = Reflect.get(module, 'require')('path').resolve(this.cache_dir, key);
try {
await fs.unlink(file);
} catch (e: any) {
if (e?.code === 'ENOENT') return;
throw e;
}
}
break;
case 'browser':
{
const db = await this.#getBrowserDB();
if (!db) return;
return new Promise<void>((resolve, reject) => {
const request = db.transaction('kv-store', 'readwrite').objectStore('kv-store').delete(key);
request.onerror = reject;
request.onsuccess = () => resolve();
});
}
}
}
}

122
src/utils/Constants.ts Normal file
View File

@@ -0,0 +1,122 @@
export const URLS = Object.freeze({
YT_BASE: 'https://www.youtube.com',
YT_MUSIC_BASE: 'https://music.youtube.com',
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/',
API: Object.freeze({
BASE: 'https://youtubei.googleapis.com',
PRODUCTION: 'https://youtubei.googleapis.com/youtubei/',
STAGING: 'https://green-youtubei.sandbox.googleapis.com/youtubei/',
RELEASE: 'https://release-youtubei.sandbox.googleapis.com/youtubei/',
TEST: 'https://test-youtubei.sandbox.googleapis.com/youtubei/',
CAMI: 'http://cami-youtubei.sandbox.googleapis.com/youtubei/',
UYTFE: 'https://uytfe.sandbox.google.com/youtubei/'
})
});
export const OAUTH = Object.freeze({
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
GRANT_TYPE: 'http://oauth.net/grant_type/device/1.0',
MODEL_NAME: 'ytlr::',
HEADERS: Object.freeze({
'accept': '*/*',
'origin': 'https://www.youtube.com',
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
'content-type': 'application/json',
'referer': 'https://www.youtube.com/tv',
'accept-language': 'en-US'
}),
REGEX: Object.freeze({
AUTH_SCRIPT: /<script id="base-js" src="(.*?)" nonce=".*?"><\/script>/,
CLIENT_IDENTITY: /.+?={};var .+?={clientId:"(?<client_id>.+?)",.+?:"(?<client_secret>.+?)"},/
})
});
export const CLIENTS = Object.freeze({
WEB: {
NAME: 'WEB'
},
YTMUSIC: {
NAME: 'WEB_REMIX',
VERSION: '1.20211213.00.00'
},
ANDROID: {
NAME: 'ANDROID',
VERSION: '17.17.32'
}
});
export const STREAM_HEADERS = Object.freeze({
'accept': '*/*',
// XXX: undici doesnt like this, 'connection': 'keep-alive',
'origin': 'https://www.youtube.com',
'referer': 'https://www.youtube.com',
'DNT': '?1'
});
export const INNERTUBE_HEADERS_BASE = Object.freeze({
'accept': '*/*',
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json'
});
export const METADATA_KEYS = Object.freeze([
'embed', 'view_count', 'average_rating', 'allow_ratings',
'length_seconds', 'channel_id', 'channel_url',
'external_channel_id', 'is_live_content', 'is_family_safe',
'is_unlisted', 'is_private', 'has_ypc_metadata',
'category', 'owner_channel_name', 'publish_date',
'upload_date', 'keywords', 'available_countries',
'owner_profile_url'
]);
export const BLACKLISTED_KEYS = Object.freeze([
'is_owner_viewing', 'is_unplugged_corpus',
'is_crawlable', 'author'
]);
export const ACCOUNT_SETTINGS = Object.freeze({
// Notifications
SUBSCRIPTIONS: 'NOTIFICATION_SUBSCRIPTION_NOTIFICATIONS',
RECOMMENDED_VIDEOS: 'NOTIFICATION_RECOMMENDATION_WEB_CONTROL',
CHANNEL_ACTIVITY: 'NOTIFICATION_COMMENT_WEB_CONTROL',
COMMENT_REPLIES: 'NOTIFICATION_COMMENT_REPLY_OTHER_WEB_CONTROL',
USER_MENTION: 'NOTIFICATION_USER_MENTION_WEB_CONTROL',
SHARED_CONTENT: 'NOTIFICATION_RETUBING_WEB_CONTROL',
// Privacy
PLAYLISTS_PRIVACY: 'PRIVACY_DISCOVERABLE_SAVED_PLAYLISTS',
SUBSCRIPTIONS_PRIVACY: 'PRIVACY_DISCOVERABLE_SUBSCRIPTIONS'
});
export const BASE64_DIALECT = Object.freeze({
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
});
export const SIG_REGEX = Object.freeze({
ACTIONS: /;.{2}\.(?<name>.{2})\(.*?,(?<param>.*?)\)/g,
FUNCTIONS: /(?<name>.{2}):function\(.*?\){(.*?)}/g
});
export const NTOKEN_REGEX = Object.freeze({
CALLS: /c\[(.*?)\]\((.+?)\)/g,
PLACEHOLDERS: /c\[(.*?)\]=c/g,
FUNCTIONS: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/
});
export const FUNCS = Object.freeze({
PUSH: 'd.push(e)',
REVERSE_1: 'd.reverse()',
REVERSE_2: 'function(d){for(var',
SPLICE: 'd.length;d.splice(e,1)',
SWAP0_1: 'd[0])[0])',
SWAP0_2: 'f=d[0];d[0]',
ROTATE_1: 'reverse().forEach',
ROTATE_2: 'unshift(d.pop())',
BASE64_DIA: 'function(){for(var',
TRANSLATE_1: 'function(d,e){for(var f',
TRANSLATE_2: 'function(d,e,f){var'
});
export default {
URLS,
OAUTH,
CLIENTS,
STREAM_HEADERS,
INNERTUBE_HEADERS_BASE,
METADATA_KEYS,
BLACKLISTED_KEYS,
ACCOUNT_SETTINGS,
BASE64_DIALECT,
SIG_REGEX,
NTOKEN_REGEX,
FUNCS
};

View File

@@ -0,0 +1,60 @@
// Polyfill CustomEvents on node
if (!Reflect.has(globalThis, 'CustomEvent')) {
// See https://github.com/nodejs/node/issues/40678#issuecomment-1126944677
class CustomEvent extends Event {
#detail;
constructor(type: string, options?: CustomEventInit<any[]>) {
super(type, options);
this.#detail = options?.detail ?? null;
}
get detail() {
return this.#detail;
}
}
Reflect.set(globalThis, 'CustomEvent', CustomEvent);
}
export default class EventEmitterLike extends EventTarget {
#legacy_listeners = new Map<(...args: any[]) => void, EventListener>();
constructor() {
super();
}
emit(type: string, ...args: any[]) {
const event = new CustomEvent(type, { detail: args });
this.dispatchEvent(event);
}
on(type: string, listener: (...args: any[]) => void) {
const wrapper: EventListener = (ev) => {
if (ev instanceof CustomEvent) {
listener(...ev.detail);
} else {
listener(ev);
}
};
this.#legacy_listeners.set(listener, wrapper);
this.addEventListener(type, wrapper);
}
once(type: string, listener: (...args: any[]) => void) {
const wrapper: EventListener = (ev) => {
if (ev instanceof CustomEvent) {
listener(...ev.detail);
} else {
listener(ev);
}
this.off(type, listener);
};
this.#legacy_listeners.set(listener, wrapper);
this.addEventListener(type, wrapper);
}
off(type: string, listener: (...args: any[]) => void) {
const wrapper = this.#legacy_listeners.get(listener);
if (wrapper) {
this.removeEventListener(type, wrapper);
this.#legacy_listeners.delete(listener);
}
}
}

134
src/utils/HTTPClient.ts Normal file
View File

@@ -0,0 +1,134 @@
import Session, { Context } from '../core/Session';
import Constants from './Constants';
import { generateSidAuth, getRandomUserAgent, getStringBetweenStrings, InnertubeError, isServer } from './Utils';
export type FetchFunction = typeof fetch;
export interface HTTPClientInit {
baseURL?: string;
}
export default class HTTPClient {
#session: Session;
#cookie?: string;
#fetch: FetchFunction;
constructor(session: Session, cookie?: string, fetch?: FetchFunction) {
this.#session = session;
this.#cookie = cookie;
this.#fetch = fetch || globalThis.fetch;
}
get fetch_function() {
return this.#fetch;
}
async fetch(
input: URL | Request | string,
init?: RequestInit & HTTPClientInit
) {
const innertube_url = Constants.URLS.API.PRODUCTION + this.#session.api_version;
const baseURL = init?.baseURL || innertube_url;
const request_url =
typeof input === 'string' ?
(!baseURL.endsWith('/') && !input.startsWith('/')) ?
new URL(`${baseURL}/${input}`) :
new URL(baseURL + input) :
input instanceof URL ?
input :
new URL(input.url, baseURL);
const headers =
init?.headers ||
(input instanceof Request ? input.headers : new Headers()) ||
new Headers();
const body =
init?.body || (input instanceof Request ? input.body : undefined);
const request_headers = new Headers(headers);
request_headers.set('Accept', '*/*');
request_headers.set('Accept-Language', `en-${this.#session.context.client.gl || 'US'}`);
request_headers.set('x-goog-visitor-id', this.#session.context.client.visitorData || '');
request_headers.set('x-origin', request_url.origin);
request_headers.set('x-youtube-client-version', this.#session.context.client.clientVersion || '');
if (isServer()) {
request_headers.set('User-Agent', getRandomUserAgent('desktop').userAgent);
request_headers.set('origin', request_url.origin);
}
request_url.searchParams.set('key', this.#session.key);
request_url.searchParams.set('prettyPrint', 'false');
const contentType = request_headers.get('Content-Type');
let request_body = body;
const is_innertube_req = baseURL === innertube_url;
// Copy context into payload when possible
if (contentType === 'application/json' && is_innertube_req && (typeof body === 'string')) {
const json = JSON.parse(body);
const n_body = {
...json,
// Deep copy since we're gonna be modifying it
context: JSON.parse(JSON.stringify(this.#session.context))
};
this.#adjustContext(n_body.context, n_body.client);
request_headers.set('x-youtube-client-version', n_body.context.client.clientVersion);
delete n_body.client;
request_body = JSON.stringify(n_body);
}
// Authenticate
if (this.#session.logged_in && is_innertube_req) {
const oauth = this.#session.oauth;
if (oauth.validateCredentials()) {
// Check if the access token is valid to avoid authorization errors.
await oauth.checkAccessTokenValidity();
request_headers.set('authorization', `Bearer ${oauth.credentials.access_token}`);
// Remove API key as it is not required when using oauth.
request_url.searchParams.delete('key');
}
if (this.#cookie) {
const papisid = getStringBetweenStrings(this.#cookie, 'PAPISID=', ';');
if (papisid) {
request_headers.set('authorization', await generateSidAuth(papisid));
}
request_headers.set('cookie', this.#cookie);
}
}
const request = new Request(request_url, input instanceof Request ? input : init);
const response = await this.#fetch(request, {
body: request_body,
headers: request_headers,
credentials: 'include',
redirect: input instanceof Request ? input.redirect : init?.redirect || 'follow'
});
// Check if 2xx
if (response.ok) {
return response;
} throw new InnertubeError(`Request to ${response.url} failed with status ${response.status}`, response);
}
#adjustContext(ctx: Context, client: string) {
switch (client) {
case 'YTMUSIC':
ctx.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION;
ctx.client.clientName = Constants.CLIENTS.YTMUSIC.NAME;
break;
case 'ANDROID':
ctx.client.clientVersion = Constants.CLIENTS.ANDROID.VERSION;
ctx.client.clientFormFactor = 'SMALL_FORM_FACTOR';
ctx.client.clientName = Constants.CLIENTS.ANDROID.NAME;
break;
default:
break;
}
}
}

341
src/utils/Utils.ts Normal file
View File

@@ -0,0 +1,341 @@
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);
};

6
src/utils/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * as Utils from './Utils';
export * as Constants from './Constants';
export { default as UniversalCache } from './Cache';
export { default as EventEmitter } from './EventEmitterLike';
export { default as HTTPClient } from './HTTPClient';
export * from './HTTPClient';