diff --git a/README.md b/README.md index daddbb69..b9434b60 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ import dashjs from 'dashjs'; const youtube = await Innertube.create({ /* setup - see above */ }); -// get the video info +// Get the video info const videoInfo = await youtube.getInfo('videoId'); // now convert to a dash manifest @@ -191,7 +191,7 @@ const player = dashjs.MediaPlayer().create(); player.initialize(videoElement, uri, true); ``` -A fully working example can be found in [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/blob/main/examples/browser/web). Alternatively, you can view it live at [ytjsexample.pages.dev](https://ytjsexample.pages.dev/). +A fully working example can be found in [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/blob/main/examples/browser/web). diff --git a/deno/package.json b/deno/package.json index 98d7d988..39505cd1 100644 --- a/deno/package.json +++ b/deno/package.json @@ -1,6 +1,6 @@ { "name": "youtubei.js", - "version": "6.4.1", + "version": "7.0.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", @@ -71,7 +71,7 @@ "build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod", "build:parser-map": "node ./scripts/gen-parser-map.mjs", "build:proto": "npx pb-gen-ts --entry-path=\"src/proto\" --out-dir=\"src/proto/generated\" --ext-in-import=\".js\"", - "build:esm": "npx tsc", + "build:esm": "npx tspc", "build:deno": "npx cpy ./src ./deno && npx esbuild ./src/utils/DashManifest.tsx --keep-names --format=esm --platform=neutral --target=es2020 --outfile=./deno/src/utils/DashManifest.js && npx cpy ./package.json ./deno && npx replace \".ts';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'./DashManifest.js';\" \"'./DashManifest.js';\" ./deno -r && npx replace \"'https://esm.sh/jintr';\" \"'https://esm.sh/jintr';\" ./deno -r", "bundle:node": "npx esbuild ./dist/src/platform/node.js --bundle --target=node10 --keep-names --format=cjs --platform=node --outfile=./bundle/node.cjs --external:jintr --external:undici --external:linkedom --external:tslib --sourcemap --banner:js=\"/* eslint-disable */\"", "bundle:browser": "npx esbuild ./dist/src/platform/web.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/browser.js --platform=browser", @@ -104,6 +104,8 @@ "pbkit": "^0.0.59", "replace": "^1.2.2", "ts-jest": "^28.0.8", + "ts-patch": "^3.0.2", + "ts-transformer-inline-file": "^0.2.0", "typescript": "^5.0.0" }, "bugs": { diff --git a/deno/src/core/clients/Kids.ts b/deno/src/core/clients/Kids.ts index ddd8ddbe..2418e858 100644 --- a/deno/src/core/clients/Kids.ts +++ b/deno/src/core/clients/Kids.ts @@ -1,16 +1,22 @@ +import Parser from '../../parser/index.ts'; import Channel from '../../parser/ytkids/Channel.ts'; import HomeFeed from '../../parser/ytkids/HomeFeed.ts'; import Search from '../../parser/ytkids/Search.ts'; import VideoInfo from '../../parser/ytkids/VideoInfo.ts'; import type Session from '../Session.ts'; +import { type ApiResponse } from '../Actions.ts'; -import { generateRandomString } from '../../utils/Utils.ts'; +import { InnertubeError, generateRandomString } from '../../utils/Utils.ts'; import { BrowseEndpoint, NextEndpoint, PlayerEndpoint, SearchEndpoint } from '../endpoints/index.ts'; +import { BlocklistPickerEndpoint } from '../endpoints/kids/index.ts'; + +import KidsBlocklistPickerItem from '../../parser/classes/ytkids/KidsBlocklistPickerItem.ts'; + 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/deno/src/core/clients/Music.ts b/deno/src/core/clients/Music.ts index 0d22d229..de1faa9b 100644 --- a/deno/src/core/clients/Music.ts +++ b/deno/src/core/clients/Music.ts @@ -20,7 +20,7 @@ import SectionList from '../../parser/classes/SectionList.ts'; import Tab from '../../parser/classes/Tab.ts'; import * as Proto from '../../proto/index.ts'; -import type { ObservedArray, YTNode } from '../../parser/helpers.ts'; +import type { ObservedArray } from '../../parser/helpers.ts'; import type { MusicSearchFilters } from '../../types/index.ts'; import { InnertubeError, generateRandomString, throwIfMissing } from '../../utils/Utils.ts'; import type Actions from '../Actions.ts'; @@ -355,17 +355,17 @@ export default class Music { * Retrieves search suggestions for the given query. * @param query - The query. */ - async getSearchSuggestions(query: string): Promise> { + async getSearchSuggestions(query: string): Promise> { const response = await this.#actions.execute( GetSearchSuggestionsEndpoint.PATH, { ...GetSearchSuggestionsEndpoint.build({ input: query }), parse: true } ); if (!response.contents_memo) - throw new InnertubeError('Unexpected response', response); + return [] as unknown as ObservedArray; - const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection).first(); + const search_suggestions_sections = response.contents_memo.getType(SearchSuggestionsSection); - return search_suggestions_section.contents; + return search_suggestions_sections; } } \ No newline at end of file diff --git a/deno/src/core/endpoints/index.ts b/deno/src/core/endpoints/index.ts index e8ff4e2d..8e64dd6e 100644 --- a/deno/src/core/endpoints/index.ts +++ b/deno/src/core/endpoints/index.ts @@ -15,4 +15,5 @@ export * as Music from './music/index.ts'; export * as Notification from './notification/index.ts'; export * as Playlist from './playlist/index.ts'; export * as Subscription from './subscription/index.ts'; -export * as Upload from './upload/index.ts'; \ No newline at end of file +export * as Upload from './upload/index.ts'; +export * as Kids from './kids/index.ts'; \ No newline at end of file diff --git a/deno/src/core/endpoints/kids/BlocklistPickerEndpoint.ts b/deno/src/core/endpoints/kids/BlocklistPickerEndpoint.ts new file mode 100644 index 00000000..ca0d6f7c --- /dev/null +++ b/deno/src/core/endpoints/kids/BlocklistPickerEndpoint.ts @@ -0,0 +1,12 @@ +import type { IBlocklistPickerRequest, BlocklistPickerRequestEndpointOptions } from '../../../types/index.ts'; + +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/deno/src/core/endpoints/kids/index.ts b/deno/src/core/endpoints/kids/index.ts new file mode 100644 index 00000000..570c3d6f --- /dev/null +++ b/deno/src/core/endpoints/kids/index.ts @@ -0,0 +1 @@ +export * as BlocklistPickerEndpoint from './BlocklistPickerEndpoint.ts'; \ No newline at end of file diff --git a/deno/src/core/mixins/MediaInfo.ts b/deno/src/core/mixins/MediaInfo.ts index 63001406..cb7f57eb 100644 --- a/deno/src/core/mixins/MediaInfo.ts +++ b/deno/src/core/mixins/MediaInfo.ts @@ -46,10 +46,16 @@ export default class MediaInfo { * @returns DASH manifest */ async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter, options: DashOptions = { include_thumbnails: false }): Promise { + const player_response = this.#page[0]; + + if (player_response.video_details && (player_response.video_details.is_live || player_response.video_details.is_post_live_dvr)) { + throw new InnertubeError('Generating DASH manifests for live and Post-Live-DVR videos is not supported. Please use the DASH and HLS manifests provided by YouTube in `streaming_data.dash_manifest_url` and `streaming_data.hls_manifest_url` instead.'); + } + let storyboards; - if (options.include_thumbnails && this.#page[0].storyboards?.is(PlayerStoryboardSpec)) { - storyboards = this.#page[0].storyboards; + if (options.include_thumbnails && player_response.storyboards?.is(PlayerStoryboardSpec)) { + storyboards = player_response.storyboards; } return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions, storyboards); @@ -83,6 +89,12 @@ export default class MediaInfo { * @param options - Download options. */ async download(options: DownloadOptions = {}): Promise> { + const player_response = this.#page[0]; + + if (player_response.video_details && (player_response.video_details.is_live || player_response.video_details.is_post_live_dvr)) { + throw new InnertubeError('Downloading is not supported for live and Post-Live-DVR videos, as they are split up into 5 second segments that are individual files, which require using a tool such as ffmpeg to stitch them together, so they cannot be returned in a single stream.'); + } + return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player, this.cpn); } diff --git a/deno/src/parser/classes/PlayerControlsOverlay.ts b/deno/src/parser/classes/PlayerControlsOverlay.ts new file mode 100644 index 00000000..d67319b0 --- /dev/null +++ b/deno/src/parser/classes/PlayerControlsOverlay.ts @@ -0,0 +1,14 @@ +import { YTNode } from '../helpers.ts'; +import { Parser, type RawNode } from '../index.ts'; +import PlayerOverflow from './PlayerOverflow.ts'; + +export default class PlayerControlsOverlay extends YTNode { + static type = 'PlayerControlsOverlay'; + + overflow: PlayerOverflow | null; + + constructor(data: RawNode) { + super(); + this.overflow = Parser.parseItem(data.overflow, PlayerOverflow); + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/PlayerOverflow.ts b/deno/src/parser/classes/PlayerOverflow.ts new file mode 100644 index 00000000..0053fde0 --- /dev/null +++ b/deno/src/parser/classes/PlayerOverflow.ts @@ -0,0 +1,16 @@ +import { YTNode } from '../helpers.ts'; +import type { RawNode } from '../index.ts'; +import NavigationEndpoint from './NavigationEndpoint.ts'; + +export default class PlayerOverflow extends YTNode { + static type = 'PlayerOverflow'; + + endpoint: NavigationEndpoint; + enable_listen_first: boolean; + + constructor(data: RawNode) { + super(); + this.endpoint = new NavigationEndpoint(data.endpoint); + this.enable_listen_first = data.enableListenFirst; + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/ToggleButton.ts b/deno/src/parser/classes/ToggleButton.ts index ea28107b..e39b00be 100644 --- a/deno/src/parser/classes/ToggleButton.ts +++ b/deno/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/deno/src/parser/classes/livechat/UpdateViewershipAction.ts b/deno/src/parser/classes/livechat/UpdateViewershipAction.ts index 0294e552..5f667457 100644 --- a/deno/src/parser/classes/livechat/UpdateViewershipAction.ts +++ b/deno/src/parser/classes/livechat/UpdateViewershipAction.ts @@ -7,6 +7,8 @@ export default class UpdateViewershipAction extends YTNode { view_count: Text; extra_short_view_count: Text; + original_view_count: Number; + unlabeled_view_count_value: Text; is_live: boolean; constructor(data: RawNode) { @@ -14,6 +16,8 @@ export default class UpdateViewershipAction extends YTNode { const view_count_renderer = data.viewCount.videoViewCountRenderer; this.view_count = new Text(view_count_renderer.viewCount); this.extra_short_view_count = new Text(view_count_renderer.extraShortViewCount); + this.original_view_count = parseInt(view_count_renderer.originalViewCount); + this.unlabeled_view_count_value = new Text(view_count_renderer.unlabeledViewCountValue); this.is_live = view_count_renderer.isLive; } } \ No newline at end of file diff --git a/deno/src/parser/classes/ytkids/KidsBlocklistPicker.ts b/deno/src/parser/classes/ytkids/KidsBlocklistPicker.ts new file mode 100644 index 00000000..0a00f95d --- /dev/null +++ b/deno/src/parser/classes/ytkids/KidsBlocklistPicker.ts @@ -0,0 +1,22 @@ +import Text from '../misc/Text.ts'; +import { YTNode } from '../../helpers.ts'; +import Button from '../Button.ts'; +import Parser, { type RawNode } from '../../index.ts'; +import KidsBlocklistPickerItem from './KidsBlocklistPickerItem.ts'; + +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/deno/src/parser/classes/ytkids/KidsBlocklistPickerItem.ts b/deno/src/parser/classes/ytkids/KidsBlocklistPickerItem.ts new file mode 100644 index 00000000..99379b5a --- /dev/null +++ b/deno/src/parser/classes/ytkids/KidsBlocklistPickerItem.ts @@ -0,0 +1,49 @@ +import Text from '../misc/Text.ts'; +import { YTNode } from '../../helpers.ts'; +import Parser, { type RawNode } from '../../index.ts'; +import ToggleButton from '../ToggleButton.ts'; +import Thumbnail from '../misc/Thumbnail.ts'; +import type Actions from '../../../core/Actions.ts'; +import { InnertubeError } from '../../../utils/Utils.ts'; +import { type ApiResponse } from '../../../core/Actions.ts'; + +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/deno/src/parser/nodes.ts b/deno/src/parser/nodes.ts index 47aeda90..1081ec70 100644 --- a/deno/src/parser/nodes.ts +++ b/deno/src/parser/nodes.ts @@ -252,11 +252,13 @@ export { default as PageHeaderView } from './classes/PageHeaderView.ts'; export { default as PageIntroduction } from './classes/PageIntroduction.ts'; export { default as PlayerAnnotationsExpanded } from './classes/PlayerAnnotationsExpanded.ts'; export { default as PlayerCaptionsTracklist } from './classes/PlayerCaptionsTracklist.ts'; +export { default as PlayerControlsOverlay } from './classes/PlayerControlsOverlay.ts'; export { default as PlayerErrorMessage } from './classes/PlayerErrorMessage.ts'; export { default as PlayerLegacyDesktopYpcOffer } from './classes/PlayerLegacyDesktopYpcOffer.ts'; export { default as PlayerLegacyDesktopYpcTrailer } from './classes/PlayerLegacyDesktopYpcTrailer.ts'; export { default as PlayerLiveStoryboardSpec } from './classes/PlayerLiveStoryboardSpec.ts'; export { default as PlayerMicroformat } from './classes/PlayerMicroformat.ts'; +export { default as PlayerOverflow } from './classes/PlayerOverflow.ts'; export { default as PlayerOverlay } from './classes/PlayerOverlay.ts'; export { default as PlayerOverlayAutoplay } from './classes/PlayerOverlayAutoplay.ts'; export { default as PlayerStoryboardSpec } from './classes/PlayerStoryboardSpec.ts'; @@ -391,6 +393,8 @@ export { default as WatchNextEndScreen } from './classes/WatchNextEndScreen.ts'; export { default as WatchNextTabbedResults } from './classes/WatchNextTabbedResults.ts'; export { default as YpcTrailer } from './classes/YpcTrailer.ts'; export { default as AnchoredSection } from './classes/ytkids/AnchoredSection.ts'; +export { default as KidsBlocklistPicker } from './classes/ytkids/KidsBlocklistPicker.ts'; +export { default as KidsBlocklistPickerItem } from './classes/ytkids/KidsBlocklistPickerItem.ts'; export { default as KidsCategoriesHeader } from './classes/ytkids/KidsCategoriesHeader.ts'; export { default as KidsCategoryTab } from './classes/ytkids/KidsCategoryTab.ts'; export { default as KidsHomeScreen } from './classes/ytkids/KidsHomeScreen.ts'; diff --git a/deno/src/platform/node.ts b/deno/src/platform/node.ts index 29b63d52..3086840d 100644 --- a/deno/src/platform/node.ts +++ b/deno/src/platform/node.ts @@ -15,17 +15,17 @@ import type { FetchFunction } from '../types/PlatformShim.ts'; import path from 'path'; import os from 'os'; import fs from 'fs/promises'; -import { readFileSync } from 'fs'; import CustomEvent from './polyfills/node-custom-event.ts'; import { fileURLToPath } from 'url'; import evaluate from './jsruntime/jinter.ts'; +import { $INLINE_JSON } from 'ts-transformer-inline-file'; const meta_url = import.meta.url; const is_cjs = !meta_url; const __dirname__ = is_cjs ? __dirname : path.dirname(fileURLToPath(meta_url)); -const package_json = JSON.parse(readFileSync(path.resolve(__dirname__, is_cjs ? '../package.json' : '../../package.json'), 'utf-8')); -const repo_url = package_json.homepage?.split('#')[0]; +const { homepage, version, bugs } = $INLINE_JSON('../../package.json'); +const repo_url = homepage?.split('#')[0]; class Cache implements ICache { #persistent_directory: string; @@ -101,8 +101,8 @@ class Cache implements ICache { Platform.load({ runtime: 'node', info: { - version: package_json.version, - bugs_url: package_json.bugs?.url || `${repo_url}/issues`, + version: version, + bugs_url: bugs?.url || `${repo_url}/issues`, repo_url }, server: true, diff --git a/deno/src/types/Endpoints.ts b/deno/src/types/Endpoints.ts index b732373e..7b14eeb0 100644 --- a/deno/src/types/Endpoints.ts +++ b/deno/src/types/Endpoints.ts @@ -352,4 +352,14 @@ export interface IEditPlaylistRequest extends ObjectSnakeToCamel