Compare commits

...

2 Commits

Author SHA1 Message Date
LuanRT
9014ac0395 chore: v4.1.1 release 2023-03-29 01:30:16 +00:00
LuanRT
31a7de6437 chore: v4.1.0 release 2023-03-24 04:35:40 +00:00
15 changed files with 113 additions and 58 deletions

View File

@@ -130,6 +130,7 @@ const youtube = await Innertube.create(/* options */);
| `lang` | `string` | Language. | `en` |
| `location` | `string` | Geolocation. | `US` |
| `account_index` | `number` | 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. | `0` |
| `visitor_data` | `string` | Setting this to a valid and persistent visitor data string will allow YouTube to give this session tailored content even when not logged in. A good way to get a valid one is by either grabbing it from a browser or calling InnerTube's `/visitor_id` endpoint. | `undefined` |
| `retrieve_player` | `boolean` | Specifies whether to retrieve the JS player. Disabling this will make session creation faster. **NOTE:** Deciphering formats is not possible without the JS player. | `true` |
| `enable_safety_mode` | `boolean` | Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content. | `false` |
| `generate_session_locally` | `boolean` | Specifies whether to generate the session data locally or retrieve it from YouTube. This can be useful if you need more performance. | `false` |

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "4.0.1",
"version": "4.1.1",
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",

View File

