Compare commits

..

10 Commits

Author SHA1 Message Date
LuanRT
56e6e23453 chore(release): v2.8.0 2023-01-06 03:18:17 -03:00
LuanRT
00fa514b03 feat: add support for generating sessions locally (#277)
* feat: add visitor data proto

* feat: add support for generating session data locally

* chore: add test
2023-01-06 03:06:49 -03:00
LuanRT
d36389c865 refactor(VideoInfo): simplify watch next feed extraction 2023-01-05 21:44:56 -03:00
LuanRT
55ca986888 chore: use optional chaining to avoid problems 2023-01-05 21:34:04 -03:00
LuanRT
b04df7e119 chore: lint 2023-01-05 21:22:50 -03:00
LuanRT
d8d92866d1 fix(Format): some types were incorrect 2023-01-05 20:56:55 -03:00
LuanRT
b4b0731589 refactor: remove unneeded check when generating search filter params
YouTube doesn't do this so I don't see why we should.
2023-01-05 20:32:14 -03:00
LuanRT
d69d701869 fix(VideoInfo): watch next feed not being parsed when logged out (#276) 2023-01-05 19:09:16 -03:00
absidue
cd4d28c951 feat: add live stream start_timestamp (#275) 2023-01-05 17:35:39 -03:00
absidue
22b9c174bb feat: add is_live and is_upcoming to VideoDetails (#271)
* feat: add is_live and is_upcoming to VideoDetails

* chore: add tests
2023-01-03 20:52:05 -03:00
15 changed files with 397 additions and 144 deletions

4
package-lock.json generated
View File

@@ -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"
],

View File

@@ -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",

View File

@@ -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> {

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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) => {

View File

@@ -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}`

View File

@@ -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];
}

View File

@@ -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 {

View File

@@ -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", [

View File

@@ -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',

View File

@@ -14,6 +14,14 @@ export const VIDEOS = [
{
ID: 'OqiXFXlYFi8',
QUERY: 'formatted comment text'
},
{
ID: 'O3cCYok_ukk',
QUERY: 'upcoming video'
},
{
ID: 'jfKfPfyJRdk',
QUERY: 'live video'
}
];

View File

@@ -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);