mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-07-03 09:35:05 +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>
|
<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">
|
<div align="center">
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ ___
|
|||||||
|
|
||||||
## Description
|
## 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).
|
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)
|
* [.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)
|
* [.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)
|
* [.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>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Retrieves account information.
|
|||||||
<p>
|
<p>
|
||||||
|
|
||||||
- `<accountinfo>#page`
|
- `<accountinfo>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
@@ -63,7 +63,7 @@ Retrieves time watched statistics.
|
|||||||
<p>
|
<p>
|
||||||
|
|
||||||
- `<timewatched>#page`
|
- `<timewatched>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
@@ -91,6 +91,9 @@ Retrieves YouTube settings.
|
|||||||
- `<settings>#sidebar_items`
|
- `<settings>#sidebar_items`
|
||||||
- Returns options available in the sidebar menu.
|
- Returns options available in the sidebar menu.
|
||||||
|
|
||||||
|
- `<settings>#page`
|
||||||
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ Retrieves basic channel analytics.
|
|||||||
<p>
|
<p>
|
||||||
|
|
||||||
- `<analytics>#page`
|
- `<analytics>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</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
|
## API
|
||||||
|
|
||||||
@@ -49,6 +49,21 @@ Retrieves track info.
|
|||||||
- `<info>#available_tabs`
|
- `<info>#available_tabs`
|
||||||
- Returns 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>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -99,7 +114,7 @@ Searches on YouTube Music.
|
|||||||
- Returns songs shelf.
|
- Returns songs shelf.
|
||||||
|
|
||||||
- `<search>#page`
|
- `<search>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
@@ -124,6 +139,9 @@ Retrieves home feed.
|
|||||||
- `<homefeed>#page`
|
- `<homefeed>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns original InnerTube response (sanitized).
|
||||||
|
|
||||||
|
- `<homefeed>#page`
|
||||||
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -139,7 +157,7 @@ Retrieves “Explore” feed.
|
|||||||
<p>
|
<p>
|
||||||
|
|
||||||
- `<explore>#page`
|
- `<explore>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
@@ -174,7 +192,7 @@ Retrieves library.
|
|||||||
- Returns available sort options.
|
- Returns available sort options.
|
||||||
|
|
||||||
- `<library>#page`
|
- `<library>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
@@ -195,7 +213,7 @@ Retrieves artist's info & content.
|
|||||||
<p>
|
<p>
|
||||||
|
|
||||||
- `<artist>#page`
|
- `<artist>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
@@ -216,7 +234,7 @@ Retrieves given album.
|
|||||||
<p>
|
<p>
|
||||||
|
|
||||||
- `<album>#page`
|
- `<album>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
@@ -249,7 +267,7 @@ Retrieves given playlist.
|
|||||||
- Checks if continuation is available.
|
- Checks if continuation is available.
|
||||||
|
|
||||||
- `<playlist>#page`
|
- `<playlist>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
@@ -303,7 +321,7 @@ Retrieves your YouTube Music recap.
|
|||||||
- Retrieves recap playlist.
|
- Retrieves recap playlist.
|
||||||
|
|
||||||
- `<recap>#page`
|
- `<recap>#page`
|
||||||
- Returns original InnerTube response (sanitized).
|
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import Library from './parser/youtube/Library';
|
|||||||
import NotificationsMenu from './parser/youtube/NotificationsMenu';
|
import NotificationsMenu from './parser/youtube/NotificationsMenu';
|
||||||
import Playlist from './parser/youtube/Playlist';
|
import Playlist from './parser/youtube/Playlist';
|
||||||
import Search from './parser/youtube/Search';
|
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 AccountManager from './core/AccountManager';
|
||||||
import Feed from './core/Feed';
|
import Feed from './core/Feed';
|
||||||
import InteractionManager from './core/InteractionManager';
|
import InteractionManager from './core/InteractionManager';
|
||||||
import YTMusic from './core/Music';
|
import YTMusic from './core/Music';
|
||||||
import PlaylistManager from './core/PlaylistManager';
|
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 TabbedFeed from './core/TabbedFeed';
|
||||||
import HomeFeed from './parser/youtube/HomeFeed';
|
import HomeFeed from './parser/youtube/HomeFeed';
|
||||||
import Proto from './proto/index';
|
import Proto from './proto/index';
|
||||||
@@ -28,6 +29,7 @@ import type Actions from './core/Actions';
|
|||||||
import type Format from './parser/classes/misc/Format';
|
import type Format from './parser/classes/misc/Format';
|
||||||
|
|
||||||
import { generateRandomString, throwIfMissing } from './utils/Utils';
|
import { generateRandomString, throwIfMissing } from './utils/Utils';
|
||||||
|
import type { FormatOptions, DownloadOptions } from './utils/FormatUtils';
|
||||||
|
|
||||||
export type InnertubeConfig = SessionOptions;
|
export type InnertubeConfig = SessionOptions;
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ export interface SearchFilters {
|
|||||||
features?: ('hd' | 'subtitles' | 'creative_commons' | '3d' | 'live' | 'purchased' | '4k' | '360' | 'location' | 'hdr' | 'vr180')[];
|
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 {
|
class Innertube {
|
||||||
session: Session;
|
session: Session;
|
||||||
@@ -47,7 +49,8 @@ class Innertube {
|
|||||||
playlist: PlaylistManager;
|
playlist: PlaylistManager;
|
||||||
interact: InteractionManager;
|
interact: InteractionManager;
|
||||||
music: YTMusic;
|
music: YTMusic;
|
||||||
studio: Studio;
|
studio: YTStudio;
|
||||||
|
kids: YTKids;
|
||||||
actions: Actions;
|
actions: Actions;
|
||||||
|
|
||||||
constructor(session: Session) {
|
constructor(session: Session) {
|
||||||
@@ -56,7 +59,8 @@ class Innertube {
|
|||||||
this.playlist = new PlaylistManager(this.session.actions);
|
this.playlist = new PlaylistManager(this.session.actions);
|
||||||
this.interact = new InteractionManager(this.session.actions);
|
this.interact = new InteractionManager(this.session.actions);
|
||||||
this.music = new YTMusic(this.session);
|
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;
|
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 {
|
export enum ClientType {
|
||||||
WEB = 'WEB',
|
WEB = 'WEB',
|
||||||
|
KIDS = 'WEB_KIDS',
|
||||||
MUSIC = 'WEB_REMIX',
|
MUSIC = 'WEB_REMIX',
|
||||||
ANDROID = 'ANDROID',
|
ANDROID = 'ANDROID',
|
||||||
ANDROID_MUSIC = 'ANDROID_MUSIC',
|
ANDROID_MUSIC = 'ANDROID_MUSIC',
|
||||||
@@ -45,6 +46,15 @@ export interface Context {
|
|||||||
deviceMake: string;
|
deviceMake: string;
|
||||||
deviceModel: string;
|
deviceModel: string;
|
||||||
utcOffsetMinutes: number;
|
utcOffsetMinutes: number;
|
||||||
|
kidsAppInfo?: {
|
||||||
|
categorySettings: {
|
||||||
|
enabledCategories: string[];
|
||||||
|
};
|
||||||
|
contentSettings: {
|
||||||
|
corpusPreference: string;
|
||||||
|
kidsNoSearchMode: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
user: {
|
user: {
|
||||||
enableSafetyMode: boolean;
|
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 EmojiPicker } from './classes/comments/EmojiPicker';
|
||||||
import { default as PdgCommentChip } from './classes/comments/PdgCommentChip';
|
import { default as PdgCommentChip } from './classes/comments/PdgCommentChip';
|
||||||
import { default as SponsorCommentBadge } from './classes/comments/SponsorCommentBadge';
|
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 CompactLink } from './classes/CompactLink';
|
||||||
import { default as CompactMix } from './classes/CompactMix';
|
import { default as CompactMix } from './classes/CompactMix';
|
||||||
import { default as CompactPlaylist } from './classes/CompactPlaylist';
|
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 SingleColumnBrowseResults } from './classes/SingleColumnBrowseResults';
|
||||||
import { default as SingleColumnMusicWatchNextResults } from './classes/SingleColumnMusicWatchNextResults';
|
import { default as SingleColumnMusicWatchNextResults } from './classes/SingleColumnMusicWatchNextResults';
|
||||||
import { default as SingleHeroImage } from './classes/SingleHeroImage';
|
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 SortFilterSubMenu } from './classes/SortFilterSubMenu';
|
||||||
import { default as SubFeedOption } from './classes/SubFeedOption';
|
import { default as SubFeedOption } from './classes/SubFeedOption';
|
||||||
import { default as SubFeedSelector } from './classes/SubFeedSelector';
|
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 WatchCardSectionSequence } from './classes/WatchCardSectionSequence';
|
||||||
import { default as WatchNextEndScreen } from './classes/WatchNextEndScreen';
|
import { default as WatchNextEndScreen } from './classes/WatchNextEndScreen';
|
||||||
import { default as WatchNextTabbedResults } from './classes/WatchNextTabbedResults';
|
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 = {
|
export const YTNodes = {
|
||||||
AccountChannel,
|
AccountChannel,
|
||||||
@@ -369,6 +376,7 @@ export const YTNodes = {
|
|||||||
EmojiPicker,
|
EmojiPicker,
|
||||||
PdgCommentChip,
|
PdgCommentChip,
|
||||||
SponsorCommentBadge,
|
SponsorCommentBadge,
|
||||||
|
CompactChannel,
|
||||||
CompactLink,
|
CompactLink,
|
||||||
CompactMix,
|
CompactMix,
|
||||||
CompactPlaylist,
|
CompactPlaylist,
|
||||||
@@ -573,6 +581,8 @@ export const YTNodes = {
|
|||||||
SingleColumnBrowseResults,
|
SingleColumnBrowseResults,
|
||||||
SingleColumnMusicWatchNextResults,
|
SingleColumnMusicWatchNextResults,
|
||||||
SingleHeroImage,
|
SingleHeroImage,
|
||||||
|
SlimOwner,
|
||||||
|
SlimVideoMetadata,
|
||||||
SortFilterSubMenu,
|
SortFilterSubMenu,
|
||||||
SubFeedOption,
|
SubFeedOption,
|
||||||
SubFeedSelector,
|
SubFeedSelector,
|
||||||
@@ -619,7 +629,11 @@ export const YTNodes = {
|
|||||||
WatchCardRichHeader,
|
WatchCardRichHeader,
|
||||||
WatchCardSectionSequence,
|
WatchCardSectionSequence,
|
||||||
WatchNextEndScreen,
|
WatchNextEndScreen,
|
||||||
WatchNextTabbedResults
|
WatchNextTabbedResults,
|
||||||
|
AnchoredSection,
|
||||||
|
KidsCategoriesHeader,
|
||||||
|
KidsCategoryTab,
|
||||||
|
KidsHomeScreen
|
||||||
};
|
};
|
||||||
|
|
||||||
const map: Record<string, YTNodeConstructor> = YTNodes;
|
const map: Record<string, YTNodeConstructor> = YTNodes;
|
||||||
|
|||||||
@@ -29,48 +29,13 @@ import type PlayerCaptionsTracklist from '../classes/PlayerCaptionsTracklist';
|
|||||||
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec';
|
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec';
|
||||||
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec';
|
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 Player from '../../core/Player';
|
||||||
import type Actions from '../../core/Actions';
|
import type Actions from '../../core/Actions';
|
||||||
import type { ApiResponse } from '../../core/Actions';
|
import type { ApiResponse } from '../../core/Actions';
|
||||||
import type { ObservedArray, YTNode } from '../helpers';
|
import type { ObservedArray, YTNode } from '../helpers';
|
||||||
|
|
||||||
import { getStringBetweenStrings, InnertubeError, streamToIterable } from '../../utils/Utils';
|
import FormatUtils, { FormatOptions, DownloadOptions, URLTransformer } from '../../utils/FormatUtils';
|
||||||
|
import { InnertubeError } 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class VideoInfo {
|
class VideoInfo {
|
||||||
#page: [ParsedResponse, ParsedResponse?];
|
#page: [ParsedResponse, ParsedResponse?];
|
||||||
@@ -332,6 +297,31 @@ class VideoInfo {
|
|||||||
return new LiveChatWrap(this);
|
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.
|
* 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 {
|
get wn_has_continuation(): boolean {
|
||||||
return !!this.#watch_next_continuation;
|
return !!this.#watch_next_continuation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Original parsed InnerTube response.
|
|
||||||
*/
|
|
||||||
get page(): [ParsedResponse, ParsedResponse?] {
|
|
||||||
return this.#page;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get songs used in the video.
|
* Get songs used in the video.
|
||||||
*/
|
*/
|
||||||
@@ -407,321 +390,10 @@ class VideoInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the format that best matches the given options.
|
* Original parsed InnerTube response.
|
||||||
* @param options - Options
|
|
||||||
*/
|
*/
|
||||||
chooseFormat(options: FormatOptions): Format {
|
get page(): [ParsedResponse, ParsedResponse?] {
|
||||||
if (!this.streaming_data)
|
return this.#page;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 NavigationEndpoint from '../classes/NavigationEndpoint';
|
||||||
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec';
|
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec';
|
||||||
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec';
|
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec';
|
||||||
|
import type Format from '../classes/misc/Format';
|
||||||
|
|
||||||
import type { ObservedArray, YTNode } from '../helpers';
|
import type { ObservedArray, YTNode } from '../helpers';
|
||||||
|
import FormatUtils, { URLTransformer, FormatOptions, DownloadOptions } from '../../utils/FormatUtils';
|
||||||
|
|
||||||
class TrackInfo {
|
class TrackInfo {
|
||||||
#page: [ ParsedResponse, ParsedResponse? ];
|
#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.
|
* Retrieves contents of the given tab.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export const CLIENTS = Object.freeze({
|
|||||||
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||||
API_VERSION: 'v1'
|
API_VERSION: 'v1'
|
||||||
},
|
},
|
||||||
|
WEB_KIDS: {
|
||||||
|
NAME: 'WEB_KIDS',
|
||||||
|
VERSION: '2.20230111.00.00'
|
||||||
|
},
|
||||||
YTMUSIC: {
|
YTMUSIC: {
|
||||||
NAME: 'WEB_REMIX',
|
NAME: 'WEB_REMIX',
|
||||||
VERSION: '1.20211213.00.00'
|
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 =
|
const headers =
|
||||||
init?.headers ||
|
init?.headers ||
|
||||||
(input instanceof Request ? input.headers : new Headers()) ||
|
(input instanceof Request ? input.headers : new Headers()) ||
|
||||||
new Headers();
|
new Headers();
|
||||||
|
|
||||||
const body = init?.body || (input instanceof Request ? input.body : undefined);
|
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');
|
const content_type = request_headers.get('Content-Type');
|
||||||
|
|
||||||
let request_body = body;
|
let request_body = body;
|
||||||
|
let is_web_kids = false;
|
||||||
|
|
||||||
const is_innertube_req =
|
const is_innertube_req =
|
||||||
baseURL === innertube_url ||
|
baseURL === innertube_url ||
|
||||||
@@ -85,11 +86,12 @@ export default class HTTPClient {
|
|||||||
|
|
||||||
delete n_body.client;
|
delete n_body.client;
|
||||||
|
|
||||||
|
is_web_kids = n_body.context.client.clientName === 'WEB_KIDS';
|
||||||
request_body = JSON.stringify(n_body);
|
request_body = JSON.stringify(n_body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate (NOTE: YouTube Kids does not support regular bearer tokens)
|
||||||
if (this.#session.logged_in && is_innertube_req) {
|
if (this.#session.logged_in && is_innertube_req && !is_web_kids) {
|
||||||
const oauth = this.#session.oauth;
|
const oauth = this.#session.oauth;
|
||||||
|
|
||||||
if (oauth.validateCredentials()) {
|
if (oauth.validateCredentials()) {
|
||||||
@@ -157,6 +159,40 @@ export default class HTTPClient {
|
|||||||
ctx.client.clientScreen = 'EMBED';
|
ctx.client.clientScreen = 'EMBED';
|
||||||
ctx.thirdParty = { embedUrl: Constants.URLS.YT_BASE };
|
ctx.thirdParty = { embedUrl: Constants.URLS.YT_BASE };
|
||||||
break;
|
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export const VIDEOS = [
|
|||||||
{
|
{
|
||||||
ID: 'jfKfPfyJRdk',
|
ID: 'jfKfPfyJRdk',
|
||||||
QUERY: 'live video'
|
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();
|
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> {
|
async function download(id: string, yt: Innertube): Promise<boolean> {
|
||||||
|
|||||||
Reference in New Issue
Block a user