@@ -3,12 +3,12 @@ import EventEmitterLike from '../utils/EventEmitterLike.ts';
import Actions from './Actions.ts';
import Player from './Player.ts';
import HTTPClient from '../utils/HTTPClient.ts';
import { Platform, DeviceCategory, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils.ts';
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth.ts';
import Proto from '../proto/index.ts';
import { ICache } from '../types/Cache.ts';
import { FetchFunction } from '../types/PlatformShim.ts';
import HTTPClient from '../utils/HTTPClient.ts';
import { DeviceCategory, getRandomUserAgent, InnertubeError, Platform, SessionError } from '../utils/Utils.ts';
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth.ts';
export enum ClientType {
WEB = 'WEB',
@@ -118,6 +118,11 @@ export interface SessionOptions {
* YouTube cookies.
*/
cookie?: string;
/**
* Setting this to a valid and persistent visitor data string will allow YouTube to give this session tailored content even when not logged in.
* A good way to get a valid one is by either grabbing it from a browser or calling InnerTube's `/visitor_id` endpoint.
*/
visitor_data?: string;
/**
* Fetch function to use.
*/
@@ -179,6 +184,7 @@ export default class Session extends EventEmitterLike {
options.lang,
options.location,
options.account_index,
options.visitor_data,
options.enable_safety_mode,
options.generate_session_locally,
options.device_category,
@@ -198,6 +204,7 @@ export default class Session extends EventEmitterLike {
lang = '',
location = '',
account_index = 0,
visitor_data = '',
enable_safety_mode = false,
generate_session_locally = false,
device_category: DeviceCategory = 'desktop',
@@ -208,9 +215,9 @@ export default class Session extends EventEmitterLike {
let session_data: SessionData;
if (generate_session_locally) {
session_data = this.#generateSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode });
session_data = this.#generateSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data });
} else {
session_data = await this.#retrieveSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode }, fetch);
session_data = await this.#retrieveSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data }, fetch);
}
return { ...session_data, account_index };
@@ -223,16 +230,24 @@ export default class Session extends EventEmitterLike {
device_category: string;
client_name: string;
enable_safety_mode: boolean;
visitor_data: string;
}, fetch: FetchFunction = Platform.shim.fetch): Promise<SessionData> {
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
let visitor_id = Constants.CLIENTS.WEB.STATIC_VISITOR_ID;
if (options.visitor_data) {
const decoded_visitor_data = Proto.decodeVisitorData(options.visitor_data);
visitor_id = decoded_visitor_data.id;
}
const res = await fetch(url, {
headers: {
'accept-language': options.lang || 'en-US',
'user-agent': getRandomUserAgent('desktop'),
'accept': '*/*',
'referer': 'https://www.youtube.com/sw.js',
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')};VISITOR_INFO1_LIVE=${Constants.CLIENTS.WEB.STATIC_VISITOR_ID};`
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')};VISITOR_INFO1_LIVE=${visitor_id};`
}
});
@@ -292,10 +307,15 @@ export default class Session extends EventEmitterLike {
time_zone: string;
device_category: DeviceCategory;
client_name: string;
enable_safety_mode: boolean
enable_safety_mode: boolean;
visitor_data: string;
}): SessionData {
const id = Constants.CLIENTS.WEB.STATIC_VISITOR_ID;
const timestamp = Math.floor(Date.now() / 1000);
let visitor_id = Constants.CLIENTS.WEB.STATIC_VISITOR_ID;
if (options.visitor_data) {
const decoded_visitor_data = Proto.decodeVisitorData(options.visitor_data);
visitor_id = decoded_visitor_data.id;
}
const context: Context = {
client: {
@@ -305,7 +325,7 @@ export default class Session extends EventEmitterLike {
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: Proto.encodeVisitorData(id, timestamp),
visitorData: Proto.encodeVisitorData(visitor_id, Math.floor(Date.now() / 1000)),
userAgent: getRandomUserAgent('desktop'),
clientName: options.client_name,
clientVersion: CLIENTS.WEB.VERSION,

View File

@@ -32,7 +32,7 @@ The parser is responsible for sanitizing and standardizing InnerTube responses w
* [`helpers.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/helpers.ts) - Helper functions/classes for the parser.
* [`parser.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/parser.ts) - The core of the parser.
* [`nodes.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/nodes.ts) - Contains a list of all the InnerTube nodes, which is used to determine the appropriate node for a given renderer. It's important to note that this file is automatically generated and should not be edited manually.
* [`misc.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/misc.ts) - Miscellaneous classes for. Also automatically generated.
* [`misc.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/misc.ts) - Miscellaneous classes. Also automatically generated.
### Clients

View File

@@ -1,10 +1,11 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
import { RawNode } from '../index.ts';
class PlayerCaptionsTracklist extends YTNode {
static type = 'PlayerCaptionsTracklist';
caption_tracks: {
caption_tracks?: {
base_url: string;
name: Text;
vss_id: string;
@@ -13,7 +14,7 @@ class PlayerCaptionsTracklist extends YTNode {
is_translatable: boolean;
}[];
audio_tracks: {
audio_tracks?: {
audio_track_id: string;
captions_initial_state: string;
default_caption_track_index: number;
@@ -22,39 +23,48 @@ class PlayerCaptionsTracklist extends YTNode {
caption_track_indices: number;
}[];
default_audio_track_index: number;
default_audio_track_index?: number;
translation_languages: {
translation_languages?: {
language_code: string;
language_name: Text;
}[];
constructor(data: any) {
constructor(data: RawNode) {
super();
this.caption_tracks = data.captionTracks.map((ct: any) => ({
base_url: ct.baseUrl,
name: new Text(ct.name),
vss_id: ct.vssId,
language_code: ct.languageCode,
kind: ct.kind,
is_translatable: ct.isTranslatable
}));
this.audio_tracks = data.audioTracks.map((at: any) => ({
audio_track_id: at.audioTrackId,
captions_initial_state: at.captionsInitialState,
default_caption_track_index: at.defaultCaptionTrackIndex,
has_default_track: at.hasDefaultTrack,
visibility: at.visibility,
caption_track_indices: at.captionTrackIndices
}));
if (Reflect.has(data, 'captionTracks')) {
this.caption_tracks = data.captionTracks.map((ct: any) => ({
base_url: ct.baseUrl,
name: new Text(ct.name),
vss_id: ct.vssId,
language_code: ct.languageCode,
kind: ct.kind,
is_translatable: ct.isTranslatable
}));
}
this.default_audio_track_index = data.defaultAudioTrackIndex;
if (Reflect.has(data, 'audioTracks')) {
this.audio_tracks = data.audioTracks.map((at: any) => ({
audio_track_id: at.audioTrackId,
captions_initial_state: at.captionsInitialState,
default_caption_track_index: at.defaultCaptionTrackIndex,
has_default_track: at.hasDefaultTrack,
visibility: at.visibility,
caption_track_indices: at.captionTrackIndices
}));
}
this.translation_languages = data.translationLanguages.map((tl: any) => ({
language_code: tl.languageCode,
language_name: new Text(tl.languageName)
}));
if (Reflect.has(data, 'defaultAudioTrackIndex')) {
this.default_audio_track_index = data.defaultAudioTrackIndex;
}
if (Reflect.has(data, 'translationLanguages')) {
this.translation_languages = data.translationLanguages.map((tl: any) => ({
language_code: tl.languageCode,
language_name: new Text(tl.languageName)
}));
}
}
}

View File

@@ -1,20 +1,25 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class ShowingResultsFor extends YTNode {
export default class ShowingResultsFor extends YTNode {
static type = 'ShowingResultsFor';
corrected_query: Text;
endpoint: NavigationEndpoint;
original_query: Text;
corrected_query_endpoint: NavigationEndpoint;
original_query_endpoint: NavigationEndpoint;
search_instead_for: Text;
showing_results_for: Text;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.corrected_query = new Text(data.correctedQuery);
this.endpoint = new NavigationEndpoint(data.correctedQueryEndpoint);
this.original_query = new Text(data.originalQuery);
this.corrected_query_endpoint = new NavigationEndpoint(data.correctedQueryEndpoint);
this.original_query_endpoint = new NavigationEndpoint(data.originalQueryEndpoint);
this.search_instead_for = new Text(data.searchInsteadFor);
this.showing_results_for = new Text(data.showingResultsFor);
}
}
export default ShowingResultsFor;
}

View File

@@ -6,6 +6,7 @@ import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import MetadataBadge from './MetadataBadge.ts';
import ExpandableMetadata from './ExpandableMetadata.ts';
import ThumbnailOverlayTimeStatus from './ThumbnailOverlayTimeStatus.ts';
import { timeToSeconds } from '../../utils/Utils.ts';
import { YTNode } from '../helpers.ts';
@@ -98,7 +99,7 @@ class Video extends YTNode {
return this.badges.some((badge) => {
if (badge.style === 'BADGE_STYLE_TYPE_LIVE_NOW' || badge.label === 'LIVE')
return true;
});
}) || this.thumbnail_overlays.firstOfType(ThumbnailOverlayTimeStatus)?.style === 'LIVE';
}
get is_upcoming(): boolean | undefined {

View File

@@ -28,8 +28,8 @@ class Library extends Feed<IBrowseResponse> {
if (!this.page.contents_memo)
throw new InnertubeError('Page contents not found');
const stats = this.page.contents_memo.getType(ProfileColumnStats)?.[0];
const user_info = this.page.contents_memo.getType(ProfileColumnUserInfo)?.[0];
const stats = this.page.contents_memo.getType(ProfileColumnStats).first();
const user_info = this.page.contents_memo.getType(ProfileColumnUserInfo).first();
this.profile = { stats, user_info };

View File

@@ -30,7 +30,7 @@ class Search extends Feed<ISearchResponse> {
if (!contents)
throw new InnertubeError('No contents found in search response');
this.results = contents.firstOfType(ItemSection)?.contents;
this.results = contents.filterType(ItemSection).find((section) => section.contents && section.contents.length > 0)?.contents;
this.refinements = this.page.refinements || [];
this.estimated_results = this.page.estimated_results;

View File

@@ -13,7 +13,7 @@ class TimeWatched {
contents?: ObservedArray<ItemSection>;
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse(response.data);
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
if (!this.#page.contents)
throw new InnertubeError('Page contents not found');

View File

@@ -26,6 +26,7 @@ const is_cjs = !meta_url;
const __dirname__ = is_cjs ? __dirname : path.dirname(fileURLToPath(meta_url));
const package_json = JSON.parse(readFileSync(path.resolve(__dirname__, is_cjs ? '../package.json' : '../../package.json'), 'utf-8'));
const repo_url = package_json.homepage?.split('#')[0];
class Cache implements ICache {
#persistent_directory: string;
@@ -102,8 +103,8 @@ Platform.load({
runtime: 'node',
info: {
version: package_json.version,
bugs_url: package_json.bugs.url,
repo_url: package_json.homepage.split('#')[0]
bugs_url: package_json.bugs?.url || `${repo_url}/issues`,
repo_url
},
server: true,
Cache: Cache,
@@ -130,4 +131,4 @@ Platform.load({
export * from './lib.ts';
import Innertube from './lib.ts';
export default Innertube;
export default Innertube;

View File

@@ -1,5 +1,5 @@
import { CLIENTS } from '../utils/Constants.ts';
import { u8ToBase64 } from '../utils/Utils.ts';
import { base64ToU8, u8ToBase64 } from '../utils/Utils.ts';
import { VideoMetadata } from '../core/Studio.ts';
import * as VisitorData from './generated/messages/youtube/VisitorData.ts';
@@ -21,6 +21,11 @@ class Proto {
return encodeURIComponent(u8ToBase64(buf).replace(/\+/g, '-').replace(/\//g, '_'));
}
static decodeVisitorData(visitor_data: string): VisitorData.Type {
const data = VisitorData.decodeBinary(base64ToU8(decodeURIComponent(visitor_data)));
return data;
}
static encodeChannelAnalyticsParams(channel_id: string): string {
const buf = ChannelAnalytics.encodeBinary({
params: {

View File

@@ -428,8 +428,15 @@ class FormatUtils {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
let id;
if (format.audio_track) {
id = `${format.itag?.toString()}-${format.audio_track.id}`;
} else {
id = format.itag?.toString();
}
const representation = this.#el(document, 'Representation', {
id: format.itag?.toString(),
id,
codecs,
bandwidth: format.bitrate?.toString(),
audioSamplingRate: format.audio_sample_rate?.toString()

View File

@@ -169,6 +169,7 @@ export default class HTTPClient {
ctx.client.androidSdkVersion = Constants.CLIENTS.ANDROID.SDK_VERSION;
break;
case 'TV_EMBEDDED':
ctx.client.clientName = Constants.CLIENTS.TV_EMBEDDED.NAME;
ctx.client.clientVersion = Constants.CLIENTS.TV_EMBEDDED.VERSION;
ctx.client.clientScreen = 'EMBED';
ctx.thirdParty = { embedUrl: Constants.URLS.YT_BASE };

View File

@@ -82,7 +82,7 @@ export function getRandomUserAgent(type: DeviceCategory): string {
}
/**
* Generates an authentication token from a cookies' sid..js
* Generates an authentication token from a cookies' sid.
* @param sid - Sid extracted from cookies
*/
export async function generateSidAuth(sid: string): Promise<string> {
@@ -116,7 +116,7 @@ export function generateRandomString(length: number): string {
* @returns seconds
*/
export function timeToSeconds(time: string): number {
const params = time.split(':').map((param) => parseInt(param));
const params = time.split(':').map((param) => parseInt(param.replace(/\D/g, '')));
switch (params.length) {
case 1:
return params[0];
@@ -217,4 +217,8 @@ export const debugFetch: FetchFunction = (input, init) => {
export function u8ToBase64(u8: Uint8Array): string {
return btoa(String.fromCharCode.apply(null, Array.from(u8)));
}
export function base64ToU8(base64: string): Uint8Array {
return new Uint8Array(atob(base64).split('').map((char) => char.charCodeAt(0)));
}