diff --git a/README.md b/README.md
index 9900e1709..dae71bbde 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
A full-featured wrapper around the InnerTube API, which is what YouTube itself uses
+
@@ -84,7 +84,7 @@ ___
## Description
-InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform[^1]. This library handles all the low-level communication with InnerTube, providing a simple, and efficient way to interact with YouTube programmatically. It is designed to emulate an actual client as closely as possible, including how API responses are [parsed](https://github.com/LuanRT/YouTube.js/tree/main/src/parser#how-it-works).
+InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform[^1]. This library handles all the low-level communication with InnerTube, providing a simple, and efficient way to interact with YouTube programmatically. It is designed to emulate an actual client as closely as possible, including how API responses are parsed.
If you have any questions or need help, feel free to reach out to us on our [Discord server][discord] or open an issue [here](https://github.com/LuanRT/YouTube.js/issues).
@@ -229,6 +229,7 @@ const yt = await Innertube.create({
* [.playlist](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/playlist.md)
* [.music](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/music.md)
* [.studio](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/studio.md)
+ * [.kids](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/kids.md)
diff --git a/docs/API/account.md b/docs/API/account.md
index dbb949868..8161d3893 100644
--- a/docs/API/account.md
+++ b/docs/API/account.md
@@ -46,7 +46,7 @@ Retrieves account information.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
@@ -63,7 +63,7 @@ Retrieves time watched statistics.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
@@ -91,6 +91,9 @@ Retrieves YouTube settings.
- `
#sidebar_items`
- Returns options available in the sidebar menu.
+- `#page`
+ - Returns the original InnerTube response(s), parsed and sanitized.
+
@@ -106,7 +109,7 @@ Retrieves basic channel analytics.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
\ No newline at end of file
diff --git a/docs/API/kids.md b/docs/API/kids.md
new file mode 100644
index 000000000..ec1ad7825
--- /dev/null
+++ b/docs/API/kids.md
@@ -0,0 +1,85 @@
+# YouTube Kids
+
+YouTube Kids is a modified version of the YouTube app, with a simplified interface and curated content. This class allows you to interact with its API.
+
+## API
+
+* Kids
+ * [.search(query)](#search)
+ * [.getInfo(video_id)](#getinfo)
+ * [.getHomeFeed()](#gethomefeed)
+
+
+### search(query)
+
+Searches the given query on YouTube Kids.
+
+**Returns:** `Promise.`
+
+| Param | Type | Description |
+| --- | --- | --- |
+| query | `string` | The query to search |
+
+
+
+Methods & Getters
+
+
+- `#page`
+ - Returns the original InnerTube response(s), parsed and sanitized.
+
+
+
+
+
+### getInfo(video_id)
+
+Retrieves video info.
+
+**Returns:** `Promise.`
+
+| Param | Type | Description |
+| --- | --- | --- |
+| video_id | `string` | The video id |
+
+
+Methods & Getters
+
+
+- `#toDash(url_transformer?)`
+ - Generates a DASH manifest from the streaming data.
+
+- `#chooseFormat(options)`
+ - Selects the format that best matches the given options. This method is used internally by `#download`.
+
+- `#download(options?)`
+ - Downloads the video.
+
+- `#addToWatchHistory()`
+ - Adds the video to the watch history.
+
+- `#page`
+ - Returns the original InnerTube response(s), parsed and sanitized.
+
+
+
+
+
+### getHomeFeed()
+
+Retrieves the home feed.
+
+**Returns:** `Promise.`
+
+
+Methods & Getters
+
+
+- `#selectCategoryTab(tab: string | KidsCategoryTab)`
+ - Selects the given category tab.
+
+- `#categories`
+ - Returns available categories.
+
+- `#page`
+ - Returns the original InnerTube response(s), parsed and sanitized.
\ No newline at end of file
diff --git a/docs/API/music.md b/docs/API/music.md
index 8bad7fb9e..8368b900e 100644
--- a/docs/API/music.md
+++ b/docs/API/music.md
@@ -1,6 +1,6 @@
-# Music
+# YouTube Music
-YouTube Music class.
+YouTube Music is a music streaming service developed by YouTube, a subsidiary of Google. It provides a tailored interface for the service oriented towards music streaming, with a greater emphasis on browsing and discovery compared to its main service. This class allows you to interact with its API.
## API
@@ -49,6 +49,21 @@ Retrieves track info.
- `#available_tabs`
- Returns available tabs.
+- `#toDash(url_transformer?)`
+ - Generates a DASH manifest from the streaming data.
+
+- `#chooseFormat(options)`
+ - Selects the format that best matches the given options. This method is used internally by `#download`.
+
+- `#download(options?)`
+ - Downloads the track.
+
+- `#addToWatchHistory()`
+ - Adds the song to the watch history.
+
+- `#page`
+ - Returns the original InnerTube response(s), parsed and sanitized.
+
@@ -99,7 +114,7 @@ Searches on YouTube Music.
- Returns songs shelf.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
@@ -124,6 +139,9 @@ Retrieves home feed.
- `#page`
- Returns original InnerTube response (sanitized).
+- `#page`
+ - Returns the original InnerTube response(s), parsed and sanitized.
+
@@ -139,7 +157,7 @@ Retrieves “Explore” feed.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
@@ -174,7 +192,7 @@ Retrieves library.
- Returns available sort options.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
@@ -195,7 +213,7 @@ Retrieves artist's info & content.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
@@ -216,7 +234,7 @@ Retrieves given album.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
@@ -249,7 +267,7 @@ Retrieves given playlist.
- Checks if continuation is available.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
@@ -303,7 +321,7 @@ Retrieves your YouTube Music recap.
- Retrieves recap playlist.
- `#page`
- - Returns original InnerTube response (sanitized).
+ - Returns the original InnerTube response(s), parsed and sanitized.
diff --git a/src/Innertube.ts b/src/Innertube.ts
index 94985c3d0..1284d58fa 100644
--- a/src/Innertube.ts
+++ b/src/Innertube.ts
@@ -11,14 +11,15 @@ import Library from './parser/youtube/Library';
import NotificationsMenu from './parser/youtube/NotificationsMenu';
import Playlist from './parser/youtube/Playlist';
import Search from './parser/youtube/Search';
-import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo';
+import VideoInfo from './parser/youtube/VideoInfo';
import AccountManager from './core/AccountManager';
import Feed from './core/Feed';
import InteractionManager from './core/InteractionManager';
import YTMusic from './core/Music';
import PlaylistManager from './core/PlaylistManager';
-import Studio from './core/Studio';
+import YTStudio from './core/Studio';
+import YTKids from './core/Kids';
import TabbedFeed from './core/TabbedFeed';
import HomeFeed from './parser/youtube/HomeFeed';
import Proto from './proto/index';
@@ -28,6 +29,7 @@ import type Actions from './core/Actions';
import type Format from './parser/classes/misc/Format';
import { generateRandomString, throwIfMissing } from './utils/Utils';
+import type { FormatOptions, DownloadOptions } from './utils/FormatUtils';
export type InnertubeConfig = SessionOptions;
@@ -39,7 +41,7 @@ export interface SearchFilters {
features?: ('hd' | 'subtitles' | 'creative_commons' | '3d' | 'live' | 'purchased' | '4k' | '360' | 'location' | 'hdr' | 'vr180')[];
}
-export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED';
+export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED' | 'YTKIDS'
class Innertube {
session: Session;
@@ -47,7 +49,8 @@ class Innertube {
playlist: PlaylistManager;
interact: InteractionManager;
music: YTMusic;
- studio: Studio;
+ studio: YTStudio;
+ kids: YTKids;
actions: Actions;
constructor(session: Session) {
@@ -56,7 +59,8 @@ class Innertube {
this.playlist = new PlaylistManager(this.session.actions);
this.interact = new InteractionManager(this.session.actions);
this.music = new YTMusic(this.session);
- this.studio = new Studio(this.session);
+ this.studio = new YTStudio(this.session);
+ this.kids = new YTKids(this.session);
this.actions = this.session.actions;
}
diff --git a/src/core/Kids.ts b/src/core/Kids.ts
new file mode 100644
index 000000000..f1c8f7d90
--- /dev/null
+++ b/src/core/Kids.ts
@@ -0,0 +1,57 @@
+import Search from '../parser/ytkids/Search';
+import HomeFeed from '../parser/ytkids/HomeFeed';
+import VideoInfo from '../parser/ytkids/VideoInfo';
+import type Session from './Session';
+import { generateRandomString } from '../utils/Utils';
+
+class Kids {
+ #session: Session;
+
+ constructor(session: Session) {
+ this.#session = session;
+ }
+
+ /**
+ * Searches the given query.
+ * @param query - The query.
+ */
+ async search(query: string): Promise {
+ const response = await this.#session.actions.execute('/search', { query, client: 'YTKIDS' });
+ return new Search(this.#session.actions, response.data);
+ }
+
+ /**
+ * Retrieves video info.
+ * @param video_id - The video id.
+ */
+ async getInfo(video_id: string): Promise {
+ const cpn = generateRandomString(16);
+
+ const initial_info = this.#session.actions.execute('/player', {
+ cpn,
+ client: 'YTKIDS',
+ videoId: video_id,
+ playbackContext: {
+ contentPlaybackContext: {
+ signatureTimestamp: this.#session.player?.sts || 0
+ }
+ }
+ });
+
+ const continuation = this.#session.actions.execute('/next', { videoId: video_id, client: 'YTKIDS' });
+
+ const response = await Promise.all([ initial_info, continuation ]);
+
+ return new VideoInfo(response, this.#session.actions, cpn);
+ }
+
+ /**
+ * Retrieves the home feed.
+ */
+ async getHomeFeed(): Promise {
+ const response = await this.#session.actions.execute('/browse', { browseId: 'FEkids_home', client: 'YTKIDS' });
+ return new HomeFeed(this.#session.actions, response.data);
+ }
+}
+
+export default Kids;
\ No newline at end of file
diff --git a/src/core/Session.ts b/src/core/Session.ts
index 21d50b4f7..f698142d0 100644
--- a/src/core/Session.ts
+++ b/src/core/Session.ts
@@ -11,6 +11,7 @@ import Proto from '../proto';
export enum ClientType {
WEB = 'WEB',
+ KIDS = 'WEB_KIDS',
MUSIC = 'WEB_REMIX',
ANDROID = 'ANDROID',
ANDROID_MUSIC = 'ANDROID_MUSIC',
@@ -45,6 +46,15 @@ export interface Context {
deviceMake: string;
deviceModel: string;
utcOffsetMinutes: number;
+ kidsAppInfo?: {
+ categorySettings: {
+ enabledCategories: string[];
+ };
+ contentSettings: {
+ corpusPreference: string;
+ kidsNoSearchMode: string;
+ };
+ };
};
user: {
enableSafetyMode: boolean;
diff --git a/src/parser/classes/CompactChannel.ts b/src/parser/classes/CompactChannel.ts
new file mode 100644
index 000000000..d8ca2819e
--- /dev/null
+++ b/src/parser/classes/CompactChannel.ts
@@ -0,0 +1,35 @@
+import Parser from '..';
+import Text from './misc/Text';
+import Thumbnail from './misc/Thumbnail';
+import NavigationEndpoint from './NavigationEndpoint';
+import type Menu from './menus/Menu';
+import { YTNode } from '../helpers';
+
+class CompactChannel extends YTNode {
+ static type = 'CompactChannel';
+
+ title: Text;
+ channel_id: string;
+ thumbnail: Thumbnail[];
+ display_name: Text;
+ video_count: Text;
+ subscriber_count: Text;
+ endpoint: NavigationEndpoint;
+ tv_banner: Thumbnail[];
+ menu: Menu | null;
+
+ constructor(data: any) {
+ super();
+ this.title = new Text(data.title);
+ this.channel_id = data.channelId;
+ this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
+ this.display_name = new Text(data.displayName);
+ this.video_count = new Text(data.videoCountText);
+ this.subscriber_count = new Text(data.subscriberCountText);
+ this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
+ this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
+ this.menu = Parser.parseItem(data.menu);
+ }
+}
+
+export default CompactChannel;
\ No newline at end of file
diff --git a/src/parser/classes/SlimOwner.ts b/src/parser/classes/SlimOwner.ts
new file mode 100644
index 000000000..ae7d2ed78
--- /dev/null
+++ b/src/parser/classes/SlimOwner.ts
@@ -0,0 +1,25 @@
+import Parser from '..';
+import Text from './misc/Text';
+import Thumbnail from './misc/Thumbnail';
+import NavigationEndpoint from './NavigationEndpoint';
+import SubscribeButton from './SubscribeButton';
+import { YTNode } from '../helpers';
+
+class SlimOwner extends YTNode {
+ static type = 'SlimOwner';
+
+ thumbnail: Thumbnail[];
+ title: Text;
+ endpoint: NavigationEndpoint;
+ subscribe_button: SubscribeButton | null;
+
+ constructor(data: any) {
+ super();
+ this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
+ this.title = new Text(data.title);
+ this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
+ this.subscribe_button = Parser.parseItem(data.subscribeButton, SubscribeButton);
+ }
+}
+
+export default SlimOwner;
\ No newline at end of file
diff --git a/src/parser/classes/SlimVideoMetadata.ts b/src/parser/classes/SlimVideoMetadata.ts
new file mode 100644
index 000000000..e256ae7b2
--- /dev/null
+++ b/src/parser/classes/SlimVideoMetadata.ts
@@ -0,0 +1,28 @@
+import Parser from '..';
+import Text from './misc/Text';
+import { YTNode } from '../helpers';
+
+class SlimVideoMetadata extends YTNode {
+ static type = 'SlimVideoMetadata';
+
+ title: Text;
+ collapsed_subtitle: Text;
+ expanded_subtitle: Text;
+ owner: any;
+ description: Text;
+ video_id: string;
+ date: Text;
+
+ constructor(data: any) {
+ super();
+ this.title = new Text(data.title);
+ this.collapsed_subtitle = new Text(data.collapsedSubtitle);
+ this.expanded_subtitle = new Text(data.expandedSubtitle);
+ this.owner = Parser.parseItem(data.owner);
+ this.description = new Text(data.description);
+ this.video_id = data.videoId;
+ this.date = new Text(data.dateText);
+ }
+}
+
+export default SlimVideoMetadata;
\ No newline at end of file
diff --git a/src/parser/classes/ytkids/AnchoredSection.ts b/src/parser/classes/ytkids/AnchoredSection.ts
new file mode 100644
index 000000000..8b038b862
--- /dev/null
+++ b/src/parser/classes/ytkids/AnchoredSection.ts
@@ -0,0 +1,31 @@
+import Parser from '../..';
+import NavigationEndpoint from '../NavigationEndpoint';
+import type SectionList from '../SectionList';
+import { YTNode } from '../../helpers';
+
+class AnchoredSection extends YTNode {
+ static type = 'AnchoredSection';
+
+ title: string;
+ content: SectionList | null;
+ endpoint: NavigationEndpoint;
+ category_assets: {
+ asset_key: string;
+ background_color: string;
+ };
+ category_type: string;
+
+ constructor(data: any) {
+ super();
+ this.title = data.title;
+ this.content = Parser.parseItem(data.content);
+ this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
+ this.category_assets = {
+ asset_key: data.categoryAssets?.assetKey,
+ background_color: data.categoryAssets?.backgroundColor
+ };
+ this.category_type = data.categoryType;
+ }
+}
+
+export default AnchoredSection;
\ No newline at end of file
diff --git a/src/parser/classes/ytkids/KidsCategoriesHeader.ts b/src/parser/classes/ytkids/KidsCategoriesHeader.ts
new file mode 100644
index 000000000..6806db8d6
--- /dev/null
+++ b/src/parser/classes/ytkids/KidsCategoriesHeader.ts
@@ -0,0 +1,19 @@
+import Parser from '../..';
+import type Button from '../Button';
+import type KidsCategoryTab from './KidsCategoryTab';
+import { YTNode } from '../../helpers';
+
+class KidsCategoriesHeader extends YTNode {
+ static type = 'kidsCategoriesHeader';
+
+ category_tabs: KidsCategoryTab[];
+ privacy_button: Button | null;
+
+ constructor(data: any) {
+ super();
+ this.category_tabs = Parser.parseArray(data.categoryTabs);
+ this.privacy_button = Parser.parseItem(data.privacyButtonRenderer);
+ }
+}
+
+export default KidsCategoriesHeader;
\ No newline at end of file
diff --git a/src/parser/classes/ytkids/KidsCategoryTab.ts b/src/parser/classes/ytkids/KidsCategoryTab.ts
new file mode 100644
index 000000000..19d2d5bf3
--- /dev/null
+++ b/src/parser/classes/ytkids/KidsCategoryTab.ts
@@ -0,0 +1,28 @@
+import Text from '../misc/Text';
+import NavigationEndpoint from '../NavigationEndpoint';
+import { YTNode } from '../../helpers';
+
+class KidsCategoryTab extends YTNode {
+ static type = 'KidsCategoryTab';
+
+ title: Text;
+ category_assets: {
+ asset_key: string;
+ background_color: string;
+ };
+ category_type: string;
+ endpoint: NavigationEndpoint;
+
+ constructor(data: any) {
+ super();
+ this.title = new Text(data.title);
+ this.category_assets = {
+ asset_key: data.categoryAssets?.assetKey,
+ background_color: data.categoryAssets?.backgroundColor
+ };
+ this.category_type = data.categoryType;
+ this.endpoint = new NavigationEndpoint(data.endpoint);
+ }
+}
+
+export default KidsCategoryTab;
\ No newline at end of file
diff --git a/src/parser/classes/ytkids/KidsHomeScreen.ts b/src/parser/classes/ytkids/KidsHomeScreen.ts
new file mode 100644
index 000000000..8c5b6f74d
--- /dev/null
+++ b/src/parser/classes/ytkids/KidsHomeScreen.ts
@@ -0,0 +1,16 @@
+import Parser from '../..';
+import type AnchoredSection from './AnchoredSection';
+import { YTNode } from '../../helpers';
+
+class KidsHomeScreen extends YTNode {
+ static type = 'kidsHomeScreen';
+
+ anchors;
+
+ constructor(data: any) {
+ super();
+ this.anchors = Parser.parseArray(data.anchors);
+ }
+}
+
+export default KidsHomeScreen;
\ No newline at end of file
diff --git a/src/parser/map.ts b/src/parser/map.ts
index a3e2968d8..acc02a2fa 100644
--- a/src/parser/map.ts
+++ b/src/parser/map.ts
@@ -59,6 +59,7 @@ import { default as CreatorHeart } from './classes/comments/CreatorHeart';
import { default as EmojiPicker } from './classes/comments/EmojiPicker';
import { default as PdgCommentChip } from './classes/comments/PdgCommentChip';
import { default as SponsorCommentBadge } from './classes/comments/SponsorCommentBadge';
+import { default as CompactChannel } from './classes/CompactChannel';
import { default as CompactLink } from './classes/CompactLink';
import { default as CompactMix } from './classes/CompactMix';
import { default as CompactPlaylist } from './classes/CompactPlaylist';
@@ -263,6 +264,8 @@ import { default as SingleActionEmergencySupport } from './classes/SingleActionE
import { default as SingleColumnBrowseResults } from './classes/SingleColumnBrowseResults';
import { default as SingleColumnMusicWatchNextResults } from './classes/SingleColumnMusicWatchNextResults';
import { default as SingleHeroImage } from './classes/SingleHeroImage';
+import { default as SlimOwner } from './classes/SlimOwner';
+import { default as SlimVideoMetadata } from './classes/SlimVideoMetadata';
import { default as SortFilterSubMenu } from './classes/SortFilterSubMenu';
import { default as SubFeedOption } from './classes/SubFeedOption';
import { default as SubFeedSelector } from './classes/SubFeedSelector';
@@ -310,6 +313,10 @@ import { default as WatchCardRichHeader } from './classes/WatchCardRichHeader';
import { default as WatchCardSectionSequence } from './classes/WatchCardSectionSequence';
import { default as WatchNextEndScreen } from './classes/WatchNextEndScreen';
import { default as WatchNextTabbedResults } from './classes/WatchNextTabbedResults';
+import { default as AnchoredSection } from './classes/ytkids/AnchoredSection';
+import { default as KidsCategoriesHeader } from './classes/ytkids/KidsCategoriesHeader';
+import { default as KidsCategoryTab } from './classes/ytkids/KidsCategoryTab';
+import { default as KidsHomeScreen } from './classes/ytkids/KidsHomeScreen';
export const YTNodes = {
AccountChannel,
@@ -369,6 +376,7 @@ export const YTNodes = {
EmojiPicker,
PdgCommentChip,
SponsorCommentBadge,
+ CompactChannel,
CompactLink,
CompactMix,
CompactPlaylist,
@@ -573,6 +581,8 @@ export const YTNodes = {
SingleColumnBrowseResults,
SingleColumnMusicWatchNextResults,
SingleHeroImage,
+ SlimOwner,
+ SlimVideoMetadata,
SortFilterSubMenu,
SubFeedOption,
SubFeedSelector,
@@ -619,7 +629,11 @@ export const YTNodes = {
WatchCardRichHeader,
WatchCardSectionSequence,
WatchNextEndScreen,
- WatchNextTabbedResults
+ WatchNextTabbedResults,
+ AnchoredSection,
+ KidsCategoriesHeader,
+ KidsCategoryTab,
+ KidsHomeScreen
};
const map: Record = YTNodes;
diff --git a/src/parser/youtube/VideoInfo.ts b/src/parser/youtube/VideoInfo.ts
index ed917aece..eb8f6fd45 100644
--- a/src/parser/youtube/VideoInfo.ts
+++ b/src/parser/youtube/VideoInfo.ts
@@ -29,48 +29,13 @@ import type PlayerCaptionsTracklist from '../classes/PlayerCaptionsTracklist';
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec';
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec';
-import { DOMParser } from 'linkedom';
-import type { Element } from 'linkedom/types/interface/element';
-import type { Node } from 'linkedom/types/interface/node';
-import type { XMLDocument } from 'linkedom/types/xml/document';
-
import type Player from '../../core/Player';
import type Actions from '../../core/Actions';
import type { ApiResponse } from '../../core/Actions';
import type { ObservedArray, YTNode } from '../helpers';
-import { getStringBetweenStrings, InnertubeError, streamToIterable } from '../../utils/Utils';
-
-export type URLTransformer = (url: URL) => URL;
-
-export interface FormatOptions {
- /**
- * Video quality; 360p, 720p, 1080p, etc... also accepts 'best' and 'bestefficiency'.
- */
- quality?: string;
- /**
- * Download type, can be: video, audio or video+audio
- */
- type?: 'video' | 'audio' | 'video+audio';
- /**
- * File format, use 'any' to download any format
- */
- format?: string;
- /**
- * InnerTube client, can be ANDROID, WEB, YTMUSIC, YTMUSIC_ANDROID, YTSTUDIO_ANDROID or TV_EMBEDDED
- */
- client?: 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED';
-}
-
-export interface DownloadOptions extends FormatOptions {
- /**
- * Download range, indicates which bytes should be downloaded.
- */
- range?: {
- start: number;
- end: number;
- }
-}
+import FormatUtils, { FormatOptions, DownloadOptions, URLTransformer } from '../../utils/FormatUtils';
+import { InnertubeError } from '../../utils/Utils';
class VideoInfo {
#page: [ParsedResponse, ParsedResponse?];
@@ -328,6 +293,31 @@ class VideoInfo {
return new LiveChatWrap(this);
}
+ /**
+ * Selects the format that best matches the given options.
+ * @param options - Options
+ */
+ chooseFormat(options: FormatOptions): Format {
+ return FormatUtils.chooseFormat(options, this.streaming_data);
+ }
+
+ /**
+ * Generates a DASH manifest from the streaming data.
+ * @param url_transformer - Function to transform the URLs.
+ * @returns DASH manifest
+ */
+ toDash(url_transformer: URLTransformer = (url) => url): string {
+ return FormatUtils.toDash(this.streaming_data, url_transformer);
+ }
+
+ /**
+ * Downloads the video.
+ * @param options - Download options.
+ */
+ async download(options: DownloadOptions = {}): Promise> {
+ return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player, this.cpn);
+ }
+
/**
* Watch next feed filters.
*/
@@ -350,19 +340,12 @@ class VideoInfo {
}
/**
- * Checks if continuation is available for the watch next feed.
- */
+ * Checks if continuation is available for the watch next feed.
+ */
get wn_has_continuation(): boolean {
return !!this.#watch_next_continuation;
}
- /**
- * Original parsed InnerTube response.
- */
- get page(): [ParsedResponse, ParsedResponse?] {
- return this.#page;
- }
-
/**
* Get songs used in the video.
*/
@@ -403,321 +386,10 @@ class VideoInfo {
}
/**
- * Selects the format that best matches the given options.
- * @param options - Options
- */
- chooseFormat(options: FormatOptions): Format {
- if (!this.streaming_data)
- throw new InnertubeError('Streaming data not available', { video_id: this.basic_info.id });
-
- const formats = [
- ...(this.streaming_data.formats || []),
- ...(this.streaming_data.adaptive_formats || [])
- ];
-
- const requires_audio = options.type ? options.type.includes('audio') : true;
- const requires_video = options.type ? options.type.includes('video') : true;
- const quality = options.quality || '360p';
-
- let best_width = -1;
-
- const is_best = [ 'best', 'bestefficiency' ].includes(quality);
- const use_most_efficient = quality !== 'best';
-
- let candidates = formats.filter((format) => {
- if (requires_audio && !format.has_audio)
- return false;
- if (requires_video && !format.has_video)
- return false;
- if (options.format !== 'any' && !format.mime_type.includes(options.format || 'mp4'))
- return false;
- if (!is_best && format.quality_label !== quality)
- return false;
- if (best_width < format.width)
- best_width = format.width;
- return true;
- });
-
- if (!candidates.length) {
- throw new InnertubeError('No matching formats found', {
- options
- });
- }
-
- if (is_best && requires_video)
- candidates = candidates.filter((format) => format.width === best_width);
-
- if (requires_audio && !requires_video) {
- const audio_only = candidates.filter((format) => !format.has_video);
- if (audio_only.length > 0) {
- candidates = audio_only;
- }
- }
-
- if (use_most_efficient) {
- // Sort by bitrate (lower is better)
- candidates.sort((a, b) => a.bitrate - b.bitrate);
- } else {
- // Sort by bitrate (higher is better)
- candidates.sort((a, b) => b.bitrate - a.bitrate);
- }
-
- return candidates[0];
- }
-
- #el(document: XMLDocument, tag: string, attrs: Record, children: Node[] = []) {
- const el = document.createElement(tag);
- for (const [ key, value ] of Object.entries(attrs)) {
- el.setAttribute(key, value);
- }
- for (const child of children) {
- if (typeof child === 'undefined') continue;
- el.appendChild(child);
- }
- return el;
- }
-
- /**
- * Generates a DASH manifest from the streaming data.
- * @param url_transformer - Function to transform the URLs.
- * @returns DASH manifest
- */
- toDash(url_transformer: URLTransformer = (url) => url): string {
- if (!this.streaming_data)
- throw new InnertubeError('Streaming data not available', { video_id: this.basic_info.id });
-
- const { adaptive_formats } = this.streaming_data;
-
- const length = adaptive_formats[0].approx_duration_ms / 1000;
-
- const document = new DOMParser().parseFromString('', 'text/xml');
- const period = document.createElement('Period');
-
- document.appendChild(this.#el(document, 'MPD', {
- xmlns: 'urn:mpeg:dash:schema:mpd:2011',
- minBufferTime: 'PT1.500S',
- profiles: 'urn:mpeg:dash:profile:isoff-main:2011',
- type: 'static',
- mediaPresentationDuration: `PT${length}S`,
- 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
- 'xsi:schemaLocation': 'urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd'
- }, [
- period
- ]));
-
- this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer);
-
- return `${document}`;
- }
-
- #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer) {
- const mimeTypes: string[] = [];
- const mimeObjects: Format[][] = [ [] ];
-
- formats.forEach((videoFormat) => {
- if (!videoFormat.index_range || !videoFormat.init_range) {
- return;
- }
- const mimeType = videoFormat.mime_type;
- const mimeTypeIndex = mimeTypes.indexOf(mimeType);
- if (mimeTypeIndex > -1) {
- mimeObjects[mimeTypeIndex].push(videoFormat);
- } else {
- mimeTypes.push(mimeType);
- mimeObjects.push([]);
- mimeObjects[mimeTypes.length - 1].push(videoFormat);
- }
- });
-
- for (let i = 0; i < mimeTypes.length; i++) {
- const set = this.#el(document, 'AdaptationSet', {
- id: `${i}`,
- mimeType: mimeTypes[i].split(';')[0],
- startWithSAP: '1',
- subsegmentAlignment: 'true'
- });
- period.appendChild(set);
- mimeObjects[i].forEach((format) => {
- if (format.has_video) {
- this.#generateRepresentationVideo(document, set, format, url_transformer);
- } else {
- this.#generateRepresentationAudio(document, set, format, url_transformer);
- }
- });
- }
- }
-
- #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer) {
- const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
-
- if (!format.index_range || !format.init_range)
- throw new InnertubeError('Index and init ranges not available', { format });
-
- const url = new URL(format.decipher(this.#player));
- url.searchParams.set('cpn', this.#cpn || '');
-
- set.appendChild(this.#el(document, 'Representation', {
- id: format.itag?.toString(),
- codecs,
- bandwidth: format.bitrate?.toString(),
- width: format.width?.toString(),
- height: format.height?.toString(),
- maxPlayoutRate: '1',
- frameRate: format.fps?.toString()
- }, [
- this.#el(document, 'BaseURL', {}, [
- document.createTextNode(url_transformer(url)?.toString())
- ]),
- this.#el(document, 'SegmentBase', {
- indexRange: `${format.index_range.start}-${format.index_range.end}`
- }, [
- this.#el(document, 'Initialization', {
- range: `${format.init_range.start}-${format.init_range.end}`
- })
- ])
- ]));
- }
-
- #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer) {
- const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
- if (!format.index_range || !format.init_range)
- throw new InnertubeError('Index and init ranges not available', { format });
-
- const url = new URL(format.decipher(this.#player));
- url.searchParams.set('cpn', this.#cpn || '');
-
- set.appendChild(this.#el(document, 'Representation', {
- id: format.itag?.toString(),
- codecs,
- bandwidth: format.bitrate?.toString()
- }, [
- this.#el(document, 'AudioChannelConfiguration', {
- schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
- value: format.audio_channels?.toString() || '2'
- }),
- this.#el(document, 'BaseURL', {}, [
- document.createTextNode(url_transformer(url)?.toString())
- ]),
- this.#el(document, 'SegmentBase', {
- indexRange: `${format.index_range.start}-${format.index_range.end}`
- }, [
- this.#el(document, 'Initialization', {
- range: `${format.init_range.start}-${format.init_range.end}`
- })
- ])
- ]));
- }
-
- /**
- * Downloads the video.
- * @param options - Download options.
+ * Original parsed InnerTube response.
*/
- async download(options: DownloadOptions = {}): Promise> {
- if (this.playability_status?.status === 'UNPLAYABLE')
- throw new InnertubeError('Video is unplayable', { video: this, error_type: 'UNPLAYABLE' });
- if (this.playability_status?.status === 'LOGIN_REQUIRED')
- throw new InnertubeError('Video is login required', { video: this, error_type: 'LOGIN_REQUIRED' });
- if (!this.streaming_data)
- throw new InnertubeError('Streaming data not available.', { video: this, error_type: 'NO_STREAMING_DATA' });
-
- const opts: DownloadOptions = {
- quality: '360p',
- type: 'video+audio',
- format: 'mp4',
- range: undefined,
- ...options
- };
-
- const format = this.chooseFormat(opts);
- const format_url = format.decipher(this.#player);
-
- // If we're not downloading the video in chunks, we just use fetch once.
- if (opts.type === 'video+audio' && !options.range) {
- const response = await this.#actions.session.http.fetch_function(`${format_url}&cpn=${this.#cpn}`, {
- method: 'GET',
- headers: Constants.STREAM_HEADERS,
- redirect: 'follow'
- });
-
- // Throw if the response is not 2xx
- if (!response.ok)
- throw new InnertubeError('The server responded with a non 2xx status code', { video: this, error_type: 'FETCH_FAILED', response });
-
- const body = response.body;
-
- if (!body)
- throw new InnertubeError('Could not get ReadableStream from fetch Response.', { video: this, error_type: 'FETCH_FAILED', response });
-
- return body;
- }
-
- // We need to download in chunks.
-
- const chunk_size = 1048576 * 10; // 10MB
-
- let chunk_start = (options.range ? options.range.start : 0);
- let chunk_end = (options.range ? options.range.end : chunk_size);
- let must_end = false;
-
- let cancel: AbortController;
-
- const readable_stream = new ReadableStream({
- // eslint-disable-next-line @typescript-eslint/no-empty-function
- start() { },
- pull: async (controller) => {
- if (must_end) {
- controller.close();
- return;
- }
-
- if ((chunk_end >= format.content_length) || options.range) {
- must_end = true;
- }
-
- return new Promise(async (resolve, reject) => {
- try {
- cancel = new AbortController();
-
- const response = await this.#actions.session.http.fetch_function(`${format_url}&cpn=${this.#cpn}&range=${chunk_start}-${chunk_end || ''}`, {
- method: 'GET',
- headers: {
- ...Constants.STREAM_HEADERS
- // XXX: use YouTube's range parameter instead of a Range header.
- // Range: `bytes=${chunk_start}-${chunk_end}`
- },
- signal: cancel.signal
- });
-
- const body = response.body;
-
- if (!body)
- throw new InnertubeError('Could not get ReadableStream from fetch Response.', { video: this, error_type: 'FETCH_FAILED', response });
-
- for await (const chunk of streamToIterable(body)) {
- controller.enqueue(chunk);
- }
-
- chunk_start = chunk_end + 1;
- chunk_end += chunk_size;
-
- resolve();
- return;
- } catch (e: any) {
- reject(e);
- }
- });
- },
- async cancel(reason) {
- cancel.abort(reason);
- }
- }, {
- highWaterMark: 1, // TODO: better value?
- size(chunk) {
- return chunk.byteLength;
- }
- });
-
- return readable_stream;
+ get page(): [ParsedResponse, ParsedResponse?] {
+ return this.#page;
}
}
diff --git a/src/parser/ytkids/HomeFeed.ts b/src/parser/ytkids/HomeFeed.ts
new file mode 100644
index 000000000..05d890f7b
--- /dev/null
+++ b/src/parser/ytkids/HomeFeed.ts
@@ -0,0 +1,48 @@
+import Feed from '../../core/Feed';
+import Actions from '../../core/Actions';
+import KidsCategoriesHeader from '../classes/ytkids/KidsCategoriesHeader';
+import KidsCategoryTab from '../classes/ytkids/KidsCategoryTab';
+import KidsHomeScreen from '../classes/ytkids/KidsHomeScreen';
+import { InnertubeError } from '../../utils/Utils';
+
+class HomeFeed extends Feed {
+ header?: KidsCategoriesHeader;
+ contents?: KidsHomeScreen;
+
+ constructor(actions: Actions, data: any, already_parsed = false) {
+ super(actions, data, already_parsed);
+ this.header = this.page.header?.item().as(KidsCategoriesHeader);
+ this.contents = this.page.contents?.item().as(KidsHomeScreen);
+ }
+
+ /**
+ * Retrieves the contents of the given category tab. Use {@link HomeFeed.categories} to get a list of available categories.
+ * @param tab - The tab to select
+ */
+ async selectCategoryTab(tab: string | KidsCategoryTab): Promise {
+ let target_tab: KidsCategoryTab | undefined;
+
+ if (typeof tab === 'string') {
+ target_tab = this.header?.category_tabs.find((t) => t.title.toString() === tab);
+ } else if (tab?.is(KidsCategoryTab)) {
+ target_tab = tab;
+ }
+
+ if (!target_tab)
+ throw new InnertubeError(`Tab "${tab}" not found`);
+
+ const page = await target_tab.endpoint.call(this.actions, { client: 'YTKIDS', parse: true });
+
+ // Copy over the header and header memo
+ page.header = this.page.header;
+ page.header_memo = this.page.header_memo;
+
+ return new HomeFeed(this.actions, page, true);
+ }
+
+ get categories(): string[] {
+ return this.header?.category_tabs.map((tab) => tab.title.toString()) || [];
+ }
+}
+
+export default HomeFeed;
\ No newline at end of file
diff --git a/src/parser/ytkids/Search.ts b/src/parser/ytkids/Search.ts
new file mode 100644
index 000000000..fce2e4ae4
--- /dev/null
+++ b/src/parser/ytkids/Search.ts
@@ -0,0 +1,24 @@
+import Feed from '../../core/Feed';
+import ItemSection from '../classes/ItemSection';
+import { InnertubeError } from '../../utils/Utils';
+import type Actions from '../../core/Actions';
+import type { ObservedArray, YTNode } from '../helpers';
+
+class Search extends Feed {
+ estimated_results: number | null;
+ contents: ObservedArray | null;
+
+ constructor(actions: Actions, data: any) {
+ super(actions, data);
+ this.estimated_results = this.page.estimated_results;
+
+ const item_section = this.memo.getType(ItemSection).first();
+
+ if (!item_section)
+ throw new InnertubeError('No item section found in search response.');
+
+ this.contents = item_section.contents;
+ }
+}
+
+export default Search;
\ No newline at end of file
diff --git a/src/parser/ytkids/VideoInfo.ts b/src/parser/ytkids/VideoInfo.ts
new file mode 100644
index 000000000..16c945cce
--- /dev/null
+++ b/src/parser/ytkids/VideoInfo.ts
@@ -0,0 +1,137 @@
+import Parser, { ParsedResponse } from '..';
+
+import ItemSection from '../classes/ItemSection';
+import NavigationEndpoint from '../classes/NavigationEndpoint';
+import PlayerOverlay from '../classes/PlayerOverlay';
+import SlimVideoMetadata from '../classes/SlimVideoMetadata';
+import TwoColumnWatchNextResults from '../classes/TwoColumnWatchNextResults';
+
+import type Format from '../classes/misc/Format';
+import type Actions from '../../core/Actions';
+import type { ApiResponse } from '../../core/Actions';
+import type { ObservedArray, YTNode } from '../helpers';
+
+import { Constants } from '../../utils';
+import { InnertubeError } from '../../utils/Utils';
+
+import FormatUtils, { DownloadOptions, FormatOptions, URLTransformer } from '../../utils/FormatUtils';
+
+class VideoInfo {
+ #page: [ParsedResponse, ParsedResponse?];
+ #actions: Actions;
+ #cpn: string;
+
+ basic_info;
+ streaming_data;
+ playability_status;
+ captions;
+
+ #playback_tracking;
+
+ slim_video_metadata?: SlimVideoMetadata | null;
+ watch_next_feed?: ObservedArray | null;
+ current_video_endpoint?: NavigationEndpoint | null;
+ player_overlays?: PlayerOverlay;
+
+ constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) {
+ this.#actions = actions;
+
+ const info = Parser.parseResponse(data[0].data);
+ const next = data?.[1]?.data ? Parser.parseResponse(data[1].data) : undefined;
+
+ this.#page = [ info, next ];
+ this.#cpn = cpn;
+
+ if (info.playability_status?.status === 'ERROR')
+ throw new InnertubeError('This video is unavailable', info.playability_status);
+
+ this.basic_info = info.video_details;
+
+ this.streaming_data = info.streaming_data;
+ this.playability_status = info.playability_status;
+ this.captions = info.captions;
+
+ this.#playback_tracking = info.playback_tracking;
+
+ const two_col = next?.contents.item().as(TwoColumnWatchNextResults);
+
+ const results = two_col?.results;
+ const secondary_results = two_col?.secondary_results;
+
+ if (results && secondary_results) {
+ this.slim_video_metadata = results.firstOfType(ItemSection)?.contents?.firstOfType(SlimVideoMetadata);
+ this.watch_next_feed = secondary_results.firstOfType(ItemSection)?.contents || secondary_results;
+ this.current_video_endpoint = next?.current_video_endpoint;
+ this.player_overlays = next?.player_overlays.item().as(PlayerOverlay);
+ }
+ }
+
+ /**
+ * Generates a DASH manifest from the streaming data.
+ * @param url_transformer - Function to transform the URLs.
+ * @returns DASH manifest
+ */
+ toDash(url_transformer: URLTransformer = (url) => url): string {
+ return FormatUtils.toDash(this.streaming_data, url_transformer, this.#cpn, this.#actions.session.player);
+ }
+
+ /**
+ * Selects the format that best matches the given options.
+ * @param options - Options
+ */
+ chooseFormat(options: FormatOptions): Format {
+ return FormatUtils.chooseFormat(options, this.streaming_data);
+ }
+
+ /**
+ * Downloads the video.
+ * @param options - Download options.
+ */
+ async download(options: DownloadOptions = {}): Promise> {
+ return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player, this.cpn);
+ }
+
+ /**
+ * Adds video to the watch history.
+ */
+ async addToWatchHistory(): Promise {
+ if (!this.#playback_tracking)
+ throw new InnertubeError('Playback tracking not available');
+
+ const url_params = {
+ cpn: this.#cpn,
+ fmt: 251,
+ rtn: 0,
+ rt: 0
+ };
+
+ const url = this.#playback_tracking.videostats_playback_url.replace('https://s.', 'https://www.');
+
+ const response = await this.#actions.stats(url, {
+ client_name: Constants.CLIENTS.WEB.NAME,
+ client_version: Constants.CLIENTS.WEB.VERSION
+ }, url_params);
+
+ return response;
+ }
+
+ /**
+ * Actions instance.
+ */
+ get actions(): Actions {
+ return this.#actions;
+ }
+
+ /**
+ * Content Playback Nonce.
+ */
+ get cpn(): string | undefined {
+ return this.#cpn;
+ }
+
+ get page(): [ParsedResponse, ParsedResponse?] {
+ return this.#page;
+ }
+}
+
+export default VideoInfo;
\ No newline at end of file
diff --git a/src/parser/ytmusic/TrackInfo.ts b/src/parser/ytmusic/TrackInfo.ts
index 06bd70b62..26e0402fc 100644
--- a/src/parser/ytmusic/TrackInfo.ts
+++ b/src/parser/ytmusic/TrackInfo.ts
@@ -22,8 +22,10 @@ import WatchNextTabbedResults from '../classes/WatchNextTabbedResults';
import type NavigationEndpoint from '../classes/NavigationEndpoint';
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec';
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec';
+import type Format from '../classes/misc/Format';
import type { ObservedArray, YTNode } from '../helpers';
+import FormatUtils, { URLTransformer, FormatOptions, DownloadOptions } from '../../utils/FormatUtils';
class TrackInfo {
#page: [ ParsedResponse, ParsedResponse? ];
@@ -86,6 +88,31 @@ class TrackInfo {
}
}
+ /**
+ * Generates a DASH manifest from the streaming data.
+ * @param url_transformer - Function to transform the URLs.
+ * @returns DASH manifest
+ */
+ toDash(url_transformer: URLTransformer = (url) => url): string {
+ return FormatUtils.toDash(this.streaming_data, url_transformer, this.#cpn, this.#actions.session.player);
+ }
+
+ /**
+ * Selects the format that best matches the given options.
+ * @param options - Options
+ */
+ chooseFormat(options: FormatOptions): Format {
+ return FormatUtils.chooseFormat(options, this.streaming_data);
+ }
+
+ /**
+ * Downloads the video.
+ * @param options - Download options.
+ */
+ async download(options: DownloadOptions = {}): Promise> {
+ return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player);
+ }
+
/**
* Retrieves contents of the given tab.
*/
diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts
index 767f0c48c..4a7f93bee 100644
--- a/src/utils/Constants.ts
+++ b/src/utils/Constants.ts
@@ -39,6 +39,10 @@ export const CLIENTS = Object.freeze({
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
API_VERSION: 'v1'
},
+ WEB_KIDS: {
+ NAME: 'WEB_KIDS',
+ VERSION: '2.20230111.00.00'
+ },
YTMUSIC: {
NAME: 'WEB_REMIX',
VERSION: '1.20211213.00.00'
diff --git a/src/utils/FormatUtils.ts b/src/utils/FormatUtils.ts
new file mode 100644
index 000000000..e3fae07e9
--- /dev/null
+++ b/src/utils/FormatUtils.ts
@@ -0,0 +1,385 @@
+import Player from '../core/Player';
+import Actions from '../core/Actions';
+
+import type Format from '../parser/classes/misc/Format';
+import type AudioOnlyPlayability from '../parser/classes/AudioOnlyPlayability';
+import type { YTNode } from '../parser/helpers';
+
+import { DOMParser } from 'linkedom';
+import type { Element } from 'linkedom/types/interface/element';
+import type { Node } from 'linkedom/types/interface/node';
+import type { XMLDocument } from 'linkedom/types/xml/document';
+
+import { Constants } from '.';
+import { getStringBetweenStrings, InnertubeError, streamToIterable } from './Utils';
+
+export type URLTransformer = (url: URL) => URL;
+
+export interface FormatOptions {
+ /**
+ * Video quality; 360p, 720p, 1080p, etc... also accepts 'best' and 'bestefficiency'.
+ */
+ quality?: string;
+ /**
+ * Download type, can be: video, audio or video+audio
+ */
+ type?: 'video' | 'audio' | 'video+audio';
+ /**
+ * File format, use 'any' to download any format
+ */
+ format?: string;
+ /**
+ * InnerTube client, can be ANDROID, WEB, YTMUSIC, YTMUSIC_ANDROID, YTSTUDIO_ANDROID or TV_EMBEDDED
+ */
+ client?: 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED';
+}
+
+export interface DownloadOptions extends FormatOptions {
+ /**
+ * Download range, indicates which bytes should be downloaded.
+ */
+ range?: {
+ start: number;
+ end: number;
+ }
+}
+
+class FormatUtils {
+ static async download(options: DownloadOptions, actions: Actions, playability_status?: {
+ status: string;
+ error_screen: YTNode | null;
+ audio_only_playablility: AudioOnlyPlayability | null;
+ embeddable: boolean;
+ reason: any;
+ }, streaming_data?: {
+ expires: Date;
+ formats: Format[];
+ adaptive_formats: Format[];
+ dash_manifest_url: string | null;
+ hls_manifest_url: string | null;
+ }, player?: Player, cpn?: string): Promise> {
+ if (playability_status?.status === 'UNPLAYABLE')
+ throw new InnertubeError('Video is unplayable', { error_type: 'UNPLAYABLE' });
+ if (playability_status?.status === 'LOGIN_REQUIRED')
+ throw new InnertubeError('Video is login required', { error_type: 'LOGIN_REQUIRED' });
+ if (!streaming_data)
+ throw new InnertubeError('Streaming data not available.', { error_type: 'NO_STREAMING_DATA' });
+
+ const opts: DownloadOptions = {
+ quality: '360p',
+ type: 'video+audio',
+ format: 'mp4',
+ range: undefined,
+ ...options
+ };
+
+ const format = this.chooseFormat(opts, streaming_data);
+ const format_url = format.decipher(player);
+
+ // If we're not downloading the video in chunks, we just use fetch once.
+ if (opts.type === 'video+audio' && !options.range) {
+ const response = await actions.session.http.fetch_function(`${format_url}&cpn=${cpn}`, {
+ method: 'GET',
+ headers: Constants.STREAM_HEADERS,
+ redirect: 'follow'
+ });
+
+ // 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)
+ throw new InnertubeError('Could not get ReadableStream from fetch Response.', { error_type: 'FETCH_FAILED', response });
+
+ return body;
+ }
+
+ // We need to download in chunks.
+
+ const chunk_size = 1048576 * 10; // 10MB
+
+ let chunk_start = (options.range ? options.range.start : 0);
+ let chunk_end = (options.range ? options.range.end : chunk_size);
+ let must_end = false;
+
+ let cancel: AbortController;
+
+ const readable_stream = new ReadableStream({
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ start() { },
+ pull: async (controller) => {
+ if (must_end) {
+ controller.close();
+ return;
+ }
+
+ if ((chunk_end >= format.content_length) || options.range) {
+ must_end = true;
+ }
+
+ return new Promise(async (resolve, reject) => {
+ try {
+ cancel = new AbortController();
+
+ const response = await actions.session.http.fetch_function(`${format_url}&cpn=${cpn}&range=${chunk_start}-${chunk_end || ''}`, {
+ method: 'GET',
+ headers: {
+ ...Constants.STREAM_HEADERS
+ // XXX: use YouTube's range parameter instead of a Range header.
+ // Range: `bytes=${chunk_start}-${chunk_end}`
+ },
+ signal: cancel.signal
+ });
+
+ const body = response.body;
+
+ if (!body)
+ throw new InnertubeError('Could not get ReadableStream from fetch Response.', { video: this, error_type: 'FETCH_FAILED', response });
+
+ for await (const chunk of streamToIterable(body)) {
+ controller.enqueue(chunk);
+ }
+
+ chunk_start = chunk_end + 1;
+ chunk_end += chunk_size;
+
+ resolve();
+ return;
+ } catch (e: any) {
+ reject(e);
+ }
+ });
+ },
+ async cancel(reason) {
+ cancel.abort(reason);
+ }
+ }, {
+ highWaterMark: 1, // TODO: better value?
+ size(chunk) {
+ return chunk.byteLength;
+ }
+ });
+
+ return readable_stream;
+ }
+
+ /**
+ * Selects the format that best matches the given options.
+ * @param options - Options
+ * @param streaming_data - Streaming data
+ */
+ static chooseFormat(options: FormatOptions, streaming_data?: {
+ expires: Date;
+ formats: Format[];
+ adaptive_formats: Format[];
+ dash_manifest_url: string | null;
+ hls_manifest_url: string | null;
+ }): Format {
+ if (!streaming_data)
+ throw new InnertubeError('Streaming data not available');
+
+ const formats = [
+ ...(streaming_data.formats || []),
+ ...(streaming_data.adaptive_formats || [])
+ ];
+
+ const requires_audio = options.type ? options.type.includes('audio') : true;
+ const requires_video = options.type ? options.type.includes('video') : true;
+ const quality = options.quality || '360p';
+
+ let best_width = -1;
+
+ const is_best = [ 'best', 'bestefficiency' ].includes(quality);
+ const use_most_efficient = quality !== 'best';
+
+ let candidates = formats.filter((format) => {
+ if (requires_audio && !format.has_audio)
+ return false;
+ if (requires_video && !format.has_video)
+ return false;
+ if (options.format !== 'any' && !format.mime_type.includes(options.format || 'mp4'))
+ return false;
+ if (!is_best && format.quality_label !== quality)
+ return false;
+ if (best_width < format.width)
+ best_width = format.width;
+ return true;
+ });
+
+ if (!candidates.length) {
+ throw new InnertubeError('No matching formats found', {
+ options
+ });
+ }
+
+ if (is_best && requires_video)
+ candidates = candidates.filter((format) => format.width === best_width);
+
+ if (requires_audio && !requires_video) {
+ const audio_only = candidates.filter((format) => !format.has_video);
+ if (audio_only.length > 0) {
+ candidates = audio_only;
+ }
+ }
+
+ if (use_most_efficient) {
+ // Sort by bitrate (lower is better)
+ candidates.sort((a, b) => a.bitrate - b.bitrate);
+ } else {
+ // Sort by bitrate (higher is better)
+ candidates.sort((a, b) => b.bitrate - a.bitrate);
+ }
+
+ return candidates[0];
+ }
+
+ static toDash(streaming_data?: {
+ expires: Date;
+ formats: Format[];
+ adaptive_formats: Format[];
+ dash_manifest_url: string | null;
+ hls_manifest_url: string | null;
+ }, url_transformer: URLTransformer = (url) => url, cpn?: string, player?: Player): string {
+ if (!streaming_data)
+ throw new InnertubeError('Streaming data not available');
+
+ const { adaptive_formats } = streaming_data;
+
+ const length = adaptive_formats[0].approx_duration_ms / 1000;
+
+ const document = new DOMParser().parseFromString('', 'text/xml');
+ const period = document.createElement('Period');
+
+ document.appendChild(this.#el(document, 'MPD', {
+ xmlns: 'urn:mpeg:dash:schema:mpd:2011',
+ minBufferTime: 'PT1.500S',
+ profiles: 'urn:mpeg:dash:profile:isoff-main:2011',
+ type: 'static',
+ mediaPresentationDuration: `PT${length}S`,
+ 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
+ 'xsi:schemaLocation': 'urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd'
+ }, [
+ period
+ ]));
+
+ this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player);
+
+ return `${document}`;
+ }
+
+ static #el(document: XMLDocument, tag: string, attrs: Record, children: Node[] = []) {
+ const el = document.createElement(tag);
+ for (const [ key, value ] of Object.entries(attrs)) {
+ el.setAttribute(key, value);
+ }
+ for (const child of children) {
+ if (typeof child === 'undefined') continue;
+ el.appendChild(child);
+ }
+ return el;
+ }
+
+ static #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer, cpn?: string, player?: Player) {
+ const mime_types: string[] = [];
+ const mime_objects: Format[][] = [ [] ];
+
+ formats.forEach((video_format) => {
+ if (!video_format.index_range || !video_format.init_range) {
+ return;
+ }
+ const mime_type = video_format.mime_type;
+ const mime_type_index = mime_types.indexOf(mime_type);
+ if (mime_type_index > -1) {
+ mime_objects[mime_type_index].push(video_format);
+ } else {
+ mime_types.push(mime_type);
+ mime_objects.push([]);
+ mime_objects[mime_types.length - 1].push(video_format);
+ }
+ });
+
+ for (let i = 0; i < mime_types.length; i++) {
+ const set = this.#el(document, 'AdaptationSet', {
+ id: `${i}`,
+ mimeType: mime_types[i].split(';')[0],
+ startWithSAP: '1',
+ subsegmentAlignment: 'true'
+ });
+
+ period.appendChild(set);
+
+ mime_objects[i].forEach((format) => {
+ if (format.has_video) {
+ this.#generateRepresentationVideo(document, set, format, url_transformer, cpn, player);
+ } else {
+ this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player);
+ }
+ });
+ }
+ }
+
+ static #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player) {
+ const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
+
+ if (!format.index_range || !format.init_range)
+ throw new InnertubeError('Index and init ranges not available', { format });
+
+ const url = new URL(format.decipher(player));
+ url.searchParams.set('cpn', cpn || '');
+
+ set.appendChild(this.#el(document, 'Representation', {
+ id: format.itag?.toString(),
+ codecs,
+ bandwidth: format.bitrate?.toString(),
+ width: format.width?.toString(),
+ height: format.height?.toString(),
+ maxPlayoutRate: '1',
+ frameRate: format.fps?.toString()
+ }, [
+ this.#el(document, 'BaseURL', {}, [
+ document.createTextNode(url_transformer(url)?.toString())
+ ]),
+ this.#el(document, 'SegmentBase', {
+ indexRange: `${format.index_range.start}-${format.index_range.end}`
+ }, [
+ this.#el(document, 'Initialization', {
+ range: `${format.init_range.start}-${format.init_range.end}`
+ })
+ ])
+ ]));
+ }
+
+ static #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player) {
+ const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
+ if (!format.index_range || !format.init_range)
+ throw new InnertubeError('Index and init ranges not available', { format });
+
+ const url = new URL(format.decipher(player));
+ url.searchParams.set('cpn', cpn || '');
+
+ set.appendChild(this.#el(document, 'Representation', {
+ id: format.itag?.toString(),
+ codecs,
+ bandwidth: format.bitrate?.toString()
+ }, [
+ this.#el(document, 'AudioChannelConfiguration', {
+ schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
+ value: format.audio_channels?.toString() || '2'
+ }),
+ this.#el(document, 'BaseURL', {}, [
+ document.createTextNode(url_transformer(url)?.toString())
+ ]),
+ this.#el(document, 'SegmentBase', {
+ indexRange: `${format.index_range.start}-${format.index_range.end}`
+ }, [
+ this.#el(document, 'Initialization', {
+ range: `${format.init_range.start}-${format.init_range.end}`
+ })
+ ])
+ ]));
+ }
+}
+
+export default FormatUtils;
\ No newline at end of file
diff --git a/src/utils/HTTPClient.ts b/src/utils/HTTPClient.ts
index 2a2fd5815..b68bf7c8f 100644
--- a/src/utils/HTTPClient.ts
+++ b/src/utils/HTTPClient.ts
@@ -40,8 +40,8 @@ export default class HTTPClient {
const headers =
init?.headers ||
- (input instanceof Request ? input.headers : new Headers()) ||
- new Headers();
+ (input instanceof Request ? input.headers : new Headers()) ||
+ new Headers();
const body = init?.body || (input instanceof Request ? input.body : undefined);
@@ -65,6 +65,7 @@ export default class HTTPClient {
const content_type = request_headers.get('Content-Type');
let request_body = body;
+ let is_web_kids = false;
const is_innertube_req =
baseURL === innertube_url ||
@@ -85,11 +86,12 @@ export default class HTTPClient {
delete n_body.client;
+ is_web_kids = n_body.context.client.clientName === 'WEB_KIDS';
request_body = JSON.stringify(n_body);
}
- // Authenticate
- if (this.#session.logged_in && is_innertube_req) {
+ // Authenticate (NOTE: YouTube Kids does not support regular bearer tokens)
+ if (this.#session.logged_in && is_innertube_req && !is_web_kids) {
const oauth = this.#session.oauth;
if (oauth.validateCredentials()) {
@@ -157,6 +159,40 @@ export default class HTTPClient {
ctx.client.clientScreen = 'EMBED';
ctx.thirdParty = { embedUrl: Constants.URLS.YT_BASE };
break;
+ case 'YTKIDS':
+ ctx.client.clientVersion = Constants.CLIENTS.WEB_KIDS.VERSION;
+ ctx.client.clientName = Constants.CLIENTS.WEB_KIDS.NAME;
+ ctx.client.kidsAppInfo = { // TODO: Make this customizable
+ categorySettings: {
+ enabledCategories: [
+ 'approved_for_you',
+ 'black_joy',
+ 'camp',
+ 'collections',
+ 'earth',
+ 'explore',
+ 'favorites',
+ 'gaming',
+ 'halloween',
+ 'hero',
+ 'learning',
+ 'move',
+ 'music',
+ 'reading',
+ 'shared_by_parents',
+ 'shows',
+ 'soccer',
+ 'sports',
+ 'spotlight',
+ 'winter'
+ ]
+ },
+ contentSettings: {
+ corpusPreference: 'KIDS_CORPUS_PREFERENCE_YOUNGER',
+ kidsNoSearchMode: 'YT_KIDS_NO_SEARCH_MODE_OFF'
+ }
+ };
+ break;
default:
break;
}
diff --git a/test/constants.ts b/test/constants.ts
index ef338ec72..4e43962a5 100644
--- a/test/constants.ts
+++ b/test/constants.ts
@@ -22,6 +22,10 @@ export const VIDEOS = [
{
ID: 'jfKfPfyJRdk',
QUERY: 'live video'
+ },
+ {
+ ID: 'juN8qEgLScw',
+ QUERY: 'Galapagos Tortoise Can\'t Get Enough Watermelon'
}
];
diff --git a/test/main.test.ts b/test/main.test.ts
index 07c6f0446..1e726eb43 100644
--- a/test/main.test.ts
+++ b/test/main.test.ts
@@ -220,6 +220,25 @@ describe('YouTube.js Tests', () => {
expect(playlist.items).toBeDefined();
});
});
+
+ describe('YouTube Kids', () => {
+ it('should search', async () => {
+ const search = await yt.kids.search('cocomelon');
+ expect(search.estimated_results).toBeDefined();
+ expect(search.contents?.length).toBeGreaterThan(0);
+ });
+
+ it('should retrieve home feed', async () => {
+ const homefeed = await yt.kids.getHomeFeed();
+ expect(homefeed.contents).toBeDefined();
+ expect(homefeed.videos.length).toBeGreaterThan(0);
+ });
+
+ it('should retrieve video info', async () => {
+ const info = await yt.kids.getInfo(VIDEOS[6].ID);
+ expect(info.basic_info?.id).toBe(VIDEOS[6].ID);
+ });
+ });
});
async function download(id: string, yt: Innertube): Promise {