Compare commits

...

16 Commits

Author SHA1 Message Date
github-actions[bot]
7d03469e64 chore(main): release 10.1.0 (#669)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-07-10 03:44:43 -03:00
Luan
62ac2f6f32 fix(proto): Update Context message
Closes #681
2024-07-10 03:41:16 -03:00
absidue
142a7d0428 fix(Player): Fix extracting the n-token decipher algorithm (#682)
* fix(Player): Fix extracting the n-token decipher algorithm

* fix: bump Jinter to v2

---------

Co-authored-by: Luan <luan.lrt4@gmail.com>
2024-07-10 02:21:39 -03:00
Luan
efa7205723 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-07-01 08:53:45 -03:00
Luan
84f90aaf29 fix(Session): Round UTC offset minutes 2024-07-01 08:53:08 -03:00
absidue
858cdd197c feat(toDash): Add the "dub" role to translated captions (#677) 2024-06-30 23:05:08 -03:00
Luan
5a8fd3ad37 feat(Session): Add configInfo to InnerTube context
Minor addition. It's needed for certain UMP requests.
2024-06-30 22:51:02 -03:00
슈리튬
a19511de24 fix(FormatUtils): Throw an error if download requests fails 2024-06-28 16:45:39 -03:00
absidue
bd9f6ac64c feat(toDash): Add option to include WebVTT or TTML captions (#673) 2024-06-25 01:22:11 -03:00
absidue
e5aab9a9b3 fix(toDash): Fix image representations not being spec compliant (#672) 2024-06-24 15:48:38 -03:00
Luan
d6fa134c3d chore(Playlist): Add MusicResponsiveHeader to header types
Oops! I forgot this one also existed : ).
2024-06-21 21:12:54 -03:00
Luan
fe953072a2 chore: fix tests 2024-06-21 20:57:38 -03:00
Luan
055fa33403 chore: lint 2024-06-21 19:32:50 -03:00
Luan
14c3a06d40 fix(YTMusic): Add support for new header layouts
This is due to a minor page redesign by YouTube Music. See https://9to5google.com/2024/06/20/youtube-music-web-album-playlist-redesign/.
2024-06-21 19:31:40 -03:00
Luan
67376afae6 chore(Format): Clean up and add some extra fields 2024-06-16 16:22:33 -03:00
Luan
4cbaa7983f fix(InfoPanelContent): Update InfoPanelContent node to also support paragraphs
This would fail when `attributedParagraphs` was missing, so we still need `paragraphs` there.
2024-06-16 15:39:47 -03:00
28 changed files with 622 additions and 168 deletions

View File

@@ -1,5 +1,25 @@
# Changelog
## [10.1.0](https://github.com/LuanRT/YouTube.js/compare/v10.0.0...v10.1.0) (2024-07-10)
### Features
* **Session:** Add `configInfo` to InnerTube context ([5a8fd3a](https://github.com/LuanRT/YouTube.js/commit/5a8fd3ad37bce1decad28ec3727453ddd430a561))
* **toDash:** Add option to include WebVTT or TTML captions ([#673](https://github.com/LuanRT/YouTube.js/issues/673)) ([bd9f6ac](https://github.com/LuanRT/YouTube.js/commit/bd9f6ac64ca9ba96e856aabe5fcc175fd9c294dc))
* **toDash:** Add the "dub" role to translated captions ([#677](https://github.com/LuanRT/YouTube.js/issues/677)) ([858cdd1](https://github.com/LuanRT/YouTube.js/commit/858cdd197cb2bb1e1d7a7285966cb56043ad8961))
### Bug Fixes
* **FormatUtils:** Throw an error if download requests fails ([a19511d](https://github.com/LuanRT/YouTube.js/commit/a19511de24bb82007aab072844efe64bbb8698da))
* **InfoPanelContent:** Update InfoPanelContent node to also support `paragraphs` ([4cbaa79](https://github.com/LuanRT/YouTube.js/commit/4cbaa7983f35a82b9907197769672ac3b300bfbe))
* **Player:** Fix extracting the n-token decipher algorithm ([#682](https://github.com/LuanRT/YouTube.js/issues/682)) ([142a7d0](https://github.com/LuanRT/YouTube.js/commit/142a7d042885188605bdc0655d3733502d1e20fa))
* **proto:** Update `Context` message ([62ac2f6](https://github.com/LuanRT/YouTube.js/commit/62ac2f6f32d35fec3c31b5f5d556bd4569fa49f9)), closes [#681](https://github.com/LuanRT/YouTube.js/issues/681)
* **Session:** Round UTC offset minutes ([84f90aa](https://github.com/LuanRT/YouTube.js/commit/84f90aaf2908ecacb9dfb6ce5497c4c4d14a72c3))
* **toDash:** Fix image representations not being spec compliant ([#672](https://github.com/LuanRT/YouTube.js/issues/672)) ([e5aab9a](https://github.com/LuanRT/YouTube.js/commit/e5aab9a9b35f0752cd5ca50bfa25936dce4718c6))
* **YTMusic:** Add support for new header layouts ([14c3a06](https://github.com/LuanRT/YouTube.js/commit/14c3a06d402989e98a9d32c79b2dc26f74fb0219))
## [10.0.0](https://github.com/LuanRT/YouTube.js/compare/v9.4.0...v10.0.0) (2024-06-09)

12
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "youtubei.js",
"version": "10.0.0",
"version": "10.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "10.0.0",
"version": "10.1.0",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"jintr": "^1.1.0",
"jintr": "^2.0.0",
"tslib": "^2.5.0",
"undici": "^5.19.1"
},
@@ -5829,9 +5829,9 @@
}
},
"node_modules/jintr": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-1.1.0.tgz",
"integrity": "sha512-Tu9wk3BpN2v+kb8yT6YBtue+/nbjeLFv4vvVC4PJ7oCidHKbifWhvORrAbQfxVIQZG+67am/mDagpiGSVtvrZg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-2.0.0.tgz",
"integrity": "sha512-RiVlevxttZ4eHEYB2dXKXDXluzHfRuw0DJQGsYuKCc5IvZj5/GbOakeqVX+Bar/G9kTty9xDJREcxukurkmYLA==",
"funding": [
"https://github.com/sponsors/LuanRT"
],

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "10.0.0",
"version": "10.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",
@@ -103,7 +103,7 @@
},
"license": "MIT",
"dependencies": {
"jintr": "^1.1.0",
"jintr": "^2.0.0",
"tslib": "^2.5.0",
"undici": "^5.19.1"
},

View File

@@ -220,12 +220,20 @@ export default class Player {
}
static extractNSigSourceCode(data: string): string {
const sc = `function descramble_nsig(a) { let b=a.split("")${getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join(""); } descramble_nsig(nsig)`;
let sc = getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}');
if (!sc)
Log.warn(TAG, 'Failed to extract n-token decipher algorithm');
if (sc)
return `function descramble_nsig(a) { let b=a.split("")${sc}} return b.join(""); } descramble_nsig(nsig)`;
return sc;
sc = getStringBetweenStrings(data, 'b=String.prototype.split.call(a,"")', '}return Array.prototype.join.call(b,"")}');
if (sc)
return `function descramble_nsig(a) { let b=String.prototype.split.call(a, "")${sc}} return Array.prototype.join.call(b, ""); } descramble_nsig(nsig)`;
// We really should throw an error here to avoid errors later, returning a pass-through function for backwards-compatibility
Log.warn(TAG, 'Failed to extract n-token decipher algorithm');
return 'function descramble_nsig(a) { return a; } descramble_nsig(nsig)';
}
get url(): string {

View File

@@ -59,6 +59,9 @@ export type Context = {
isWebNativeShareAvailable: boolean;
};
memoryTotalKbytes?: string;
configInfo?: {
appInstallData: string;
},
kidsAppInfo?: {
categorySettings: {
enabledCategories: string[];
@@ -97,6 +100,7 @@ type ContextData = {
enable_safety_mode: boolean;
browser_name?: string;
browser_version?: string;
app_install_data?: string;
device_make: string;
device_model: string;
on_behalf_of_user?: string;
@@ -435,6 +439,9 @@ export default class Session extends EventEmitter {
const [ [ device_info ], api_key ] = ytcfg;
const config_info = device_info[61];
const app_install_data = config_info[config_info.length - 1];
const context_info = {
hl: options.lang || device_info[0],
gl: options.location || device_info[2],
@@ -450,6 +457,7 @@ export default class Session extends EventEmitter {
browser_version: device_info[87],
device_make: device_info[11],
device_model: device_info[12],
app_install_data: app_install_data,
enable_safety_mode: options.enable_safety_mode
};
@@ -480,7 +488,7 @@ export default class Session extends EventEmitter {
deviceModel: args.device_model,
browserName: args.browser_name,
browserVersion: args.browser_version,
utcOffsetMinutes: -new Date().getTimezoneOffset(),
utcOffsetMinutes: -Math.floor((new Date()).getTimezoneOffset()),
memoryTotalKbytes: '8000000',
mainAppWebInfo: {
graftUrl: Constants.URLS.YT_BASE,
@@ -499,6 +507,9 @@ export default class Session extends EventEmitter {
}
};
if (args.app_install_data)
context.client.configInfo = { appInstallData: args.app_install_data };
if (args.on_behalf_of_user)
context.user.onBehalfOfUser = args.on_behalf_of_user;

View File

@@ -54,11 +54,16 @@ export default class MediaInfo {
}
let storyboards;
let captions;
if (options.include_thumbnails && player_response.storyboards) {
storyboards = player_response.storyboards;
}
if (typeof options.captions_format === 'string' && player_response.captions?.caption_tracks) {
captions = player_response.captions.caption_tracks;
}
return FormatUtils.toDash(
this.streaming_data,
this.page[0].video_details?.is_post_live_dvr,
@@ -68,6 +73,7 @@ export default class MediaInfo {
this.#actions.session.player,
this.#actions,
storyboards,
captions,
options
);
}

View File

@@ -10,7 +10,8 @@ export default class InfoPanelContent extends YTNode {
title: Text;
source: Text;
attributed_paragraphs: Text[];
paragraphs?: Text[];
attributed_paragraphs?: Text[];
thumbnail: Thumbnail[];
source_endpoint: NavigationEndpoint;
truncate_paragraphs: boolean;
@@ -21,7 +22,13 @@ export default class InfoPanelContent extends YTNode {
super();
this.title = new Text(data.title);
this.source = new Text(data.source);
this.attributed_paragraphs = data.attributedParagraphs.map((p: AttributedText) => Text.fromAttributed(p));
if (Reflect.has(data, 'paragraphs'))
this.paragraphs = data.paragraphs.map((p: RawNode) => new Text(p));
if (Reflect.has(data, 'attributedParagraphs'))
this.attributed_paragraphs = data.attributedParagraphs.map((p: AttributedText) => Text.fromAttributed(p));
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
this.source_endpoint = new NavigationEndpoint(data.sourceEndpoint);
this.truncate_paragraphs = !!data.truncateParagraphs;

View File

@@ -5,11 +5,13 @@ export default class MusicEditablePlaylistDetailHeader extends YTNode {
static type = 'MusicEditablePlaylistDetailHeader';
header: YTNode;
edit_header: YTNode;
playlist_id: string;
constructor(data: RawNode) {
super();
this.header = Parser.parseItem(data.header);
// TODO: Parse data.editHeader.musicPlaylistEditHeaderRenderer.
this.edit_header = Parser.parseItem(data.editHeader);
this.playlist_id = data.playlistId;
}
}

View File

@@ -0,0 +1,28 @@
import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Dropdown from './Dropdown.js';
import Text from './misc/Text.js';
export default class MusicPlaylistEditHeader extends YTNode {
static type = 'MusicPlaylistEditHeader';
title: Text;
edit_title: Text;
edit_description: Text;
privacy: string;
playlist_id: string;
endpoint: NavigationEndpoint;
privacy_dropdown: Dropdown | null;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.edit_title = new Text(data.editTitle);
this.edit_description = new Text(data.editDescription);
this.privacy = data.privacy;
this.playlist_id = data.playlistId;
this.endpoint = new NavigationEndpoint(data.collaborationSettingsCommand);
this.privacy_dropdown = Parser.parseItem(data.privacyDropdown, Dropdown);
}
}

View File

@@ -7,6 +7,8 @@ import MusicPlayButton from './MusicPlayButton.js';
import ToggleButton from './ToggleButton.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import Button from './Button.js';
import DownloadButton from './DownloadButton.js';
import type { ObservedArray } from '../helpers.js';
@@ -14,7 +16,7 @@ export default class MusicResponsiveHeader extends YTNode {
static type = 'MusicResponsiveHeader';
thumbnail: MusicThumbnail | null;
buttons: ObservedArray<ToggleButton | MusicPlayButton | Menu> | null;
buttons: ObservedArray<DownloadButton | ToggleButton | MusicPlayButton | Button | Menu>;
title: Text;
subtitle: Text;
strapline_text_one: Text;
@@ -26,7 +28,7 @@ export default class MusicResponsiveHeader extends YTNode {
constructor(data: RawNode) {
super();
this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
this.buttons = Parser.parseArray(data.buttons, [ ToggleButton, MusicPlayButton, Menu ]);
this.buttons = Parser.parseArray(data.buttons, [ DownloadButton, ToggleButton, MusicPlayButton, Button, Menu ]);
this.title = new Text(data.title);
this.subtitle = new Text(data.subtitle);
this.strapline_text_one = new Text(data.straplineTextOne);

View File

@@ -2,17 +2,19 @@ import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export interface CaptionTrackData {
base_url: string;
name: Text;
vss_id: string;
language_code: string;
kind?: 'asr' | 'frc';
is_translatable: boolean;
}
export default class PlayerCaptionsTracklist extends YTNode {
static type = 'PlayerCaptionsTracklist';
caption_tracks?: {
base_url: string;
name: Text;
vss_id: string;
language_code: string;
kind?: 'asr' | 'frc';
is_translatable: boolean;
}[];
caption_tracks?: CaptionTrackData[];
audio_tracks?: {
audio_track_id: string;

View File

@@ -6,46 +6,51 @@ export default class Format {
#this_response_nsig_cache?: Map<string, string>;
itag: number;
url?: string;
width?: number;
height?: number;
last_modified: Date;
content_length?: number;
quality?: string;
xtags?: string;
drm_families?: string[];
fps?: number;
quality_label?: string;
projection_type?: 'RECTANGULAR' | 'EQUIRECTANGULAR' | 'EQUIRECTANGULAR_THREED_TOP_BOTTOM' | 'MESH';
average_bitrate?: number;
bitrate: number;
spatial_audio_type?: 'AMBISONICS_5_1' | 'AMBISONICS_QUAD' | 'FOA_WITH_NON_DIEGETIC';
target_duration_dec?: number;
fair_play_key_uri?: string;
stereo_layout?: 'LEFT_RIGHT' | 'TOP_BOTTOM';
max_dvr_duration_sec?: number;
high_replication?: boolean;
audio_quality?: string;
approx_duration_ms: number;
audio_sample_rate?: number;
audio_channels?: number;
loudness_db?: number;
signature_cipher?: string;
is_drc?: boolean;
drm_track_type?: string;
distinct_params?: string;
track_absolute_loudness_lkfs?: number;
mime_type: string;
is_type_otf: boolean;
bitrate: number;
average_bitrate?: number;
width: number;
height: number;
projection_type?: 'RECTANGULAR' | 'EQUIRECTANGULAR' | 'EQUIRECTANGULAR_THREED_TOP_BOTTOM' | 'MESH';
stereo_layout?: 'LEFT_RIGHT' | 'TOP_BOTTOM';
init_range?: {
start: number;
end: number;
};
index_range?: {
start: number;
end: number;
};
last_modified: Date;
content_length?: number;
quality?: string;
quality_label?: string;
fps?: number;
url?: string;
cipher?: string;
signature_cipher?: string;
audio_quality?: string;
audio_track?: {
audio_is_default: boolean;
display_name: string;
id: string;
};
approx_duration_ms: number;
audio_sample_rate?: number;
audio_channels?: number;
loudness_db?: number;
spatial_audio_type?: 'AMBISONICS_5_1' | 'AMBISONICS_QUAD' | 'FOA_WITH_NON_DIEGETIC';
max_dvr_duration_sec?: number;
target_duration_dec?: number;
has_audio: boolean;
has_video: boolean;
has_text: boolean;
@@ -53,14 +58,11 @@ export default class Format {
is_dubbed?: boolean;
is_descriptive?: boolean;
is_original?: boolean;
is_drc?: boolean;
color_info?: {
primaries?: string;
transfer_characteristics?: string;
matrix_coefficients?: string;
};
caption_track?: {
display_name: string;
vss_id: string;
@@ -79,56 +81,116 @@ export default class Format {
this.is_type_otf = data.type === 'FORMAT_STREAM_TYPE_OTF';
this.bitrate = data.bitrate;
this.average_bitrate = data.averageBitrate;
this.width = data.width;
this.height = data.height;
this.projection_type = data.projectionType;
this.stereo_layout = data.stereoLayout?.replace('STEREO_LAYOUT_', '');
this.init_range = data.initRange ? {
start: parseInt(data.initRange.start),
end: parseInt(data.initRange.end)
} : undefined;
if (Reflect.has(data, 'width') && Reflect.has(data, 'height')) {
this.width = parseInt(data.width);
this.height = parseInt(data.height);
}
this.index_range = data.indexRange ? {
start: parseInt(data.indexRange.start),
end: parseInt(data.indexRange.end)
} : undefined;
if (Reflect.has(data, 'projectionType'))
this.projection_type = data.projectionType;
if (Reflect.has(data, 'stereoLayout'))
this.stereo_layout = data.stereoLayout?.replace('STEREO_LAYOUT_', '');
if (Reflect.has(data, 'initRange'))
this.init_range = {
start: parseInt(data.initRange.start),
end: parseInt(data.initRange.end)
};
if (Reflect.has(data, 'indexRange'))
this.index_range = {
start: parseInt(data.indexRange.start),
end: parseInt(data.indexRange.end)
};
this.last_modified = new Date(Math.floor(parseInt(data.lastModified) / 1000));
this.content_length = parseInt(data.contentLength);
this.quality = data.quality;
this.quality_label = data.qualityLabel;
this.fps = data.fps;
this.url = data.url;
this.cipher = data.cipher;
this.signature_cipher = data.signatureCipher;
this.audio_quality = data.audioQuality;
if (Reflect.has(data, 'contentLength'))
this.content_length = parseInt(data.contentLength);
if (Reflect.has(data, 'quality'))
this.quality = data.quality;
if (Reflect.has(data, 'qualityLabel'))
this.quality_label = data.qualityLabel;
if (Reflect.has(data, 'fps'))
this.fps = data.fps;
if (Reflect.has(data, 'url'))
this.url = data.url;
if (Reflect.has(data, 'cipher'))
this.cipher = data.cipher;
if (Reflect.has(data, 'signatureCipher'))
this.signature_cipher = data.signatureCipher;
if (Reflect.has(data, 'audioQuality'))
this.audio_quality = data.audioQuality;
this.approx_duration_ms = parseInt(data.approxDurationMs);
this.audio_sample_rate = parseInt(data.audioSampleRate);
this.audio_channels = data.audioChannels;
this.loudness_db = data.loudnessDb;
this.spatial_audio_type = data.spatialAudioType?.replace('SPATIAL_AUDIO_TYPE_', '');
this.max_dvr_duration_sec = data.maxDvrDurationSec;
this.target_duration_dec = data.targetDurationSec;
if (Reflect.has(data, 'audioSampleRate'))
this.audio_sample_rate = parseInt(data.audioSampleRate);
if (Reflect.has(data, 'audioChannels'))
this.audio_channels = data.audioChannels;
if (Reflect.has(data, 'loudnessDb'))
this.loudness_db = data.loudnessDb;
if (Reflect.has(data, 'spatialAudioType'))
this.spatial_audio_type = data.spatialAudioType?.replace('SPATIAL_AUDIO_TYPE_', '');
if (Reflect.has(data, 'maxDvrDurationSec'))
this.max_dvr_duration_sec = data.maxDvrDurationSec;
if (Reflect.has(data, 'targetDurationSec'))
this.target_duration_dec = data.targetDurationSec;
this.has_audio = !!data.audioBitrate || !!data.audioQuality;
this.has_video = !!data.qualityLabel;
this.has_text = !!data.captionTrack;
this.color_info = data.colorInfo ? {
primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''),
transfer_characteristics: data.colorInfo.transferCharacteristics?.replace('COLOR_TRANSFER_CHARACTERISTICS_', ''),
matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '')
} : undefined;
if (Reflect.has(data, 'xtags'))
this.xtags = data.xtags;
if (Reflect.has(data, 'audioTrack')) {
if (Reflect.has(data, 'fairPlayKeyUri'))
this.fair_play_key_uri = data.fairPlayKeyUri;
if (Reflect.has(data, 'drmFamilies'))
this.drm_families = data.drmFamilies;
if (Reflect.has(data, 'drmTrackType'))
this.drm_track_type = data.drmTrackType;
if (Reflect.has(data, 'distinctParams'))
this.distinct_params = data.distinctParams;
if (Reflect.has(data, 'trackAbsoluteLoudnessLkfs'))
this.track_absolute_loudness_lkfs = data.trackAbsoluteLoudnessLkfs;
if (Reflect.has(data, 'highReplication'))
this.high_replication = data.highReplication;
if (Reflect.has(data, 'colorInfo'))
this.color_info = {
primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''),
transfer_characteristics: data.colorInfo.transferCharacteristics?.replace('COLOR_TRANSFER_CHARACTERISTICS_', ''),
matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '')
};
if (Reflect.has(data, 'audioTrack'))
this.audio_track = {
audio_is_default: data.audioTrack.audioIsDefault,
display_name: data.audioTrack.displayName,
id: data.audioTrack.id
};
}
if (Reflect.has(data, 'captionTrack')) {
if (Reflect.has(data, 'captionTrack'))
this.caption_track = {
display_name: data.captionTrack.displayName,
vss_id: data.captionTrack.vssId,
@@ -136,7 +198,6 @@ export default class Format {
kind: data.captionTrack.kind,
id: data.captionTrack.id
};
}
if (this.has_audio || this.has_text) {
const args = new URLSearchParams(this.cipher || this.signature_cipher);

View File

@@ -261,6 +261,7 @@ export { default as MusicLargeCardItemCarousel } from './classes/MusicLargeCardI
export { default as MusicMultiRowListItem } from './classes/MusicMultiRowListItem.js';
export { default as MusicNavigationButton } from './classes/MusicNavigationButton.js';
export { default as MusicPlayButton } from './classes/MusicPlayButton.js';
export { default as MusicPlaylistEditHeader } from './classes/MusicPlaylistEditHeader.js';
export { default as MusicPlaylistShelf } from './classes/MusicPlaylistShelf.js';
export { default as MusicQueue } from './classes/MusicQueue.js';
export { default as MusicResponsiveHeader } from './classes/MusicResponsiveHeader.js';

View File

@@ -26,6 +26,7 @@ import Format from './classes/misc/Format.js';
import VideoDetails from './classes/misc/VideoDetails.js';
import NavigationEndpoint from './classes/NavigationEndpoint.js';
import CommentView from './classes/comments/CommentView.js';
import MusicThumbnail from './classes/MusicThumbnail.js';
import type { KeyInfo } from './generator.js';
import type { ObservedArray, YTNodeConstructor, YTNode } from './helpers.js';
@@ -367,6 +368,11 @@ export function parseResponse<T extends IParsedResponse = IParsedResponse>(data:
parsed_data.player_overlays = player_overlays;
}
const background = parseItem(data.background, MusicThumbnail);
if (background) {
parsed_data.background = background;
}
const playback_tracking = data.playbackTracking ? {
videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl,
videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl

View File

@@ -19,9 +19,10 @@ import type AlertWithButton from '../classes/AlertWithButton.js';
import type NavigationEndpoint from '../classes/NavigationEndpoint.js';
import type PlayerAnnotationsExpanded from '../classes/PlayerAnnotationsExpanded.js';
import type EngagementPanelSectionList from '../classes/EngagementPanelSectionList.js';
import type { AppendContinuationItemsAction } from '../nodes.js';
import type { AppendContinuationItemsAction, MusicThumbnail } from '../nodes.js';
export interface IParsedResponse {
background?: MusicThumbnail;
actions?: SuperParsedResult<YTNode>;
actions_memo?: Memo;
contents?: SuperParsedResult<YTNode>;
@@ -134,6 +135,7 @@ export interface INextResponse {
}
export interface IBrowseResponse {
background?: MusicThumbnail;
continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation |
MusicShelfContinuation | GridContinuation | PlaylistPanelContinuation;
continuation_contents_memo?: Memo;

View File

@@ -20,6 +20,7 @@ export interface IRawPlayerConfig {
}
export interface IRawResponse {
background?: RawNode;
contents?: RawData;
onResponseReceivedActions?: RawNode[];
onResponseReceivedEndpoints?: RawNode[];

View File

@@ -3,33 +3,35 @@ import { Parser } from '../index.js';
import MicroformatData from '../classes/MicroformatData.js';
import MusicCarouselShelf from '../classes/MusicCarouselShelf.js';
import MusicDetailHeader from '../classes/MusicDetailHeader.js';
import MusicResponsiveHeader from '../classes/MusicResponsiveHeader.js';
import MusicShelf from '../classes/MusicShelf.js';
import type MusicThumbnail from '../classes/MusicThumbnail.js';
import type { ApiResponse } from '../../core/index.js';
import type { ObservedArray } from '../helpers.js';
import { observe, type ObservedArray } from '../helpers.js';
import type { IBrowseResponse } from '../types/index.js';
import type MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js';
class Album {
#page: IBrowseResponse;
header?: MusicDetailHeader;
header?: MusicDetailHeader | MusicResponsiveHeader;
contents: ObservedArray<MusicResponsiveListItem>;
sections: ObservedArray<MusicCarouselShelf>;
url: string | null;
background?: MusicThumbnail;
url?: string;
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
this.header = this.#page.header?.item().as(MusicDetailHeader);
this.url = this.#page.microformat?.as(MicroformatData).url_canonical || null;
if (!this.#page.contents_memo)
throw new Error('No contents found in the response');
this.contents = this.#page.contents_memo.getType(MusicShelf)?.first().contents;
this.sections = this.#page.contents_memo.getType(MusicCarouselShelf) || [];
this.header = this.#page.contents_memo.getType(MusicDetailHeader, MusicResponsiveHeader)?.first();
this.contents = this.#page.contents_memo.getType(MusicShelf)?.first().contents || observe([]);
this.sections = this.#page.contents_memo.getType(MusicCarouselShelf) || observe([]);
this.background = this.#page.background;
this.url = this.#page.microformat?.as(MicroformatData).url_canonical;
}
get page(): IBrowseResponse {

View File

@@ -6,22 +6,25 @@ import MusicEditablePlaylistDetailHeader from '../classes/MusicEditablePlaylistD
import MusicPlaylistShelf from '../classes/MusicPlaylistShelf.js';
import MusicShelf from '../classes/MusicShelf.js';
import SectionList from '../classes/SectionList.js';
import MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js';
import MusicResponsiveHeader from '../classes/MusicResponsiveHeader.js';
import { InnertubeError } from '../../utils/Utils.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import { observe, type ObservedArray } from '../helpers.js';
import type { ApiResponse, Actions } from '../../core/index.js';
import type { IBrowseResponse } from '../types/index.js';
import type MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js';
import type MusicThumbnail from '../classes/MusicThumbnail.js';
class Playlist {
#page: IBrowseResponse;
#actions: Actions;
#continuation: string | null;
#last_fetched_suggestions: any;
#suggestions_continuation: any;
#last_fetched_suggestions: ObservedArray<MusicResponsiveListItem> | null;
#suggestions_continuation: string | null;
header?: MusicDetailHeader;
items?: ObservedArray<YTNode> | null;
header?: MusicResponsiveHeader | MusicDetailHeader | MusicEditablePlaylistDetailHeader;
contents?: ObservedArray<MusicResponsiveListItem>;
background?: MusicThumbnail;
constructor(response: ApiResponse, actions: Actions) {
this.#actions = actions;
@@ -32,16 +35,17 @@ class Playlist {
if (this.#page.continuation_contents) {
const data = this.#page.continuation_contents?.as(MusicPlaylistShelfContinuation);
this.items = data.contents;
if (!data.contents)
throw new InnertubeError('No contents found in the response');
this.contents = data.contents.as(MusicResponsiveListItem);
this.#continuation = data.continuation;
} else {
if (this.#page.header?.item().type === 'MusicEditablePlaylistDetailHeader') {
this.header = this.#page.header?.item().as(MusicEditablePlaylistDetailHeader).header?.as(MusicDetailHeader);
} else {
this.header = this.#page.header?.item().as(MusicDetailHeader);
}
this.items = this.#page.contents_memo?.getType(MusicPlaylistShelf).first().contents || null;
this.#continuation = this.#page.contents_memo?.getType(MusicPlaylistShelf).first().continuation || null;
if (!this.#page.contents_memo)
throw new InnertubeError('No contents found in the response');
this.header = this.#page.contents_memo.getType(MusicResponsiveHeader, MusicEditablePlaylistDetailHeader, MusicDetailHeader)?.first();
this.contents = this.#page.contents_memo.getType(MusicPlaylistShelf)?.first()?.contents || observe([]);
this.background = this.#page.background;
this.#continuation = this.#page.contents_memo.getType(MusicPlaylistShelf)?.first()?.continuation || null;
}
}
@@ -64,7 +68,12 @@ class Playlist {
* Retrieves related playlists
*/
async getRelated(): Promise<MusicCarouselShelf> {
let section_continuation = this.#page.contents_memo?.getType(SectionList)?.[0].continuation;
const target_section_list = this.#page.contents_memo?.getType(SectionList).find((section_list) => section_list.continuation);
if (!target_section_list)
throw new InnertubeError('Could not find "Related" section.');
let section_continuation = target_section_list.continuation;
while (section_continuation) {
const data = await this.#actions.execute('/browse', {
@@ -76,7 +85,7 @@ class Playlist {
const section_list = data.continuation_contents?.as(SectionListContinuation);
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
const related = sections?.matchCondition((section) => section.is(MusicCarouselShelf))?.as(MusicCarouselShelf);
const related = sections?.find((section) => section.is(MusicCarouselShelf))?.as(MusicCarouselShelf);
if (related)
return related;
@@ -84,10 +93,10 @@ class Playlist {
section_continuation = section_list?.continuation;
}
throw new InnertubeError('Target section not found.');
throw new InnertubeError('Could not fetch related playlists.');
}
async getSuggestions(refresh = true) {
async getSuggestions(refresh = true): Promise<ObservedArray<MusicResponsiveListItem>> {
const require_fetch = refresh || !this.#last_fetched_suggestions;
const fetch_promise = require_fetch ? this.#fetchSuggestions() : Promise.resolve(null);
const fetch_result = await fetch_promise;
@@ -97,11 +106,12 @@ class Playlist {
this.#suggestions_continuation = fetch_result.continuation;
}
return fetch_result?.items || this.#last_fetched_suggestions;
return fetch_result?.items || this.#last_fetched_suggestions || observe([]);
}
async #fetchSuggestions(): Promise<{ items: never[] | ObservedArray<MusicResponsiveListItem>, continuation: string | null }> {
const continuation = this.#suggestions_continuation || this.#page.contents_memo?.get('SectionList')?.[0].as(SectionList).continuation;
async #fetchSuggestions(): Promise<{ items: ObservedArray<MusicResponsiveListItem>, continuation: string | null }> {
const target_section_list = this.#page.contents_memo?.getType(SectionList).find((section_list) => section_list.continuation);
const continuation = this.#suggestions_continuation || target_section_list?.continuation;
if (continuation) {
const page = await this.#actions.execute('/browse', {
@@ -113,16 +123,16 @@ class Playlist {
const section_list = page.continuation_contents?.as(SectionListContinuation);
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
const suggestions = sections?.matchCondition((section) => section.is(MusicShelf))?.as(MusicShelf);
const suggestions = sections?.find((section) => section.is(MusicShelf))?.as(MusicShelf);
return {
items: suggestions?.contents || [],
items: suggestions?.contents || observe([]),
continuation: suggestions?.continuation || null
};
}
return {
items: [],
items: observe([]),
continuation: null
};
}
@@ -131,6 +141,10 @@ class Playlist {
return this.#page;
}
get items(): ObservedArray<MusicResponsiveListItem> {
return this.contents || observe([]);
}
get has_continuation(): boolean {
return !!this.#continuation;
}

View File

@@ -7,13 +7,13 @@ const TAG = 'JsRuntime';
export default function evaluate(code: string, env: Record<string, VMPrimative>) {
Log.debug(TAG, 'Evaluating JavaScript:\n', code);
const runtime = new Jinter(code);
const runtime = new Jinter();
for (const [ key, value ] of Object.entries(env)) {
runtime.scope.set(key, value);
}
const result = runtime.interpret();
const result = runtime.evaluate(code);
Log.debug(TAG, 'Done. Result:', result);

View File

@@ -18,9 +18,17 @@ import {
export declare namespace $.youtube.InnertubePayload.Context {
export type Client = {
unkparam: number;
deviceMake: string;
deviceModel: string;
nameId: number;
clientVersion: string;
clientName: string;
osName: string;
osVersion: string;
acceptLanguage: string;
acceptRegion: string;
androidSdkVersion: number;
windowWidthPoints: number;
windowHeightPoints: number;
}
}
@@ -28,9 +36,17 @@ export type Type = $.youtube.InnertubePayload.Context.Client;
export function getDefaultValue(): $.youtube.InnertubePayload.Context.Client {
return {
unkparam: 0,
deviceMake: "",
deviceModel: "",
nameId: 0,
clientVersion: "",
clientName: "",
osName: "",
osVersion: "",
acceptLanguage: "",
acceptRegion: "",
androidSdkVersion: 0,
windowWidthPoints: 0,
windowHeightPoints: 0,
};
}
@@ -43,24 +59,52 @@ export function createValue(partialValue: Partial<$.youtube.InnertubePayload.Con
export function encodeJson(value: $.youtube.InnertubePayload.Context.Client): unknown {
const result: any = {};
if (value.unkparam !== undefined) result.unkparam = tsValueToJsonValueFns.int32(value.unkparam);
if (value.deviceMake !== undefined) result.deviceMake = tsValueToJsonValueFns.string(value.deviceMake);
if (value.deviceModel !== undefined) result.deviceModel = tsValueToJsonValueFns.string(value.deviceModel);
if (value.nameId !== undefined) result.nameId = tsValueToJsonValueFns.int32(value.nameId);
if (value.clientVersion !== undefined) result.clientVersion = tsValueToJsonValueFns.string(value.clientVersion);
if (value.clientName !== undefined) result.clientName = tsValueToJsonValueFns.string(value.clientName);
if (value.osName !== undefined) result.osName = tsValueToJsonValueFns.string(value.osName);
if (value.osVersion !== undefined) result.osVersion = tsValueToJsonValueFns.string(value.osVersion);
if (value.acceptLanguage !== undefined) result.acceptLanguage = tsValueToJsonValueFns.string(value.acceptLanguage);
if (value.acceptRegion !== undefined) result.acceptRegion = tsValueToJsonValueFns.string(value.acceptRegion);
if (value.androidSdkVersion !== undefined) result.androidSdkVersion = tsValueToJsonValueFns.int32(value.androidSdkVersion);
if (value.windowWidthPoints !== undefined) result.windowWidthPoints = tsValueToJsonValueFns.int32(value.windowWidthPoints);
if (value.windowHeightPoints !== undefined) result.windowHeightPoints = tsValueToJsonValueFns.int32(value.windowHeightPoints);
return result;
}
export function decodeJson(value: any): $.youtube.InnertubePayload.Context.Client {
const result = getDefaultValue();
if (value.unkparam !== undefined) result.unkparam = jsonValueToTsValueFns.int32(value.unkparam);
if (value.deviceMake !== undefined) result.deviceMake = jsonValueToTsValueFns.string(value.deviceMake);
if (value.deviceModel !== undefined) result.deviceModel = jsonValueToTsValueFns.string(value.deviceModel);
if (value.nameId !== undefined) result.nameId = jsonValueToTsValueFns.int32(value.nameId);
if (value.clientVersion !== undefined) result.clientVersion = jsonValueToTsValueFns.string(value.clientVersion);
if (value.clientName !== undefined) result.clientName = jsonValueToTsValueFns.string(value.clientName);
if (value.osName !== undefined) result.osName = jsonValueToTsValueFns.string(value.osName);
if (value.osVersion !== undefined) result.osVersion = jsonValueToTsValueFns.string(value.osVersion);
if (value.acceptLanguage !== undefined) result.acceptLanguage = jsonValueToTsValueFns.string(value.acceptLanguage);
if (value.acceptRegion !== undefined) result.acceptRegion = jsonValueToTsValueFns.string(value.acceptRegion);
if (value.androidSdkVersion !== undefined) result.androidSdkVersion = jsonValueToTsValueFns.int32(value.androidSdkVersion);
if (value.windowWidthPoints !== undefined) result.windowWidthPoints = jsonValueToTsValueFns.int32(value.windowWidthPoints);
if (value.windowHeightPoints !== undefined) result.windowHeightPoints = jsonValueToTsValueFns.int32(value.windowHeightPoints);
return result;
}
export function encodeBinary(value: $.youtube.InnertubePayload.Context.Client): Uint8Array {
const result: WireMessage = [];
if (value.unkparam !== undefined) {
const tsValue = value.unkparam;
if (value.deviceMake !== undefined) {
const tsValue = value.deviceMake;
result.push(
[12, tsValueToWireValueFns.string(tsValue)],
);
}
if (value.deviceModel !== undefined) {
const tsValue = value.deviceModel;
result.push(
[13, tsValueToWireValueFns.string(tsValue)],
);
}
if (value.nameId !== undefined) {
const tsValue = value.nameId;
result.push(
[16, tsValueToWireValueFns.int32(tsValue)],
);
@@ -71,12 +115,48 @@ export function encodeBinary(value: $.youtube.InnertubePayload.Context.Client):
[17, tsValueToWireValueFns.string(tsValue)],
);
}
if (value.clientName !== undefined) {
const tsValue = value.clientName;
if (value.osName !== undefined) {
const tsValue = value.osName;
result.push(
[18, tsValueToWireValueFns.string(tsValue)],
);
}
if (value.osVersion !== undefined) {
const tsValue = value.osVersion;
result.push(
[19, tsValueToWireValueFns.string(tsValue)],
);
}
if (value.acceptLanguage !== undefined) {
const tsValue = value.acceptLanguage;
result.push(
[21, tsValueToWireValueFns.string(tsValue)],
);
}
if (value.acceptRegion !== undefined) {
const tsValue = value.acceptRegion;
result.push(
[22, tsValueToWireValueFns.string(tsValue)],
);
}
if (value.androidSdkVersion !== undefined) {
const tsValue = value.androidSdkVersion;
result.push(
[34, tsValueToWireValueFns.int32(tsValue)],
);
}
if (value.windowWidthPoints !== undefined) {
const tsValue = value.windowWidthPoints;
result.push(
[37, tsValueToWireValueFns.int32(tsValue)],
);
}
if (value.windowHeightPoints !== undefined) {
const tsValue = value.windowHeightPoints;
result.push(
[38, tsValueToWireValueFns.int32(tsValue)],
);
}
return serialize(result);
}
@@ -84,12 +164,26 @@ export function decodeBinary(binary: Uint8Array): $.youtube.InnertubePayload.Con
const result = getDefaultValue();
const wireMessage = deserialize(binary);
const wireFields = new Map(wireMessage);
field: {
const wireValue = wireFields.get(12);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.string(wireValue);
if (value === undefined) break field;
result.deviceMake = value;
}
field: {
const wireValue = wireFields.get(13);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.string(wireValue);
if (value === undefined) break field;
result.deviceModel = value;
}
field: {
const wireValue = wireFields.get(16);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.int32(wireValue);
if (value === undefined) break field;
result.unkparam = value;
result.nameId = value;
}
field: {
const wireValue = wireFields.get(17);
@@ -103,7 +197,49 @@ export function decodeBinary(binary: Uint8Array): $.youtube.InnertubePayload.Con
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.string(wireValue);
if (value === undefined) break field;
result.clientName = value;
result.osName = value;
}
field: {
const wireValue = wireFields.get(19);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.string(wireValue);
if (value === undefined) break field;
result.osVersion = value;
}
field: {
const wireValue = wireFields.get(21);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.string(wireValue);
if (value === undefined) break field;
result.acceptLanguage = value;
}
field: {
const wireValue = wireFields.get(22);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.string(wireValue);
if (value === undefined) break field;
result.acceptRegion = value;
}
field: {
const wireValue = wireFields.get(34);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.int32(wireValue);
if (value === undefined) break field;
result.androidSdkVersion = value;
}
field: {
const wireValue = wireFields.get(37);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.int32(wireValue);
if (value === undefined) break field;
result.windowWidthPoints = value;
}
field: {
const wireValue = wireFields.get(38);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.int32(wireValue);
if (value === undefined) break field;
result.windowHeightPoints = value;
}
return result;
}

View File

@@ -90,7 +90,7 @@ import {
export declare namespace $.youtube {
export type InnertubePayload = {
context?: Context;
target?: string;
videoId?: string;
title?: Title;
description?: Description;
tags?: Tags;
@@ -100,6 +100,7 @@ export declare namespace $.youtube {
privacy?: Privacy;
madeForKids?: MadeForKids;
ageRestricted?: AgeRestricted;
field83?: number;
}
}
@@ -108,7 +109,7 @@ export type Type = $.youtube.InnertubePayload;
export function getDefaultValue(): $.youtube.InnertubePayload {
return {
context: undefined,
target: undefined,
videoId: undefined,
title: undefined,
description: undefined,
tags: undefined,
@@ -118,6 +119,7 @@ export function getDefaultValue(): $.youtube.InnertubePayload {
privacy: undefined,
madeForKids: undefined,
ageRestricted: undefined,
field83: undefined,
};
}
@@ -131,7 +133,7 @@ export function createValue(partialValue: Partial<$.youtube.InnertubePayload>):
export function encodeJson(value: $.youtube.InnertubePayload): unknown {
const result: any = {};
if (value.context !== undefined) result.context = encodeJson_1(value.context);
if (value.target !== undefined) result.target = tsValueToJsonValueFns.string(value.target);
if (value.videoId !== undefined) result.videoId = tsValueToJsonValueFns.string(value.videoId);
if (value.title !== undefined) result.title = encodeJson_2(value.title);
if (value.description !== undefined) result.description = encodeJson_3(value.description);
if (value.tags !== undefined) result.tags = encodeJson_4(value.tags);
@@ -141,13 +143,14 @@ export function encodeJson(value: $.youtube.InnertubePayload): unknown {
if (value.privacy !== undefined) result.privacy = encodeJson_8(value.privacy);
if (value.madeForKids !== undefined) result.madeForKids = encodeJson_9(value.madeForKids);
if (value.ageRestricted !== undefined) result.ageRestricted = encodeJson_10(value.ageRestricted);
if (value.field83 !== undefined) result.field83 = tsValueToJsonValueFns.int32(value.field83);
return result;
}
export function decodeJson(value: any): $.youtube.InnertubePayload {
const result = getDefaultValue();
if (value.context !== undefined) result.context = decodeJson_1(value.context);
if (value.target !== undefined) result.target = jsonValueToTsValueFns.string(value.target);
if (value.videoId !== undefined) result.videoId = jsonValueToTsValueFns.string(value.videoId);
if (value.title !== undefined) result.title = decodeJson_2(value.title);
if (value.description !== undefined) result.description = decodeJson_3(value.description);
if (value.tags !== undefined) result.tags = decodeJson_4(value.tags);
@@ -157,6 +160,7 @@ export function decodeJson(value: any): $.youtube.InnertubePayload {
if (value.privacy !== undefined) result.privacy = decodeJson_8(value.privacy);
if (value.madeForKids !== undefined) result.madeForKids = decodeJson_9(value.madeForKids);
if (value.ageRestricted !== undefined) result.ageRestricted = decodeJson_10(value.ageRestricted);
if (value.field83 !== undefined) result.field83 = jsonValueToTsValueFns.int32(value.field83);
return result;
}
@@ -168,8 +172,8 @@ export function encodeBinary(value: $.youtube.InnertubePayload): Uint8Array {
[1, { type: WireType.LengthDelimited as const, value: encodeBinary_1(tsValue) }],
);
}
if (value.target !== undefined) {
const tsValue = value.target;
if (value.videoId !== undefined) {
const tsValue = value.videoId;
result.push(
[2, tsValueToWireValueFns.string(tsValue)],
);
@@ -228,6 +232,12 @@ export function encodeBinary(value: $.youtube.InnertubePayload): Uint8Array {
[69, { type: WireType.LengthDelimited as const, value: encodeBinary_10(tsValue) }],
);
}
if (value.field83 !== undefined) {
const tsValue = value.field83;
result.push(
[83, tsValueToWireValueFns.int32(tsValue)],
);
}
return serialize(result);
}
@@ -247,7 +257,7 @@ export function decodeBinary(binary: Uint8Array): $.youtube.InnertubePayload {
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.string(wireValue);
if (value === undefined) break field;
result.target = value;
result.videoId = value;
}
field: {
const wireValue = wireFields.get(3);
@@ -312,5 +322,12 @@ export function decodeBinary(binary: Uint8Array): $.youtube.InnertubePayload {
if (value === undefined) break field;
result.ageRestricted = value;
}
field: {
const wireValue = wireFields.get(83);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.int32(wireValue);
if (value === undefined) break field;
result.field83 = value;
}
return result;
}

View File

@@ -240,12 +240,20 @@ export function encodeVideoMetadataPayload(video_id: string, metadata: UpdateVid
const data: InnertubePayload.Type = {
context: {
client: {
unkparam: 14,
clientName: CLIENTS.ANDROID.NAME,
clientVersion: CLIENTS.YTSTUDIO_ANDROID.VERSION
nameId: 3,
osName: 'Android',
androidSdkVersion: CLIENTS.ANDROID.SDK_VERSION,
osVersion: '13',
acceptLanguage: 'en-US',
acceptRegion: 'US',
deviceMake: 'Google',
deviceModel: 'sdk_gphone64_x86_64',
windowHeightPoints: 840,
windowWidthPoints: 432,
clientVersion: CLIENTS.ANDROID.VERSION
}
},
target: video_id
videoId: video_id
};
if (Reflect.has(metadata, 'title'))
@@ -302,12 +310,20 @@ export function encodeCustomThumbnailPayload(video_id: string, bytes: Uint8Array
const data: InnertubePayload.Type = {
context: {
client: {
unkparam: 14,
clientName: CLIENTS.ANDROID.NAME,
clientVersion: CLIENTS.YTSTUDIO_ANDROID.VERSION
nameId: 3,
osName: 'Android',
androidSdkVersion: CLIENTS.ANDROID.SDK_VERSION,
osVersion: '13',
acceptLanguage: 'en-US',
acceptRegion: 'US',
deviceMake: 'Google',
deviceModel: 'sdk_gphone64_x86_64',
windowHeightPoints: 840,
windowWidthPoints: 432,
clientVersion: CLIENTS.ANDROID.VERSION
}
},
target: video_id,
videoId: video_id,
videoThumbnail: {
type: 3,
thumbnail: {

View File

@@ -11,17 +11,24 @@ message VisitorData {
message InnertubePayload {
message Context {
message Client {
required int32 unkparam = 16;
required string client_version = 17;
required string client_name = 18;
string deviceMake = 12;
string deviceModel = 13;
int32 nameId = 16;
string clientVersion = 17;
string osName = 18;
string osVersion = 19;
string acceptLanguage = 21;
string acceptRegion = 22;
int32 windowWidthPoints = 37;
int32 windowHeightPoints = 38;
int32 androidSdkVersion = 34;
}
required Client client = 1;
}
required Context context = 1;
// This can be either a target id or a video id.
optional string target = 2;
optional string videoId = 2;
/**** YT Sudio stuff ****/

View File

@@ -1,4 +1,13 @@
export interface StreamingInfoOptions {
/**
* The format to use for the captions, when the video has captions.
* If this option is not set, the DASH manifest will not include the captions.
*
* Possible values:
* * `vtt`: Tells YouTube to return the captions in the WebVTT format
* * `ttml`: Tells YouTube to return the captions in the TTML format
*/
captions_format?: 'vtt' | 'ttml';
/**
* The label to use for the non-DRC streams when a video has DRC and streams.
*

View File

@@ -13,6 +13,7 @@ import type { SegmentInfo as FSegmentInfo } from './StreamingInfo.js';
import type { FormatFilter, URLTransformer } from '../types/FormatUtils.js';
import type PlayerLiveStoryboardSpec from '../parser/classes/PlayerLiveStoryboardSpec.js';
import type { StreamingInfoOptions } from '../types/StreamingInfoOptions.js';
import type { CaptionTrackData } from '../parser/classes/PlayerCaptionsTracklist.js';
interface DashManifestProps {
streamingData: IStreamingData;
@@ -24,6 +25,7 @@ interface DashManifestProps {
player?: Player;
actions?: Actions;
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
captionTracks?: CaptionTrackData[];
}
async function OTFPostLiveDvrSegmentInfo({ info }: { info: FSegmentInfo }) {
@@ -73,14 +75,16 @@ async function DashManifest({
player,
actions,
storyboards,
captionTracks,
options
}: DashManifestProps) {
const {
getDuration,
audio_sets,
video_sets,
image_sets
} = getStreamingInfo(streamingData, isPostLiveDvr, transformURL, rejectFormat, cpn, player, actions, storyboards, options);
image_sets,
text_sets
} = getStreamingInfo(streamingData, isPostLiveDvr, transformURL, rejectFormat, cpn, player, actions, storyboards, captionTracks, options);
// XXX: DASH spec: https://standards.iso.org/ittf/PubliclyAvailableStandards/c083314_ISO_IEC%2023009-1_2022(en).zip
@@ -229,6 +233,36 @@ async function DashManifest({
</adaptation-set>;
})
}
{
text_sets.map((set, index) => {
return <adaptation-set
id={index + audio_sets.length + video_sets.length + image_sets.length}
mimeType={set.mime_type}
lang={set.language}
contentType="text"
>
{
set.track_roles.map((role) => (
<role
schemeIdUri="urn:mpeg:dash:role:2011"
value={role}
/>
))
}
<label id={index + audio_sets.length}>
{set.track_name}
</label>
<representation
id={set.representation.uid}
bandwidth="0"
>
<base-url>
{set.representation.base_url}
</base-url>
</representation>
</adaptation-set>;
})
}
</period>
</mpd>;
}
@@ -242,6 +276,7 @@ export function toDash(
player?: Player,
actions?: Actions,
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec,
caption_tracks?: CaptionTrackData[],
options?: StreamingInfoOptions
) {
if (!streaming_data)
@@ -258,6 +293,7 @@ export function toDash(
player={player}
actions={actions}
storyboards={storyboards}
captionTracks={caption_tracks}
/>
);
}

View File

@@ -90,6 +90,10 @@ export async function download(
signal: cancel.signal
});
// Throw if the response is not 2xx
if (!response.ok)
throw new InnertubeError('The server responded with a non 2xx status code', { error_type: 'FETCH_FAILED', response });
const body = response.body;
if (!body)
@@ -155,7 +159,7 @@ export function chooseFormat(options: FormatOptions, streaming_data?: IStreaming
return false;
if (!is_best && format.quality_label !== quality)
return false;
if (best_width < format.width)
if (format.width && (best_width < format.width))
best_width = format.width;
return true;
});

View File

@@ -12,6 +12,7 @@ import type { Format } from '../parser/misc.js';
import type { PlayerLiveStoryboardSpec } from '../parser/nodes.js';
import type { FormatFilter, URLTransformer } from '../types/FormatUtils.js';
import type { StreamingInfoOptions } from '../types/StreamingInfoOptions.js';
import type { CaptionTrackData } from '../parser/classes/PlayerCaptionsTracklist.js';
const TAG_ = 'StreamingInfo';
@@ -20,6 +21,7 @@ export interface StreamingInfo {
audio_sets: AudioSet[];
video_sets: VideoSet[];
image_sets: ImageSet[];
text_sets: TextSet[];
}
export interface AudioSet {
@@ -85,8 +87,8 @@ export interface VideoSet {
export interface VideoRepresentation {
uid: string;
bitrate: number;
width: number;
height: number;
width?: number;
height?: number;
fps?: number;
codecs?: string;
segment_info: SegmentInfo;
@@ -122,6 +124,19 @@ export interface ImageRepresentation {
getURL(n: number): string;
}
export interface TextSet {
mime_type: string;
language: string;
track_name: string;
track_roles: ('caption' | 'dub')[];
representation: TextRepresentation;
}
export interface TextRepresentation {
uid: string;
base_url: string;
}
interface PostLiveDvrInfo {
duration: number,
segment_count: number
@@ -706,7 +721,7 @@ function getImageRepresentation(
thumbnail_width: board.thumbnail_width,
rows: board.rows,
columns: board.columns,
template_duration: template_duration,
template_duration: Math.round(template_duration),
template_url: transform_url(template_url).toString(),
getURL(n) {
return template_url.toString().replace('$Number$', n.toString());
@@ -735,6 +750,36 @@ function getImageSets(
}));
}
function getTextSets(
caption_tracks: CaptionTrackData[],
format: 'vtt' | 'ttml',
transform_url: URLTransformer
): TextSet[] {
const mime_type = format === 'vtt' ? 'text/vtt' : 'application/ttml+xml';
return caption_tracks.map((caption_track) => {
const url = new URL(caption_track.base_url);
url.searchParams.set('fmt', format);
const track_roles: ('caption' | 'dub')[] = [ 'caption' ];
if (url.searchParams.has('tlang')) {
track_roles.push('dub');
}
return {
mime_type,
language: caption_track.language_code,
track_name: caption_track.name.toString(),
track_roles,
representation: {
uid: `text-${caption_track.vss_id}`,
base_url: transform_url(url).toString()
}
};
});
}
export function getStreamingInfo(
streaming_data?: IStreamingData,
is_post_live_dvr = false,
@@ -744,6 +789,7 @@ export function getStreamingInfo(
player?: Player,
actions?: Actions,
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec,
caption_tracks?: CaptionTrackData[],
options?: StreamingInfoOptions
) {
if (!streaming_data)
@@ -839,11 +885,21 @@ export function getStreamingInfo(
image_sets = getImageSets(duration, actions, storyboards, url_transformer);
}
let text_sets: TextSet[] = [];
if (caption_tracks && options?.captions_format) {
if ((options.captions_format as string) !== 'vtt' && (options.captions_format as string) !== 'ttml') {
throw new InnertubeError('Invalid captions format', options.captions_format);
}
text_sets = getTextSets(caption_tracks, options.captions_format, url_transformer);
}
const info : StreamingInfo = {
getDuration,
audio_sets,
video_sets,
image_sets
image_sets,
text_sets
};
return info;

View File

@@ -381,8 +381,8 @@ describe('YouTube.js Tests', () => {
const playlist = await innertube.music.getPlaylist('PLQxo8OvVvJ1WI_Bp67F2wdIl_R2Rc_1-u');
expect(playlist).toBeDefined();
expect(playlist.header).toBeDefined();
expect(playlist.items).toBeDefined();
expect(playlist.items?.length).toBeGreaterThan(0);
expect(playlist.contents).toBeDefined();
expect(playlist.contents?.length).toBeGreaterThan(0);
});
test('Innertube#music.getLyrics', async () => {