chore: v3.0.0 release

This commit is contained in:
LuanRT
2023-02-17 04:23:49 +00:00
commit 52b7400442
497 changed files with 27613 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 LuanRT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

759
README.md Normal file
View File

@@ -0,0 +1,759 @@
<!-- BADGE LINKS -->
[npm]: https://www.npmjs.com/package/youtubei.js
[versions]: https://www.npmjs.com/package/youtubei.js?activeTab=versions
[codefactor]: https://www.codefactor.io/repository/github/luanrt/youtube.js
[actions]: https://github.com/LuanRT/YouTube.js/actions
[say-thanks]: https://saythanks.io/to/LuanRT
[github-sponsors]:https://github.com/sponsors/LuanRT
<!-- OTHER LINKS -->
[project]: https://github.com/LuanRT/YouTube.js
[twitter]: https://twitter.com/thesciencephile
[discord]: https://discord.gg/syDu7Yks54
[nodejs]: https://nodejs.org
<h1 align=center>YouTube.js</h1>
<p align=center>A full-featured wrapper around the InnerTube API</p>
<div align="center">
[![CI](https://github.com/LuanRT/YouTube.js/actions/workflows/test.yml/badge.svg)][actions]
[![NPM Version](https://img.shields.io/npm/v/youtubei.js?color=%2335C757)][versions]
[![Codefactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)][codefactor]
[![Downloads](https://img.shields.io/npm/dt/youtubei.js)][npm]
[![Discord](https://img.shields.io/badge/discord-online-brightgreen.svg)][discord]
[![Say thanks](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)][say-thanks]
<br>
[![Donate](https://img.shields.io/badge/donate-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white)][github-sponsors]
</div>
<p align="center">
<a><sub>Special thanks to:<sub></a>
</p>
<table align="center">
<body>
<tr>
<td align="center">
<a href="https://serpapi.com/" target="_blank">
<img width="80" alt="SerpApi" src="https://luanrt.is-a.dev/assets/img/serpapi.svg" />
<br>
<b>
<sub>
Scrape Google and other search engines from a fast, easy and complete API.
</sub>
</b>
</a>
</td>
</tr>
</body>
</table>
___
<details>
<summary>Table of Contents</summary>
<ol>
<li>
<a href="#about">About</a>
</li>
<li>
<a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#prerequisites">Prerequisites</a></li>
<li><a href="#installation">Installation</a></li>
</ul>
</li>
<li>
<a href="#usage">Usage</a>
<ul>
<li><a href="#browser-usage">Browser Usage</a></li>
<li><a href="#caching">Caching</a></li>
<li><a href="#api">API</a></li>
</ul>
</li>
<li><a href="#extending-the-library">Extending the library</a></li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#disclaimer">Disclaimer</a></li>
<li><a href="#license">License</a></li>
</ol>
</details>
## 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.
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).
## Getting Started
### Prerequisites
YouTube.js runs on Node.js, Deno, and modern browsers.
It requires a runtime with the following features:
- [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
- On Node we use [undici]()'s fetch implementation which requires Node.js 16.8+. You may provide your fetch implementation if you need to use an older version. See [providing your own fetch implementation](#custom-fetch) for more information.
- The `Response` object returned by fetch must thus be spec compliant and return a `ReadableStream` object if you want to use the `VideoInfo#download` method. (Implementations like `node-fetch` returns a non-standard `Readable` object.)
- [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) and [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) are required.
### Installation
```bash
# NPM
npm install youtubei.js@latest
# Yarn
yarn add youtubei.js@latest
# Git (edge version)
npm install github:LuanRT/YouTube.js
```
**TODO:** Deno install instructions (deno.land)
## Usage
Create an InnerTube instance:
```ts
// const { Innertube } = require('youtubei.js');
import { Innertube } from 'youtubei.js';
const youtube = await Innertube.create();
```
## Browser Usage
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in [`examples/browser/proxy/deno.ts`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/proxy/deno.ts).
You may provide your own fetch implementation to be used by YouTube.js. Which we will use here to modify and send the requests through our proxy. See [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/web) for a simple example using [Vite](https://vitejs.dev/).
```ts
// Pre-bundled version for the web
import { Innertube } from 'youtubei.js/bundle/browser';
await Innertube.create({
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
// Modify the request
// and send it to the proxy
// fetch the URL
return fetch(request, init);
}
});
```
### Streaming
YouTube.js supports streaming of videos in the browser by converting YouTube's streaming data into an MPEG-DASH manifest.
The example below uses [`dash.js`](https://github.com/Dash-Industry-Forum/dash.js) to play the video.
```ts
import { Innertube } from 'youtubei.js';
import dashjs from 'dashjs';
const youtube = await Innertube.create({ /* setup - see above */ });
// get the video info
const videoInfo = await youtube.getInfo('videoId');
// now convert to a dash manifest
// again - to be able to stream the video in the browser - we must proxy the requests through our own server
// to do this, we provide a method to transform the URLs before writing them to the manifest
const manifest = videoInfo.toDash(url => {
// modify the url
// and return it
return url;
});
const uri = "data:application/dash+xml;charset=utf-8;base64," + btoa(manifest);
const videoElement = document.getElementById('video_player');
const player = dashjs.MediaPlayer().create();
player.initialize(videoElement, uri, true);
```
A fully working example can be found in [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/blob/main/examples/browser/web). Alternatively, you can view it live at [ytjsexample.pages.dev](https://ytjsexample.pages.dev/).
<a name="custom-fetch"></a>
## Providing your own fetch implementation
You may provide your own fetch implementation to be used by YouTube.js. This can be useful in some cases to modify the requests before they are sent and transform the responses before they are returned (eg. for proxies).
```ts
// provide a fetch implementation
const yt = await Innertube.create({
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
// make the request with your own fetch implementation
// and return the response
return new Response(
/* ... */
);
}
});
```
<a name="caching"></a>
## Caching
To improve performance, you may wish to cache the transformed player instance which we use to decode the streaming urls.
Our cache uses the `node:fs` module in Node-like environments, `Deno.writeFile` in Deno, and `indexedDB` in browsers.
```ts
import { Innertube, UniversalCache } from 'youtubei.js';
// By default, cache stores files in the OS temp directory (or indexedDB in browsers).
const yt = await Innertube.create({
cache: new UniversalCache()
});
// You may wish to make the cache persistent (on Node and Deno)
const yt = await Innertube.create({
cache: new UniversalCache(
// Enables persistent caching
true,
// Path to the cache directory will create the directory if it doesn't exist
'./.cache'
)
});
```
## API
* `Innertube`
<details>
<summary>Objects</summary>
<p>
* [.session](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/session.md)
* [.account](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/account.md)
* [.interact](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/interaction-manager.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)
* [.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>
<details>
<summary>Methods</summary>
<p>
* [.getInfo(video_id, client?)](#getinfo)
* [.getBasicInfo(video_id, client?)](#getbasicinfo)
* [.search(query, filters?)](#search)
* [.getSearchSuggestions(query)](#getsearchsuggestions)
* [.getComments(video_id, sort_by?)](#getcomments)
* [.getHomeFeed()](#gethomefeed)
* [.getLibrary()](#getlibrary)
* [.getHistory()](#gethistory)
* [.getTrending()](#gettrending)
* [.getSubscriptionsFeed()](#getsubscriptionsfeed)
* [.getChannel(id)](#getchannel)
* [.getNotifications()](#getnotifications)
* [.getUnseenNotificationsCount()](#getunseennotificationscount)
* [.getPlaylist(id)](#getplaylist)
* [.getHashtag(hashtag)](#gethashtag)
* [.getStreamingData(video_id, options)](#getstreamingdata)
* [.download(video_id, options?)](#download)
* [.resolveURL(url)](#resolveurl)
* [.call(endpoint, args?)](#call)
</p>
</details>
<a name="getinfo"></a>
### getInfo(video_id, client?)
Retrieves video info, including playback data and even layout elements such as menus, buttons, etc — all nicely parsed.
**Returns**: `Promise<VideoInfo>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The id of the video |
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC`, `YTMUSIC_ANDROID` or `TV_EMBEDDED` |
<details>
<summary>Methods & Getters</summary>
<p>
- `<info>#like()`
- Likes the video.
- `<info>#dislike()`
- Dislikes the video.
- `<info>#removeRating()`
- Removes like/dislike.
- `<info>#getLiveChat()`
- Returns a LiveChat instance.
- `<info>#chooseFormat(options)`
- Used to choose streaming data formats.
- `<info>#toDash(url_transformer?, format_filter?)`
- Converts streaming data to an MPEG-DASH manifest.
- `<info>#download(options)`
- Downloads the video. See [download](#download).
- `<info>#filters`
- Returns filters that can be applied to the watch next feed.
- `<info>#selectFilter(name)`
- Applies the given filter to the watch next feed and returns a new instance of [`VideoInfo`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube/VideoInfo.ts).
- `<info>#getWatchNextContinuation()`
- Retrieves the next batch of items for the watch next feed.
- `<info>#addToWatchHistory()`
- Adds the video to the watch history.
- `<info>#page`
- Returns original InnerTube response (sanitized).
</p>
</details>
<a name="getbasicinfo"></a>
### getBasicInfo(video_id, client?)
Suitable for cases where you only need basic video metadata. Also, it is faster than [`getInfo()`](#getinfo).
**Returns**: `Promise<VideoInfo>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The id of the video |
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC_ANDROID`, `YTMUSIC`, `TV_EMBEDDED` |
<a name="search"></a>
### search(query, filters?)
Searches the given query on YouTube.
**Returns**: `Promise<Search>`
> **Note**
> `Search` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
| Param | Type | Description |
| --- | --- | --- |
| query | `string` | The search query |
| filters? | `SearchFilters` | Search filters |
<details>
<summary>Methods & Getters</summary>
<p>
- `<search>#selectRefinementCard(SearchRefinementCard | string)`
- Applies given refinement card and returns a new Search instance.
- `<search>#refinement_card_queries`
- Returns available refinement cards, this is a simplified version of the `refinement_cards` object.
- `<search>#getContinuation()`
- Retrieves next batch of results.
</p>
</details>
<a name="getsearchsuggestions"></a>
### getSearchSuggestions(query)
Retrieves search suggestions for given query.
**Returns**: `Promise<string[]>`
| Param | Type | Description |
| --- | --- | --- |
| query | `string` | The search query |
<a name="getcomments"></a>
### getComments(video_id, sort_by?)
Retrieves comments for given video.
**Returns**: `Promise<Comments>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The video id |
| sort_by | `string` | Can be: `TOP_COMMENTS` or `NEWEST_FIRST` |
See [`./examples/comments`](https://github.com/LuanRT/YouTube.js/blob/main/examples/comments) for examples.
<a name="gethomefeed"></a>
### getHomeFeed()
Retrieves YouTube's home feed.
**Returns**: `Promise<HomeFeed>`
> **Note**
> `HomeFeed` extends the [`FilterableFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/filterable-feed.md) class.
<details>
<summary>Methods & Getters</summary>
<p>
- `<home_feed>#videos`
- Returns all videos in the home feed.
- `<home_feed>#posts`
- Returns all posts in the home feed.
- `<home_feed>#shelves`
- Returns all shelves in the home feed.
- `<home_feed>#filters`
- Returns available filters.
- `<home_feed>#applyFilter(name | ChipCloudChip)`
- Applies given filter and returns a new HomeFeed instance.
- `<home_feed>#getContinuation()`
- Retrieves feed continuation.
</p>
</details>
<a name="getlibrary"></a>
### getLibrary()
Retrieves the account's library.
**Returns**: `Promise<Library>`
> **Note**
> `Library` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
<details>
<summary>Methods & Getters</summary>
<p>
- `<library>#history`
- `<library>#watch_later`
- `<library>#liked_videos`
- `<library>#playlists_section`
- `<library>#clips`
</p>
</details>
<a name="gethistory"></a>
### getHistory()
Retrieves watch history.
**Returns**: `Promise<History>`
> **Note**
> `History` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
<details>
<summary>Methods & Getters</summary>
<p>
- `<history>#getContinuation()`
- Retrieves next batch of contents.
</p>
</details>
<a name="gettrending"></a>
### getTrending()
Retrieves trending content.
**Returns**: `Promise<TabbedFeed<IBrowseResponse>>`
<a name="getsubscriptionsfeed"></a>
### getSubscriptionsFeed()
Retrieves the subscriptions feed.
**Returns**: `Promise<Feed<IBrowseResponse>>`
<a name="getchannel"></a>
### getChannel(id)
Retrieves contents for a given channel.
**Returns**: `Promise<Channel>`
> **Note**
> `Channel` extends the [`TabbedFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/tabbed-feed.md) class.
| Param | Type | Description |
| --- | --- | --- |
| id | `string` | Channel id |
<details>
<summary>Methods & Getters</summary>
<p>
- `<channel>#getVideos()`
- `<channel>#getShorts()`
- `<channel>#getLiveStreams()`
- `<channel>#getPlaylists()`
- `<channel>#getHome()`
- `<channel>#getCommunity()`
- `<channel>#getChannels()`
- `<channel>#getAbout()`
- `<channel>#search(query)`
- `<channel>#applyFilter(filter)`
- `<channel>#applyContentTypeFilter(content_type_filter)`
- `<channel>#applySort(sort)`
- `<channel>#getContinuation()`
- `<channel>#filters`
- `<channel>#content_type_filters`
- `<channel>#sort_filters`
- `<channel>#page`
</p>
</details>
See [`./examples/channel`](https://github.com/LuanRT/YouTube.js/blob/main/examples/channel) for examples.
<a name="getnotifications"></a>
### getNotifications()
Retrieves notifications.
**Returns**: `Promise<NotificationsMenu>`
<details>
<summary>Methods & Getter</summary>
<p>
- `<notifications>#getContinuation()`
- Retrieves next batch of notifications.
</p>
</details>
<a name="getunseennotificationscount"></a>
### getUnseenNotificationsCount()
Retrieves unseen notifications count.
**Returns**: `Promise<number>`
<a name="getplaylist"></a>
### getPlaylist(id)
Retrieves playlist contents.
**Returns**: `Promise<Playlist>`
> **Note**
> `Playlist` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
| Param | Type | Description |
| --- | --- | --- |
| id | `string` | Playlist id |
<details>
<summary>Methods & Getter</summary>
<p>
- `<playlist>#items`
- Returns the items of the playlist.
</p>
</details>
<a name="gethashtag"></a>
### getHashtag(hashtag)
Retrieves a given hashtag's page.
**Returns**: `Promise<HashtagFeed>`
> **Note**
> `HashtagFeed` extends the [`FilterableFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/filterable-feed.md) class.
| Param | Type | Description |
| --- | --- | --- |
| hashtag | `string` | The hashtag |
<details>
<summary>Methods & Getter</summary>
<p>
- `<hashtag>#applyFilter(filter)`
- Applies given filter and returns a new `HashtagFeed` instance.
- `<hashtag>#getContinuation()`
- Retrieves next batch of contents.
</p>
</details>
<a name="getstreamingdata"></a>
### getStreamingData(video_id, options)
Returns deciphered streaming data.
> **Note**
> This will be deprecated in the future. It is recommended to retrieve streaming data from a `VideoInfo`/`TrackInfo` object instead if you want to select formats manually, see the example below.
```ts
const info = await yt.getBasicInfo('somevideoid');
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
console.info('Playback url:', url);
```
**Returns**: `Promise<object>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| options | `FormatOptions` | Format options |
<a name="download"></a>
### download(video_id, options?)
Downloads a given video.
**Returns**: `Promise<ReadableStream<Uint8Array>>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| options | `DownloadOptions` | Download options |
See [`./examples/download`](https://github.com/LuanRT/YouTube.js/blob/main/examples/download) for examples.
<a name="resolveurl"></a>
### resolveURL(url)
Resolves a given url.
**Returns**: `Promise<NavigationEndpoint>`
| Param | Type | Description |
| --- | --- | --- |
| url | `string` | Url to resolve |
<a name="call"></a>
### call(endpoint, args?)
Utility to call navigation endpoints.
**Returns**: `Promise<T extends IParsedResponse | IParsedResponse | ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| endpoint | `NavigationEndpoint` | The target endpoint |
| args? | `object` | Additional payload arguments |
## Extending the library
YouTube.js is completely modular and easy to extend. Almost all methods, classes, and utilities used internally are exposed and can be used to implement your own extensions without having to modify the library's source code.
For example, let's say we want to implement a method to retrieve video info manually. We can do that by using an instance of the `Actions` class:
```ts
import { Innertube } from 'youtubei.js';
(async () => {
const yt = await Innertube.create();
async function getVideoInfo(videoId: string) {
const videoInfo = await yt.actions.execute('/player', {
// anything added here will be merged with the default payload and sent to InnerTube.
videoId,
client: 'YTMUSIC', // InnerTube client, can be ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB or TV_EMBEDDED
parse: true // tells YouTube.js to parse the response, this is not sent to InnerTube.
});
return videoInfo;
}
const videoInfo = await getVideoInfo('jLTOuvBTLxA');
console.info(videoInfo);
})();
```
Or perhaps there's a `NavigationEndpoint` in a parsed response and we want to call it to see what happens:
```ts
import { Innertube, YTNodes } from 'youtubei.js';
(async () => {
const yt = await Innertube.create();
const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
const albums = artist.sections[1].as(YTNodes.MusicCarouselShelf);
// Say we want to click the “More” button:
const button = albums.as(YTNodes.MusicCarouselShelf).header?.more_content;
if (button) {
// After making sure it exists, we can call its navigation endpoint:
const page = await button.endpoint.call(yt.actions, { parse: true });
console.info(page);
}
})();
```
### Parser
YouTube.js' parser allows you to parse InnerTube responses and turn their nodes into strongly typed objects that can be easily manipulated. It also provides a set of utility methods that make working with InnerTube much easier.
Example:
```ts
// See ./examples/parser
import { Parser, YTNodes } from 'youtubei.js';
import { readFileSync } from 'fs';
// Artist page response from YouTube Music
const data = readFileSync('./artist.json').toString();
const page = Parser.parseResponse(JSON.parse(data));
const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);
console.info('Header:', header);
/**
* The parser encapsulates all arrays in a proxy object.
* A proxy intercepts access to the actual data, allowing
* the parser to add type safety and many utility methods
* that make working with InnerTube much easier.
*/
const tab = page.contents?.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
if (!tab)
throw new Error('Target tab not found');
if (!tab.content)
throw new Error('Target tab appears to be empty');
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
console.info('Sections:', sections);
```
Documentation for the parser can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/src/parser).
<!-- CONTRIBUTING -->
## Contributing
Contributions, issues, and feature requests are welcome.
Feel free to check the [issues page](https://github.com/LuanRT/YouTube.js/issues) and our [guidelines](https://github.com/LuanRT/YouTube.js/blob/main/CONTRIBUTING.md) if you want to contribute.
Thank you to all the wonderful people who have contributed to this project:
<a href="https://github.com/LuanRT/YouTube.js/graphs/contributors">
<img src="https://contrib.rocks/image?repo=LuanRT/YouTube.js" />
</a>
## Contact
LuanRT - [@thesciencephile][twitter] - luan.lrt4@gmail.com
Project Link: [https://github.com/LuanRT/YouTube.js][project]
## Disclaimer
This project is not affiliated with, endorsed, or sponsored by YouTube or any of its affiliates or subsidiaries.
All trademarks, logos, and brand names are the property of their respective owners and are used only to directly describe the services being provided, as such, any usage of trademarks to refer to such services is considered nominative use.
Should you have any questions or concerns please contact me directly via email.
[^1]: https://gizmodo.com/how-project-innertube-helped-pull-youtube-out-of-the-gu-1704946491
## License
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
<p align=" right">
(<a href="#top">back to top</a>)
</p>

3
deno.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './deno/src/platform/deno.ts';
import Innertube from './deno/src/platform/deno.ts';
export default Innertube;

132
deno/package.json Normal file
View File

@@ -0,0 +1,132 @@
{
"name": "youtubei.js",
"version": "3.0.0",
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",
"typesVersions": {
"*": {
"agnostic": [
"./dist/src/platform/lib.d.ts"
],
"web": [
"./dist/src/platform/lib.d.ts"
],
"web.bundle": [
"./dist/src/platform/lib.d.ts"
],
"web.bundle.min": [
"./dist/src/platform/lib.d.ts"
]
}
},
"exports": {
".": {
"node": {
"import": "./dist/src/platform/node.js",
"require": "./bundle/node.cjs"
},
"deno": "./dist/src/platform/deno.js",
"types": "./dist/src/platform/lib.d.ts",
"browser": "./dist/src/platform/web.js",
"default": "./dist/src/platform/web.js"
},
"./agnostic": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./dist/src/platform/lib.js"
},
"./web": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./dist/src/platform/web.js"
},
"./web.bundle": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./bundle/browser.js"
},
"./web.bundle.min": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./bundle/browser.min.js"
}
},
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"contributors": [
"Wykerd (https://github.com/wykerd/)",
"MasterOfBob777 (https://github.com/MasterOfBob777)",
"patrickkfkan (https://github.com/patrickkfkan)",
"akkadaska (https://github.com/akkadaska)"
],
"directories": {
"test": "./test",
"examples": "./examples",
"dist": "./dist"
},
"scripts": {
"test": "npx jest --verbose",
"lint": "npx eslint ./src",
"lint:fix": "npx eslint --fix ./src",
"build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod",
"build:parser-map": "node ./scripts/build-parser-map.cjs",
"build:proto": "npx pb-gen-ts --entry-path=\"src/proto\" --out-dir=\"src/proto/generated\" --ext-in-import=\".js\"",
"build:esm": "npx tsc",
"build:deno": "npx cpy ./src ./deno && npx cpy ./package.json ./deno && npx replace \".ts';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'https://esm.sh/linkedom';\" \"'https://esm.sh/linkedom';\" ./deno -r && npx replace \"'https://esm.sh/jintr';\" \"'https://esm.sh/jintr';\" ./deno -r && npx replace \"new Jinter\" \"new Jinter\" ./deno -r",
"bundle:node": "npx esbuild ./dist/src/platform/node.js --bundle --target=node10 --keep-names --format=cjs --platform=node --outfile=./bundle/node.cjs --external:jintr --external:undici --external:linkedom --sourcemap --banner:js=\"/* eslint-disable */\"",
"bundle:browser": "npx esbuild ./dist/src/platform/web.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --outfile=./bundle/browser.js --platform=browser",
"bundle:browser:prod": "npm run bundle:browser -- --outfile=./bundle/browser.min.js --minify",
"prepare": "npm run build",
"watch": "npx tsc --watch"
},
"repository": {
"type": "git",
"url": "git+https://github.com/LuanRT/YouTube.js.git"
},
"license": "MIT",
"dependencies": {
"jintr": "^0.4.1",
"linkedom": "^0.14.12",
"undici": "^5.19.1"
},
"devDependencies": {
"@types/jest": "^28.1.7",
"@types/node": "^17.0.45",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"cpy-cli": "^4.2.0",
"esbuild": "^0.14.49",
"eslint": "^8.19.0",
"eslint-plugin-tsdoc": "^0.2.16",
"glob": "^8.0.3",
"jest": "^28.1.3",
"pbkit": "^0.0.59",
"replace": "^1.2.2",
"ts-jest": "^28.0.8",
"typescript": "^4.9.5"
},
"bugs": {
"url": "https://github.com/LuanRT/YouTube.js/issues"
},
"homepage": "https://github.com/LuanRT/YouTube.js#readme",
"keywords": [
"yt",
"dl",
"ytdl",
"youtube",
"youtubedl",
"youtube-dl",
"youtube-downloader",
"youtube-music",
"youtube-studio",
"innertube",
"unofficial",
"downloader",
"livechat",
"studio",
"upload",
"ytmusic",
"search",
"music",
"api"
]
}

307
deno/src/Innertube.ts Normal file
View File

@@ -0,0 +1,307 @@
import Session, { SessionOptions } from './core/Session.ts';
import NavigationEndpoint from './parser/classes/NavigationEndpoint.ts';
import Channel from './parser/youtube/Channel.ts';
import Comments from './parser/youtube/Comments.ts';
import History from './parser/youtube/History.ts';
import Library from './parser/youtube/Library.ts';
import NotificationsMenu from './parser/youtube/NotificationsMenu.ts';
import Playlist from './parser/youtube/Playlist.ts';
import Search from './parser/youtube/Search.ts';
import VideoInfo from './parser/youtube/VideoInfo.ts';
import HashtagFeed from './parser/youtube/HashtagFeed.ts';
import AccountManager from './core/AccountManager.ts';
import Feed from './core/Feed.ts';
import InteractionManager from './core/InteractionManager.ts';
import YTKids from './core/Kids.ts';
import YTMusic from './core/Music.ts';
import PlaylistManager from './core/PlaylistManager.ts';
import YTStudio from './core/Studio.ts';
import TabbedFeed from './core/TabbedFeed.ts';
import HomeFeed from './parser/youtube/HomeFeed.ts';
import Proto from './proto/index.ts';
import Constants from './utils/Constants.ts';
import type Actions from './core/Actions.ts';
import type Format from './parser/classes/misc/Format.ts';
import type { ApiResponse } from './core/Actions.ts';
import type { IBrowseResponse, IParsedResponse } from './parser/types/index.ts';
import type { DownloadOptions, FormatOptions } from './utils/FormatUtils.ts';
import { generateRandomString, throwIfMissing } from './utils/Utils.ts';
export type InnertubeConfig = SessionOptions;
export interface SearchFilters {
upload_date?: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
type?: 'all' | 'video' | 'channel' | 'playlist' | 'movie';
duration?: 'all' | 'short' | 'medium' | 'long';
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
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' | 'YTKIDS'
class Innertube {
session: Session;
account: AccountManager;
playlist: PlaylistManager;
interact: InteractionManager;
music: YTMusic;
studio: YTStudio;
kids: YTKids;
actions: Actions;
constructor(session: Session) {
this.session = session;
this.account = new AccountManager(this.session.actions);
this.playlist = new PlaylistManager(this.session.actions);
this.interact = new InteractionManager(this.session.actions);
this.music = new YTMusic(this.session);
this.studio = new YTStudio(this.session);
this.kids = new YTKids(this.session);
this.actions = this.session.actions;
}
static async create(config: InnertubeConfig = {}): Promise<Innertube> {
return new Innertube(await Session.create(config));
}
/**
* Retrieves video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ video_id });
const cpn = generateRandomString(16);
const initial_info = this.actions.getVideoInfo(video_id, cpn, client);
const continuation = this.actions.execute('/next', { videoId: video_id });
const response = await Promise.all([ initial_info, continuation ]);
return new VideoInfo(response, this.actions, this.session.player, cpn);
}
/**
* Retrieves basic video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ video_id });
const cpn = generateRandomString(16);
const response = await this.actions.getVideoInfo(video_id, cpn, client);
return new VideoInfo([ response ], this.actions, this.session.player, cpn);
}
/**
* Searches a given query.
* @param query - The search query.
* @param filters - Search filters.
*/
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
throwIfMissing({ query });
const args = {
query,
...{
params: filters ? Proto.encodeSearchFilters(filters) : undefined
}
};
const response = await this.actions.execute('/search', args);
return new Search(this.actions, response);
}
/**
* Retrieves search suggestions for a given query.
* @param query - The search query.
*/
async getSearchSuggestions(query: string): Promise<string[]> {
throwIfMissing({ query });
const url = new URL(`${Constants.URLS.YT_SUGGESTIONS}search`);
url.searchParams.set('q', query);
url.searchParams.set('hl', this.session.context.client.hl);
url.searchParams.set('gl', this.session.context.client.gl);
url.searchParams.set('ds', 'yt');
url.searchParams.set('client', 'youtube');
url.searchParams.set('xssi', 't');
url.searchParams.set('oe', 'UTF');
const response = await this.session.http.fetch(url);
const response_data = await response.text();
const data = JSON.parse(response_data.replace(')]}\'', ''));
const suggestions = data[1].map((suggestion: any) => suggestion[0]);
return suggestions;
}
/**
* Retrieves comments for a video.
* @param video_id - The video id.
* @param sort_by - Sorting options.
*/
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
throwIfMissing({ video_id });
const payload = Proto.encodeCommentsSectionParams(video_id, {
sort_by: sort_by || 'TOP_COMMENTS'
});
const response = await this.actions.execute('/next', { continuation: payload });
return new Comments(this.actions, response.data);
}
/**
* Retrieves YouTube's home feed (aka recommendations).
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
return new HomeFeed(this.actions, response);
}
/**
* Returns the account's library.
*/
async getLibrary(): Promise<Library> {
const response = await this.actions.execute('/browse', { browseId: 'FElibrary' });
return new Library(this.actions, response);
}
/**
* Retrieves watch history.
* Which can also be achieved with {@link getLibrary}.
*/
async getHistory(): Promise<History> {
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
return new History(this.actions, response);
}
/**
* Retrieves trending content.
*/
async getTrending(): Promise<TabbedFeed<IBrowseResponse>> {
const response = await this.actions.execute('/browse', { browseId: 'FEtrending', parse: true });
return new TabbedFeed(this.actions, response);
}
/**
* Retrieves subscriptions feed.
*/
async getSubscriptionsFeed(): Promise<Feed<IBrowseResponse>> {
const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions', parse: true });
return new Feed(this.actions, response);
}
/**
* Retrieves contents for a given channel.
* @param id - Channel id
*/
async getChannel(id: string): Promise<Channel> {
throwIfMissing({ id });
const response = await this.actions.execute('/browse', { browseId: id });
return new Channel(this.actions, response);
}
/**
* Retrieves notifications.
*/
async getNotifications(): Promise<NotificationsMenu> {
const response = await this.actions.execute('/notification/get_notification_menu', { notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' });
return new NotificationsMenu(this.actions, response);
}
/**
* Retrieves unseen notifications count.
*/
async getUnseenNotificationsCount(): Promise<number> {
const response = await this.actions.execute('/notification/get_unseen_count');
// TODO: properly parse this
return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;
}
/**
* Retrieves playlist contents.
* @param id - Playlist id
*/
async getPlaylist(id: string): Promise<Playlist> {
throwIfMissing({ id });
if (!id.startsWith('VL')) {
id = `VL${id}`;
}
const response = await this.actions.execute('/browse', { browseId: id });
return new Playlist(this.actions, response);
}
/**
* Retrieves a given hashtag's page.
* @param hashtag - The hashtag to fetch.
*/
async getHashtag(hashtag: string): Promise<HashtagFeed> {
throwIfMissing({ hashtag });
const params = Proto.encodeHashtag(hashtag);
const response = await this.actions.execute('/browse', { browseId: 'FEhashtag', params });
return new HashtagFeed(this.actions, response);
}
/**
* An alternative to {@link download}.
* Returns deciphered streaming data.
*
* If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}.
* @param video_id - The video id.
* @param options - Format options.
*/
async getStreamingData(video_id: string, options: FormatOptions = {}): Promise<Format> {
const info = await this.getBasicInfo(video_id);
return info.chooseFormat(options);
}
/**
* Downloads a given video. If you only need the direct download link see {@link getStreamingData}.
* If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}.
* @param video_id - The video id.
* @param options - Download options.
*/
async download(video_id: string, options?: DownloadOptions): Promise<ReadableStream<Uint8Array>> {
const info = await this.getBasicInfo(video_id, options?.client);
return info.download(options);
}
/**
* Resolves the given URL.
* @param url - The URL.
*/
async resolveURL(url: string): Promise<NavigationEndpoint> {
const response = await this.actions.execute('/navigation/resolve_url', { url, parse: true });
return response.endpoint;
}
/**
* Utility method to call an endpoint without having to use {@link Actions}.
* @param endpoint -The endpoint to call.
* @param args - Call arguments.
*/
call<T extends IParsedResponse>(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise<T>;
call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise<ApiResponse>;
call(endpoint: NavigationEndpoint, args?: object): Promise<IParsedResponse | ApiResponse> {
return endpoint.call(this.actions, args);
}
}
export default Innertube;

View File

@@ -0,0 +1,110 @@
import Proto from '../proto/index.ts';
import type Actions from './Actions.ts';
import type { ApiResponse } from './Actions.ts';
import Analytics from '../parser/youtube/Analytics.ts';
import TimeWatched from '../parser/youtube/TimeWatched.ts';
import AccountInfo from '../parser/youtube/AccountInfo.ts';
import Settings from '../parser/youtube/Settings.ts';
import { InnertubeError } from '../utils/Utils.ts';
class AccountManager {
#actions: Actions;
channel: {
editName: (new_name: string) => Promise<ApiResponse>;
editDescription: (new_description: string) => Promise<ApiResponse>;
getBasicAnalytics: () => Promise<Analytics>;
};
constructor(actions: Actions) {
this.#actions = actions;
this.channel = {
/**
* Edits channel name.
* @param new_name - The new channel name.
*/
editName: (new_name: string) => {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
return this.#actions.execute('/channel/edit_name', {
givenName: new_name,
client: 'ANDROID'
});
},
/**
* Edits channel description.
* @param new_description - The new description.
*/
editDescription: (new_description: string) => {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
return this.#actions.execute('/channel/edit_description', {
givenDescription: new_description,
client: 'ANDROID'
});
},
/**
* Retrieves basic channel analytics.
*/
getBasicAnalytics: () => this.getAnalytics()
};
}
/**
* Retrieves channel info.
*/
async getInfo(): Promise<AccountInfo> {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('/account/accounts_list', { client: 'ANDROID' });
return new AccountInfo(response);
}
/**
* Retrieves time watched statistics.
*/
async getTimeWatched(): Promise<TimeWatched> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPtime_watched',
client: 'ANDROID'
});
return new TimeWatched(response);
}
/**
* Opens YouTube settings.
*/
async getSettings(): Promise<Settings> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPaccount_overview'
});
return new Settings(this.#actions, response);
}
/**
* Retrieves basic channel analytics.
*/
async getAnalytics(): Promise<Analytics> {
const info = await this.getInfo();
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
const response = await this.#actions.execute('/browse', {
browseId: 'FEanalytics_screen',
client: 'ANDROID',
params
});
return new Analytics(response);
}
}
export default AccountManager;

231
deno/src/core/Actions.ts Normal file
View File

@@ -0,0 +1,231 @@
import Parser, { NavigateAction } from '../parser/index.ts';
import { InnertubeError } from '../utils/Utils.ts';
import type Session from './Session.ts';
import type {
IBrowseResponse, IGetNotificationsMenuResponse,
INextResponse, IPlayerResponse, IResolveURLResponse,
ISearchResponse, IUpdatedMetadataResponse,
IParsedResponse, IRawResponse
} from '../parser/types/index.ts';
export interface ApiResponse {
success: boolean;
status_code: number;
data: IRawResponse;
}
export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/updated_metadata' | '/notification/get_notification_menu' | string;
export type ParsedResponse<T> =
T extends '/player' ? IPlayerResponse :
T extends '/search' ? ISearchResponse :
T extends '/browse' ? IBrowseResponse :
T extends '/next' ? INextResponse :
T extends '/updated_metadata' ? IUpdatedMetadataResponse :
T extends '/navigation/resolve_url' ? IResolveURLResponse :
T extends '/notification/get_notification_menu' ? IGetNotificationsMenuResponse :
IParsedResponse;
class Actions {
#session: Session;
constructor(session: Session) {
this.#session = session;
}
get session(): Session {
return this.#session;
}
/**
* Mimmics the Axios API using Fetch's Response object.
* @param response - The response object.
*/
async #wrap(response: Response): Promise<ApiResponse> {
return {
success: response.ok,
status_code: response.status,
data: JSON.parse(await response.text())
};
}
/**
* Used to retrieve video info.
* @param id - The video ID.
* @param cpn - Content Playback Nonce.
* @param client - The client to use.
* @param playlist_id - The playlist ID.
*/
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise<ApiResponse> {
const data: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
vis: 0,
splay: false,
referer: 'https://www.youtube.com',
currentUrl: `/watch?v=${id}`,
autonavState: 'STATE_NONE',
signatureTimestamp: this.#session.player?.sts || 0,
autoCaptionsDefaultOn: false,
html5Preference: 'HTML5_PREF_WANTS',
lactMilliseconds: '-1'
}
},
attestationRequest: {
omitBotguardData: true
},
videoId: id
};
if (client) {
data.client = client;
}
if (cpn) {
data.cpn = cpn;
}
if (playlist_id) {
data.playlistId = playlist_id;
}
const response = await this.#session.http.fetch('/player', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Makes calls to the playback tracking API.
* @param url - The URL to call.
* @param client - The client to use.
* @param params - Call parameters.
*/
async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }): Promise<Response> {
const s_url = new URL(url);
s_url.searchParams.set('ver', '2');
s_url.searchParams.set('c', client.client_name.toLowerCase());
s_url.searchParams.set('cbrver', client.client_version);
s_url.searchParams.set('cver', client.client_version);
for (const key of Object.keys(params)) {
s_url.searchParams.set(key, params[key]);
}
const response = await this.#session.http.fetch(s_url);
return response;
}
/**
* Executes an API call.
* @param endpoint - The endpoint to call.
* @param args - Call arguments
*/
async execute<T extends InnertubeEndpoint>(endpoint: T, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }): Promise<ParsedResponse<T>>;
async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }): Promise<ApiResponse>;
async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse<T> | ApiResponse> {
let data;
if (args && !args.protobuf) {
data = { ...args };
if (Reflect.has(data, 'browseId')) {
if (this.#needsLogin(data.browseId) && !this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
}
if (Reflect.has(data, 'override_endpoint'))
delete data.override_endpoint;
if (Reflect.has(data, 'parse'))
delete data.parse;
if (Reflect.has(data, 'request'))
delete data.request;
if (Reflect.has(data, 'clientActions'))
delete data.clientActions;
if (Reflect.has(data, 'settingItemIdForClient'))
delete data.settingItemIdForClient;
if (Reflect.has(data, 'action')) {
data.actions = [ data.action ];
delete data.action;
}
if (Reflect.has(data, 'boolValue')) {
data.newValue = { boolValue: data.boolValue };
delete data.boolValue;
}
if (Reflect.has(data, 'token')) {
data.continuation = data.token;
delete data.token;
}
if (data?.client === 'YTMUSIC') {
data.isAudioOnly = true;
}
} else if (args) {
data = args.serialized_data;
}
const target_endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : endpoint;
const response = await this.#session.http.fetch(target_endpoint, {
method: 'POST',
body: args?.protobuf ? data : JSON.stringify((data || {})),
headers: {
'Content-Type': args?.protobuf ?
'application/x-protobuf' :
'application/json'
}
});
if (args?.parse) {
let parsed_response = Parser.parseResponse<ParsedResponse<T>>(await response.json());
// Handle redirects
if (this.#isBrowse(parsed_response) && parsed_response.on_response_received_actions?.first()?.type === 'navigateAction') {
const navigate_action = parsed_response.on_response_received_actions.firstOfType(NavigateAction);
if (navigate_action) {
parsed_response = await navigate_action.endpoint.call(this, { parse: true });
}
}
return parsed_response;
}
return this.#wrap(response);
}
#isBrowse(response: IParsedResponse): response is IBrowseResponse {
return 'on_response_received_actions' in response;
}
#needsLogin(id: string) {
return [
'FElibrary',
'FEhistory',
'FEsubscriptions',
'FEmusic_listening_review',
'FEmusic_library_landing',
'SPaccount_overview',
'SPaccount_notifications',
'SPaccount_privacy',
'SPtime_watched'
].includes(id);
}
}
export default Actions;

214
deno/src/core/Feed.ts Normal file
View File

@@ -0,0 +1,214 @@
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers.ts';
import Parser, { ReloadContinuationItemsCommand } from '../parser/index.ts';
import { concatMemos, InnertubeError } from '../utils/Utils.ts';
import type Actions from './Actions.ts';
import BackstagePost from '../parser/classes/BackstagePost.ts';
import Channel from '../parser/classes/Channel.ts';
import CompactVideo from '../parser/classes/CompactVideo.ts';
import GridChannel from '../parser/classes/GridChannel.ts';
import GridPlaylist from '../parser/classes/GridPlaylist.ts';
import GridVideo from '../parser/classes/GridVideo.ts';
import Playlist from '../parser/classes/Playlist.ts';
import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo.ts';
import PlaylistVideo from '../parser/classes/PlaylistVideo.ts';
import Post from '../parser/classes/Post.ts';
import ReelItem from '../parser/classes/ReelItem.ts';
import ReelShelf from '../parser/classes/ReelShelf.ts';
import RichShelf from '../parser/classes/RichShelf.ts';
import Shelf from '../parser/classes/Shelf.ts';
import Tab from '../parser/classes/Tab.ts';
import Video from '../parser/classes/Video.ts';
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction.ts';
import ContinuationItem from '../parser/classes/ContinuationItem.ts';
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults.ts';
import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults.ts';
import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo.ts';
import type MusicQueue from '../parser/classes/MusicQueue.ts';
import type RichGrid from '../parser/classes/RichGrid.ts';
import type SectionList from '../parser/classes/SectionList.ts';
import type { IParsedResponse } from '../parser/types/index.ts';
import type { ApiResponse } from './Actions.ts';
class Feed<T extends IParsedResponse = IParsedResponse> {
#page: T;
#continuation?: ObservedArray<ContinuationItem>;
#actions: Actions;
#memo: Memo;
constructor(actions: Actions, response: ApiResponse | IParsedResponse, already_parsed = false) {
if (this.#isParsed(response) || already_parsed) {
this.#page = response as T;
} else {
this.#page = Parser.parseResponse<T>(response.data);
}
const memo = concatMemos(...[
this.#page.contents_memo,
this.#page.continuation_contents_memo,
this.#page.on_response_received_commands_memo,
this.#page.on_response_received_endpoints_memo,
this.#page.on_response_received_actions_memo,
this.#page.sidebar_memo,
this.#page.header_memo
]);
if (!memo)
throw new InnertubeError('No memo found in feed');
this.#memo = memo;
this.#actions = actions;
}
#isParsed(response: IParsedResponse | ApiResponse): response is IParsedResponse {
return !('data' in response);
}
/**
* Get all videos on a given page via memo
*/
static getVideosFromMemo(memo: Memo) {
return memo.getType<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
Video,
GridVideo,
ReelItem,
CompactVideo,
PlaylistVideo,
PlaylistPanelVideo,
WatchCardCompactVideo
]);
}
/**
* Get all playlists on a given page via memo
*/
static getPlaylistsFromMemo(memo: Memo) {
return memo.getType<Playlist | GridPlaylist>([ Playlist, GridPlaylist ]);
}
/**
* Get all the videos in the feed
*/
get videos() {
return Feed.getVideosFromMemo(this.#memo);
}
/**
* Get all the community posts in the feed
*/
get posts() {
return this.#memo.getType<Post | BackstagePost>([ BackstagePost, Post ]);
}
/**
* Get all the channels in the feed
*/
get channels() {
return this.#memo.getType<Channel | GridChannel>([ Channel, GridChannel ]);
}
/**
* Get all playlists in the feed
*/
get playlists() {
return Feed.getPlaylistsFromMemo(this.#memo);
}
get memo() {
return this.#memo;
}
/**
* Returns contents from the page.
*/
get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand {
const tab_content = this.#memo.getType(Tab)?.first().content;
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand).first();
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction).first();
return tab_content || reload_continuation_items || append_continuation_items;
}
/**
* Returns all segments/sections from the page.
*/
get shelves() {
return this.#memo.getType<Shelf | RichShelf | ReelShelf>([ Shelf, RichShelf, ReelShelf ]);
}
/**
* Finds shelf by title.
*/
getShelf(title: string) {
return this.shelves.get({ title });
}
/**
* Returns secondary contents from the page.
*/
get secondary_contents(): SuperParsedResult<YTNode> | undefined {
if (!this.#page.contents?.is_node)
return undefined;
const node = this.#page.contents?.item();
if (!node.is(TwoColumnBrowseResults, TwoColumnSearchResults))
return undefined;
return node.secondary_contents;
}
get actions(): Actions {
return this.#actions;
}
/**
* Get the original page data
*/
get page(): T {
return this.#page;
}
/**
* Checks if the feed has continuation.
*/
get has_continuation(): boolean {
return (this.#memo.get('ContinuationItem') || []).length > 0;
}
/**
* Retrieves continuation data as it is.
*/
async getContinuationData(): Promise<T | undefined> {
if (this.#continuation) {
if (this.#continuation.length > 1)
throw new InnertubeError('There are too many continuations, you\'ll need to find the correct one yourself in this.page');
if (this.#continuation.length === 0)
throw new InnertubeError('There are no continuations');
const response = await this.#continuation[0].endpoint.call<T>(this.#actions, { parse: true });
return response;
}
this.#continuation = this.#memo.getType(ContinuationItem);
if (this.#continuation)
return this.getContinuationData();
}
/**
* Retrieves next batch of contents and returns a new {@link Feed} object.
*/
async getContinuation(): Promise<Feed<T>> {
const continuation_data = await this.getContinuationData();
if (!continuation_data)
throw new InnertubeError('Could not get continuation data');
return new Feed<T>(this.actions, continuation_data, true);
}
}
export default Feed;

View File

@@ -0,0 +1,74 @@
import ChipCloudChip from '../parser/classes/ChipCloudChip.ts';
import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar.ts';
import { InnertubeError } from '../utils/Utils.ts';
import Feed from './Feed.ts';
import type { ObservedArray } from '../parser/helpers.ts';
import type { IParsedResponse } from '../parser/types/ParsedResponse.ts';
import type Actions from './Actions.ts';
import type { ApiResponse } from './Actions.ts';
class FilterableFeed<T extends IParsedResponse> extends Feed<T> {
#chips?: ObservedArray<ChipCloudChip>;
constructor(actions: Actions, data: ApiResponse | T, already_parsed = false) {
super(actions, data, already_parsed);
}
/**
* Returns the filter chips.
*/
get filter_chips(): ObservedArray<ChipCloudChip> {
if (this.#chips)
return this.#chips || [];
if (this.memo.getType(FeedFilterChipBar)?.length > 1)
throw new InnertubeError('There are too many feed filter chipbars, you\'ll need to find the correct one yourself in this.page');
if (this.memo.getType(FeedFilterChipBar)?.length === 0)
throw new InnertubeError('There are no feed filter chipbars');
this.#chips = this.memo.getType(ChipCloudChip);
return this.#chips || [];
}
/**
* Returns available filters.
*/
get filters(): string[] {
return this.filter_chips.map((chip) => chip.text.toString()) || [];
}
/**
* Applies given filter and returns a new {@link Feed} object.
*/
async getFilteredFeed(filter: string | ChipCloudChip): Promise<Feed<T>> {
let target_filter: ChipCloudChip | undefined;
if (typeof filter === 'string') {
if (!this.filters.includes(filter))
throw new InnertubeError('Filter not found', { available_filters: this.filters });
target_filter = this.filter_chips.find((chip) => chip.text.toString() === filter);
} else if (filter.type === 'ChipCloudChip') {
target_filter = filter;
} else {
throw new InnertubeError('Invalid filter');
}
if (!target_filter)
throw new InnertubeError('Filter not found');
if (target_filter.is_selected)
return this;
const response = await target_filter.endpoint?.call(this.actions, { parse: true });
if (!response)
throw new InnertubeError('Failed to get filtered feed');
return new Feed(this.actions, response, true);
}
}
export default FilterableFeed;

View File

@@ -0,0 +1,187 @@
import Proto from '../proto/index.ts';
import type Actions from './Actions.ts';
import type { ApiResponse } from './Actions.ts';
import { throwIfMissing } from '../utils/Utils.ts';
class InteractionManager {
#actions: Actions;
constructor(actions: Actions) {
this.#actions = actions;
}
/**
* Likes a given video.
* @param video_id - The video ID
*/
async like(video_id: string): Promise<ApiResponse> {
throwIfMissing({ video_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/like/like', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Dislikes a given video.
* @param video_id - The video ID
*/
async dislike(video_id: string): Promise<ApiResponse> {
throwIfMissing({ video_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/like/dislike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Removes a like/dislike.
* @param video_id - The video ID
*/
async removeRating(video_id: string): Promise<ApiResponse> {
throwIfMissing({ video_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/like/removelike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Subscribes to a given channel.
* @param channel_id - The channel ID
*/
async subscribe(channel_id: string): Promise<ApiResponse> {
throwIfMissing({ channel_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/subscription/subscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'EgIIAhgA'
});
return action;
}
/**
* Unsubscribes from a given channel.
* @param channel_id - The channel ID
*/
async unsubscribe(channel_id: string): Promise<ApiResponse>{
throwIfMissing({ channel_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/subscription/unsubscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'CgIIAhgA'
});
return action;
}
/**
* Posts a comment on a given video.
* @param video_id - The video ID
* @param text - The comment text
*/
async comment(video_id: string, text: string): Promise<ApiResponse> {
throwIfMissing({ video_id, text });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/comment/create_comment', {
client: 'ANDROID',
commentText: text,
createCommentParams: Proto.encodeCommentParams(video_id)
});
return action;
}
/**
* Translates a given text using YouTube's comment translate feature.
*
* @param target_language - an ISO language code
* @param args - optional arguments
*/
async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) {
throwIfMissing({ text, target_language });
const target_action = Proto.encodeCommentActionParams(22, { text, target_language, ...args });
const response = await this.#actions.execute('/comment/perform_comment_action', {
client: 'ANDROID',
actions: [ target_action ]
});
const mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload;
return {
success: response.success,
status_code: response.status_code,
translated_content: mutation.translatedContent.content,
data: response.data
};
}
/**
* Changes notification preferences for a given channel.
* Only works with channels you are subscribed to.
* @param channel_id - The channel ID.
* @param type - The notification type.
*/
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE'): Promise<ApiResponse> {
throwIfMissing({ channel_id, type });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const pref_types = {
PERSONALIZED: 1,
ALL: 2,
NONE: 3
};
if (!Object.keys(pref_types).includes(type.toUpperCase()))
throw new Error(`Invalid notification preference type: ${type}`);
const action = await this.#actions.execute('/notification/modify_channel_preference', {
client: 'WEB',
params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types])
});
return action;
}
}
export default InteractionManager;

68
deno/src/core/Kids.ts Normal file
View File

@@ -0,0 +1,68 @@
import Search from '../parser/ytkids/Search.ts';
import HomeFeed from '../parser/ytkids/HomeFeed.ts';
import VideoInfo from '../parser/ytkids/VideoInfo.ts';
import Channel from '../parser/ytkids/Channel.ts';
import type Session from './Session.ts';
import { generateRandomString } from '../utils/Utils.ts';
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);
}
/**
* 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 contents of the given channel.
* @param channel_id - The channel id.
*/
async getChannel(channel_id: string): Promise<Channel> {
const response = await this.#session.actions.execute('/browse', { browseId: channel_id, client: 'YTKIDS' });
return new Channel(this.#session.actions, response);
}
/**
* 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);
}
}
export default Kids;

367
deno/src/core/Music.ts Normal file
View File

@@ -0,0 +1,367 @@
import Album from '../parser/ytmusic/Album.ts';
import Artist from '../parser/ytmusic/Artist.ts';
import Explore from '../parser/ytmusic/Explore.ts';
import HomeFeed from '../parser/ytmusic/HomeFeed.ts';
import Library from '../parser/ytmusic/Library.ts';
import Playlist from '../parser/ytmusic/Playlist.ts';
import Recap from '../parser/ytmusic/Recap.ts';
import Search from '../parser/ytmusic/Search.ts';
import TrackInfo from '../parser/ytmusic/TrackInfo.ts';
import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo.ts';
import Message from '../parser/classes/Message.ts';
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf.ts';
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf.ts';
import MusicQueue from '../parser/classes/MusicQueue.ts';
import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem.ts';
import PlaylistPanel from '../parser/classes/PlaylistPanel.ts';
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection.ts';
import SectionList from '../parser/classes/SectionList.ts';
import Tab from '../parser/classes/Tab.ts';
import { observe } from '../parser/helpers.ts';
import Proto from '../proto/index.ts';
import { generateRandomString, InnertubeError, throwIfMissing } from '../utils/Utils.ts';
import type { ObservedArray, YTNode } from '../parser/helpers.ts';
import type Actions from './Actions.ts';
import type Session from './Session.ts';
class Music {
#session: Session;
#actions: Actions;
constructor(session: Session) {
this.#session = session;
this.#actions = session.actions;
}
/**
* Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
* @param target - Video id or a list item.
*/
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
if (target instanceof MusicTwoRowItem) {
return this.#fetchInfoFromListItem(target);
} else if (typeof target === 'string') {
return this.#fetchInfoFromVideoId(target);
}
throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target);
}
async #fetchInfoFromVideoId(video_id: string): Promise<TrackInfo> {
const cpn = generateRandomString(16);
const initial_info = this.#actions.execute('/player', {
cpn,
client: 'YTMUSIC',
videoId: video_id,
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
const continuation = this.#actions.execute('/next', {
client: 'YTMUSIC',
videoId: video_id
});
const response = await Promise.all([ initial_info, continuation ]);
return new TrackInfo(response, this.#actions, cpn);
}
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): Promise<TrackInfo> {
if (!list_item)
throw new InnertubeError('List item cannot be undefined');
if (!list_item.endpoint)
throw new Error('This item does not have an endpoint.');
const cpn = generateRandomString(16);
const initial_info = list_item.endpoint.call(this.#actions, {
cpn,
client: 'YTMUSIC',
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
const continuation = list_item.endpoint.call(this.#actions, {
client: 'YTMUSIC',
enablePersistentPlaylistPanel: true,
override_endpoint: '/next'
});
const response = await Promise.all([ initial_info, continuation ]);
return new TrackInfo(response, this.#actions, cpn);
}
/**
* Searches on YouTube Music.
* @param query - Search query.
* @param filters - Search filters.
*/
async search(query: string, filters: {
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
} = {}): Promise<Search> {
throwIfMissing({ query });
const payload: {
query: string;
client: string;
params?: string;
} = { query, client: 'YTMUSIC' };
if (filters.type && filters.type !== 'all') {
payload.params = Proto.encodeMusicSearchFilters(filters);
}
const response = await this.#actions.execute('/search', payload);
return new Search(response, this.#actions, Reflect.has(filters, 'type') && filters.type !== 'all');
}
/**
* Retrieves the home feed.
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_home'
});
return new HomeFeed(response, this.#actions);
}
/**
* Retrieves the Explore feed.
*/
async getExplore(): Promise<Explore> {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_explore'
});
return new Explore(response);
// TODO: return new Explore(response, this.#actions);
}
/**
* Retrieves the library.
*/
async getLibrary(): Promise<Library> {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_library_landing'
});
return new Library(response, this.#actions);
}
/**
* Retrieves artist's info & content.
* @param artist_id - The artist id.
*/
async getArtist(artist_id: string): Promise<Artist> {
throwIfMissing({ artist_id });
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
throw new InnertubeError('Invalid artist id', artist_id);
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: artist_id
});
return new Artist(response, this.#actions);
}
/**
* Retrieves album.
* @param album_id - The album id.
*/
async getAlbum(album_id: string): Promise<Album> {
throwIfMissing({ album_id });
if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
throw new InnertubeError('Invalid album id', album_id);
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: album_id
});
return new Album(response);
}
/**
* Retrieves playlist.
* @param playlist_id - The playlist id.
*/
async getPlaylist(playlist_id: string): Promise<Playlist> {
throwIfMissing({ playlist_id });
if (!playlist_id.startsWith('VL')) {
playlist_id = `VL${playlist_id}`;
}
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: playlist_id
});
return new Playlist(response, this.#actions);
}
/**
* Retrieves up next.
* @param video_id - The video id.
* @param automix - Whether to enable automix.
*/
async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
throwIfMissing({ video_id });
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs?.first();
if (!tab)
throw new InnertubeError('Could not find target tab.');
const music_queue = tab.content?.as(MusicQueue);
if (!music_queue || !music_queue.content)
throw new InnertubeError('Music queue was empty, the given id is probably invalid.', music_queue);
const playlist_panel = music_queue.content.as(PlaylistPanel);
if (!playlist_panel.playlist_id && automix) {
const automix_preview_video = playlist_panel.contents.firstOfType(AutomixPreviewVideo);
if (!automix_preview_video)
throw new InnertubeError('Automix item not found');
const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
if (!page || !page.contents_memo)
throw new InnertubeError('Could not fetch automix');
return page.contents_memo.getType(PlaylistPanel).first();
}
return playlist_panel;
}
/**
* Retrieves related content.
* @param video_id - The video id.
*/
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
throwIfMissing({ video_id });
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
return shelves;
}
/**
* Retrieves song lyrics.
* @param video_id - The video id.
*/
async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
throwIfMissing({ video_id });
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
if (page.contents.item().key('type').string() === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text, video_id);
const section_list = page.contents.item().as(SectionList).contents;
return section_list.firstOfType(MusicDescriptionShelf);
}
/**
* Retrieves recap.
*/
async getRecap(): Promise<Recap> {
const response = await this.#actions.execute('/browse', {
browseId: 'FEmusic_listening_review',
client: 'YTMUSIC_ANDROID'
});
return new Recap(response, this.#actions);
}
/**
* Retrieves search suggestions for the given query.
* @param query - The query.
*/
async getSearchSuggestions(query: string): Promise<ObservedArray<YTNode>> {
const response = await this.#actions.execute('/music/get_search_suggestions', {
parse: true,
input: query,
client: 'YTMUSIC'
});
const search_suggestions_section = response.contents_memo?.getType(SearchSuggestionsSection)?.[0];
if (!search_suggestions_section?.contents.is_array)
return observe([] as YTNode[]);
return search_suggestions_section?.contents.array();
}
}
export default Music;

269
deno/src/core/OAuth.ts Normal file
View File

@@ -0,0 +1,269 @@
import Constants from '../utils/Constants.ts';
import { OAuthError, Platform } from '../utils/Utils.ts';
import type Session from './Session.ts';
export interface Credentials {
/**
* Token used to sign in.
*/
access_token: string;
/**
* Token used to get a new access token.
*/
refresh_token: string;
/**
* Access token's expiration date, which is usually 24hrs-ish.
*/
expires: Date;
}
// TODO: actual type info for this.
export type OAuthAuthPendingData = any;
export type OAuthAuthEventHandler = (data: {
credentials: Credentials;
status: 'SUCCESS';
}) => any;
export type OAuthAuthPendingEventHandler = (data: OAuthAuthPendingData) => any;
export type OAuthAuthErrorEventHandler = (err: OAuthError) => any;
class OAuth {
#identity?: Record<string, string>;
#session: Session;
#credentials?: Credentials;
#polling_interval = 5;
constructor(session: Session) {
this.#session = session;
}
/**
* Starts the auth flow in case no valid credentials are available.
*/
async init(credentials?: Credentials): Promise<void> {
this.#credentials = credentials;
if (this.validateCredentials()) {
if (!this.has_access_token_expired)
this.#session.emit('auth', {
credentials: this.#credentials,
status: 'SUCCESS'
});
} else if (!(await this.#loadCachedCredentials())) {
await this.#getUserCode();
}
}
async cacheCredentials(): Promise<void> {
const encoder = new TextEncoder();
const data = encoder.encode(JSON.stringify(this.#credentials));
await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer);
}
async #loadCachedCredentials(): Promise<boolean> {
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
if (!data) return false;
const decoder = new TextDecoder();
const credentials = JSON.parse(decoder.decode(data));
this.#credentials = {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token,
expires: new Date(credentials.expires)
};
this.#session.emit('auth', {
credentials: this.#credentials,
status: 'SUCCESS'
});
return true;
}
async removeCache(): Promise<void> {
await this.#session.cache?.remove('youtubei_oauth_credentials');
}
/**
* Asks the server for a user code and verification URL.
*/
async #getUserCode(): Promise<void> {
this.#identity = await this.#getClientIdentity();
const data = {
client_id: this.#identity.client_id,
scope: Constants.OAUTH.SCOPE,
device_id: Platform.shim.uuidv4(),
model_name: Constants.OAUTH.MODEL_NAME
};
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), {
body: JSON.stringify(data),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const response_data = await response.json();
this.#session.emit('auth-pending', response_data);
this.#polling_interval = response_data.interval;
this.#startPolling(response_data.device_code);
}
/**
* Polls the authorization server until access is granted by the user.
*/
#startPolling(device_code: string): void {
const poller = setInterval(async () => {
const data = {
...this.#identity,
code: device_code,
grant_type: Constants.OAUTH.GRANT_TYPE
};
try {
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
body: JSON.stringify(data),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const response_data = await response.json();
if (response_data.error) {
switch (response_data.error) {
case 'access_denied':
this.#session.emit('auth-error', new OAuthError('Access was denied.', { status: 'ACCESS_DENIED' }));
break;
case 'expired_token':
this.#session.emit('auth-error', new OAuthError('The device code has expired, restarting auth flow.', { status: 'DEVICE_CODE_EXPIRED' }));
clearInterval(poller);
this.#getUserCode();
break;
default:
break;
}
return;
}
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
this.#credentials = {
access_token: response_data.access_token,
refresh_token: response_data.refresh_token,
expires: expiration_date
};
this.#session.emit('auth', {
credentials: this.#credentials,
status: 'SUCCESS'
});
clearInterval(poller);
} catch (err) {
clearInterval(poller);
return this.#session.emit('auth-error', new OAuthError('Could not obtain user code.', { status: 'FAILED', error: err }));
}
}, this.#polling_interval * 1000);
}
/**
* Refresh access token if the same has expired.
*/
async refreshIfRequired(): Promise<void> {
if (this.has_access_token_expired) {
await this.#refreshAccessToken();
}
}
async #refreshAccessToken(): Promise<void> {
if (!this.#credentials) return;
this.#identity = await this.#getClientIdentity();
const data = {
...this.#identity,
refresh_token: this.#credentials.refresh_token,
grant_type: 'refresh_token'
};
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
body: JSON.stringify(data),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const response_data = await response.json();
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
this.#credentials = {
access_token: response_data.access_token,
refresh_token: response_data.refresh_token || this.#credentials.refresh_token,
expires: expiration_date
};
this.#session.emit('update-credentials', {
credentials: this.#credentials,
status: 'SUCCESS'
});
}
async revokeCredentials(): Promise<Response | undefined> {
if (!this.#credentials) return;
await this.removeCache();
return this.#session.http.fetch_function(new URL(`/o/oauth2/revoke?token=${encodeURIComponent(this.#credentials.access_token)}`, Constants.URLS.YT_BASE), {
method: 'post'
});
}
/**
* Retrieves client identity from YouTube TV.
*/
async #getClientIdentity(): Promise<{ [key: string]: string; }> {
const response = await this.#session.http.fetch_function(new URL('/tv', Constants.URLS.YT_BASE), { headers: Constants.OAUTH.HEADERS });
const response_data = await response.text();
const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(response_data)?.[1];
if (!url_body)
throw new OAuthError('Could not obtain script url.', { status: 'FAILED' });
const script = await this.#session.http.fetch(url_body, { baseURL: Constants.URLS.YT_BASE });
const client_identity = (await script.text())
.replace(/\n/g, '')
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
const groups = client_identity?.groups;
if (!groups)
throw new OAuthError('Could not obtain client identity.', { status: 'FAILED' });
return groups;
}
get credentials(): Credentials | undefined {
return this.#credentials;
}
get has_access_token_expired(): boolean {
const timestamp = this.#credentials ? new Date(this.#credentials.expires).getTime() : -Infinity;
return new Date().getTime() > timestamp;
}
validateCredentials(): this is this & { credentials: Credentials } {
return this.#credentials &&
Reflect.has(this.#credentials, 'access_token') &&
Reflect.has(this.#credentials, 'refresh_token') &&
Reflect.has(this.#credentials, 'expires') || false;
}
}
export default OAuth;

206
deno/src/core/Player.ts Normal file
View File

@@ -0,0 +1,206 @@
import { Platform, getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils.ts';
import Constants from '../utils/Constants.ts';
import { ICache } from '../types/Cache.ts';
import { FetchFunction } from '../types/PlatformShim.ts';
export default class Player {
#nsig_sc;
#sig_sc;
#sig_sc_timestamp;
#player_id;
constructor(signature_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) {
this.#nsig_sc = nsig_sc;
this.#sig_sc = sig_sc;
this.#sig_sc_timestamp = signature_timestamp;
this.#player_id = player_id;
}
static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch): Promise<Player> {
const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
const res = await fetch(url);
if (res.status !== 200)
throw new PlayerError('Failed to request player id');
const js = await res.text();
const player_id = getStringBetweenStrings(js, 'player\\/', '\\/');
if (!player_id)
throw new PlayerError('Failed to get player id');
// We have the playerID now we can check if we have a cached player
if (cache) {
const cached_player = await Player.fromCache(cache, player_id);
if (cached_player)
return cached_player;
}
const player_url = new URL(`/s/player/${player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE);
const player_res = await fetch(player_url, {
headers: {
'user-agent': getRandomUserAgent('desktop')
}
});
if (!player_res.ok) {
throw new PlayerError(`Failed to get player data: ${player_res.status}`);
}
const player_js = await player_res.text();
const sig_timestamp = this.extractSigTimestamp(player_js);
const sig_sc = this.extractSigSourceCode(player_js);
const nsig_sc = this.extractNSigSourceCode(player_js);
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
}
decipher(url?: string, signature_cipher?: string, cipher?: string): string {
url = url || signature_cipher || cipher;
if (!url)
throw new PlayerError('No valid URL to decipher');
const args = new URLSearchParams(url);
const url_components = new URL(args.get('url') || url);
if (signature_cipher || cipher) {
const signature = Platform.shim.eval(this.#sig_sc, {
sig: args.get('s')
});
if (typeof signature !== 'string')
throw new PlayerError('Failed to decipher signature');
const sp = args.get('sp');
sp ?
url_components.searchParams.set(sp, signature) :
url_components.searchParams.set('signature', signature);
}
const n = url_components.searchParams.get('n');
if (n) {
const nsig = Platform.shim.eval(this.#nsig_sc, {
nsig: n
});
if (typeof nsig !== 'string')
throw new PlayerError('Failed to decipher nsig');
if (nsig.startsWith('enhanced_except_')) {
console.warn('Warning:\nCould not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!');
}
url_components.searchParams.set('n', nsig);
}
return url_components.toString();
}
static async fromCache(cache: ICache, player_id: string): Promise<Player | null> {
const buffer = await cache.get(player_id);
if (!buffer)
return null;
const view = new DataView(buffer);
const version = view.getUint32(0, true);
if (version !== Player.LIBRARY_VERSION)
return null;
const sig_timestamp = view.getUint32(4, true);
const sig_len = view.getUint32(8, true);
const sig_buf = buffer.slice(12, 12 + sig_len);
const nsig_buf = buffer.slice(12 + sig_len);
const decoder = new TextDecoder();
const sig_sc = decoder.decode(sig_buf);
const nsig_sc = decoder.decode(nsig_buf);
return new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
}
static async fromSource(cache: ICache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string): Promise<Player> {
const player = new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
await player.cache(cache);
return player;
}
async cache(cache?: ICache): Promise<void> {
if (!cache) return;
const encoder = new TextEncoder();
const sig_buf = encoder.encode(this.#sig_sc);
const nsig_buf = encoder.encode(this.#nsig_sc);
const buffer = new ArrayBuffer(12 + sig_buf.byteLength + nsig_buf.byteLength);
const view = new DataView(buffer);
view.setUint32(0, Player.LIBRARY_VERSION, true);
view.setUint32(4, this.#sig_sc_timestamp, true);
view.setUint32(8, sig_buf.byteLength, true);
new Uint8Array(buffer).set(sig_buf, 12);
new Uint8Array(buffer).set(nsig_buf, 12 + sig_buf.byteLength);
await cache.set(this.#player_id, new Uint8Array(buffer));
}
static extractSigTimestamp(data: string): number {
return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0');
}
static extractSigSourceCode(data: string): string {
const calls = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
const obj_name = calls?.split(/\.|\[/)?.[0]?.replace(';', '')?.trim();
const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};');
if (!functions || !calls)
console.warn(new PlayerError('Failed to extract signature decipher algorithm'));
return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`;
}
static extractNSigSourceCode(data: string): string {
const sc = `function descramble_nsig(a) { let b=a.split("")${getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join(""); } descramble_nsig(nsig)`;
if (!sc)
console.warn(new PlayerError('Failed to extract n-token decipher algorithm'));
return sc;
}
get url(): string {
return new URL(`/s/player/${this.#player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
}
get sts(): number {
return this.#sig_sc_timestamp;
}
get nsig_sc(): string {
return this.#nsig_sc;
}
get sig_sc(): string {
return this.#sig_sc;
}
static get LIBRARY_VERSION(): number {
return 2;
}
}

View File

@@ -0,0 +1,209 @@
import type Feed from './Feed.ts';
import type Actions from './Actions.ts';
import Playlist from '../parser/youtube/Playlist.ts';
import { InnertubeError, throwIfMissing } from '../utils/Utils.ts';
class PlaylistManager {
#actions: Actions;
constructor(actions: Actions) {
this.#actions = actions;
}
/**
* Creates a playlist.
* @param title - The title of the playlist.
* @param video_ids - An array of video IDs to add to the playlist.
*/
async create(title: string, video_ids: string[]): Promise<{ success: boolean; status_code: number; playlist_id?: string; data: any }> {
throwIfMissing({ title, video_ids });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('/playlist/create', {
title,
ids: video_ids,
parse: false
});
return {
success: response.success,
status_code: response.status_code,
playlist_id: response.data.playlistId,
data: response.data
};
}
/**
* Deletes a given playlist.
* @param playlist_id - The playlist ID.
*/
async delete(playlist_id: string): Promise<{ playlist_id: string; success: boolean; status_code: number; data: any }> {
throwIfMissing({ playlist_id });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id });
return {
playlist_id,
success: response.success,
status_code: response.status_code,
data: response.data
};
}
/**
* Adds videos to a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to add to the playlist.
*/
async addVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
throwIfMissing({ playlist_id, video_ids });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('/browse/edit_playlist', {
playlistId: playlist_id,
actions: video_ids.map((id) => ({
action: 'ACTION_ADD_VIDEO',
addedVideoId: id
})),
parse: false
});
return {
playlist_id,
action_result: response.data.actions // TODO: implement actions in the parser
};
}
/**
* Removes videos from a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to remove from the playlist.
*/
async removeVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
throwIfMissing({ playlist_id, video_ids });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const info = await this.#actions.execute('/browse', {
browseId: `VL${playlist_id}`,
parse: true
});
const playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
const payload = {
playlistId: playlist_id,
actions: [] as {
action: string;
setVideoId: string;
}[]
};
const getSetVideoIds = async (pl: Feed): Promise<void> => {
const videos = pl.videos.filter((video) => video_ids.includes(video.key('id').string()));
videos.forEach((video) =>
payload.actions.push({
action: 'ACTION_REMOVE_VIDEO',
setVideoId: video.key('set_video_id').string()
})
);
if (payload.actions.length < video_ids.length) {
const next = await pl.getContinuation();
return getSetVideoIds(next);
}
};
await getSetVideoIds(playlist);
if (!payload.actions.length)
throw new InnertubeError('Given video ids were not found in this playlist.', video_ids);
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
return {
playlist_id,
action_result: response.data.actions // TODO: implement actions in the parser
};
}
/**
* Moves a video to a new position within a given playlist.
* @param playlist_id - The playlist ID.
* @param moved_video_id - The video ID to move.
* @param predecessor_video_id - The video ID to move the moved video before.
*/
async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string): Promise<{ playlist_id: string; action_result: any; }> {
throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const info = await this.#actions.execute('/browse', {
browseId: `VL${playlist_id}`,
parse: true
});
const playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
const payload = {
playlistId: playlist_id,
actions: [] as {
action: string,
setVideoId?: string,
movedSetVideoIdPredecessor?: string
}[]
};
let set_video_id_0: string | undefined, set_video_id_1: string | undefined;
const getSetVideoIds = async (pl: Feed): Promise<void> => {
const video_0 = pl.videos.find((video) => moved_video_id === video.key('id').string());
const video_1 = pl.videos.find((video) => predecessor_video_id === video.key('id').string());
set_video_id_0 = set_video_id_0 || video_0?.key('set_video_id').string();
set_video_id_1 = set_video_id_1 || video_1?.key('set_video_id').string();
if (!set_video_id_0 || !set_video_id_1) {
const next = await pl.getContinuation();
return getSetVideoIds(next);
}
};
await getSetVideoIds(playlist);
payload.actions.push({
action: 'ACTION_MOVE_VIDEO_AFTER',
setVideoId: set_video_id_0,
movedSetVideoIdPredecessor: set_video_id_1
});
const response = await this.#actions.execute('/browse/edit_playlist', {
...payload,
parse: false
});
return {
playlist_id,
action_result: response.data.actions // TODO: implement actions in the parser
};
}
}
export default PlaylistManager;

416
deno/src/core/Session.ts Normal file
View File

@@ -0,0 +1,416 @@
import Constants, { CLIENTS } from '../utils/Constants.ts';
import EventEmitterLike from '../utils/EventEmitterLike.ts';
import Actions from './Actions.ts';
import Player from './Player.ts';
import HTTPClient from '../utils/HTTPClient.ts';
import { Platform, DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils.ts';
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth.ts';
import Proto from '../proto/index.ts';
import { ICache } from '../types/Cache.ts';
import { FetchFunction } from '../types/PlatformShim.ts';
export enum ClientType {
WEB = 'WEB',
KIDS = 'WEB_KIDS',
MUSIC = 'WEB_REMIX',
ANDROID = 'ANDROID',
ANDROID_MUSIC = 'ANDROID_MUSIC',
ANDROID_CREATOR = 'ANDROID_CREATOR',
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
}
export interface Context {
client: {
hl: string;
gl: string;
remoteHost?: string;
screenDensityFloat: number;
screenHeightPoints: number;
screenPixelDensity: number;
screenWidthPoints: number;
visitorData: string;
userAgent: string;
clientName: string;
clientVersion: string;
clientScreen?: string,
androidSdkVersion?: string;
osName: string;
osVersion: string;
platform: string;
clientFormFactor: string;
userInterfaceTheme: string;
timeZone: string;
browserName?: string;
browserVersion?: string;
originalUrl: string;
deviceMake: string;
deviceModel: string;
utcOffsetMinutes: number;
kidsAppInfo?: {
categorySettings: {
enabledCategories: string[];
};
contentSettings: {
corpusPreference: string;
kidsNoSearchMode: string;
};
};
};
user: {
enableSafetyMode: boolean;
lockedSafetyMode: boolean;
};
thirdParty?: {
embedUrl: string;
};
request: {
useSsl: true;
};
}
export interface SessionOptions {
/**
* Language.
*/
lang?: string;
/**
* Geolocation.
*/
location?: string;
/**
* The account index to use. This is useful if you have multiple accounts logged in.
* **NOTE:**
* Only works if you are signed in with cookies.
*/
account_index?: number;
/**
* Specifies whether to retrieve the JS player. Disabling this will make session creation faster.
* **NOTE:** Deciphering formats is not possible without the JS player.
*/
retrieve_player?: boolean;
/**
* Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content.
*/
enable_safety_mode?: boolean;
/**
* Specifies whether to generate the session data locally or retrieve it from YouTube.
* This can be useful if you need more performance.
*/
generate_session_locally?: boolean;
/**
* Platform to use for the session.
*/
device_category?: DeviceCategory;
/**
* InnerTube client type.
*/
client_type?: ClientType;
/**
* The time zone.
*/
timezone?: string;
/**
* Used to cache the deciphering functions from the JS player.
*/
cache?: ICache;
/**
* YouTube cookies.
*/
cookie?: string;
/**
* Fetch function to use.
*/
fetch?: FetchFunction;
}
export interface SessionData {
context: Context;
api_key: string;
api_version: string;
}
export default class Session extends EventEmitterLike {
#api_version: string;
#key: string;
#context: Context;
#account_index: number;
#player?: Player;
oauth: OAuth;
http: HTTPClient;
logged_in: boolean;
actions: Actions;
cache?: ICache;
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: ICache) {
super();
this.#context = context;
this.#account_index = account_index;
this.#key = api_key;
this.#api_version = api_version;
this.#player = player;
this.http = new HTTPClient(this, cookie, fetch);
this.actions = new Actions(this);
this.oauth = new OAuth(this);
this.logged_in = !!cookie;
this.cache = cache;
}
on(type: 'auth', listener: OAuthAuthEventHandler): void;
on(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
on(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
on(type: 'update-credentials', listener: OAuthAuthEventHandler): void;
on(type: string, listener: (...args: any[]) => void): void {
super.on(type, listener);
}
once(type: 'auth', listener: OAuthAuthEventHandler): void;
once(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
once(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
once(type: string, listener: (...args: any[]) => void): void {
super.once(type, listener);
}
static async create(options: SessionOptions = {}) {
const { context, api_key, api_version, account_index } = await Session.getSessionData(
options.lang,
options.location,
options.account_index,
options.enable_safety_mode,
options.generate_session_locally,
options.device_category,
options.client_type,
options.timezone,
options.fetch
);
return new Session(
context, api_key, api_version, account_index,
options.retrieve_player === false ? undefined : await Player.create(options.cache, options.fetch),
options.cookie, options.fetch, options.cache
);
}
static async getSessionData(
lang = '',
location = '',
account_index = 0,
enable_safety_mode = false,
generate_session_locally = false,
device_category: DeviceCategory = 'desktop',
client_name: ClientType = ClientType.WEB,
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
fetch: FetchFunction = Platform.shim.fetch
) {
let session_data: SessionData;
if (generate_session_locally) {
session_data = this.#generateSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode });
} else {
session_data = await this.#retrieveSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode }, fetch);
}
return { ...session_data, account_index };
}
static async #retrieveSessionData(options: {
lang: string;
location: string;
time_zone: string;
device_category: string;
client_name: string;
enable_safety_mode: boolean;
}, fetch: FetchFunction = Platform.shim.fetch): Promise<SessionData> {
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
const res = await fetch(url, {
headers: {
'accept-language': options.lang || 'en-US',
'user-agent': getRandomUserAgent('desktop'),
'accept': '*/*',
'referer': 'https://www.youtube.com/sw.js',
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')}`
}
});
if (!res.ok)
throw new SessionError(`Failed to retrieve session data: ${res.status}`);
const text = await res.text();
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
const ytcfg = data[0][2];
const api_version = `v${ytcfg[0][0][6]}`;
const [ [ device_info ], api_key ] = ytcfg;
const context: Context = {
client: {
hl: device_info[0],
gl: options.location || device_info[2],
remoteHost: device_info[3],
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: device_info[13],
userAgent: device_info[14],
clientName: options.client_name,
clientVersion: device_info[16],
osName: device_info[17],
osVersion: device_info[18],
platform: options.device_category.toUpperCase(),
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
timeZone: device_info[79] || options.time_zone,
browserName: device_info[86],
browserVersion: device_info[87],
originalUrl: Constants.URLS.YT_BASE,
deviceMake: device_info[11],
deviceModel: device_info[12],
utcOffsetMinutes: new Date().getTimezoneOffset()
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false
},
request: {
useSsl: true
}
};
return { context, api_key, api_version };
}
static #generateSessionData(options: {
lang: string;
location: string;
time_zone: string;
device_category: DeviceCategory;
client_name: string;
enable_safety_mode: boolean
}): SessionData {
const id = generateRandomString(11);
const timestamp = Math.floor(Date.now() / 1000);
const context: Context = {
client: {
hl: options.lang || 'en',
gl: options.location || 'US',
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: Proto.encodeVisitorData(id, timestamp),
userAgent: getRandomUserAgent('desktop'),
clientName: options.client_name,
clientVersion: CLIENTS.WEB.VERSION,
osName: 'Windows',
osVersion: '10.0',
platform: options.device_category.toUpperCase(),
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
timeZone: options.time_zone,
originalUrl: Constants.URLS.YT_BASE,
deviceMake: '',
deviceModel: '',
utcOffsetMinutes: new Date().getTimezoneOffset()
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false
},
request: {
useSsl: true
}
};
return { context, api_key: CLIENTS.WEB.API_KEY, api_version: CLIENTS.WEB.API_VERSION };
}
async signIn(credentials?: Credentials): Promise<void> {
return new Promise(async (resolve, reject) => {
const error_handler: OAuthAuthErrorEventHandler = (err) => reject(err);
this.once('auth', (data) => {
this.off('auth-error', error_handler);
if (data.status === 'SUCCESS') {
this.logged_in = true;
resolve();
}
reject(data);
});
this.once('auth-error', error_handler);
try {
await this.oauth.init(credentials);
if (this.oauth.validateCredentials()) {
await this.oauth.refreshIfRequired();
this.logged_in = true;
resolve();
}
} catch (err) {
reject(err);
}
});
}
/**
* Signs out of the current account and revokes the credentials.
*/
async signOut(): Promise<Response | undefined> {
if (!this.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.oauth.revokeCredentials();
this.logged_in = false;
return response;
}
/**
* InnerTube API key.
*/
get key(): string {
return this.#key;
}
/**
* InnerTube API version.
*/
get api_version(): string {
return this.#api_version;
}
get client_version(): string {
return this.#context.client.clientVersion;
}
get client_name(): string {
return this.#context.client.clientName;
}
get account_index(): number {
return this.#account_index;
}
get context(): Context {
return this.#context;
}
get player(): Player | undefined {
return this.#player;
}
get lang(): string {
return this.#context.client.hl;
}
}

211
deno/src/core/Studio.ts Normal file
View File

@@ -0,0 +1,211 @@
import Proto from '../proto/index.ts';
import { Constants } from '../utils/index.ts';
import { InnertubeError, MissingParamError, Platform } from '../utils/Utils.ts';
import type { ApiResponse } from './Actions.ts';
import type Session from './Session.ts';
interface UploadResult {
status: string;
scottyResourceId: string;
}
interface InitialUploadData {
frontend_upload_id: string;
upload_id: string;
upload_url: string;
scotty_resource_id: string;
chunk_granularity: string;
}
export interface VideoMetadata {
title?: string;
description?: string;
tags?: string[];
category?: number;
license?: string;
age_restricted?: boolean;
made_for_kids?: boolean;
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
}
export interface UploadedVideoMetadata {
title?: string;
description?: string;
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
is_draft?: boolean;
}
class Studio {
#session: Session;
constructor(session: Session) {
this.#session = session;
}
/**
* Uploads a custom thumbnail and sets it for a video.
* @example
* ```ts
* const buffer = fs.readFileSync('./my_awesome_thumbnail.jpg');
* const response = await yt.studio.setThumbnail(video_id, buffer);
* ```
*/
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
if (!video_id || !buffer)
throw new MissingParamError('One or more parameters are missing.');
const payload = Proto.encodeCustomThumbnailPayload(video_id, buffer);
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
protobuf: true,
serialized_data: payload
});
return response;
}
/**
* Updates given video's metadata.
* @example
* ```ts
* const response = await yt.studio.updateVideoMetadata('videoid', {
* tags: [ 'astronomy', 'NASA', 'APOD' ],
* title: 'Artemis Mission',
* description: 'A nicely written description...',
* category: 27,
* license: 'creative_commons'
* // ...
* });
* ```
*/
async updateVideoMetadata(video_id: string, metadata: VideoMetadata): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
protobuf: true,
serialized_data: payload
});
return response;
}
/**
* Uploads a video to YouTube.
* @example
* ```ts
* const file = fs.readFileSync('./my_awesome_video.mp4');
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
* ```
*/
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const initial_data = await this.#getInitialUploadData();
const upload_result = await this.#uploadVideo(initial_data.upload_url, file);
if (upload_result.status !== 'STATUS_SUCCESS')
throw new InnertubeError('Could not process video.');
const response = await this.#setVideoMetadata(initial_data, upload_result, metadata);
return response;
}
async #getInitialUploadData(): Promise<InitialUploadData> {
const frontend_upload_id = `innertube_android:${Platform.shim.uuidv4()}:0:v=3,api=1,cf=3`;
const payload = {
frontendUploadId: frontend_upload_id,
deviceDisplayName: 'Pixel 6 Pro',
fileId: `goog-edited-video://generated?videoFileUri=content://media/external/video/media/${Platform.shim.uuidv4()}`,
mp4MoovAtomRelocationStatus: 'UNSUPPORTED',
transcodeResult: 'DISABLED',
connectionType: 'WIFI'
};
const response = await this.#session.http.fetch('/upload/youtubei', {
baseURL: Constants.URLS.YT_UPLOAD,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'x-goog-upload-command': 'start',
'x-goog-upload-protocol': 'resumable'
},
body: JSON.stringify(payload)
});
if (!response.ok)
throw new InnertubeError('Could not get initial upload data');
return {
frontend_upload_id,
upload_id: response.headers.get('x-guploader-uploadid') as string,
upload_url: response.headers.get('x-goog-upload-url') as string,
scotty_resource_id: response.headers.get('x-goog-upload-header-scotty-resource-id') as string,
chunk_granularity: response.headers.get('x-goog-upload-chunk-granularity') as string
};
}
async #uploadVideo(upload_url: string, file: BodyInit): Promise<UploadResult> {
const response = await this.#session.http.fetch_function(upload_url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'x-goog-upload-command': 'upload, finalize',
'x-goog-upload-file-name': `file-${Date.now()}`,
'x-goog-upload-offset': '0'
},
body: file
});
if (!response.ok)
throw new InnertubeError('Could not upload video');
const data = await response.json();
return data;
}
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadata) {
const metadata_payload = {
resourceId: {
scottyResourceId: {
id: upload_result.scottyResourceId
}
},
frontendUploadId: initial_data.frontend_upload_id,
initialMetadata: {
title: {
newTitle: metadata.title || new Date().toDateString()
},
description: {
newDescription: metadata.description || '',
shouldSegment: true
},
privacy: {
newPrivacy: metadata.privacy || 'PRIVATE'
},
draftState: {
isDraft: metadata.is_draft || false
}
}
};
const response = await this.#session.actions.execute('/upload/createvideo', {
client: 'ANDROID',
...metadata_payload
});
return response;
}
}
export default Studio;

View File

@@ -0,0 +1,61 @@
import Tab from '../parser/classes/Tab.ts';
import Feed from './Feed.ts';
import { InnertubeError } from '../utils/Utils.ts';
import type Actions from './Actions.ts';
import type { ObservedArray } from '../parser/helpers.ts';
import type { IParsedResponse } from '../parser/types/ParsedResponse.ts';
import type { ApiResponse } from './Actions.ts';
class TabbedFeed<T extends IParsedResponse> extends Feed<T> {
#tabs?: ObservedArray<Tab>;
#actions: Actions;
constructor(actions: Actions, data: ApiResponse | IParsedResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.#actions = actions;
this.#tabs = this.page.contents_memo?.getType(Tab);
}
get tabs(): string[] {
return this.#tabs?.map((tab) => tab.title.toString()) ?? [];
}
async getTabByName(title: string): Promise<TabbedFeed<T>> {
const tab = this.#tabs?.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
if (!tab)
throw new InnertubeError(`Tab "${title}" not found`);
if (tab.selected)
return this;
const response = await tab.endpoint.call(this.#actions);
return new TabbedFeed<T>(this.#actions, response, false);
}
async getTabByURL(url: string): Promise<TabbedFeed<T>> {
const tab = this.#tabs?.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url);
if (!tab)
throw new InnertubeError(`Tab "${url}" not found`);
if (tab.selected)
return this;
const response = await tab.endpoint.call(this.#actions);
return new TabbedFeed<T>(this.#actions, response, false);
}
hasTabWithURL(url: string): boolean {
return this.#tabs?.some((tab) => tab.endpoint.metadata.url?.split('/').pop() === url) ?? false;
}
get title(): string | undefined {
return this.page.contents_memo?.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
}
}
export default TabbedFeed;

38
deno/src/core/index.ts Normal file
View File

@@ -0,0 +1,38 @@
export { default as AccountManager } from './AccountManager.ts';
export * from './AccountManager.ts';
export { default as Actions } from './Actions.ts';
export * from './Actions.ts';
export { default as Feed } from './Feed.ts';
export * from './Feed.ts';
export { default as FilterableFeed } from './FilterableFeed.ts';
export * from './FilterableFeed.ts';
export { default as InteractionManager } from './InteractionManager.ts';
export * from './InteractionManager.ts';
export { default as Kids } from './Kids.ts';
export * from './Kids.ts';
export { default as Music } from './Music.ts';
export * from './Music.ts';
export { default as OAuth } from './OAuth.ts';
export * from './OAuth.ts';
export { default as Player } from './Player.ts';
export * from './Player.ts';
export { default as PlaylistManager } from './PlaylistManager.ts';
export * from './PlaylistManager.ts';
export { default as Session } from './Session.ts';
export * from './Session.ts';
export { default as Studio } from './Studio.ts';
export * from './Studio.ts';
export { default as TabbedFeed } from './TabbedFeed.ts';
export * from './TabbedFeed.ts';

344
deno/src/parser/README.md Normal file
View File

@@ -0,0 +1,344 @@
# Parser
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data.
Structure:
* [`/classes`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/classes) - InnerTube nodes.
* [`/types`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/types) - General response types.
* [`/youtube`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube) - Contains the logic for parsing YouTube responses.
* [`/ytmusic`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/ytmusic) - Contains the logic for parsing YouTube Music responses.
* [`/ytkids`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/ytkids) - Contains the logic for parsing YouTube Kids responses.
* [`helpers.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/helpers.ts) - Helper functions/classes for the parser.
* [`parser.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/parser.ts) - The core of the parser.
* [`map.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/map.ts) - A list of all InnerTube nodes, it is used to determine which node to use for a given renderer. Note that this file is auto-generated and should not be edited manually.
## Table of Contents
<ol>
<li>
<a href="#api">API</a>
</li>
<li>
<a href="#usage">Usage</a>
<ul>
<li><a href="#observedarray">ObservedArray</a></li>
<li><a href="#superparsedresponse">SuperParsedResponse</a></li>
<li><a href="#ytnode">YTNode</a></li>
<li><a href="#memo">Memo</a></li>
</ul>
</li>
<li><a href="#adding-new-nodes">Adding new nodes</a></li>
<li><a href="#how-it-works">How it works</a></li>
</ol>
___
## API
* Parser
* [.parse](#parse)
* [.parseItem](#parse)
* [.parseArray](#parse)
* [.parseResponse](#parseresponse)
<a name="parse"></a>
#### parse(data, requireArray, validTypes)
Responsible for parsing individual nodes.
| Param | Type | Description |
| --- | --- | --- |
| data | `any` | The data |
| requireArray | `?boolean` | Whether the response should be an array |
| validTypes | `YTNodeConstructor<T> \| YTNodeConstructor<T>[] \| undefined` | Types of `YTNode` allowed |
When `requireArray` is `true`, the response will be an `ObservedArray<YTNodes>`.
When `validTypes` is `undefined`, the response will be an array of YTNodes.
When `validTypes` is an array, the response will be an array of YTNodes that are of the types specified in the array.
When `validTypes` is a single type, the response will be an array of YTNodes that are of the type specified.
If you do not specify `requireArray`, the return type of the function will not be known at runtime, and therefore we return the response wrapped in a helper, `SuperParsedResponse`, to gain access to the response.
You may use the `Parser#parseArray` and `Parser#parseItem` methods to parse the response in a deterministic way.
<a name="parseresponse"></a>
#### parseResponse(data)
Unlike `parse`, this can be used to parse the entire response object.
| Param | Type | Description |
| --- | --- | --- |
| data | `object` | Raw InnerTube response |
## Usage
## ObservedArray
You may use `ObservedArray<T extends YTNode>` as a normal array, but it provides additional methods for typesafe access and casting.
```ts
// For example, we have a feed, and want all the videos:
const feed = new ObservedArray<YTNode>([...feed.contents]);
const videos = feed.filterType(GridVideo);
// This is now a GridVideo[]
// Or we want only the first video:
const firstVideo = feed.firstOfType(GridVideo);
// We may cast the whole array to a GridVideo[] and throw if we have any non-GridVideo elements:
const allVideos = feed.as(GridVideo);
// There are some extra methods for ObservedArray<T extends YTNode>
// which we use internally but not documented here (yet).
// see the source code for more details.
```
## SuperParsedResponse
Represents a parsed response in an unknown state. Either a `YTNode` or an `ObservedArray<YTNode>` or `null`.
You will need to assert the type and unwrap the response to get the actual value.
```ts
// We can assert we have a YTNode:
const response = Parser.parse(data);
if (response.is_item) {
const node = response.item();
}
// We can assert we have an ObservedArray<YTNode>:
const response = Parser.parse(data);
if (response.is_array) {
const nodes = response.array();
}
// Or lastly a null response:
const response = Parser.parse(data);
const is_null = response.is_null;
```
## YTNode
All renderers returned by InnerTube are converted to this generic class and then extended for the specific renderers.
This class is what allows us a typesafe way to use data returned by the InnerTube API.
Here's how to use this class to access returned data:
### Type Casting
```ts
// We can cast a YTNode to a child class of YTNode
const results = node.as(TwoColumnSearchResults);
// This will throw if the node is not a TwoColumnSearchResults
// We thus may want to check for the type of the node before casting
if (node.is(TwoColumnSearchResults)) {
// We do not need to recast the node, it is already a TwoColumnSearchResults after calling is() and using it in the branch where is() returns true
const results = node;
}
// Sometimes we can expect multiple types of nodes, we can just pass all possible types as params.
const results = node.as(TwoColumnSearchResults, VideoList);
// The type of `results` will now be `TwoColumnSearchResults | VideoList`
// Similarly, we can check if the node is of a certain type.
if (node.is(TwoColumnSearchResults, VideoList)) {
// Again no casting is needed, the node is already of the correct type.
const results = node;
}
```
### Accessing properties without casting
Sometimes multiple nodes have the same properties and we don't want to check the type of the node before accessing the property, for example, the property "contents" is used by many node types, and we may add more in the future, as such we want to only assert the property instead of casting to a specific type.
```ts
// Accessing a property on a node which you aren't sure if it exists.
const prop = node.key("contents");
// This returns the value wrapped into a `Maybe` type
// which you can use to find the type of the value
// note, however, this throws an error if the key doesn't exist
// we may want to check for the key before accessing it.
if (node.hasKey("contents")) {
const prop = node.key("contents");
}
// We can assert the type of the value.
const prop = node.key("contents");
if (prop.isString()) {
const value = prop.string();
}
// We can do more complex assertions too,
// like checking for instanceof.
const prop = node.key("contents");
if (prop.isInstanceof(Text)) {
const text = prop.instanceof(Text);
// and then use the value as the given type
text.runs.forEach(run => {
console.log(run.text);
});
}
// There are some special methods for using with the parser —
// such as getting the value as a YTNode.
const prop = node.key("contents");
if (prop.isNode()) {
const node = prop.node();
}
// Like with YTNode, keys can also be checked for YTNode child class types.
const prop = node.key("contents");
if (prop.isNodeOfType(TwoColumnSearchResults)) {
const results = prop.nodeOfType(TwoColumnSearchResults);
}
// Or we can check for multiple types of nodes.
const prop = node.key("contents");
if (prop.isNodeOfType([TwoColumnSearchResults, VideoList])) {
const results = prop.nodeOfType<TwoColumnSearchResults | VideoList>([TwoColumnSearchResults, VideoList]);
}
// Sometimes an ObservedArray is returned when working with parsed data.
// We've got a helper for that too;
const prop = node.key("contents");
if (prop.isObserved()) {
const array = prop.observed();
// Now we may use all the ObservedArray methods as normal,
// like finding nodes of a certain type for example.
const results = array.filterType(GridVideo);
}
// Other times a SuperParsedResult is returned, like when using the `Parser#parse` method.
const prop = node.key("contents");
if (prop.isParsed()) {
const result = prop.parsed();
// SuperParsedResult is another helper for typesafe access to the parsed data,
// it is explained above with the `Parser#parse` method.
const results = results.array();
const videos = results.filterType(Video);
}
// Sometimes we just want to debug something and are not interested in finding the type.
// This will, however, warn you when being used.
const prop = node.key("contents");
const value = prop.any();
// Arrays are also a special case as every element may be of a different type,
// the `arrayOfMaybe` method will return an array of `Maybe`s.
const prop = node.key("contents");
if (prop.isArray()) {
const array = prop.arrayOfMaybe();
// This will return Maybe[]
}
// Or if you want zero type safety you can use the `array` method.
const prop = node.key("contents");
if (prop.isArray()) {
const array = prop.array();
// This will return any[]
}
```
## Memo
The `Memo` class is a helper class for memoizing values in the `Parser#parseResponse` method. It is useful for finding nodes after parsing the response.
Say we want all of the videos in a search result. We can use the `Memo` to find all of them quickly without recursing through the response.
```ts
const response = Parser.parseResponse(data);
const videos = response.contents_memo.getType(Video);
// This returns the nodes as a ObservedArray<Video>.
```
`Memo` extends `Map<string, YTNode[]>` and can be used as a normal `Map` too if you want.
## Adding new nodes
Instructions can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md).
## How it works
If you decompile a YouTube client and analyze it for a while you will notice that it has classes named `protos/youtube/api/innertube/MusicItemRenderer`, `protos/youtube/api/innertube/SectionListRenderer`, etc.
These classes are used to parse objects from the response, map them into models and generate the UI. The website works similarly, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
Here we're taking a similar approach, the parser goes through all the renderers and parses their inner element(s). The final result is a nicely structured JSON, and on top of that, it also parses navigation endpoints which allow us to make an API call with all required parameters in one line and even emulate client actions (eg; clicking a button).
Here is your average, arguably ugly InnerTube response:
<details>
<summary>Click to see</summary>
<p>
```js
{
sidebar: {
playlistSidebarRenderer: {
items: [
{
playlistSidebarPrimaryInfoRenderer: {
title: {
simpleText: '..'
},
description: {
runs: [
{
text: '..'
},
//....
]
},
stats: [
{
simpleText: '..'
},
{
runs: [
{
text: '..'
}
]
}
]
}
}
]
}
}
}
```
</p>
</details>
And what we get after parsing it:
<details>
<summary>Click to see</summary>
<p>
```js
{
sidebar: {
type: 'PlaylistSidebar',
contents: [
{
type: 'PlaylistSidebarPrimaryInfo',
title: { text: '..' },
description: { text: '..' },
stats: [
{
text: '..'
},
{
text: '..'
}
]
}
]
}
}
```
</p>
</details>

View File

@@ -0,0 +1,18 @@
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class AccountChannel extends YTNode {
static type = 'AccountChannel';
title: Text;
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
}
export default AccountChannel;

View File

@@ -0,0 +1,45 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import AccountItemSectionHeader from './AccountItemSectionHeader.ts';
import { YTNode } from '../helpers.ts';
class AccountItem {
static type = 'AccountItem';
account_name: Text;
account_photo: Thumbnail[];
is_selected: boolean;
is_disabled: boolean;
has_channel: boolean;
endpoint: NavigationEndpoint;
account_byline: Text;
constructor(data: any) {
this.account_name = new Text(data.accountName);
this.account_photo = Thumbnail.fromResponse(data.accountPhoto);
this.is_selected = data.isSelected;
this.is_disabled = data.isDisabled;
this.has_channel = data.hasChannel;
this.endpoint = new NavigationEndpoint(data.serviceEndpoint);
this.account_byline = new Text(data.accountByline);
}
}
class AccountItemSection extends YTNode {
static type = 'AccountItemSection';
contents;
header;
constructor(data: any) {
super();
this.contents = data.contents.map((ac: any) => new AccountItem(ac.accountItem));
this.header = Parser.parseItem<AccountItemSectionHeader>(data.header, AccountItemSectionHeader);
}
}
export default AccountItemSection;

View File

@@ -0,0 +1,15 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class AccountItemSectionHeader extends YTNode {
static type = 'AccountItemSectionHeader';
title: Text;
constructor(data: any) {
super();
this.title = new Text(data.title);
}
}
export default AccountItemSectionHeader;

View File

@@ -0,0 +1,20 @@
import Parser from '../index.ts';
import AccountChannel from './AccountChannel.ts';
import AccountItemSection from './AccountItemSection.ts';
import { YTNode } from '../helpers.ts';
class AccountSectionList extends YTNode {
static type = 'AccountSectionList';
contents;
footers;
constructor(data: any) {
super();
this.contents = Parser.parseItem<AccountItemSection>(data.contents[0], AccountItemSection);
this.footers = Parser.parseItem<AccountChannel>(data.footers[0], AccountChannel);
}
}
export default AccountSectionList;

View File

@@ -0,0 +1,17 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class Alert extends YTNode {
static type = 'Alert';
text: Text;
alert_type: string;
constructor(data: any) {
super();
this.text = new Text(data.text);
this.alert_type = data.type;
}
}
export default Alert;

View File

@@ -0,0 +1,14 @@
import { YTNode } from '../helpers.ts';
class AudioOnlyPlayability extends YTNode {
static type = 'AudioOnlyPlayability';
audio_only_availability: string;
constructor (data: any) {
super();
this.audio_only_availability = data.audioOnlyAvailability;
}
}
export default AudioOnlyPlayability;

View File

@@ -0,0 +1,19 @@
import { YTNode } from '../helpers.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
class AutomixPreviewVideo extends YTNode {
static type = 'AutomixPreviewVideo';
playlist_video?: { endpoint: NavigationEndpoint };
constructor(data: any) {
super();
if (data?.content?.automixPlaylistVideoRenderer?.navigationEndpoint) {
this.playlist_video = {
endpoint: new NavigationEndpoint(data.content.automixPlaylistVideoRenderer.navigationEndpoint)
};
}
}
}
export default AutomixPreviewVideo;

View File

@@ -0,0 +1,18 @@
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class BackstageImage extends YTNode {
static type = 'BackstageImage';
image: Thumbnail[];
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.image = Thumbnail.fromResponse(data.image);
this.endpoint = new NavigationEndpoint(data.command);
}
}
export default BackstageImage;

View File

@@ -0,0 +1,75 @@
import Parser from '../index.ts';
import Author from './misc/Author.ts';
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import type CommentActionButtons from './comments/CommentActionButtons.ts';
import type Menu from './menus/Menu.ts';
import { YTNode } from '../helpers.ts';
class BackstagePost extends YTNode {
static type = 'BackstagePost';
id: string;
author: Author;
content: Text;
published: Text;
poll_status?: string;
vote_status?: string;
vote_count?: Text;
menu?: Menu | null;
action_buttons;
vote_button;
surface: string;
endpoint?: NavigationEndpoint;
attachment;
constructor(data: any) {
super();
this.id = data.postId;
this.author = new Author({
...data.authorText,
navigationEndpoint: data.authorEndpoint
}, null, data.authorThumbnail);
this.content = new Text(data.contentText);
this.published = new Text(data.publishedTimeText);
if (data.pollStatus) {
this.poll_status = data.pollStatus;
}
if (data.voteStatus) {
this.vote_status = data.voteStatus;
}
if (data.voteCount) {
this.vote_count = new Text(data.voteCount);
}
if (data.actionMenu) {
this.menu = Parser.parseItem<Menu>(data.actionMenu);
}
if (data.actionButtons) {
this.action_buttons = Parser.parseItem<CommentActionButtons>(data.actionButtons);
}
if (data.voteButton) {
this.vote_button = Parser.parseItem(data.voteButton);
}
if (data.navigationEndpoint) {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
if (data.backstageAttachment) {
this.attachment = Parser.parseItem(data.backstageAttachment);
}
this.surface = data.surface;
}
}
export default BackstagePost;

View File

@@ -0,0 +1,15 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class BackstagePostThread extends YTNode {
static type = 'BackstagePostThread';
post;
constructor(data: any) {
super();
this.post = Parser.parseItem(data.post);
}
}
export default BackstagePostThread;

View File

@@ -0,0 +1,15 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class BrowseFeedActions extends YTNode {
static type = 'BrowseFeedActions';
contents;
constructor(data: any) {
super();
this.contents = Parser.parseArray(data.contents);
}
}
export default BrowseFeedActions;

View File

@@ -0,0 +1,18 @@
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
class BrowserMediaSession extends YTNode {
static type = 'BrowserMediaSession';
album;
thumbnails;
constructor (data: any) {
super();
this.album = new Text(data.album);
this.thumbnails = Thumbnail.fromResponse(data.thumbnailDetails);
}
}
export default BrowserMediaSession;

View File

@@ -0,0 +1,45 @@
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class Button extends YTNode {
static type = 'Button';
text?: string;
label?: string;
tooltip?: string;
icon_type?: string;
is_disabled?: boolean;
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
if (data.text) {
this.text = new Text(data.text).toString();
}
if (data.accessibility?.label) {
this.label = data.accessibility?.label;
}
if (data.tooltip) {
this.tooltip = data.tooltip;
}
if (data.icon?.iconType) {
this.icon_type = data.icon?.iconType;
}
if (Reflect.has(data, 'isDisabled')) {
this.is_disabled = data.isDisabled;
}
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command);
}
}
export default Button;

View File

@@ -0,0 +1,76 @@
import Parser from '../index.ts';
import Author from './misc/Author.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import type Button from './Button.ts';
import type ChannelHeaderLinks from './ChannelHeaderLinks.ts';
import type SubscribeButton from './SubscribeButton.ts';
import { YTNode } from '../helpers.ts';
class C4TabbedHeader extends YTNode {
static type = 'C4TabbedHeader';
author: Author;
banner?: Thumbnail[];
tv_banner?: Thumbnail[];
mobile_banner?: Thumbnail[];
subscribers?: Text;
videos_count?: Text;
sponsor_button?: Button | null;
subscribe_button?: SubscribeButton | null;
header_links?: ChannelHeaderLinks | null;
channel_handle?: Text;
channel_id?: string;
constructor(data: any) {
super();
this.author = new Author({
simpleText: data.title,
navigationEndpoint: data.navigationEndpoint
}, data.badges, data.avatar);
if (data.banner) {
this.banner = Thumbnail.fromResponse(data.banner);
}
if (data.tv_banner) {
this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
}
if (data.mobile_banner) {
this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);
}
if (data.subscriberCountText) {
this.subscribers = new Text(data.subscriberCountText);
}
if (data.videosCountText) {
this.videos_count = new Text(data.videosCountText);
}
if (data.sponsorButton) {
this.sponsor_button = Parser.parseItem<Button>(data.sponsorButton);
}
if (data.subscribeButton) {
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
}
if (data.headerLinks) {
this.header_links = Parser.parseItem<ChannelHeaderLinks>(data.headerLinks);
}
if (data.channelHandleText) {
this.channel_handle = new Text(data.channelHandleText);
}
if (data.channelId) {
this.channel_id = data.channelId;
}
}
}
export default C4TabbedHeader;

View File

@@ -0,0 +1,19 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class CallToActionButton extends YTNode {
static type = 'CallToActionButton';
label: Text;
icon_type: string;
style: string;
constructor(data: any) {
super();
this.label = new Text(data.label);
this.icon_type = data.icon.iconType;
this.style = data.style;
}
}
export default CallToActionButton;

View File

@@ -0,0 +1,35 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class Card extends YTNode {
static type = 'Card';
teaser;
content;
card_id: string | null;
feature: string | null;
cue_ranges: {
start_card_active_ms: string;
end_card_active_ms: string;
teaser_duration_ms: string;
icon_after_teaser_ms: string;
}[];
constructor(data: any) {
super();
this.teaser = Parser.parseItem(data.teaser);
this.content = Parser.parseItem(data.content);
this.card_id = data.cardId || null;
this.feature = data.feature || null;
this.cue_ranges = data.cueRanges.map((cr: any) => ({
start_card_active_ms: cr.startCardActiveMs,
end_card_active_ms: cr.endCardActiveMs,
teaser_duration_ms: cr.teaserDurationMs,
icon_after_teaser_ms: cr.iconAfterTeaserMs
}));
}
}
export default Card;

View File

@@ -0,0 +1,20 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class CardCollection extends YTNode {
static type = 'CardCollection';
cards;
header: Text;
allow_teaser_dismiss: boolean;
constructor(data: any) {
super();
this.cards = Parser.parseArray(data.cards);
this.header = new Text(data.headerText);
this.allow_teaser_dismiss = data.allowTeaserDismiss;
}
}
export default CardCollection;

View File

@@ -0,0 +1,15 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class CarouselHeader extends YTNode {
static type = 'CarouselHeader';
contents: YTNode[];
constructor(data: any) {
super();
this.contents = Parser.parseArray(data.contents);
}
}
export default CarouselHeader;

View File

@@ -0,0 +1,25 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
import Thumbnail from './misc/Thumbnail.ts';
class CarouselItem extends YTNode {
static type = 'CarouselItem';
items: YTNode[];
background_color: string;
layout_style: string;
pagination_thumbnails: Thumbnail[];
paginator_alignment: string;
constructor (data: any) {
super();
this.items = Parser.parseArray(data.carouselItems);
this.background_color = data.backgroundColor;
this.layout_style = data.layoutStyle;
this.pagination_thumbnails = Thumbnail.fromResponse(data.paginationThumbnails);
this.paginator_alignment = data.paginatorAlignment;
}
}
export default CarouselItem;

View File

@@ -0,0 +1,44 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Author from './misc/Author.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import type SubscribeButton from './SubscribeButton.ts';
import { YTNode } from '../helpers.ts';
class Channel extends YTNode {
static type = 'Channel';
id: string;
author: Author;
subscribers: Text;
videos: Text;
long_byline: Text;
short_byline: Text;
endpoint: NavigationEndpoint;
subscribe_button: SubscribeButton | null;
description_snippet: Text;
constructor(data: any) {
super();
this.id = data.channelId;
this.author = new Author({
...data.title,
navigationEndpoint: data.navigationEndpoint
}, data.ownerBadges, data.thumbnail);
// TODO: subscriberCountText is now the channel's handle and videoCountText is the subscriber count. Why haven't they renamed the properties?
this.subscribers = new Text(data.subscriberCountText);
this.videos = new Text(data.videoCountText);
this.long_byline = new Text(data.longBylineText);
this.short_byline = new Text(data.shortBylineText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
this.description_snippet = new Text(data.descriptionSnippet);
}
}
export default Channel;

View File

@@ -0,0 +1,56 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import type Button from './Button.ts';
import { YTNode } from '../helpers.ts';
class ChannelAboutFullMetadata extends YTNode {
static type = 'ChannelAboutFullMetadata';
id: string;
name: Text;
avatar: Thumbnail[];
canonical_channel_url: string;
primary_links: {
endpoint: NavigationEndpoint;
icon: Thumbnail[];
title: Text;
}[];
views: Text;
joined: Text;
description: Text;
email_reveal: NavigationEndpoint;
can_reveal_email: boolean;
country: Text;
buttons: Button[];
constructor(data: any) {
super();
this.id = data.channelId;
this.name = new Text(data.title);
this.avatar = Thumbnail.fromResponse(data.avatar);
this.canonical_channel_url = data.canonicalChannelUrl;
this.primary_links = data.primaryLinks?.map((link: any) => ({
endpoint: new NavigationEndpoint(link.navigationEndpoint),
icon: Thumbnail.fromResponse(link.icon),
title: new Text(link.title)
})) ?? [];
this.views = new Text(data.viewCountText);
this.joined = new Text(data.joinedDateText);
this.description = new Text(data.description);
this.email_reveal = new NavigationEndpoint(data.onBusinessEmailRevealClickCommand);
this.can_reveal_email = !data.signInForBusinessEmail;
this.country = new Text(data.country);
this.buttons = Parser.parseArray<Button>(data.actionButtons);
}
}
export default ChannelAboutFullMetadata;

View File

@@ -0,0 +1,30 @@
import { Parser } from '../index.ts';
import Button from './Button.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class ChannelAgeGate extends YTNode {
static type = 'ChannelAgeGate';
channel_title: string;
avatar: Thumbnail[];
header: Text;
main_text: Text;
sign_in_button: Button | null;
secondary_text: Text;
constructor(data: RawNode) {
super();
this.channel_title = data.channelTitle;
this.avatar = Thumbnail.fromResponse(data.avatar);
this.header = new Text(data.header);
this.main_text = new Text(data.mainText);
this.sign_in_button = Parser.parseItem<Button>(data.signInButton, Button);
this.secondary_text = new Text(data.secondaryText);
}
}
export default ChannelAgeGate;

View File

@@ -0,0 +1,18 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class ChannelFeaturedContent extends YTNode {
static type = 'ChannelFeaturedContent';
title: Text;
items;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.items = Parser.parse(data.items);
}
}
export default ChannelFeaturedContent;

View File

@@ -0,0 +1,31 @@
import NavigationEndpoint from './NavigationEndpoint.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
class HeaderLink {
endpoint: NavigationEndpoint;
icon: Thumbnail[];
title: Text;
constructor(data: any) {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.icon = Thumbnail.fromResponse(data.icon);
this.title = new Text(data.title);
}
}
class ChannelHeaderLinks extends YTNode {
static type = 'ChannelHeaderLinks';
primary: HeaderLink[];
secondary: HeaderLink[];
constructor(data: any) {
super();
this.primary = data.primaryLinks?.map((link: any) => new HeaderLink(link)) || [];
this.secondary = data.secondaryLinks?.map((link: any) => new HeaderLink(link)) || [];
}
}
export default ChannelHeaderLinks;

View File

@@ -0,0 +1,39 @@
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
class ChannelMetadata extends YTNode {
static type = 'ChannelMetadata';
title: string;
description: string;
url: string;
rss_urls: any; // Array?
vanity_channel_url: string;
external_id: string;
is_family_safe: boolean;
keywords: string[];
avatar: Thumbnail[];
available_countries: string[];
android_deep_link: string;
android_appindexing_link: string;
ios_appindexing_link: string;
constructor(data: any) {
super();
this.title = data.title;
this.description = data.description;
this.url = data.channelUrl;
this.rss_urls = data.rssUrl;
this.vanity_channel_url = data.vanityChannelUrl;
this.external_id = data.externalId;
this.is_family_safe = data.isFamilySafe;
this.keywords = data.keywords;
this.avatar = Thumbnail.fromResponse(data.avatar);
this.available_countries = data.availableCountryCodes;
this.android_deep_link = data.androidDeepLink;
this.android_appindexing_link = data.androidAppindexingLink;
this.ios_appindexing_link = data.iosAppindexingLink;
}
}
export default ChannelMetadata;

View File

@@ -0,0 +1,15 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class ChannelMobileHeader extends YTNode {
static type = 'ChannelMobileHeader';
title: Text;
constructor(data: any) {
super();
this.title = new Text(data.title);
}
}
export default ChannelMobileHeader;

View File

@@ -0,0 +1,24 @@
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class ChannelOptions extends YTNode {
static type = 'ChannelOptions';
avatar: Thumbnail[];
endpoint: NavigationEndpoint;
name: string;
links: Text[];
constructor(data: any) {
super();
this.avatar = Thumbnail.fromResponse(data.avatar);
this.endpoint = new NavigationEndpoint(data.avatarEndpoint);
this.name = data.name;
this.links = data.links.map((link: any) => new Text(link));
}
}
export default ChannelOptions;

View File

@@ -0,0 +1,27 @@
import Parser from '../index.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class ChannelSubMenu extends YTNode {
static type = 'ChannelSubMenu';
content_type_sub_menu_items: {
endpoint: NavigationEndpoint;
selected: boolean;
title: string;
}[];
sort_setting;
constructor(data: any) {
super();
this.content_type_sub_menu_items = data.contentTypeSubMenuItems.map((item: any) => ({
endpoint: new NavigationEndpoint(item.navigationEndpoint || item.endpoint),
selected: item.selected,
title: item.title
}));
this.sort_setting = Parser.parseItem(data.sortSetting);
}
}
export default ChannelSubMenu;

View File

@@ -0,0 +1,20 @@
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class ChannelThumbnailWithLink extends YTNode {
static type = 'ChannelThumbnailWithLink';
thumbnails: Thumbnail[];
endpoint: NavigationEndpoint;
label: string;
constructor(data: any) {
super();
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.label = data.accessibility.accessibilityData.label;
}
}
export default ChannelThumbnailWithLink;

View File

@@ -0,0 +1,23 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class ChannelVideoPlayer extends YTNode {
static type = 'ChannelVideoPlayer';
id: string;
title: Text;
description: Text;
views: Text;
published: Text;
constructor(data: any) {
super();
this.id = data.videoId;
this.title = new Text(data.title);
this.description = new Text(data.description);
this.views = new Text(data.viewCountText);
this.published = new Text(data.publishedTimeText);
}
}
export default ChannelVideoPlayer;

View File

@@ -0,0 +1,21 @@
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
class Chapter extends YTNode {
static type = 'Chapter';
title: Text;
time_range_start_millis: number;
thumbnail: Thumbnail[];
constructor(data: any) {
super();
this.title = new Text(data.title);
this.time_range_start_millis = data.timeRangeStartMillis;
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
}
}
export default Chapter;

View File

@@ -0,0 +1,34 @@
import NavigationEndpoint from './NavigationEndpoint.ts';
import Text from './misc/Text.ts';
import { timeToSeconds } from '../../utils/Utils.ts';
import { YTNode } from '../helpers.ts';
class ChildVideo extends YTNode {
static type = 'ChildVideo';
id: string;
title: Text;
duration: {
text: string;
seconds: number;
};
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.id = data.videoId;
this.title = new Text(data.title);
this.duration = {
text: data.lengthText.simpleText,
seconds: timeToSeconds(data.lengthText.simpleText)
};
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
}
export default ChildVideo;

View File

@@ -0,0 +1,25 @@
import Parser from '../index.ts';
import Button from './Button.ts';
import ChipCloudChip from './ChipCloudChip.ts';
import { YTNode } from '../helpers.ts';
class ChipCloud extends YTNode {
static type = 'ChipCloud';
chips;
next_button;
previous_button;
horizontal_scrollable;
constructor(data: any) {
super();
// TODO: check this assumption that chipcloudchip is always returned
this.chips = Parser.parseArray<ChipCloudChip>(data.chips, ChipCloudChip);
this.next_button = Parser.parseItem<Button>(data.nextButton, Button);
this.previous_button = Parser.parseItem<Button>(data.previousButton, Button);
this.horizontal_scrollable = data.horizontalScrollable;
}
}
export default ChipCloud;

View File

@@ -0,0 +1,21 @@
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class ChipCloudChip extends YTNode {
static type = 'ChipCloudChip';
is_selected: boolean;
endpoint: NavigationEndpoint | undefined;
text: string;
constructor(data: any) {
super();
// TODO: is this isSelected or just selected
this.is_selected = data.isSelected;
this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : undefined;
this.text = new Text(data.text).toString();
}
}
export default ChipCloudChip;

View File

@@ -0,0 +1,26 @@
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class CollaboratorInfoCardContent extends YTNode {
static type = 'CollaboratorInfoCardContent';
channel_avatar: Thumbnail[];
custom_text: Text;
channel_name: Text;
subscriber_count: Text;
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.channel_avatar = Thumbnail.fromResponse(data.channelAvatar);
this.custom_text = new Text(data.customText);
this.channel_name = new Text(data.channelName);
this.subscriber_count = new Text(data.subscriberCountText);
this.endpoint = new NavigationEndpoint(data.endpoint);
}
}
export default CollaboratorInfoCardContent;

View File

@@ -0,0 +1,22 @@
import NavigationEndpoint from './NavigationEndpoint.ts';
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
class CollageHeroImage extends YTNode {
static type = 'CollageHeroImage';
left: Thumbnail[];
top_right: Thumbnail[];
bottom_right: Thumbnail[];
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.left = Thumbnail.fromResponse(data.leftThumbnail);
this.top_right = Thumbnail.fromResponse(data.topRightThumbnail);
this.bottom_right = Thumbnail.fromResponse(data.bottomRightThumbnail);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
}
export default CollageHeroImage;

View File

@@ -0,0 +1,35 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import type Menu from './menus/Menu.ts';
import { YTNode } from '../helpers.ts';
class CompactChannel extends YTNode {
static type = 'CompactChannel';
title: Text;
channel_id: string;
thumbnail: Thumbnail[];
display_name: Text;
video_count: Text;
subscriber_count: Text;
endpoint: NavigationEndpoint;
tv_banner: Thumbnail[];
menu: Menu | null;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.channel_id = data.channelId;
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
this.display_name = new Text(data.displayName);
this.video_count = new Text(data.videoCountText);
this.subscriber_count = new Text(data.subscriberCountText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
this.menu = Parser.parseItem<Menu>(data.menu);
}
}
export default CompactChannel;

View File

@@ -0,0 +1,20 @@
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class CompactLink extends YTNode {
static type = 'CompactLink';
title: string;
endpoint: NavigationEndpoint;
style: string;
constructor(data: any) {
super();
this.title = new Text(data.title).toString();
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.style = data.style;
}
}
export default CompactLink;

View File

@@ -0,0 +1,11 @@
import Playlist from './Playlist.ts';
class CompactMix extends Playlist {
static type = 'CompactMix';
constructor(data: any) {
super(data);
}
}
export default CompactMix;

View File

@@ -0,0 +1,11 @@
import Playlist from './Playlist.ts';
class CompactPlaylist extends Playlist {
static type = 'CompactPlaylist';
constructor(data: any) {
super(data);
}
}
export default CompactPlaylist;

View File

@@ -0,0 +1,27 @@
import { YTNode } from '../helpers.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
class CompactStation extends YTNode {
static type = 'CompactStation';
title: Text;
description: Text;
video_count: Text;
endpoint: NavigationEndpoint;
thumbnail: Thumbnail[];
constructor(data: any) {
super();
this.title = new Text(data.title);
this.description = new Text(data.description);
this.video_count = new Text(data.videoCountText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
}
}
export default CompactStation;

View File

@@ -0,0 +1,80 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Author from './misc/Author.ts';
import { timeToSeconds } from '../../utils/Utils.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import type Menu from './menus/Menu.ts';
import MetadataBadge from './MetadataBadge.ts';
import { YTNode } from '../helpers.ts';
class CompactVideo extends YTNode {
static type = 'CompactVideo';
id: string;
thumbnails: Thumbnail[];
rich_thumbnail;
title: Text;
author: Author;
view_count: Text;
short_view_count: Text;
published: Text;
badges: MetadataBadge[];
duration: {
text: string;
seconds: number;
};
thumbnail_overlays;
endpoint: NavigationEndpoint;
menu: Menu | null;
constructor(data: any) {
super();
this.id = data.videoId;
this.thumbnails = Thumbnail.fromResponse(data.thumbnail) || null;
this.rich_thumbnail = data.richThumbnail && Parser.parse(data.richThumbnail);
this.title = new Text(data.title);
this.author = new Author(data.longBylineText, data.ownerBadges, data.channelThumbnail);
this.view_count = new Text(data.viewCountText);
this.short_view_count = new Text(data.shortViewCountText);
this.published = new Text(data.publishedTimeText);
this.badges = Parser.parseArray(data.badges, MetadataBadge);
this.duration = {
text: new Text(data.lengthText).toString(),
seconds: timeToSeconds(new Text(data.lengthText).toString())
};
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.menu = Parser.parseItem<Menu>(data.menu);
}
get best_thumbnail() {
return this.thumbnails[0];
}
get is_fundraiser(): boolean {
return this.badges.some((badge) => badge.label === 'Fundraiser');
}
get is_live(): boolean {
return this.badges.some((badge) => {
if (badge.style === 'BADGE_STYLE_TYPE_LIVE_NOW' || badge.label === 'LIVE')
return true;
});
}
get is_new(): boolean {
return this.badges.some((badge) => badge.label === 'New');
}
get is_premiere(): boolean {
return this.badges.some((badge) => badge.style === 'PREMIERE');
}
}
export default CompactVideo;

View File

@@ -0,0 +1,24 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Button from './Button.ts';
import { YTNode } from '../helpers.ts';
class ConfirmDialog extends YTNode {
static type = 'ConfirmDialog';
title: Text;
confirm_button: Button | null;
cancel_button: Button | null;
dialog_messages: Text[];
constructor (data: any) {
super();
this.title = new Text(data.title);
this.confirm_button = Parser.parseItem<Button>(data.confirmButton, Button);
this.cancel_button = Parser.parseItem<Button>(data.cancelButton, Button);
this.dialog_messages = data.dialogMessages.map((txt: any) => new Text(txt));
}
}
export default ConfirmDialog;

View File

@@ -0,0 +1,24 @@
import Parser from '../index.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class ContinuationItem extends YTNode {
static type = 'ContinuationItem';
trigger: string;
button?;
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.trigger = data.trigger;
if (data.button) {
this.button = Parser.parseItem(data.button);
}
this.endpoint = new NavigationEndpoint(data.continuationEndpoint);
}
}
export default ContinuationItem;

View File

@@ -0,0 +1,20 @@
import Parser from '../index.ts';
import Button from './Button.ts';
import { YTNode } from '../helpers.ts';
class CopyLink extends YTNode {
static type = 'CopyLink';
copy_button: Button | null;
short_url: string;
style: string;
constructor(data: any) {
super();
this.copy_button = Parser.parseItem<Button>(data.copyButton, Button);
this.short_url = data.shortUrl;
this.style = data.style;
}
}
export default CopyLink;

View File

@@ -0,0 +1,27 @@
import Parser from '../index.ts';
import { ObservedArray, YTNode } from '../helpers.ts';
import Button from './Button.ts';
import Dropdown from './Dropdown.ts';
import DropdownItem from './DropdownItem.ts';
import Text from './misc/Text.ts';
class CreatePlaylistDialog extends YTNode {
static type = 'CreatePlaylistDialog';
title: string;
title_placeholder: string;
privacy_option: ObservedArray<DropdownItem> | null;
cancel_button: Button | null;
create_button: Button | null;
constructor(data: any) {
super();
this.title = new Text(data.dialogTitle).toString();
this.title_placeholder = data.titlePlaceholder || '';
this.privacy_option = Parser.parseItem(data.privacyOption, Dropdown)?.entries || null;
this.create_button = Parser.parseItem(data.cancelButton);
this.cancel_button = Parser.parseItem(data.cancelButton);
}
}
export default CreatePlaylistDialog;

View File

@@ -0,0 +1,19 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
import type Button from './Button.ts';
import type MultiMarkersPlayerBar from './MultiMarkersPlayerBar.ts';
class DecoratedPlayerBar extends YTNode {
static type = 'DecoratedPlayerBar';
player_bar: MultiMarkersPlayerBar | null;
player_bar_action_button: Button | null;
constructor(data: any) {
super();
this.player_bar = Parser.parseItem<MultiMarkersPlayerBar>(data.playerBar);
this.player_bar_action_button = Parser.parseItem<Button>(data.playerBarActionButton);
}
}
export default DecoratedPlayerBar;

View File

@@ -0,0 +1,38 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
class DefaultPromoPanel extends YTNode {
static type = 'DefaultPromoPanel';
title: Text;
description: Text;
endpoint: NavigationEndpoint;
large_form_factor_background_thumbnail;
small_form_factor_background_thumbnail;
scrim_color_values: number[];
min_panel_display_duration_ms: number;
min_video_play_duration_ms: number;
scrim_duration: number;
metadata_order: string;
panel_layout: string;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.description = new Text(data.description);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.large_form_factor_background_thumbnail = Parser.parseItem(data.largeFormFactorBackgroundThumbnail);
this.small_form_factor_background_thumbnail = Parser.parseItem(data.smallFormFactorBackgroundThumbnail);
this.scrim_color_values = data.scrimColorValues;
this.min_panel_display_duration_ms = data.minPanelDisplayDurationMs;
this.min_video_play_duration_ms = data.minVideoPlayDurationMs;
this.scrim_duration = data.scrimDuration;
this.metadata_order = data.metadataOrder;
this.panel_layout = data.panelLayout;
}
}
export default DefaultPromoPanel;

View File

@@ -0,0 +1,20 @@
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class DidYouMean extends YTNode {
static type = 'DidYouMean';
text: string;
corrected_query: Text;
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.text = new Text(data.didYouMean).toString();
this.corrected_query = new Text(data.correctedQuery);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.correctedQueryEndpoint);
}
}
export default DidYouMean;

View File

@@ -0,0 +1,21 @@
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class DownloadButton extends YTNode {
static type = 'DownloadButton';
style: string;
size: string; // TODO: check this
endpoint: NavigationEndpoint;
target_id: string;
constructor(data: any) {
super();
this.style = data.style;
this.size = data.size;
this.endpoint = new NavigationEndpoint(data.command);
this.target_id = data.targetId;
}
}
export default DownloadButton;

View File

@@ -0,0 +1,19 @@
import Parser from '../index.ts';
import { ObservedArray, YTNode } from '../helpers.ts';
import DropdownItem from './DropdownItem.ts';
class Dropdown extends YTNode {
static type = 'Dropdown';
label: string;
entries: ObservedArray<DropdownItem>;
constructor(data: any) {
super();
this.label = data.label || '';
this.entries = Parser.parseArray(data.entries, DropdownItem);
}
}
export default Dropdown;

View File

@@ -0,0 +1,41 @@
import { YTNode } from '../helpers.ts';
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
class DropdownItem extends YTNode {
static type = 'DropdownItem';
label: string;
selected: boolean;
value?: number | string;
icon_type?: string;
description?: string;
endpoint?: NavigationEndpoint;
constructor(data: any) {
super();
this.label = new Text(data.label).toString();
this.selected = !!data.isSelected;
if (data.int32Value) {
this.value = data.int32Value;
} else if (data.stringValue) {
this.value = data.stringValue;
}
if (data.onSelectCommand?.browseEndpoint) {
this.endpoint = new NavigationEndpoint(data.onSelectCommand);
}
if (data.icon?.iconType) {
this.icon_type = data.icon?.iconType;
}
if (data.descriptionText) {
this.description = new Text(data.descriptionText).toString();
}
}
}
export default DropdownItem;

View File

@@ -0,0 +1,28 @@
import Parser from '../index.ts';
import ChildElement from './misc/ChildElement.ts';
import { YTNode } from '../helpers.ts';
class Element extends YTNode {
static type = 'Element';
model;
child_elements?: ChildElement[];
constructor(data: any) {
super();
if (Reflect.has(data, 'elementRenderer')) {
return Parser.parseItem<Element>(data, Element) as Element;
}
const type = data.newElement.type.componentType;
this.model = Parser.parse(type?.model);
if (data.newElement?.childElements) {
this.child_elements = data.newElement?.childElements?.map((el: any) => new ChildElement(el)) || null;
}
}
}
export default Element;

View File

@@ -0,0 +1,20 @@
import Text from './misc/Text.ts';
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class EmergencyOnebox extends YTNode {
static type = 'EmergencyOnebox';
title: Text;
first_option;
menu;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.first_option = Parser.parse(data.firstOption);
this.menu = Parser.parse(data.menu);
}
}
export default EmergencyOnebox;

View File

@@ -0,0 +1,23 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class EmojiPickerCategory extends YTNode {
static type = 'EmojiPickerCategory';
category_id: string;
title: Text;
emoji_ids: string[];
image_loading_lazy: boolean;
category_type: string;
constructor(data: any) {
super();
this.category_id = data.categoryId;
this.title = new Text(data.title);
this.emoji_ids = data.emojiIds;
this.image_loading_lazy = !!data.imageLoadingLazy;
this.category_type = data.categoryType;
}
}
export default EmojiPickerCategory;

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers.ts';
class EmojiPickerCategoryButton extends YTNode {
static type = 'EmojiPickerCategoryButton';
category_id: string;
icon_type: string;
tooltip: string;
constructor(data: any) {
super();
this.category_id = data.categoryId;
this.icon_type = data.icon?.iconType;
this.tooltip = data.tooltip;
}
}
export default EmojiPickerCategoryButton;

View File

@@ -0,0 +1,26 @@
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class EmojiPickerUpsellCategory extends YTNode {
static type = 'EmojiPickerUpsellCategory';
category_id: string;
title: Text;
upsell: Text;
emoji_tooltip: string;
endpoint: NavigationEndpoint;
emoji_ids: string[];
constructor(data: any) {
super();
this.category_id = data.categoryId;
this.title = new Text(data.title);
this.upsell = new Text(data.upsell);
this.emoji_tooltip = data.emojiTooltip;
this.endpoint = new NavigationEndpoint(data.command);
this.emoji_ids = data.emojiIds;
}
}
export default EmojiPickerUpsellCategory;

View File

@@ -0,0 +1,27 @@
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class EndScreenPlaylist extends YTNode {
static type = 'EndScreenPlaylist';
id: string;
title: Text;
author: Text;
endpoint: NavigationEndpoint;
thumbnails: Thumbnail[];
video_count: Text;
constructor(data: any) {
super();
this.id = data.playlistId;
this.title = new Text(data.title);
this.author = new Text(data.longBylineText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.video_count = new Text(data.videoCountText);
}
}
export default EndScreenPlaylist;

View File

@@ -0,0 +1,42 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Author from './misc/Author.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class EndScreenVideo extends YTNode {
static type = 'EndScreenVideo';
id: string;
title: Text;
thumbnails: Thumbnail[];
thumbnail_overlays;
author: Author;
endpoint: NavigationEndpoint;
short_view_count: Text;
badges;
duration: {
text: string;
seconds: number;
};
constructor(data: any) {
super();
this.id = data.videoId;
this.title = new Text(data.title);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
this.author = new Author(data.shortBylineText, data.ownerBadges);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.short_view_count = new Text(data.shortViewCountText);
this.badges = Parser.parse(data.badges);
this.duration = {
text: new Text(data.lengthText).toString(),
seconds: data.lengthInSeconds
};
}
}
export default EndScreenVideo;

View File

@@ -0,0 +1,17 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class Endscreen extends YTNode {
static type = 'Endscreen';
elements;
start_ms: string; // Or number?
constructor(data: any) {
super();
this.elements = Parser.parseArray(data.elements);
this.start_ms = data.startMs;
}
}
export default Endscreen;

View File

@@ -0,0 +1,74 @@
import Parser from '../index.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class EndscreenElement extends YTNode {
static type = 'EndscreenElement';
style;
title;
endpoint;
image;
icon;
metadata;
call_to_action;
hovercard_button;
is_subscribe;
playlist_length;
thumbnail_overlays;
left;
top;
width;
aspect_ratio;
start_ms;
end_ms;
id: string;
constructor(data: any) {
super();
this.style = `${data.style}`;
this.title = new Text(data.title);
this.endpoint = new NavigationEndpoint(data.endpoint);
if (data.image) {
this.image = Thumbnail.fromResponse(data.image);
}
if (data.icon) {
this.icon = Thumbnail.fromResponse(data.icon);
}
if (data.metadata) {
this.metadata = new Text(data.metadata);
}
if (data.callToAction) {
this.call_to_action = new Text(data.callToAction);
}
if (data.hovercardButton) {
this.hovercard_button = Parser.parseItem(data.hovercardButton);
}
if (data.isSubscribe) {
this.is_subscribe = !!data.isSubscribe;
}
if (data.playlistLength) {
this.playlist_length = new Text(data.playlistLength);
}
this.thumbnail_overlays = data.thumbnailOverlays ? Parser.parseArray(data.thumbnailOverlays) : undefined;
this.left = parseFloat(data.left);
this.width = parseFloat(data.width);
this.top = parseFloat(data.top);
this.aspect_ratio = parseFloat(data.aspectRatio);
this.start_ms = parseFloat(data.startMs);
this.end_ms = parseFloat(data.endMs);
this.id = data.id;
}
}
export default EndscreenElement;

View File

@@ -0,0 +1,41 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import Button from './Button.ts';
import HorizontalCardList from './HorizontalCardList.ts';
import { YTNode } from '../helpers.ts';
class ExpandableMetadata extends YTNode {
static type = 'ExpandableMetadata';
header: {
collapsed_title: Text;
collapsed_thumbnail: Thumbnail[];
collapsed_label: Text;
expanded_title: Text;
};
expanded_content: HorizontalCardList | null;
expand_button: Button | null;
collapse_button: Button | null;
constructor(data: any) {
super();
this.header = {
collapsed_title: new Text(data.header.collapsedTitle),
collapsed_thumbnail: Thumbnail.fromResponse(data.header.collapsedThumbnail),
collapsed_label: new Text(data.header.collapsedLabel),
expanded_title: new Text(data.header.expandedTitle)
};
this.expanded_content = Parser.parseItem<HorizontalCardList>(data.expandedContent);
this.expand_button = Parser.parseItem<Button>(data.expandButton);
this.collapse_button = Parser.parseItem<Button>(data.collapseButton);
}
}
export default ExpandableMetadata;

View File

@@ -0,0 +1,22 @@
import Parser from '../index.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class ExpandableTab extends YTNode {
static type = 'ExpandableTab';
title: string;
endpoint: NavigationEndpoint;
selected: boolean;
content;
constructor(data: any) {
super();
this.title = data.title;
this.endpoint = new NavigationEndpoint(data.endpoint);
this.selected = data.selected; // If this.selected then we may have content else we do not
this.content = data.content ? Parser.parseItem(data.content) : null;
}
}
export default ExpandableTab;

View File

@@ -0,0 +1,20 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class ExpandedShelfContents extends YTNode {
static type = 'ExpandedShelfContents';
items;
constructor(data: any) {
super();
this.items = Parser.parseArray(data.items);
}
// XXX: alias for consistency
get contents() {
return this.items;
}
}
export default ExpandedShelfContents;

View File

@@ -0,0 +1,16 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
import ChipCloudChip from './ChipCloudChip.ts';
class FeedFilterChipBar extends YTNode {
static type = 'FeedFilterChipBar';
contents;
constructor(data: any) {
super();
this.contents = Parser.parseArray<ChipCloudChip>(data.contents, ChipCloudChip);
}
}
export default FeedFilterChipBar;

View File

@@ -0,0 +1,15 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class FeedTabbedHeader extends YTNode {
static type = 'FeedTabbedHeader';
title: Text;
constructor(data: any) {
super();
this.title = new Text(data.title);
}
}
export default FeedTabbedHeader;

View File

@@ -0,0 +1,15 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class GameCard extends YTNode {
static type = 'GameCard';
game;
constructor(data: any) {
super();
this.game = Parser.parseItem(data.game);
}
}
export default GameCard;

View File

@@ -0,0 +1,26 @@
import { YTNode } from '../helpers.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
class GameDetails extends YTNode {
static type = 'GameDetails';
title: Text;
box_art: Thumbnail[];
box_art_overlay_text: Text;
endpoint: NavigationEndpoint;
is_official_box_art: boolean;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.box_art = Thumbnail.fromResponse(data.boxArt);
this.box_art_overlay_text = new Text(data.boxArtOverlayText);
this.endpoint = new NavigationEndpoint(data.endpoint);
this.is_official_box_art = data.isOfficialBoxArt;
}
}
export default GameDetails;

View File

@@ -0,0 +1,44 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class Grid extends YTNode {
static type = 'Grid';
items;
is_collapsible?: boolean;
visible_row_count?: string;
target_id?: string;
continuation: string | null;
header?;
constructor(data: any) {
super();
this.items = Parser.parseArray(data.items);
if (data.header) {
this.header = Parser.parse(data.header);
}
if (data.isCollapsible) {
this.is_collapsible = data.isCollapsible;
}
if (data.visibleRowCount) {
this.visible_row_count = data.visibleRowCount;
}
if (data.targetId) {
this.target_id = data.targetId;
}
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
}
// XXX: alias for consistency
get contents() {
return this.items;
}
}
export default Grid;

View File

@@ -0,0 +1,33 @@
import Author from './misc/Author.ts';
import Parser from '../index.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class GridChannel extends YTNode {
static type = 'GridChannel';
id: string;
author: Author;
subscribers: Text;
video_count: Text;
endpoint: NavigationEndpoint;
subscribe_button;
constructor(data: any) {
super();
this.id = data.channelId;
this.author = new Author({
...data.title,
navigationEndpoint: data.navigationEndpoint
}, data.ownerBadges, data.thumbnail);
this.subscribers = new Text(data.subscriberCountText);
this.video_count = new Text(data.videoCountText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.subscribe_button = Parser.parse(data.subscribeButton);
}
}
export default GridChannel;

View File

@@ -0,0 +1,15 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class GridHeader extends YTNode {
static type = 'GridHeader';
title: Text;
constructor(data: any) {
super();
this.title = new Text(data.title);
}
}
export default GridHeader;

View File

@@ -0,0 +1,44 @@
import Text from './misc/Text.ts';
import Parser from '../index.ts';
import Thumbnail from './misc/Thumbnail.ts';
import PlaylistAuthor from './misc/PlaylistAuthor.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import NavigatableText from './misc/NavigatableText.ts';
import { YTNode } from '../helpers.ts';
class GridPlaylist extends YTNode {
static type = 'GridPlaylist';
id: string;
title: Text;
author?: PlaylistAuthor;
badges;
endpoint: NavigationEndpoint;
view_playlist: NavigatableText;
thumbnails: Thumbnail[];
thumbnail_renderer;
sidebar_thumbnails: Thumbnail[] | null;
video_count: Text;
video_count_short: Text;
constructor(data: any) {
super();
this.id = data.playlistId;
this.title = new Text(data.title);
if (data.shortBylineText) {
this.author = new PlaylistAuthor(data.shortBylineText, data.ownerBadges);
}
this.badges = Parser.parse(data.ownerBadges);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.view_playlist = new NavigatableText(data.viewPlaylistText);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_renderer = Parser.parse(data.thumbnailRenderer);
this.sidebar_thumbnails = [].concat(...data.sidebarThumbnails?.map((thumbnail: any) => Thumbnail.fromResponse(thumbnail)) || []) || null;
this.video_count = new Text(data.thumbnailText);
this.video_count_short = new Text(data.videoCountShortText);
}
}
export default GridPlaylist;

View File

@@ -0,0 +1,45 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import Author from './misc/Author.ts';
import type Menu from './menus/Menu.ts';
import { YTNode } from '../helpers.ts';
class GridVideo extends YTNode {
static type = 'GridVideo';
id: string;
title: Text;
thumbnails: Thumbnail[];
thumbnail_overlays;
rich_thumbnail;
published: Text;
duration: Text | null;
author: Author;
views: Text;
short_view_count: Text;
endpoint: NavigationEndpoint;
menu: Menu | null;
constructor(data: any) {
super();
const length_alt = data.thumbnailOverlays.find((overlay: any) => overlay.hasOwnProperty('thumbnailOverlayTimeStatusRenderer'))?.thumbnailOverlayTimeStatusRenderer;
this.id = data.videoId;
this.title = new Text(data.title);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.rich_thumbnail = data.richThumbnail && Parser.parse(data.richThumbnail);
this.published = new Text(data.publishedTimeText);
this.duration = data.lengthText ? new Text(data.lengthText) : length_alt?.text ? new Text(length_alt.text) : null;
this.author = data.shortBylineText && new Author(data.shortBylineText, data.ownerBadges);
this.views = new Text(data.viewCountText);
this.short_view_count = new Text(data.shortViewCountText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.menu = Parser.parseItem<Menu>(data.menu);
}
}
export default GridVideo;

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers.ts';
import Text from './misc/Text.ts';
import type { RawNode } from '../index.ts';
class HashtagHeader extends YTNode {
static type = 'HashtagHeader';
hashtag: Text;
hashtag_info: Text;
constructor(data: RawNode) {
super();
this.hashtag = new Text(data.hashtag);
this.hashtag_info = new Text(data.hashtagInfoText);
}
}
export default HashtagHeader;

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers.ts';
class HeatMarker extends YTNode {
static type = 'HeatMarker';
time_range_start_millis: number;
marker_duration_millis: number;
heat_marker_intensity_score_normalized: number;
constructor(data: any) {
super();
this.time_range_start_millis = data.timeRangeStartMillis;
this.marker_duration_millis = data.markerDurationMillis;
this.heat_marker_intensity_score_normalized = data.heatMarkerIntensityScoreNormalized;
}
}
export default HeatMarker;

View File

@@ -0,0 +1,25 @@
import Parser from '../index.ts';
import type HeatMarker from './HeatMarker.ts';
import { YTNode } from '../helpers.ts';
class Heatmap extends YTNode {
static type = 'Heatmap';
max_height_dp: number;
min_height_dp: number;
show_hide_animation_duration_millis: number;
heat_markers: HeatMarker[];
heat_markers_decorations: any;
constructor(data: any) {
super();
this.max_height_dp = data.maxHeightDp;
this.min_height_dp = data.minHeightDp;
this.show_hide_animation_duration_millis = data.showHideAnimationDurationMillis;
this.heat_markers = Parser.parseArray<HeatMarker>(data.heatMarkers);
this.heat_markers_decorations = Parser.parseArray(data.heatMarkersDecorations);
}
}
export default Heatmap;

View File

@@ -0,0 +1,88 @@
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
class Panel {
static type = 'Panel';
thumbnail?: {
image: {
url: string;
width: number;
height: number;
}[];
endpoint: NavigationEndpoint;
on_long_press_endpoint: NavigationEndpoint;
content_mode: string;
crop_options: string;
};
background_image: {
image: {
url: string;
width: number;
height: number;
}[];
gradient_image: {
url: string;
width: number;
height: number;
}[];
};
strapline: string;
title: string;
description: string;
text_on_tap_endpoint: NavigationEndpoint;
cta: {
icon_name: string;
title: string;
endpoint: NavigationEndpoint;
accessibility_text: string;
state: string;
};
constructor(data: any) {
if (data.thumbnail) {
this.thumbnail = {
image: data.thumbnail.image.sources,
endpoint: new NavigationEndpoint(data.thumbnail.onTap),
on_long_press_endpoint: new NavigationEndpoint(data.thumbnail.onLongPress),
content_mode: data.thumbnail.contentMode,
crop_options: data.thumbnail.cropOptions
};
}
this.background_image = {
image: data.backgroundImage.image.sources,
gradient_image: data.backgroundImage.gradientImage.sources
};
this.strapline = data.strapline;
this.title = data.title;
this.description = data.description;
this.cta = {
icon_name: data.cta.iconName,
title: data.cta.title,
endpoint: new NavigationEndpoint(data.cta.onTap),
accessibility_text: data.cta.accessibilityText,
state: data.cta.state
};
this.text_on_tap_endpoint = new NavigationEndpoint(data.textOnTap);
}
}
class HighlightsCarousel extends YTNode {
static type = 'HighlightsCarousel';
panels: Panel[];
constructor(data: any) {
super();
this.panels = data.highlightsCarousel.panels.map((el: any) => new Panel(el));
}
}
export default HighlightsCarousel;

View File

@@ -0,0 +1,11 @@
import SearchSuggestion from './SearchSuggestion.ts';
class HistorySuggestion extends SearchSuggestion {
static type = 'HistorySuggestion';
constructor(data: any) {
super(data);
}
}
export default HistorySuggestion;

View File

@@ -0,0 +1,24 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
import SearchRefinementCard from './SearchRefinementCard.ts';
import Button from './Button.ts';
import MacroMarkersListItem from './MacroMarkersListItem.ts';
class HorizontalCardList extends YTNode {
static type = 'HorizontalCardList';
cards;
header;
previous_button;
next_button;
constructor(data: any) {
super();
this.cards = Parser.parseArray<SearchRefinementCard | MacroMarkersListItem>(data.cards);
this.header = Parser.parseItem(data.header);
this.previous_button = Parser.parseItem<Button>(data.previousButton, Button);
this.next_button = Parser.parseItem<Button>(data.nextButton, Button);
}
}
export default HorizontalCardList;

View File

@@ -0,0 +1,22 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
class HorizontalList extends YTNode {
static type = 'HorizontalList';
visible_item_count: string;
items;
constructor(data: any) {
super();
this.visible_item_count = data.visibleItemCount;
this.items = Parser.parseArray(data.items);
}
// XXX: alias for consistency
get contents() {
return this.items;
}
}
export default HorizontalList;

Some files were not shown because too many files have changed in this diff Show More