diff --git a/docs/API/interaction-manager.md b/docs/API/interaction-manager.md index 43a2db33f..a13df4e93 100644 --- a/docs/API/interaction-manager.md +++ b/docs/API/interaction-manager.md @@ -12,7 +12,7 @@ Handles direct interactions. * [.unsubscribe(video_id)](#unsubscribe) * [.comment(video_id, text)](#comment) * [.translate(text, target_language, args?)](#translate) - * [.setNotificationPreferences(channel_id, type)](#setnotificationpreferences) + * [.setNotificationPreferences(channel_id, type)](#setnotificationpreferences) ### like(video_id) diff --git a/docs/API/kids.md b/docs/API/kids.md index 4a02e6f52..df2ea2dcd 100644 --- a/docs/API/kids.md +++ b/docs/API/kids.md @@ -9,6 +9,7 @@ YouTube Kids is a modified version of the YouTube app, with a simplified interfa * [.getInfo(video_id)](#getinfo) * [.getChannel(channel_id)](#getchannel) * [.getHomeFeed()](#gethomefeed) + * [.blockChannel(channel_id)](#blockchannel) ### search(query) @@ -110,4 +111,17 @@ Retrieves the home feed. - Returns available categories. - `#page` - - Returns the original InnerTube response(s), parsed and sanitized. \ No newline at end of file + - Returns the original InnerTube response(s), parsed and sanitized. + + + + +### blockChannel(channel_id) + +Retrieves the list of supervised accounts that the signed-in user has access to and blocks the given channel for each of them. + +**Returns:** `Promise.` + +| Param | Type | Description | +| --- | --- | --- | +| channel_id | `string` | Channel id | \ No newline at end of file diff --git a/examples/blockchannel/index.js b/examples/blockchannel/index.js new file mode 100644 index 000000000..be9afc1e2 --- /dev/null +++ b/examples/blockchannel/index.js @@ -0,0 +1,23 @@ +import { Innertube, UniversalCache } from 'youtubei.js'; + +(async () => { + const yt = await Innertube.create({ cache: new UniversalCache(true, './credcache') }); + + yt.session.on('auth-pending', (data) => { + console.log(`Go to ${data.verification_url} in your browser and enter code ${data.user_code} to authenticate.`); + }); + yt.session.on('auth', async () => { + console.log('Sign in successful'); + await yt.session.oauth.cacheCredentials(); + }); + yt.session.on('update-credentials', async () => { + await yt.session.oauth.cacheCredentials(); + }); + + // Attempt to sign in + await yt.session.signIn(); + + // Block Channel for all kids / profiles on the signed-in account. + const resp = await yt.kids.blockChannel('UCpbpfcZfo-hoDAx2m1blFhg'); + console.info('Blocked channel for ', resp.length, ' profiles.'); +})(); \ No newline at end of file diff --git a/src/core/clients/Kids.ts b/src/core/clients/Kids.ts index 3f58a802a..5981f3ef3 100644 --- a/src/core/clients/Kids.ts +++ b/src/core/clients/Kids.ts @@ -1,16 +1,22 @@ +import Parser from '../../parser/index.js'; import Channel from '../../parser/ytkids/Channel.js'; import HomeFeed from '../../parser/ytkids/HomeFeed.js'; import Search from '../../parser/ytkids/Search.js'; import VideoInfo from '../../parser/ytkids/VideoInfo.js'; import type Session from '../Session.js'; +import { type ApiResponse } from '../Actions.js'; -import { generateRandomString } from '../../utils/Utils.js'; +import { InnertubeError, generateRandomString } from '../../utils/Utils.js'; import { BrowseEndpoint, NextEndpoint, PlayerEndpoint, SearchEndpoint } from '../endpoints/index.js'; +import { BlocklistPickerEndpoint } from '../endpoints/kids/index.js'; + +import KidsBlocklistPickerItem from '../../parser/classes/ytkids/KidsBlocklistPickerItem.js'; + export default class Kids { #session: Session; @@ -80,4 +86,38 @@ export default class Kids { ); return new HomeFeed(this.#session.actions, response); } + + /** + * Retrieves the list of supervised accounts that the signed-in user has + * access to, and blocks the given channel for each of them. + * @param channel_id - The channel id to block. + * @returns A list of API responses. + */ + async blockChannel(channel_id: string): Promise { + if (!this.#session.logged_in) + throw new InnertubeError('You must be signed in to perform this operation.'); + + const blocklist_payload = BlocklistPickerEndpoint.build({ channel_id: channel_id }); + const response = await this.#session.actions.execute(BlocklistPickerEndpoint.PATH, blocklist_payload ); + const popup = response.data.command.confirmDialogEndpoint; + const popup_fragment = { contents: popup.content, engagementPanels: [] }; + const kid_picker = Parser.parseResponse(popup_fragment); + const kids = kid_picker.contents_memo?.getType(KidsBlocklistPickerItem); + + if (!kids) + throw new InnertubeError('Could not find any kids profiles or supervised accounts.'); + + // Iterate through the kids and block the channel if not already blocked. + const responses: ApiResponse[] = []; + + for (const kid of kids) { + if (!kid.block_button?.is_toggled) { + kid.setActions(this.#session.actions); + // Block channel and add to the response list. + responses.push(await kid.blockChannel()); + } + } + + return responses; + } } \ No newline at end of file diff --git a/src/core/endpoints/index.ts b/src/core/endpoints/index.ts index 8837334c0..ad8599602 100644 --- a/src/core/endpoints/index.ts +++ b/src/core/endpoints/index.ts @@ -15,4 +15,5 @@ export * as Music from './music/index.js'; export * as Notification from './notification/index.js'; export * as Playlist from './playlist/index.js'; export * as Subscription from './subscription/index.js'; -export * as Upload from './upload/index.js'; \ No newline at end of file +export * as Upload from './upload/index.js'; +export * as Kids from './kids/index.js'; \ No newline at end of file diff --git a/src/core/endpoints/kids/BlocklistPickerEndpoint.ts b/src/core/endpoints/kids/BlocklistPickerEndpoint.ts new file mode 100644 index 000000000..06f0678ab --- /dev/null +++ b/src/core/endpoints/kids/BlocklistPickerEndpoint.ts @@ -0,0 +1,12 @@ +import type { IBlocklistPickerRequest, BlocklistPickerRequestEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/kids/get_kids_blocklist_picker'; + +/** + * Builds a `/kids/get_kids_blocklist_picker` request payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: BlocklistPickerRequestEndpointOptions): IBlocklistPickerRequest { + return { blockedForKidsContent: { external_channel_id: options.channel_id } }; +} \ No newline at end of file diff --git a/src/core/endpoints/kids/index.ts b/src/core/endpoints/kids/index.ts new file mode 100644 index 000000000..04b9ed5eb --- /dev/null +++ b/src/core/endpoints/kids/index.ts @@ -0,0 +1 @@ +export * as BlocklistPickerEndpoint from './BlocklistPickerEndpoint.js'; \ No newline at end of file diff --git a/src/parser/classes/ToggleButton.ts b/src/parser/classes/ToggleButton.ts index f8a3b8162..f0a9b8ac1 100644 --- a/src/parser/classes/ToggleButton.ts +++ b/src/parser/classes/ToggleButton.ts @@ -28,7 +28,7 @@ export default class ToggleButton extends YTNode { this.toggled_tooltip = data.toggledTooltip; this.is_toggled = data.isToggled; this.is_disabled = data.isDisabled; - this.icon_type = data.defaultIcon.iconType; + this.icon_type = data.defaultIcon?.iconType; const acc_label = data?.defaultText?.accessibility?.accessibilityData?.label || diff --git a/src/parser/classes/ytkids/KidsBlocklistPicker.ts b/src/parser/classes/ytkids/KidsBlocklistPicker.ts new file mode 100644 index 000000000..51e29c661 --- /dev/null +++ b/src/parser/classes/ytkids/KidsBlocklistPicker.ts @@ -0,0 +1,22 @@ +import Text from '../misc/Text.js'; +import { YTNode } from '../../helpers.js'; +import Button from '../Button.js'; +import Parser, { type RawNode } from '../../index.js'; +import KidsBlocklistPickerItem from './KidsBlocklistPickerItem.js'; + +export default class KidsBlocklistPicker extends YTNode { + static type = 'KidsBlocklistPicker'; + + title: Text; + child_rows: KidsBlocklistPickerItem[] | null; + done_button: Button | null; + successful_toast_action_message: Text; + + constructor(data: RawNode) { + super(); + this.title = new Text(data.title); + this.child_rows = Parser.parse(data.childRows, true, [ KidsBlocklistPickerItem ]); + this.done_button = Parser.parseItem(data.doneButton, [ Button ]); + this.successful_toast_action_message = new Text(data.successfulToastActionMessage); + } +} \ No newline at end of file diff --git a/src/parser/classes/ytkids/KidsBlocklistPickerItem.ts b/src/parser/classes/ytkids/KidsBlocklistPickerItem.ts new file mode 100644 index 000000000..0933bfe07 --- /dev/null +++ b/src/parser/classes/ytkids/KidsBlocklistPickerItem.ts @@ -0,0 +1,49 @@ +import Text from '../misc/Text.js'; +import { YTNode } from '../../helpers.js'; +import Parser, { type RawNode } from '../../index.js'; +import ToggleButton from '../ToggleButton.js'; +import Thumbnail from '../misc/Thumbnail.js'; +import type Actions from '../../../core/Actions.js'; +import { InnertubeError } from '../../../utils/Utils.js'; +import { type ApiResponse } from '../../../core/Actions.js'; + +export default class KidsBlocklistPickerItem extends YTNode { + static type = 'KidsBlocklistPickerItem'; + + #actions?: Actions; + + child_display_name: Text; + child_account_description: Text; + avatar: Thumbnail[]; + block_button: ToggleButton | null; + blocked_entity_key: string; + + constructor(data: RawNode) { + super(); + this.child_display_name = new Text(data.childDisplayName); + this.child_account_description = new Text(data.childAccountDescription); + this.avatar = Thumbnail.fromResponse(data.avatar); + this.block_button = Parser.parseItem(data.blockButton, [ ToggleButton ]); + this.blocked_entity_key = data.blockedEntityKey; + } + + async blockChannel(): Promise { + if (!this.#actions) + throw new InnertubeError('An active caller must be provide to perform this operation.'); + + const button = this.block_button; + + if (!button) + throw new InnertubeError('Block button was not found.', { child_display_name: this.child_display_name }); + + if (button.is_toggled) + throw new InnertubeError('This channel is already blocked.', { child_display_name: this.child_display_name }); + + const response = await button.endpoint.call(this.#actions, { parse: false }); + return response; + } + + setActions(actions: Actions | undefined) { + this.#actions = actions; + } +} \ No newline at end of file diff --git a/src/parser/nodes.ts b/src/parser/nodes.ts index ac04857ba..afa97a91d 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -393,6 +393,8 @@ export { default as WatchNextEndScreen } from './classes/WatchNextEndScreen.js'; export { default as WatchNextTabbedResults } from './classes/WatchNextTabbedResults.js'; export { default as YpcTrailer } from './classes/YpcTrailer.js'; export { default as AnchoredSection } from './classes/ytkids/AnchoredSection.js'; +export { default as KidsBlocklistPicker } from './classes/ytkids/KidsBlocklistPicker.js'; +export { default as KidsBlocklistPickerItem } from './classes/ytkids/KidsBlocklistPickerItem.js'; export { default as KidsCategoriesHeader } from './classes/ytkids/KidsCategoriesHeader.js'; export { default as KidsCategoryTab } from './classes/ytkids/KidsCategoryTab.js'; export { default as KidsHomeScreen } from './classes/ytkids/KidsHomeScreen.js'; diff --git a/src/types/Endpoints.ts b/src/types/Endpoints.ts index 1de4de848..124d6ec5e 100644 --- a/src/types/Endpoints.ts +++ b/src/types/Endpoints.ts @@ -352,4 +352,14 @@ export interface IEditPlaylistRequest extends ObjectSnakeToCamel