feat: add support for YouTube Kids (#291)

* dev: add `WEB_KIDS` innertube client

* refactor: move DASH manifest stuff out of `VideoInfo`
This makes it easier to use these functions elsewhere.

* feat(ytkids): add `Kids#getInfo()` & `Kids#search()`

* feat: add `Innertube#kids.getHomeFeed()`

* docs: add YouTube Kids API ref

* docs: fix typo

* docs: fix yet another typo

* docs: update YouTube Music API ref
Unrelated but required to reflect changes made to the DASH manifest generation functions

* chore: lint

* chore: add tests

* feat: include `captions` in `VideoInfo`

* chore: fix tests
This commit is contained in:
LuanRT
2023-01-23 03:39:51 -03:00
committed by GitHub
parent 13ad3774c9
commit 2bbefefbb7
25 changed files with 1114 additions and 384 deletions

View File

@@ -14,7 +14,7 @@
<h1 align=center>YouTube.js</h1>
<p align=center>A full-featured wrapper around the InnerTube API, which is what YouTube itself uses</p>
<p align=center>A full-featured wrapper around the InnerTube API</p>
<div align="center">
@@ -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)
</p>
</details>

View File

@@ -46,7 +46,7 @@ Retrieves account information.
<p>
- `<accountinfo>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -63,7 +63,7 @@ Retrieves time watched statistics.
<p>
- `<timewatched>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -91,6 +91,9 @@ Retrieves YouTube settings.
- `<settings>#sidebar_items`
- Returns options available in the sidebar menu.
- `<settings>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -106,7 +109,7 @@ Retrieves basic channel analytics.
<p>
- `<analytics>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>

85
docs/API/kids.md Normal file
View File

@@ -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)
<a name="search"></a>
### search(query)
Searches the given query on YouTube Kids.
**Returns:** `Promise.<Search>`
| Param | Type | Description |
| --- | --- | --- |
| query | `string` | The query to search |
<details>
<summary>Methods & Getters</summary>
<p>
- `<search>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getinfo"></a>
### getInfo(video_id)
Retrieves video info.
**Returns:** `Promise.<VideoInfo>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The video id |
<details>
<summary>Methods & Getters</summary>
<p>
- `<info>#toDash(url_transformer?)`
- Generates a DASH manifest from the streaming data.
- `<info>#chooseFormat(options)`
- Selects the format that best matches the given options. This method is used internally by `#download`.
- `<info>#download(options?)`
- Downloads the video.
- `<info>#addToWatchHistory()`
- Adds the video to the watch history.
- `<info>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="gethomefeed"></a>
### getHomeFeed()
Retrieves the home feed.
**Returns:** `Promise.<HomeFeed>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<feed>#selectCategoryTab(tab: string | KidsCategoryTab)`
- Selects the given category tab.
- `<feed>#categories`
- Returns available categories.
- `<feed>#page`
- Returns the original InnerTube response(s), parsed and sanitized.

View File

@@ -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.
- `<info>#available_tabs`
- Returns available tabs.
- `<info>#toDash(url_transformer?)`
- Generates a DASH manifest from the streaming data.
- `<info>#chooseFormat(options)`
- Selects the format that best matches the given options. This method is used internally by `#download`.
- `<info>#download(options?)`
- Downloads the track.
- `<info>#addToWatchHistory()`
- Adds the song to the watch history.
- `<info>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -99,7 +114,7 @@ Searches on YouTube Music.
- Returns songs shelf.
- `<search>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -124,6 +139,9 @@ Retrieves home feed.
- `<homefeed>#page`
- Returns original InnerTube response (sanitized).
- `<homefeed>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -139,7 +157,7 @@ Retrieves “Explore” feed.
<p>
- `<explore>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -174,7 +192,7 @@ Retrieves library.
- Returns available sort options.
- `<library>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -195,7 +213,7 @@ Retrieves artist's info & content.
<p>
- `<artist>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -216,7 +234,7 @@ Retrieves given album.
<p>
- `<album>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -249,7 +267,7 @@ Retrieves given playlist.
- Checks if continuation is available.
- `<playlist>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -303,7 +321,7 @@ Retrieves your YouTube Music recap.
- Retrieves recap playlist.
- `<recap>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>

View File

@@ -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;
}

57
src/core/Kids.ts Normal file
View File

@@ -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<Search> {
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<VideoInfo> {
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<HomeFeed> {
const response = await this.#session.actions.execute('/browse', { browseId: 'FEkids_home', client: 'YTKIDS' });
return new HomeFeed(this.#session.actions, response.data);
}
}
export default Kids;

View File

@@ -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;

View File

@@ -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<Menu>(data.menu);
}
}
export default CompactChannel;

View File

@@ -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<SubscribeButton>(data.subscribeButton, SubscribeButton);
}
}
export default SlimOwner;

View File

@@ -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;

View File

@@ -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<SectionList>(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;

View File

@@ -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<KidsCategoryTab>(data.categoryTabs);
this.privacy_button = Parser.parseItem<Button>(data.privacyButtonRenderer);
}
}
export default KidsCategoriesHeader;

View File

@@ -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;

View File

@@ -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<AnchoredSection>(data.anchors);
}
}
export default KidsHomeScreen;

View File

@@ -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<string, YTNodeConstructor> = YTNodes;

View File

@@ -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?];
@@ -332,6 +297,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<ReadableStream<Uint8Array>> {
return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player, this.cpn);
}
/**
* Watch next feed filters.
*/
@@ -354,19 +344,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.
*/
@@ -407,321 +390,10 @@ class VideoInfo {
}
/**
* Selects the format that best matches the given options.
* @param options - Options
* Original parsed InnerTube response.
*/
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<string, string | undefined>, 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.
*/
async download(options: DownloadOptions = {}): Promise<ReadableStream<Uint8Array>> {
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<Uint8Array>({
// 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;
}
}

View File

@@ -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<HomeFeed> {
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;

View File

@@ -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<YTNode> | 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;

View File

@@ -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<YTNode> | 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<ReadableStream<Uint8Array>> {
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<Response> {
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;

View File

@@ -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<ReadableStream<Uint8Array>> {
return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player);
}
/**
* Retrieves contents of the given tab.
*/

View File

@@ -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'

385
src/utils/FormatUtils.ts Normal file
View File

@@ -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<ReadableStream<Uint8Array>> {
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<Uint8Array>({
// 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<string, string | undefined>, 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;

View File

@@ -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;
}

View File

@@ -22,6 +22,10 @@ export const VIDEOS = [
{
ID: 'jfKfPfyJRdk',
QUERY: 'live video'
},
{
ID: 'juN8qEgLScw',
QUERY: 'Galapagos Tortoise Can\'t Get Enough Watermelon'
}
];

View File

@@ -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<boolean> {