mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b00e2c6ce | ||
|
|
ea82beaa10 | ||
|
|
0ba8c54257 | ||
|
|
7315fca1b4 | ||
|
|
0602dd2c3d | ||
|
|
13321888e8 | ||
|
|
d48b9d0946 | ||
|
|
592ddac30f | ||
|
|
1ec2ea85e2 | ||
|
|
064436cef3 | ||
|
|
4022d7aa89 | ||
|
|
cd69ce73c1 | ||
|
|
1c08bfe113 | ||
|
|
a624963384 | ||
|
|
66e34f9388 |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## [9.4.0](https://github.com/LuanRT/YouTube.js/compare/v9.3.0...v9.4.0) (2024-04-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Format:** Add `projection_type` and `stereo_layout` ([#643](https://github.com/LuanRT/YouTube.js/issues/643)) ([064436c](https://github.com/LuanRT/YouTube.js/commit/064436cef30e892d8f569d4f7b146557fd72b09f))
|
||||
* **Format:** Add `spatial_audio_type` ([#647](https://github.com/LuanRT/YouTube.js/issues/647)) ([0ba8c54](https://github.com/LuanRT/YouTube.js/commit/0ba8c54257b068d7e4518c982396acb42f1dd41d))
|
||||
* **Parser:** Add `MusicResponsiveHeader` node ([ea82bea](https://github.com/LuanRT/YouTube.js/commit/ea82beaa10f6c877d6dd3102e10f6ae382560e0f))
|
||||
|
||||
## [9.3.0](https://github.com/LuanRT/YouTube.js/compare/v9.2.1...v9.3.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **CommentView:** Implement comment interaction methods ([1c08bfe](https://github.com/LuanRT/YouTube.js/commit/1c08bfe113804c69fbc4e49b42442d9a63d73be6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **CommentThread:** Replies not being parsed correctly ([66e34f9](https://github.com/LuanRT/YouTube.js/commit/66e34f9388429a2088d5c5835d19eebdc881c957))
|
||||
|
||||
## [9.2.1](https://github.com/LuanRT/YouTube.js/compare/v9.2.0...v9.2.1) (2024-04-09)
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ A `CommentThread` represents a top-level comment and its replies.
|
||||
## API
|
||||
|
||||
* CommentThread
|
||||
* [.comment](#comment) ⇒ `Comment`
|
||||
* [.replies](#replies) ⇒ `Comment[]`
|
||||
* [.comment](#comment) ⇒ `Comment | CommentView`
|
||||
* [.replies](#replies) ⇒ `(Comment | CommentView)[]`
|
||||
* [.getReplies](#getreplies) ⇒ `function`
|
||||
* [.getContinuation](#getcontinuation) ⇒ `function`
|
||||
* [.has_continuation](#hascontinuation) ⇒ `boolean`
|
||||
@@ -14,7 +14,7 @@ A `CommentThread` represents a top-level comment and its replies.
|
||||
|
||||
<a name="comment"></a>
|
||||
### comment
|
||||
The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
|
||||
The top-level comment. **Note:** More about the `Comment` node [here](./Comment.md) (OUTDATED! `Comment` has been replaced by [`CommentView`](./CommentView.md) nodes).
|
||||
|
||||
**Type:** [`Comment`](../../src/parser/classes/comments/Comment.ts)
|
||||
|
||||
@@ -22,7 +22,7 @@ The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
|
||||
### replies
|
||||
An array of replies to the top-level comment. (not populated until [`getReplies()`](#getreplies) is called).
|
||||
|
||||
**Type:** [`Comment[]`](../../src/parser/classes/comments/Comment.ts)
|
||||
**Type:** [`(Comment | CommentView)[]`](../../src/parser/classes/comments/Comment.ts)
|
||||
|
||||
<a name="getreplies"></a>
|
||||
### getReplies()
|
||||
|
||||
48
examples/comments/CommentView.md
Normal file
48
examples/comments/CommentView.md
Normal file
@@ -0,0 +1,48 @@
|
||||
## CommentView
|
||||
Contains information about a single comment. A [`CommentView`](../../src/parser/classes/comments/CommentView.ts) can be a top-level comment or a reply to a top-level comment.
|
||||
|
||||
## API
|
||||
|
||||
* Comment
|
||||
* [.like](#like) ⇒ `function`
|
||||
* [.unlike](#like) ⇒ `function`
|
||||
* [.dislike](#dislike) ⇒ `function`
|
||||
* [.undislike](#dislike) ⇒ `function`
|
||||
* [.reply](#reply) ⇒ `function`
|
||||
* [.translate](#translate) ⇒ `function`
|
||||
|
||||
<a name="like"></a>
|
||||
### like()
|
||||
Likes the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="unlike"></a>
|
||||
### unlike()
|
||||
Unlikes the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="dislike"></a>
|
||||
### dislike()
|
||||
Dislikes the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="undislike"></a>
|
||||
### undislike()
|
||||
Undislikes the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="reply"></a>
|
||||
### reply(comment_text: string)
|
||||
Replies to the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="translate"></a>
|
||||
### translate(target_language: string)
|
||||
Translates the comment to the given language.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse & { content?: string }>`
|
||||
@@ -59,7 +59,4 @@ Returns whether there are more comments to be fetched.
|
||||
### page
|
||||
Returns original InnerTube response (sanitized).
|
||||
|
||||
**Returns:** `ParsedResponse`
|
||||
|
||||
## Example
|
||||
See [`index.ts`](./index.ts).
|
||||
**Returns:** `ParsedResponse`
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
|
||||
|
||||
const comment_section = await yt.getComments('a-rqu-hjobc');
|
||||
|
||||
console.info(`This video has ${comment_section.header?.comments_count.toString() || 'N/A'} comments.\n`);
|
||||
|
||||
for (const thread of comment_section.contents) {
|
||||
const comment = thread.comment;
|
||||
|
||||
if (comment) {
|
||||
console.info(
|
||||
`${comment.is_pinned ? '[Pinned]' : ''}`,
|
||||
`${comment.is_member ? `${comment.sponsor_comment_badge?.tooltip}` : ''}`,
|
||||
`${comment.author.name} • ${comment.published}\n`,
|
||||
`${comment.content.toString()}`, '\n',
|
||||
`Likes: ${comment.vote_count}`, '\n'
|
||||
);
|
||||
|
||||
if (thread.has_replies) {
|
||||
console.info('Replies:', '\n');
|
||||
|
||||
let comment_thread = await thread.getReplies();
|
||||
|
||||
while (true) {
|
||||
for (const reply of comment_thread?.replies || []) {
|
||||
console.info(
|
||||
`> ${reply.author.name} • ${reply.published}\n`,
|
||||
`${reply.content.toString()}`, '\n',
|
||||
`Likes: ${reply.vote_count}`, '\n'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
comment_thread = await comment_thread.getContinuation();
|
||||
} catch { break; };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
}
|
||||
})();
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "9.2.1",
|
||||
"version": "9.4.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "youtubei.js",
|
||||
"version": "9.2.1",
|
||||
"version": "9.4.0",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "9.2.1",
|
||||
"version": "9.4.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",
|
||||
|
||||
@@ -325,6 +325,18 @@ export default class Innertube {
|
||||
return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves playlists.
|
||||
*/
|
||||
async getPlaylists() {
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEplaylist_aggregation' }), parse: true }
|
||||
);
|
||||
|
||||
const feed = new Feed(this.actions, response);
|
||||
return feed.playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves playlist contents.
|
||||
* @param id - Playlist id
|
||||
|
||||
@@ -168,6 +168,7 @@ export default class Actions {
|
||||
'FEhistory',
|
||||
'FEsubscriptions',
|
||||
'FEchannels',
|
||||
'FEplaylist_aggregation',
|
||||
'FEmusic_listening_review',
|
||||
'FEmusic_library_landing',
|
||||
'SPaccount_overview',
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
|
||||
import AutomixPreviewVideo from '../../parser/classes/AutomixPreviewVideo.js';
|
||||
import Message from '../../parser/classes/Message.js';
|
||||
import MusicCarouselShelf from '../../parser/classes/MusicCarouselShelf.js';
|
||||
import MusicDescriptionShelf from '../../parser/classes/MusicDescriptionShelf.js';
|
||||
import MusicQueue from '../../parser/classes/MusicQueue.js';
|
||||
import MusicTwoRowItem from '../../parser/classes/MusicTwoRowItem.js';
|
||||
@@ -278,7 +277,7 @@ export default class Music {
|
||||
* Retrieves related content.
|
||||
* @param video_id - The video id.
|
||||
*/
|
||||
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
|
||||
async getRelated(video_id: string): Promise<SectionList | Message> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.execute(
|
||||
@@ -297,9 +296,9 @@ export default class Music {
|
||||
if (!page.contents)
|
||||
throw new InnertubeError('Unexpected response', page);
|
||||
|
||||
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
|
||||
const contents = page.contents.item().as(SectionList, Message);
|
||||
|
||||
return shelves;
|
||||
return contents;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { encodeShortsParam } from '../../proto/index.js';
|
||||
import type { IPlayerRequest, PlayerEndpointOptions } from '../../types/index.js';
|
||||
|
||||
export const PATH = '/player';
|
||||
@@ -9,11 +8,6 @@ export const PATH = '/player';
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: PlayerEndpointOptions): IPlayerRequest {
|
||||
const is_android =
|
||||
opts.client === 'ANDROID' ||
|
||||
opts.client === 'YTMUSIC_ANDROID' ||
|
||||
opts.client === 'YTSTUDIO_ANDROID';
|
||||
|
||||
return {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
@@ -43,8 +37,7 @@ export function build(opts: PlayerEndpointOptions): IPlayerRequest {
|
||||
...{
|
||||
client: opts.client,
|
||||
playlistId: opts.playlist_id,
|
||||
// Workaround streaming URLs returning 403 or getting throttled when using Android based clients.
|
||||
params: is_android ? encodeShortsParam() : opts.params
|
||||
params: opts.params
|
||||
}
|
||||
};
|
||||
}
|
||||
14
src/parser/classes/ChipBarView.ts
Normal file
14
src/parser/classes/ChipBarView.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { YTNode, type ObservedArray } from '../helpers.js';
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import ChipView from './ChipView.js';
|
||||
|
||||
export default class ChipBarView extends YTNode {
|
||||
static type = 'ChipBarView';
|
||||
|
||||
chips: ObservedArray<ChipView> | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.chips = Parser.parseArray(data.chips, ChipView);
|
||||
}
|
||||
}
|
||||
20
src/parser/classes/ChipView.ts
Normal file
20
src/parser/classes/ChipView.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { type RawNode } from '../index.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
|
||||
export default class ChipView extends YTNode {
|
||||
static type = 'ChipView';
|
||||
|
||||
text: string;
|
||||
display_type: string;
|
||||
endpoint: NavigationEndpoint;
|
||||
chip_entity_key: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.text = data.text;
|
||||
this.display_type = data.displayType;
|
||||
this.endpoint = new NavigationEndpoint(data.tapCommand);
|
||||
this.chip_entity_key = data.chipEntityKey;
|
||||
}
|
||||
}
|
||||
43
src/parser/classes/MusicResponsiveHeader.ts
Normal file
43
src/parser/classes/MusicResponsiveHeader.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import { YTNode } from '../helpers.js';
|
||||
import MusicThumbnail from './MusicThumbnail.js';
|
||||
import MusicDescriptionShelf from './MusicDescriptionShelf.js';
|
||||
import MusicInlineBadge from './MusicInlineBadge.js';
|
||||
import MusicPlayButton from './MusicPlayButton.js';
|
||||
import ToggleButton from './ToggleButton.js';
|
||||
import Menu from './menus/Menu.js';
|
||||
|
||||
import type { ObservedArray } from '../helpers.js';
|
||||
|
||||
export default class MusicResponsiveHeader extends YTNode {
|
||||
static type = 'MusicResponsiveHeader';
|
||||
|
||||
thumbnail: MusicThumbnail | null;
|
||||
buttons: ObservedArray<ToggleButton | MusicPlayButton | Menu> | null;
|
||||
title: Text;
|
||||
subtitle: Text;
|
||||
strapline_text_one: Text;
|
||||
strapline_thumbnail: MusicThumbnail | null;
|
||||
second_subtitle: Text;
|
||||
subtitle_badge?: ObservedArray<MusicInlineBadge> | null;
|
||||
description?: MusicDescriptionShelf | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
|
||||
this.buttons = Parser.parseArray(data.buttons, [ ToggleButton, MusicPlayButton, Menu ]);
|
||||
this.title = new Text(data.title);
|
||||
this.subtitle = new Text(data.subtitle);
|
||||
this.strapline_text_one = new Text(data.straplineTextOne);
|
||||
this.strapline_thumbnail = Parser.parseItem(data.straplineThumbnail, MusicThumbnail);
|
||||
this.second_subtitle = new Text(data.secondSubtitle);
|
||||
|
||||
if (Reflect.has(data, 'subtitleBadge')) {
|
||||
this.subtitle_badge = Parser.parseArray(data.subtitleBadge, MusicInlineBadge);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'description')) {
|
||||
this.description = Parser.parseItem(data.description, MusicDescriptionShelf);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export default class CommentThread extends YTNode {
|
||||
if (!response.on_response_received_endpoints_memo)
|
||||
throw new InnertubeError('Unexpected response.', response);
|
||||
|
||||
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
|
||||
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment, CommentView).map((comment) => {
|
||||
comment.setActions(this.#actions);
|
||||
return comment;
|
||||
}));
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { YTNode } from '../../helpers.js';
|
||||
import type { RawNode } from '../../index.js';
|
||||
|
||||
import type Actions from '../../../core/Actions.js';
|
||||
import NavigationEndpoint from '../NavigationEndpoint.js';
|
||||
import Author from '../misc/Author.js';
|
||||
import Text from '../misc/Text.js';
|
||||
import CommentReplyDialog from './CommentReplyDialog.js';
|
||||
import { InnertubeError } from '../../../utils/Utils.js';
|
||||
import * as Proto from '../../../proto/index.js';
|
||||
|
||||
import type Actions from '../../../core/Actions.js';
|
||||
import type { ApiResponse } from '../../../core/Actions.js';
|
||||
import type { RawNode } from '../../index.js';
|
||||
|
||||
export default class CommentView extends YTNode {
|
||||
static type = 'CommentView';
|
||||
|
||||
#actions?: Actions;
|
||||
|
||||
like_command?: NavigationEndpoint;
|
||||
dislike_command?: NavigationEndpoint;
|
||||
unlike_command?: NavigationEndpoint;
|
||||
undislike_command?: NavigationEndpoint;
|
||||
reply_command?: NavigationEndpoint;
|
||||
|
||||
comment_id: string;
|
||||
is_pinned: boolean;
|
||||
keys: {
|
||||
@@ -51,7 +62,7 @@ export default class CommentView extends YTNode {
|
||||
};
|
||||
}
|
||||
|
||||
applyMutations(comment?: RawNode, toolbar_state?: RawNode) {
|
||||
applyMutations(comment?: RawNode, toolbar_state?: RawNode, toolbar_surface?: RawNode) {
|
||||
if (comment) {
|
||||
this.content = Text.fromAttributed(comment.properties.content);
|
||||
this.published_time = comment.properties.publishedTime;
|
||||
@@ -78,8 +89,148 @@ export default class CommentView extends YTNode {
|
||||
if (toolbar_state) {
|
||||
this.is_hearted = toolbar_state.heartState === 'TOOLBAR_HEART_STATE_HEARTED';
|
||||
this.is_liked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_LIKED';
|
||||
this.is_disliked = toolbar_state.likeState === 'TOOLBAR_HEART_STATE_DISLIKED';
|
||||
this.is_disliked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_DISLIKED';
|
||||
}
|
||||
|
||||
if (toolbar_surface && !Reflect.has(toolbar_surface, 'prepareAccountCommand')) {
|
||||
this.like_command = new NavigationEndpoint(toolbar_surface.likeCommand);
|
||||
this.dislike_command = new NavigationEndpoint(toolbar_surface.dislikeCommand);
|
||||
this.unlike_command = new NavigationEndpoint(toolbar_surface.unlikeCommand);
|
||||
this.undislike_command = new NavigationEndpoint(toolbar_surface.undislikeCommand);
|
||||
this.reply_command = new NavigationEndpoint(toolbar_surface.replyCommand);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Likes the comment.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the like command is not found.
|
||||
*/
|
||||
async like(): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.like_command)
|
||||
throw new InnertubeError('Like command not found.');
|
||||
|
||||
if (this.is_liked)
|
||||
throw new InnertubeError('This comment is already liked.', { comment_id: this.comment_id });
|
||||
|
||||
return this.like_command.call(this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dislikes the comment.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the dislike command is not found.
|
||||
*/
|
||||
async dislike(): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.dislike_command)
|
||||
throw new InnertubeError('Dislike command not found.');
|
||||
|
||||
if (this.is_disliked)
|
||||
throw new InnertubeError('This comment is already disliked.', { comment_id: this.comment_id });
|
||||
|
||||
return this.dislike_command.call(this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlikes the comment.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the unlike command is not found.
|
||||
*/
|
||||
async unlike(): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.unlike_command)
|
||||
throw new InnertubeError('Unlike command not found.');
|
||||
|
||||
if (!this.is_liked)
|
||||
throw new InnertubeError('This comment is not liked.', { comment_id: this.comment_id });
|
||||
|
||||
return this.unlike_command.call(this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undislikes the comment.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the undislike command is not found.
|
||||
*/
|
||||
async undislike(): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.undislike_command)
|
||||
throw new InnertubeError('Undislike command not found.');
|
||||
|
||||
if (!this.is_disliked)
|
||||
throw new InnertubeError('This comment is not disliked.', { comment_id: this.comment_id });
|
||||
|
||||
return this.undislike_command.call(this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replies to the comment.
|
||||
* @param comment_text - The text of the reply.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the reply command is not found.
|
||||
*/
|
||||
async reply(comment_text: string): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.reply_command)
|
||||
throw new InnertubeError('Reply command not found.');
|
||||
|
||||
const dialog = this.reply_command.dialog?.as(CommentReplyDialog);
|
||||
|
||||
if (!dialog)
|
||||
throw new InnertubeError('Reply dialog not found.');
|
||||
|
||||
const reply_button = dialog.reply_button;
|
||||
|
||||
if (!reply_button)
|
||||
throw new InnertubeError('Reply button not found in the dialog.');
|
||||
|
||||
if (!reply_button.endpoint)
|
||||
throw new InnertubeError('Reply button endpoint not found.');
|
||||
|
||||
return reply_button.endpoint.call(this.#actions, { commentText: comment_text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the comment to the specified target language.
|
||||
* @param target_language - The target language to translate the comment to, e.g. 'en', 'ja'.
|
||||
* @returns A Promise that resolves to an ApiResponse object with the translated content, if available.
|
||||
* @throws if the Actions instance is not set for this comment or if the comment content is not found.
|
||||
*/
|
||||
async translate(target_language: string): Promise<ApiResponse & { content?: string }> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.content)
|
||||
throw new InnertubeError('Comment content not found.', { comment_id: this.comment_id });
|
||||
|
||||
// Emojis must be removed otherwise InnerTube throws a 400 status code at us.
|
||||
const text = this.content.toString().replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, '');
|
||||
|
||||
const payload = {
|
||||
text,
|
||||
target_language
|
||||
};
|
||||
|
||||
const action = Proto.encodeCommentActionParams(22, payload);
|
||||
const response = await this.#actions.execute('comment/perform_comment_action', { action, client: 'ANDROID' });
|
||||
|
||||
// XXX: Should move this to Parser#parseResponse
|
||||
const mutations = response.data.frameworkUpdates?.entityBatchUpdate?.mutations;
|
||||
const content = mutations?.[0]?.payload?.commentEntityPayload?.translatedContent?.content;
|
||||
|
||||
return { ...response, content };
|
||||
}
|
||||
|
||||
setActions(actions: Actions | undefined) {
|
||||
|
||||
@@ -12,6 +12,8 @@ export default class Format {
|
||||
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;
|
||||
@@ -41,6 +43,7 @@ export default class Format {
|
||||
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;
|
||||
@@ -77,6 +80,8 @@ export default class Format {
|
||||
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),
|
||||
@@ -101,6 +106,7 @@ export default class Format {
|
||||
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;
|
||||
this.has_audio = !!data.audioBitrate || !!data.audioQuality;
|
||||
|
||||
@@ -55,8 +55,10 @@ export { default as ChannelThumbnailWithLink } from './classes/ChannelThumbnailW
|
||||
export { default as ChannelVideoPlayer } from './classes/ChannelVideoPlayer.js';
|
||||
export { default as Chapter } from './classes/Chapter.js';
|
||||
export { default as ChildVideo } from './classes/ChildVideo.js';
|
||||
export { default as ChipBarView } from './classes/ChipBarView.js';
|
||||
export { default as ChipCloud } from './classes/ChipCloud.js';
|
||||
export { default as ChipCloudChip } from './classes/ChipCloudChip.js';
|
||||
export { default as ChipView } from './classes/ChipView.js';
|
||||
export { default as ClipAdState } from './classes/ClipAdState.js';
|
||||
export { default as ClipCreation } from './classes/ClipCreation.js';
|
||||
export { default as ClipCreationScrubber } from './classes/ClipCreationScrubber.js';
|
||||
@@ -261,6 +263,7 @@ export { default as MusicNavigationButton } from './classes/MusicNavigationButto
|
||||
export { default as MusicPlayButton } from './classes/MusicPlayButton.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';
|
||||
export { default as MusicResponsiveListItem } from './classes/MusicResponsiveListItem.js';
|
||||
export { default as MusicResponsiveListItemFixedColumn } from './classes/MusicResponsiveListItemFixedColumn.js';
|
||||
export { default as MusicResponsiveListItemFlexColumn } from './classes/MusicResponsiveListItemFlexColumn.js';
|
||||
|
||||
@@ -710,7 +710,10 @@ export function applyCommentsMutations(memo: Memo, mutations: RawNode[]) {
|
||||
.find((mutation) => mutation.payload?.engagementToolbarStateEntityPayload?.key === comment_view.keys.toolbar_state)
|
||||
?.payload?.engagementToolbarStateEntityPayload;
|
||||
|
||||
comment_view.applyMutations(comment_mutation, toolbar_state_mutation);
|
||||
const engagement_toolbar = mutations.find((mutation) => mutation.entityKey === comment_view.keys.toolbar_surface)
|
||||
?.payload?.engagementToolbarSurfaceEntityPayload;
|
||||
|
||||
comment_view.applyMutations(comment_mutation, toolbar_state_mutation, engagement_toolbar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,14 @@ export const OAUTH = Object.freeze({
|
||||
});
|
||||
export const CLIENTS = Object.freeze({
|
||||
iOS: {
|
||||
NAME_ID: '5',
|
||||
NAME: 'iOS',
|
||||
VERSION: '18.06.35',
|
||||
USER_AGENT: 'com.google.ios.youtube/18.06.35 (iPhone; CPU iPhone OS 14_4 like Mac OS X; en_US)',
|
||||
DEVICE_MODEL: 'iPhone10,6'
|
||||
},
|
||||
WEB: {
|
||||
NAME_ID: '1',
|
||||
NAME: 'WEB',
|
||||
VERSION: '2.20240111.09.00',
|
||||
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||
@@ -47,28 +49,34 @@ export const CLIENTS = Object.freeze({
|
||||
STATIC_VISITOR_ID: '6zpwvWUNAco'
|
||||
},
|
||||
WEB_KIDS: {
|
||||
NAME_ID: '76',
|
||||
NAME: 'WEB_KIDS',
|
||||
VERSION: '2.20230111.00.00'
|
||||
},
|
||||
YTMUSIC: {
|
||||
NAME_ID: '67',
|
||||
NAME: 'WEB_REMIX',
|
||||
VERSION: '1.20211213.00.00'
|
||||
},
|
||||
ANDROID: {
|
||||
NAME_ID: '3',
|
||||
NAME: 'ANDROID',
|
||||
VERSION: '18.48.37',
|
||||
SDK_VERSION: 33,
|
||||
USER_AGENT: 'com.google.android.youtube/18.48.37(Linux; U; Android 13; en_US; sdk_gphone64_x86_64 Build/UPB4.230623.005) gzip'
|
||||
},
|
||||
YTSTUDIO_ANDROID: {
|
||||
NAME_ID: '14',
|
||||
NAME: 'ANDROID_CREATOR',
|
||||
VERSION: '22.43.101'
|
||||
},
|
||||
YTMUSIC_ANDROID: {
|
||||
NAME_ID: '21',
|
||||
NAME: 'ANDROID_MUSIC',
|
||||
VERSION: '5.34.51'
|
||||
},
|
||||
TV_EMBEDDED: {
|
||||
NAME_ID: '85',
|
||||
NAME: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',
|
||||
VERSION: '2.0'
|
||||
}
|
||||
|
||||
@@ -57,9 +57,16 @@ export default class HTTPClient {
|
||||
request_headers.set('Accept', '*/*');
|
||||
request_headers.set('Accept-Language', '*');
|
||||
request_headers.set('X-Goog-Visitor-Id', this.#session.context.client.visitorData || '');
|
||||
request_headers.set('X-Origin', request_url.origin);
|
||||
request_headers.set('X-Youtube-Client-Version', this.#session.context.client.clientVersion || '');
|
||||
|
||||
const client_constant = Object.values(Constants.CLIENTS).find((client) => {
|
||||
return client.NAME === this.#session.context.client.clientName;
|
||||
});
|
||||
|
||||
if (client_constant) {
|
||||
request_headers.set('X-Youtube-Client-Name', client_constant.NAME_ID);
|
||||
}
|
||||
|
||||
if (Platform.shim.server) {
|
||||
request_headers.set('User-Agent', getRandomUserAgent('desktop'));
|
||||
request_headers.set('origin', request_url.origin);
|
||||
@@ -90,6 +97,14 @@ export default class HTTPClient {
|
||||
this.#adjustContext(n_body.context, n_body.client);
|
||||
request_headers.set('x-youtube-client-version', n_body.context.client.clientVersion);
|
||||
|
||||
const client_constant = Object.values(Constants.CLIENTS).find((client) => {
|
||||
return client.NAME === n_body.context.client.clientName;
|
||||
});
|
||||
|
||||
if (client_constant) {
|
||||
request_headers.set('X-Youtube-Client-Name', client_constant.NAME_ID);
|
||||
}
|
||||
|
||||
delete n_body.client;
|
||||
|
||||
if (Platform.shim.server) {
|
||||
@@ -109,7 +124,6 @@ export default class HTTPClient {
|
||||
request_headers.set('User-Agent', Constants.CLIENTS.ANDROID.USER_AGENT);
|
||||
request_headers.set('X-GOOG-API-FORMAT-VERSION', '2');
|
||||
request_headers.delete('X-Youtube-Client-Version');
|
||||
request_headers.delete('X-Origin');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createWriteStream, existsSync } from 'node:fs';
|
||||
import { Innertube, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
|
||||
|
||||
jest.useRealTimers();
|
||||
|
||||
describe('YouTube.js Tests', () => {
|
||||
let innertube: Innertube;
|
||||
|
||||
@@ -99,7 +101,7 @@ describe('YouTube.js Tests', () => {
|
||||
let comments: YT.Comments;
|
||||
|
||||
beforeAll(async () => {
|
||||
comments = await innertube.getComments('gmX-ceF-N1k');
|
||||
comments = await innertube.getComments('bUHZ2k9DYHY');
|
||||
expect(comments).toBeDefined();
|
||||
expect(comments.header).toBeDefined();
|
||||
expect(comments.contents).toBeDefined();
|
||||
@@ -112,10 +114,20 @@ describe('YouTube.js Tests', () => {
|
||||
expect(incremental_continuation.contents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('CommentThread#getReplies', async () => {
|
||||
let comment_thread = comments.contents.first();
|
||||
let loaded_comment_thread = await comment_thread.getReplies();
|
||||
expect(loaded_comment_thread.replies).toBeDefined();
|
||||
describe('CommentThread#getReplies', () => {
|
||||
let loaded_comment_thread: YTNodes.CommentThread;
|
||||
|
||||
beforeAll(async () => {
|
||||
let comment_thread = comments.contents.first();
|
||||
loaded_comment_thread = await comment_thread.getReplies();
|
||||
expect(loaded_comment_thread.replies).toBeDefined();
|
||||
});
|
||||
|
||||
test('CommentThread#getContinuation', async () => {
|
||||
const incremental_continuation = await loaded_comment_thread.getContinuation();
|
||||
expect(incremental_continuation.replies).toBeDefined();
|
||||
expect(incremental_continuation.replies?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -390,7 +402,6 @@ describe('YouTube.js Tests', () => {
|
||||
test('Innertube#music.getRelated', async () => {
|
||||
const related = await innertube.music.getRelated('eaJHysi5tYg');
|
||||
expect(related).toBeDefined();
|
||||
expect(related?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Innertube#music.getSearchSuggestions', async () => {
|
||||
|
||||
Reference in New Issue
Block a user