mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56e6e23453 | ||
|
|
00fa514b03 | ||
|
|
d36389c865 | ||
|
|
55ca986888 | ||
|
|
b04df7e119 | ||
|
|
d8d92866d1 | ||
|
|
b4b0731589 | ||
|
|
d69d701869 | ||
|
|
cd4d28c951 | ||
|
|
22b9c174bb |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "youtubei.js",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "Full-featured wrapper around YouTube's private API. Supports YouTube, YouTube Music and YouTube Studio (WIP).",
|
||||
"main": "./dist/index.js",
|
||||
"browser": "./bundle/browser.js",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import UniversalCache from '../utils/Cache';
|
||||
import Constants from '../utils/Constants';
|
||||
import Constants, { CLIENTS } from '../utils/Constants';
|
||||
import EventEmitterLike from '../utils/EventEmitterLike';
|
||||
import Actions from './Actions';
|
||||
import Player from './Player';
|
||||
|
||||
import HTTPClient, { FetchFunction } from '../utils/HTTPClient';
|
||||
import { DeviceCategory, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
|
||||
import { DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
|
||||
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth';
|
||||
import Proto from '../proto';
|
||||
|
||||
export enum ClientType {
|
||||
WEB = 'WEB',
|
||||
@@ -21,7 +22,7 @@ export interface Context {
|
||||
client: {
|
||||
hl: string;
|
||||
gl: string;
|
||||
remoteHost: string;
|
||||
remoteHost?: string;
|
||||
screenDensityFloat: number;
|
||||
screenHeightPoints: number;
|
||||
screenPixelDensity: number;
|
||||
@@ -38,8 +39,8 @@ export interface Context {
|
||||
clientFormFactor: string;
|
||||
userInterfaceTheme: string;
|
||||
timeZone: string;
|
||||
browserName: string;
|
||||
browserVersion: string;
|
||||
browserName?: string;
|
||||
browserVersion?: string;
|
||||
originalUrl: string;
|
||||
deviceMake: string;
|
||||
deviceModel: string;
|
||||
@@ -58,25 +59,72 @@ export interface Context {
|
||||
}
|
||||
|
||||
export interface SessionOptions {
|
||||
/**
|
||||
* Language.
|
||||
*/
|
||||
lang?: string;
|
||||
/**
|
||||
* Geolocation.
|
||||
*/
|
||||
location?: string;
|
||||
/**
|
||||
* The account index to use. This is useful if you have multiple accounts logged in.
|
||||
* **NOTE:**
|
||||
* Only works if you are signed in with cookies.
|
||||
*/
|
||||
account_index?: number;
|
||||
/**
|
||||
* Specifies whether to retrieve the JS player. Disabling this will make session creation faster.
|
||||
* **NOTE:** Deciphering formats is not possible without the JS player.
|
||||
*/
|
||||
retrieve_player?: boolean;
|
||||
/**
|
||||
* Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content.
|
||||
*/
|
||||
enable_safety_mode?: boolean;
|
||||
/**
|
||||
* Specifies whether to generate the session data locally or retrieve it from YouTube.
|
||||
* This can be useful if you need more performance.
|
||||
*/
|
||||
generate_session_locally?: boolean;
|
||||
/**
|
||||
* Platform to use for the session.
|
||||
*/
|
||||
device_category?: DeviceCategory;
|
||||
/**
|
||||
* InnerTube client type.
|
||||
*/
|
||||
client_type?: ClientType;
|
||||
/**
|
||||
* The time zone.
|
||||
*/
|
||||
timezone?: string;
|
||||
/**
|
||||
* Used to cache the deciphering functions from the JS player.
|
||||
*/
|
||||
cache?: UniversalCache;
|
||||
/**
|
||||
* YouTube cookies.
|
||||
*/
|
||||
cookie?: string;
|
||||
/**
|
||||
* Fetch function to use.
|
||||
*/
|
||||
fetch?: FetchFunction;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
context: Context;
|
||||
api_key: string;
|
||||
api_version: string;
|
||||
}
|
||||
|
||||
export default class Session extends EventEmitterLike {
|
||||
#api_version;
|
||||
#key;
|
||||
#context;
|
||||
#account_index;
|
||||
#player;
|
||||
#api_version: string;
|
||||
#key: string;
|
||||
#context: Context;
|
||||
#account_index: number;
|
||||
#player?: Player;
|
||||
|
||||
oauth: OAuth;
|
||||
http: HTTPClient;
|
||||
@@ -121,6 +169,7 @@ export default class Session extends EventEmitterLike {
|
||||
options.location,
|
||||
options.account_index,
|
||||
options.enable_safety_mode,
|
||||
options.generate_session_locally,
|
||||
options.device_category,
|
||||
options.client_type,
|
||||
options.timezone,
|
||||
@@ -135,30 +184,49 @@ export default class Session extends EventEmitterLike {
|
||||
}
|
||||
|
||||
static async getSessionData(
|
||||
lang = 'en-US',
|
||||
lang = '',
|
||||
location = '',
|
||||
account_index = 0,
|
||||
enable_safety_mode = false,
|
||||
generate_session_locally = false,
|
||||
device_category: DeviceCategory = 'desktop',
|
||||
client_name: ClientType = ClientType.WEB,
|
||||
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
fetch: FetchFunction = globalThis.fetch
|
||||
) {
|
||||
let session_data: SessionData;
|
||||
|
||||
if (generate_session_locally) {
|
||||
session_data = this.#generateSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode });
|
||||
} else {
|
||||
session_data = await this.#retrieveSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode }, fetch);
|
||||
}
|
||||
|
||||
return { ...session_data, account_index };
|
||||
}
|
||||
|
||||
static async #retrieveSessionData(options: {
|
||||
lang: string;
|
||||
location: string;
|
||||
time_zone: string;
|
||||
device_category: string;
|
||||
client_name: string;
|
||||
enable_safety_mode: boolean;
|
||||
}, fetch: FetchFunction = globalThis.fetch): Promise<SessionData> {
|
||||
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'accept-language': lang,
|
||||
'accept-language': options.lang || 'en-US',
|
||||
'user-agent': getRandomUserAgent('desktop'),
|
||||
'accept': '*/*',
|
||||
'referer': 'https://www.youtube.com/sw.js',
|
||||
'cookie': `PREF=tz=${tz.replace('/', '.')}`
|
||||
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new SessionError(`Failed to get session data: ${res.status}`);
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new SessionError(`Failed to retrieve session data: ${res.status}`);
|
||||
|
||||
const text = await res.text();
|
||||
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
|
||||
@@ -172,22 +240,22 @@ export default class Session extends EventEmitterLike {
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: device_info[0],
|
||||
gl: location || device_info[2],
|
||||
gl: options.location || device_info[2],
|
||||
remoteHost: device_info[3],
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 720,
|
||||
screenHeightPoints: 1080,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1280,
|
||||
screenWidthPoints: 1920,
|
||||
visitorData: device_info[13],
|
||||
userAgent: device_info[14],
|
||||
clientName: client_name,
|
||||
clientName: options.client_name,
|
||||
clientVersion: device_info[16],
|
||||
osName: device_info[17],
|
||||
osVersion: device_info[18],
|
||||
platform: device_category.toUpperCase(),
|
||||
platform: options.device_category.toUpperCase(),
|
||||
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
|
||||
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
|
||||
timeZone: device_info[79],
|
||||
timeZone: device_info[79] || options.time_zone,
|
||||
browserName: device_info[86],
|
||||
browserVersion: device_info[87],
|
||||
originalUrl: Constants.URLS.YT_BASE,
|
||||
@@ -196,7 +264,7 @@ export default class Session extends EventEmitterLike {
|
||||
utcOffsetMinutes: new Date().getTimezoneOffset()
|
||||
},
|
||||
user: {
|
||||
enableSafetyMode: enable_safety_mode,
|
||||
enableSafetyMode: options.enable_safety_mode,
|
||||
lockedSafetyMode: false
|
||||
},
|
||||
request: {
|
||||
@@ -204,7 +272,53 @@ export default class Session extends EventEmitterLike {
|
||||
}
|
||||
};
|
||||
|
||||
return { context, api_key, api_version, account_index };
|
||||
return { context, api_key, api_version };
|
||||
}
|
||||
|
||||
static #generateSessionData(options: {
|
||||
lang: string;
|
||||
location: string;
|
||||
time_zone: string;
|
||||
device_category: DeviceCategory;
|
||||
client_name: string;
|
||||
enable_safety_mode: boolean
|
||||
}): SessionData {
|
||||
const id = generateRandomString(11);
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: options.lang || 'en',
|
||||
gl: options.location || 'US',
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 1080,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1920,
|
||||
visitorData: Proto.encodeVisitorData(id, timestamp),
|
||||
userAgent: getRandomUserAgent('desktop'),
|
||||
clientName: options.client_name,
|
||||
clientVersion: CLIENTS.WEB.VERSION,
|
||||
osName: 'Windows',
|
||||
osVersion: '10.0',
|
||||
platform: options.device_category.toUpperCase(),
|
||||
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
|
||||
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
|
||||
timeZone: options.time_zone,
|
||||
originalUrl: Constants.URLS.YT_BASE,
|
||||
deviceMake: '',
|
||||
deviceModel: '',
|
||||
utcOffsetMinutes: new Date().getTimezoneOffset()
|
||||
},
|
||||
user: {
|
||||
enableSafetyMode: options.enable_safety_mode,
|
||||
lockedSafetyMode: false
|
||||
},
|
||||
request: {
|
||||
useSsl: true
|
||||
}
|
||||
};
|
||||
|
||||
return { context, api_key: CLIENTS.WEB.API_KEY, api_version: CLIENTS.WEB.API_VERSION };
|
||||
}
|
||||
|
||||
async signIn(credentials?: Credentials): Promise<void> {
|
||||
|
||||
@@ -34,6 +34,7 @@ class PlayerMicroformat extends YTNode {
|
||||
publish_date: string;
|
||||
upload_date: string;
|
||||
available_countries: string[];
|
||||
start_timestamp: Date | null;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -65,6 +66,7 @@ class PlayerMicroformat extends YTNode {
|
||||
this.publish_date = data.publishDate;
|
||||
this.upload_date = data.uploadDate;
|
||||
this.available_countries = data.availableCountries;
|
||||
this.start_timestamp = data.liveBroadcastDetails?.startTimestamp ? new Date(data.liveBroadcastDetails.startTimestamp) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ class TwoColumnWatchNextResults extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.results = Parser.parse(data.results?.results.contents, true);
|
||||
this.secondary_results = Parser.parse(data.secondaryResults?.secondaryResults.results, true);
|
||||
this.conversation_bar = Parser.parse(data?.conversationBar);
|
||||
this.results = Parser.parseArray(data.results?.results.contents);
|
||||
this.secondary_results = Parser.parseArray(data.secondaryResults?.secondaryResults.results);
|
||||
this.conversation_bar = Parser.parseItem(data?.conversationBar);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import Player from '../../../core/Player';
|
||||
import { InnertubeError } from '../../../utils/Utils';
|
||||
|
||||
class Format {
|
||||
itag: string;
|
||||
itag: number;
|
||||
mime_type: string;
|
||||
bitrate;
|
||||
average_bitrate;
|
||||
width;
|
||||
height;
|
||||
bitrate: number;
|
||||
average_bitrate: number;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
init_range: {
|
||||
start: number;
|
||||
@@ -23,15 +23,15 @@ class Format {
|
||||
content_length: number;
|
||||
quality: string;
|
||||
quality_label: string | undefined;
|
||||
fps: string | undefined;
|
||||
fps: number | undefined;
|
||||
url: string;
|
||||
cipher: string | undefined;
|
||||
signature_cipher: string | undefined;
|
||||
audio_quality: string | undefined;
|
||||
approx_duration_ms: number;
|
||||
audio_sample_rate: number;
|
||||
audio_channels: string;
|
||||
loudness_db: string;
|
||||
audio_channels: number;
|
||||
loudness_db: number;
|
||||
has_audio: boolean;
|
||||
has_video: boolean;
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ class VideoDetails {
|
||||
view_count: number;
|
||||
author: string;
|
||||
is_private: boolean;
|
||||
is_live: boolean;
|
||||
is_live_content: boolean;
|
||||
is_upcoming: boolean;
|
||||
is_crawlable: boolean;
|
||||
|
||||
constructor(data: any) {
|
||||
@@ -29,7 +31,9 @@ class VideoDetails {
|
||||
this.view_count = parseInt(data.viewCount);
|
||||
this.author = data.author;
|
||||
this.is_private = !!data.isPrivate;
|
||||
this.is_live = !!data.isLive;
|
||||
this.is_live_content = !!data.isLiveContent;
|
||||
this.is_upcoming = !!data.isUpcoming;
|
||||
this.is_crawlable = !!data.isCrawlable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +375,10 @@ export type ObservedArray<T extends YTNode = YTNode> = Array<T> & {
|
||||
* Get the first of a specific type
|
||||
*/
|
||||
firstOfType<R extends YTNode, K extends YTNodeConstructor<R>[]>(...types: K): InstanceType<K[number]> | undefined;
|
||||
/**
|
||||
* Get the first item
|
||||
*/
|
||||
first: () => T | undefined;
|
||||
/**
|
||||
* This is similar to filter but throws if there's a type mismatch.
|
||||
*/
|
||||
@@ -435,6 +439,7 @@ export function observe<T extends YTNode>(obj: Array<T>): ObservedArray<T> {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (prop == 'firstOfType') {
|
||||
return (...types: YTNodeConstructor<YTNode>[]) => {
|
||||
return target.find((node: YTNode) => {
|
||||
@@ -445,6 +450,10 @@ export function observe<T extends YTNode>(obj: Array<T>): ObservedArray<T> {
|
||||
};
|
||||
}
|
||||
|
||||
if (prop == 'first') {
|
||||
return () => target[0];
|
||||
}
|
||||
|
||||
if (prop == 'as') {
|
||||
return (...types: YTNodeConstructor<YTNode>[]) => {
|
||||
return observe(target.map((node: YTNode) => {
|
||||
|
||||
@@ -124,16 +124,17 @@ class VideoInfo {
|
||||
|
||||
this.basic_info = { // This type is inferred so no need for an explicit type
|
||||
...info.video_details,
|
||||
/**
|
||||
* Microformat is a bit redundant, so only
|
||||
* a few things there are interesting to us.
|
||||
*/
|
||||
...{
|
||||
/**
|
||||
* Microformat is a bit redundant, so only
|
||||
* a few things there are interesting to us.
|
||||
*/
|
||||
embed: info.microformat?.is(PlayerMicroformat) ? info.microformat?.embed : null,
|
||||
channel: info.microformat?.is(PlayerMicroformat) ? info.microformat?.channel : null,
|
||||
is_unlisted: info.microformat?.is_unlisted,
|
||||
is_family_safe: info.microformat?.is_family_safe,
|
||||
has_ypc_metadata: info.microformat?.is(PlayerMicroformat) ? info.microformat?.has_ypc_metadata : null
|
||||
has_ypc_metadata: info.microformat?.is(PlayerMicroformat) ? info.microformat?.has_ypc_metadata : null,
|
||||
start_timestamp: info.microformat?.is(PlayerMicroformat) ? info.microformat.start_timestamp : null
|
||||
},
|
||||
like_count: undefined as number | undefined,
|
||||
is_liked: undefined as boolean | undefined,
|
||||
@@ -161,7 +162,7 @@ class VideoInfo {
|
||||
this.merchandise = results.firstOfType(MerchandiseShelf);
|
||||
this.related_chip_cloud = secondary_results.firstOfType(RelatedChipCloud)?.content.item().as(ChipCloud);
|
||||
|
||||
this.watch_next_feed = secondary_results.firstOfType(ItemSection)?.contents;
|
||||
this.watch_next_feed = secondary_results.firstOfType(ItemSection)?.contents || secondary_results;
|
||||
|
||||
if (this.watch_next_feed && Array.isArray(this.watch_next_feed))
|
||||
this.#watch_next_continuation = this.watch_next_feed.pop()?.as(ContinuationItem);
|
||||
@@ -177,7 +178,7 @@ class VideoInfo {
|
||||
const comments_entry_point = results.get({ target_id: 'comments-entry-point' })?.as(ItemSection);
|
||||
|
||||
this.comments_entry_point_header = comments_entry_point?.contents?.firstOfType(CommentsEntryPointHeader);
|
||||
this.livechat = next?.contents_memo.getType(LiveChat)?.[0];
|
||||
this.livechat = next?.contents_memo.getType(LiveChat).first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +187,9 @@ class VideoInfo {
|
||||
* @param target_filter - Filter to apply.
|
||||
*/
|
||||
async selectFilter(target_filter: string | ChipCloudChip | undefined): Promise<VideoInfo> {
|
||||
if (!this.related_chip_cloud)
|
||||
throw new InnertubeError('Chip cloud not found, cannot apply filter');
|
||||
|
||||
let cloud_chip: ChipCloudChip;
|
||||
|
||||
if (typeof target_filter === 'string') {
|
||||
@@ -235,15 +239,19 @@ class VideoInfo {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves watch next feed continuation.
|
||||
*/
|
||||
async getWatchNextContinuation(): Promise<VideoInfo> {
|
||||
if (!this.#watch_next_continuation)
|
||||
throw new InnertubeError('Watch next feed continuation not found');
|
||||
|
||||
const response = await this.#watch_next_continuation?.endpoint.call(this.#actions, { parse: true });
|
||||
const data = response?.on_response_received_endpoints?.get({ type: 'appendContinuationItemsAction' });
|
||||
|
||||
if (!data)
|
||||
throw new InnertubeError('Continuation not found');
|
||||
throw new InnertubeError('AppendContinuationItemsAction not found');
|
||||
|
||||
this.watch_next_feed = data?.contents;
|
||||
this.#watch_next_continuation = this.watch_next_feed?.pop()?.as(ContinuationItem);
|
||||
@@ -324,7 +332,7 @@ class VideoInfo {
|
||||
* Watch next feed filters.
|
||||
*/
|
||||
get filters(): string[] {
|
||||
return this.related_chip_cloud?.chips?.map((chip) => chip.text.toString()) || [];
|
||||
return this.related_chip_cloud?.chips?.map((chip) => chip.text?.toString()) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,10 +349,17 @@ class VideoInfo {
|
||||
return this.#cpn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if continuation is available for the watch next feed.
|
||||
*/
|
||||
get wn_has_continuation(): boolean {
|
||||
return !!this.#watch_next_continuation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Original parsed InnerTube response.
|
||||
*/
|
||||
get page(): [ ParsedResponse, ParsedResponse? ] {
|
||||
get page(): [ParsedResponse, ParsedResponse?] {
|
||||
return this.#page;
|
||||
}
|
||||
|
||||
@@ -363,7 +378,7 @@ class VideoInfo {
|
||||
for (let i = 0; i < metadata.rows.length; i++) {
|
||||
const row = metadata.rows[i];
|
||||
if (row.is(MetadataRowHeader)) {
|
||||
if (row.content.toString().toLowerCase().startsWith('music')) {
|
||||
if (row.content?.toString().toLowerCase().startsWith('music')) {
|
||||
is_music_section = true;
|
||||
i++; // Skip the learn more link
|
||||
}
|
||||
@@ -372,7 +387,7 @@ class VideoInfo {
|
||||
if (!is_music_section)
|
||||
continue;
|
||||
if (row.is(MetadataRow))
|
||||
current_song[row.title.toString().toLowerCase().replace(/ /g, '_')] = row.contents;
|
||||
current_song[row.title?.toString().toLowerCase().replace(/ /g, '_')] = row.contents;
|
||||
// TODO: this makes no sense, we continue above when
|
||||
if (row.has_divider_line) {
|
||||
songs.push(current_song);
|
||||
@@ -542,16 +557,16 @@ class VideoInfo {
|
||||
url.searchParams.set('cpn', this.#cpn || '');
|
||||
|
||||
set.appendChild(this.#el(document, 'Representation', {
|
||||
id: format.itag,
|
||||
id: format.itag?.toString(),
|
||||
codecs,
|
||||
bandwidth: format.bitrate,
|
||||
width: format.width,
|
||||
height: format.height,
|
||||
bandwidth: format.bitrate?.toString(),
|
||||
width: format.width?.toString(),
|
||||
height: format.height?.toString(),
|
||||
maxPlayoutRate: '1',
|
||||
frameRate: format.fps
|
||||
frameRate: format.fps?.toString()
|
||||
}, [
|
||||
this.#el(document, 'BaseURL', {}, [
|
||||
document.createTextNode(url_transformer(url).toString())
|
||||
document.createTextNode(url_transformer(url)?.toString())
|
||||
]),
|
||||
this.#el(document, 'SegmentBase', {
|
||||
indexRange: `${format.index_range.start}-${format.index_range.end}`
|
||||
@@ -572,16 +587,16 @@ class VideoInfo {
|
||||
url.searchParams.set('cpn', this.#cpn || '');
|
||||
|
||||
set.appendChild(this.#el(document, 'Representation', {
|
||||
id: format.itag,
|
||||
id: format.itag?.toString(),
|
||||
codecs,
|
||||
bandwidth: format.bitrate
|
||||
bandwidth: format.bitrate?.toString()
|
||||
}, [
|
||||
this.#el(document, 'AudioChannelConfiguration', {
|
||||
schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
|
||||
value: format.audio_channels || '2'
|
||||
value: format.audio_channels?.toString() || '2'
|
||||
}),
|
||||
this.#el(document, 'BaseURL', {}, [
|
||||
document.createTextNode(url_transformer(url).toString())
|
||||
document.createTextNode(url_transformer(url)?.toString())
|
||||
]),
|
||||
this.#el(document, 'SegmentBase', {
|
||||
indexRange: `${format.index_range.start}-${format.index_range.end}`
|
||||
|
||||
@@ -2,9 +2,14 @@ import { CLIENTS } from '../utils/Constants';
|
||||
import { u8ToBase64 } from '../utils/Utils';
|
||||
import { VideoMetadata } from '../core/Studio';
|
||||
|
||||
import { ChannelAnalytics, CreateCommentParams, GetCommentsSectionParams, InnertubePayload, LiveMessageParams, MusicSearchFilter, NotificationPreferences, PeformCommentActionParams, SearchFilter, SearchFilter_Filters } from './youtube';
|
||||
import { ChannelAnalytics, CreateCommentParams, GetCommentsSectionParams, InnertubePayload, LiveMessageParams, MusicSearchFilter, NotificationPreferences, PeformCommentActionParams, SearchFilter, SearchFilter_Filters, VisitorData } from './youtube';
|
||||
|
||||
class Proto {
|
||||
static encodeVisitorData(id: string, timestamp: number): string {
|
||||
const buf = VisitorData.toBinary({ id, timestamp });
|
||||
return encodeURIComponent(u8ToBase64(buf).replace(/\+/g, '-').replace(/\//g, '_'));
|
||||
}
|
||||
|
||||
static encodeChannelAnalyticsParams(channel_id: string): string {
|
||||
const buf = ChannelAnalytics.toBinary({
|
||||
params: {
|
||||
@@ -74,9 +79,6 @@ class Proto {
|
||||
data.noFilter = 0;
|
||||
|
||||
if (data.filters) {
|
||||
if (filters.upload_date && filters.type !== 'video')
|
||||
throw new Error(`Upload date filter cannot be used with type ${filters.type}`);
|
||||
|
||||
if (filters.upload_date) {
|
||||
data.filters.uploadDate = upload_date[filters.upload_date];
|
||||
}
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
syntax = "proto2";
|
||||
package youtube;
|
||||
|
||||
message ChannelAnalytics {
|
||||
message Params {
|
||||
required string channel_id = 1001;
|
||||
}
|
||||
|
||||
required Params params = 32;
|
||||
message VisitorData {
|
||||
required string id = 1;
|
||||
required int32 timestamp = 5;
|
||||
}
|
||||
|
||||
message InnertubePayload {
|
||||
@@ -91,6 +88,14 @@ message InnertubePayload {
|
||||
optional VideoThumbnail video_thumbnail = 20;
|
||||
}
|
||||
|
||||
message ChannelAnalytics {
|
||||
message Params {
|
||||
required string channel_id = 1001;
|
||||
}
|
||||
|
||||
required Params params = 32;
|
||||
}
|
||||
|
||||
message SoundInfoParams {
|
||||
message Sound {
|
||||
message Params {
|
||||
|
||||
@@ -15,22 +15,17 @@ import { reflectionMergePartial } from "@protobuf-ts/runtime";
|
||||
import { MESSAGE_TYPE } from "@protobuf-ts/runtime";
|
||||
import { MessageType } from "@protobuf-ts/runtime";
|
||||
/**
|
||||
* @generated from protobuf message youtube.ChannelAnalytics
|
||||
* @generated from protobuf message youtube.VisitorData
|
||||
*/
|
||||
export interface ChannelAnalytics {
|
||||
export interface VisitorData {
|
||||
/**
|
||||
* @generated from protobuf field: youtube.ChannelAnalytics.Params params = 32;
|
||||
* @generated from protobuf field: string id = 1;
|
||||
*/
|
||||
params?: ChannelAnalytics_Params;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message youtube.ChannelAnalytics.Params
|
||||
*/
|
||||
export interface ChannelAnalytics_Params {
|
||||
id: string;
|
||||
/**
|
||||
* @generated from protobuf field: string channel_id = 1001;
|
||||
* @generated from protobuf field: int32 timestamp = 5;
|
||||
*/
|
||||
channelId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message youtube.InnertubePayload
|
||||
@@ -213,6 +208,24 @@ export interface InnertubePayload_VideoThumbnail_Thumbnail {
|
||||
*/
|
||||
imageData: Uint8Array;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message youtube.ChannelAnalytics
|
||||
*/
|
||||
export interface ChannelAnalytics {
|
||||
/**
|
||||
* @generated from protobuf field: youtube.ChannelAnalytics.Params params = 32;
|
||||
*/
|
||||
params?: ChannelAnalytics_Params;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message youtube.ChannelAnalytics.Params
|
||||
*/
|
||||
export interface ChannelAnalytics_Params {
|
||||
/**
|
||||
* @generated from protobuf field: string channel_id = 1001;
|
||||
*/
|
||||
channelId: string;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message youtube.SoundInfoParams
|
||||
*/
|
||||
@@ -646,26 +659,30 @@ export interface SearchFilter_Filters {
|
||||
featuresVr180?: number;
|
||||
}
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class ChannelAnalytics$Type extends MessageType<ChannelAnalytics> {
|
||||
class VisitorData$Type extends MessageType<VisitorData> {
|
||||
constructor() {
|
||||
super("youtube.ChannelAnalytics", [
|
||||
{ no: 32, name: "params", kind: "message", T: () => ChannelAnalytics_Params }
|
||||
super("youtube.VisitorData", [
|
||||
{ no: 1, name: "id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
|
||||
{ no: 5, name: "timestamp", kind: "scalar", T: 5 /*ScalarType.INT32*/ }
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<ChannelAnalytics>): ChannelAnalytics {
|
||||
const message = {};
|
||||
create(value?: PartialMessage<VisitorData>): VisitorData {
|
||||
const message = { id: "", timestamp: 0 };
|
||||
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<ChannelAnalytics>(this, message, value);
|
||||
reflectionMergePartial<VisitorData>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ChannelAnalytics): ChannelAnalytics {
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: VisitorData): VisitorData {
|
||||
let message = target ?? this.create(), end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* youtube.ChannelAnalytics.Params params */ 32:
|
||||
message.params = ChannelAnalytics_Params.internalBinaryRead(reader, reader.uint32(), options, message.params);
|
||||
case /* string id */ 1:
|
||||
message.id = reader.string();
|
||||
break;
|
||||
case /* int32 timestamp */ 5:
|
||||
message.timestamp = reader.int32();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
@@ -678,10 +695,13 @@ class ChannelAnalytics$Type extends MessageType<ChannelAnalytics> {
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(message: ChannelAnalytics, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* youtube.ChannelAnalytics.Params params = 32; */
|
||||
if (message.params)
|
||||
ChannelAnalytics_Params.internalBinaryWrite(message.params, writer.tag(32, WireType.LengthDelimited).fork(), options).join();
|
||||
internalBinaryWrite(message: VisitorData, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* string id = 1; */
|
||||
if (message.id !== "")
|
||||
writer.tag(1, WireType.LengthDelimited).string(message.id);
|
||||
/* int32 timestamp = 5; */
|
||||
if (message.timestamp !== 0)
|
||||
writer.tag(5, WireType.Varint).int32(message.timestamp);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
|
||||
@@ -689,56 +709,9 @@ class ChannelAnalytics$Type extends MessageType<ChannelAnalytics> {
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message youtube.ChannelAnalytics
|
||||
* @generated MessageType for protobuf message youtube.VisitorData
|
||||
*/
|
||||
export const ChannelAnalytics = new ChannelAnalytics$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class ChannelAnalytics_Params$Type extends MessageType<ChannelAnalytics_Params> {
|
||||
constructor() {
|
||||
super("youtube.ChannelAnalytics.Params", [
|
||||
{ no: 1001, name: "channel_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<ChannelAnalytics_Params>): ChannelAnalytics_Params {
|
||||
const message = { channelId: "" };
|
||||
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<ChannelAnalytics_Params>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ChannelAnalytics_Params): ChannelAnalytics_Params {
|
||||
let message = target ?? this.create(), end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* string channel_id */ 1001:
|
||||
message.channelId = reader.string();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(message: ChannelAnalytics_Params, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* string channel_id = 1001; */
|
||||
if (message.channelId !== "")
|
||||
writer.tag(1001, WireType.LengthDelimited).string(message.channelId);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message youtube.ChannelAnalytics.Params
|
||||
*/
|
||||
export const ChannelAnalytics_Params = new ChannelAnalytics_Params$Type();
|
||||
export const VisitorData = new VisitorData$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class InnertubePayload$Type extends MessageType<InnertubePayload> {
|
||||
constructor() {
|
||||
@@ -1456,6 +1429,100 @@ class InnertubePayload_VideoThumbnail_Thumbnail$Type extends MessageType<Innertu
|
||||
*/
|
||||
export const InnertubePayload_VideoThumbnail_Thumbnail = new InnertubePayload_VideoThumbnail_Thumbnail$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class ChannelAnalytics$Type extends MessageType<ChannelAnalytics> {
|
||||
constructor() {
|
||||
super("youtube.ChannelAnalytics", [
|
||||
{ no: 32, name: "params", kind: "message", T: () => ChannelAnalytics_Params }
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<ChannelAnalytics>): ChannelAnalytics {
|
||||
const message = {};
|
||||
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<ChannelAnalytics>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ChannelAnalytics): ChannelAnalytics {
|
||||
let message = target ?? this.create(), end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* youtube.ChannelAnalytics.Params params */ 32:
|
||||
message.params = ChannelAnalytics_Params.internalBinaryRead(reader, reader.uint32(), options, message.params);
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(message: ChannelAnalytics, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* youtube.ChannelAnalytics.Params params = 32; */
|
||||
if (message.params)
|
||||
ChannelAnalytics_Params.internalBinaryWrite(message.params, writer.tag(32, WireType.LengthDelimited).fork(), options).join();
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message youtube.ChannelAnalytics
|
||||
*/
|
||||
export const ChannelAnalytics = new ChannelAnalytics$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class ChannelAnalytics_Params$Type extends MessageType<ChannelAnalytics_Params> {
|
||||
constructor() {
|
||||
super("youtube.ChannelAnalytics.Params", [
|
||||
{ no: 1001, name: "channel_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<ChannelAnalytics_Params>): ChannelAnalytics_Params {
|
||||
const message = { channelId: "" };
|
||||
globalThis.Object.defineProperty(message, MESSAGE_TYPE, { enumerable: false, value: this });
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<ChannelAnalytics_Params>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ChannelAnalytics_Params): ChannelAnalytics_Params {
|
||||
let message = target ?? this.create(), end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* string channel_id */ 1001:
|
||||
message.channelId = reader.string();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(message: ChannelAnalytics_Params, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* string channel_id = 1001; */
|
||||
if (message.channelId !== "")
|
||||
writer.tag(1001, WireType.LengthDelimited).string(message.channelId);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message youtube.ChannelAnalytics.Params
|
||||
*/
|
||||
export const ChannelAnalytics_Params = new ChannelAnalytics_Params$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class SoundInfoParams$Type extends MessageType<SoundInfoParams> {
|
||||
constructor() {
|
||||
super("youtube.SoundInfoParams", [
|
||||
|
||||
@@ -35,7 +35,9 @@ export const OAUTH = Object.freeze({
|
||||
export const CLIENTS = Object.freeze({
|
||||
WEB: {
|
||||
NAME: 'WEB',
|
||||
VERSION: '2.20220902.01.00'
|
||||
VERSION: '2.20230104.01.00',
|
||||
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||
API_VERSION: 'v1'
|
||||
},
|
||||
YTMUSIC: {
|
||||
NAME: 'WEB_REMIX',
|
||||
|
||||
@@ -14,6 +14,14 @@ export const VIDEOS = [
|
||||
{
|
||||
ID: 'OqiXFXlYFi8',
|
||||
QUERY: 'formatted comment text'
|
||||
},
|
||||
{
|
||||
ID: 'O3cCYok_ukk',
|
||||
QUERY: 'upcoming video'
|
||||
},
|
||||
{
|
||||
ID: 'jfKfPfyJRdk',
|
||||
QUERY: 'live video'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -40,10 +40,30 @@ describe('YouTube.js Tests', () => {
|
||||
expect(heatmap).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have watch next feed', () => {
|
||||
expect(info.watch_next_feed).toBeDefined();
|
||||
});
|
||||
|
||||
it('should retrieve basic video info', async () => {
|
||||
const b_info = await yt.getBasicInfo(VIDEOS[0].ID);
|
||||
expect(b_info.basic_info.id).toBe(VIDEOS[0].ID);
|
||||
});
|
||||
|
||||
it('should be upcoming', async () => {
|
||||
const b_info = await yt.getBasicInfo(VIDEOS[4].ID);
|
||||
expect(b_info.basic_info.is_upcoming).toBe(true);
|
||||
});
|
||||
|
||||
it('should be live', async () => {
|
||||
const b_info = await yt.getBasicInfo(VIDEOS[5].ID);
|
||||
expect(b_info.basic_info.is_live).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract live stream start timestamp', async () => {
|
||||
const b_info = await yt.getBasicInfo(VIDEOS[4].ID);
|
||||
expect(b_info.basic_info.start_timestamp).not.toBeNull()
|
||||
expect(b_info.basic_info.start_timestamp!.toISOString()).toBe('2024-03-30T23:00:00.000Z');
|
||||
})
|
||||
});
|
||||
|
||||
describe('Search', () => {
|
||||
@@ -121,6 +141,11 @@ describe('YouTube.js Tests', () => {
|
||||
expect(nop_yt.session.player).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create a session from data generated locally', async () => {
|
||||
const loc_yt = await Innertube.create({ generate_session_locally: true, retrieve_player: false });
|
||||
expect(loc_yt.session.context).toBeDefined();
|
||||
});
|
||||
|
||||
it('should resolve a URL', async () => {
|
||||
const url = await yt.resolveURL('https://www.youtube.com/@linustechtips');
|
||||
expect(url.payload.browseId).toBe(CHANNELS[0].ID);
|
||||
|
||||
Reference in New Issue
Block a user