mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-15 10:32:14 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
85
docs/API/kids.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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
57
src/core/Kids.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
35
src/parser/classes/CompactChannel.ts
Normal file
35
src/parser/classes/CompactChannel.ts
Normal 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;
|
||||
25
src/parser/classes/SlimOwner.ts
Normal file
25
src/parser/classes/SlimOwner.ts
Normal 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;
|
||||
28
src/parser/classes/SlimVideoMetadata.ts
Normal file
28
src/parser/classes/SlimVideoMetadata.ts
Normal 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;
|
||||
31
src/parser/classes/ytkids/AnchoredSection.ts
Normal file
31
src/parser/classes/ytkids/AnchoredSection.ts
Normal 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;
|
||||
19
src/parser/classes/ytkids/KidsCategoriesHeader.ts
Normal file
19
src/parser/classes/ytkids/KidsCategoriesHeader.ts
Normal 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;
|
||||
28
src/parser/classes/ytkids/KidsCategoryTab.ts
Normal file
28
src/parser/classes/ytkids/KidsCategoryTab.ts
Normal 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;
|
||||
16
src/parser/classes/ytkids/KidsHomeScreen.ts
Normal file
16
src/parser/classes/ytkids/KidsHomeScreen.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
src/parser/ytkids/HomeFeed.ts
Normal file
48
src/parser/ytkids/HomeFeed.ts
Normal 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;
|
||||
24
src/parser/ytkids/Search.ts
Normal file
24
src/parser/ytkids/Search.ts
Normal 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;
|
||||
137
src/parser/ytkids/VideoInfo.ts
Normal file
137
src/parser/ytkids/VideoInfo.ts
Normal 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;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
385
src/utils/FormatUtils.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export const VIDEOS = [
|
||||
{
|
||||
ID: 'jfKfPfyJRdk',
|
||||
QUERY: 'live video'
|
||||
},
|
||||
{
|
||||
ID: 'juN8qEgLScw',
|
||||
QUERY: 'Galapagos Tortoise Can\'t Get Enough Watermelon'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user