mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-26 16:18:51 +00:00
chore: clean up build steps
This commit is contained in:
241
src/utils/Cache.ts
Normal file
241
src/utils/Cache.ts
Normal 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
122
src/utils/Constants.ts
Normal 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
|
||||
};
|
||||
60
src/utils/EventEmitterLike.ts
Normal file
60
src/utils/EventEmitterLike.ts
Normal 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
134
src/utils/HTTPClient.ts
Normal 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
341
src/utils/Utils.ts
Normal 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
6
src/utils/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user