diff --git a/README.md b/README.md index 35f616fa..6a902b93 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/deno/package.json b/deno/package.json index 247e42fb..9537af69 100644 --- a/deno/package.json +++ b/deno/package.json @@ -1,6 +1,6 @@ { "name": "youtubei.js", - "version": "4.0.1", + "version": "4.1.0", "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", diff --git a/deno/src/core/Session.ts b/deno/src/core/Session.ts index 9b1356f3..a11d2ba1 100644 --- a/deno/src/core/Session.ts +++ b/deno/src/core/Session.ts @@ -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 { 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, diff --git a/deno/src/parser/README.md b/deno/src/parser/README.md index fb316e8e..108e1d95 100644 --- a/deno/src/parser/README.md +++ b/deno/src/parser/README.md @@ -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 diff --git a/deno/src/parser/classes/ShowingResultsFor.ts b/deno/src/parser/classes/ShowingResultsFor.ts index 6c45a709..29b6bee6 100644 --- a/deno/src/parser/classes/ShowingResultsFor.ts +++ b/deno/src/parser/classes/ShowingResultsFor.ts @@ -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; \ No newline at end of file +} \ No newline at end of file diff --git a/deno/src/parser/classes/Video.ts b/deno/src/parser/classes/Video.ts index c8e42087..49b04e18 100644 --- a/deno/src/parser/classes/Video.ts +++ b/deno/src/parser/classes/Video.ts @@ -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 { diff --git a/deno/src/platform/node.ts b/deno/src/platform/node.ts index 5b4b3574..d54d0e45 100644 --- a/deno/src/platform/node.ts +++ b/deno/src/platform/node.ts @@ -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; \ No newline at end of file +export default Innertube; diff --git a/deno/src/proto/index.ts b/deno/src/proto/index.ts index 628c643c..397e059f 100644 --- a/deno/src/proto/index.ts +++ b/deno/src/proto/index.ts @@ -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: { diff --git a/deno/src/utils/FormatUtils.ts b/deno/src/utils/FormatUtils.ts index 1f1ae95c..f25c1095 100644 --- a/deno/src/utils/FormatUtils.ts +++ b/deno/src/utils/FormatUtils.ts @@ -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() diff --git a/deno/src/utils/HTTPClient.ts b/deno/src/utils/HTTPClient.ts index 52c5e6ca..0d8d03e3 100644 --- a/deno/src/utils/HTTPClient.ts +++ b/deno/src/utils/HTTPClient.ts @@ -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 }; diff --git a/deno/src/utils/Utils.ts b/deno/src/utils/Utils.ts index 488d28f7..99ca3ade 100644 --- a/deno/src/utils/Utils.ts +++ b/deno/src/utils/Utils.ts @@ -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 { @@ -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))); } \ No newline at end of file