mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56e6e23453 | ||
|
|
00fa514b03 | ||
|
|
d36389c865 | ||
|
|
55ca986888 | ||
|
|
b04df7e119 | ||
|
|
d8d92866d1 | ||
|
|
b4b0731589 | ||
|
|
d69d701869 | ||
|
|
cd4d28c951 | ||
|
|
22b9c174bb | ||
|
|
b704c8e78c | ||
|
|
bbfeb99f55 | ||
|
|
f2adeeeab4 | ||
|
|
3756e63996 | ||
|
|
a27807b6c1 | ||
|
|
5cfb969e33 | ||
|
|
1163125f5c | ||
|
|
9ac5043309 | ||
|
|
6a4b4f3359 | ||
|
|
2b3642ba63 | ||
|
|
fb2e237284 | ||
|
|
6f3deaf16a | ||
|
|
d4382e81c3 | ||
|
|
89956cab46 | ||
|
|
ac9341c769 | ||
|
|
cac762569a | ||
|
|
9978ebf085 | ||
|
|
b036e2fcdc | ||
|
|
e37f42f41b | ||
|
|
883a023624 | ||
|
|
506834b253 | ||
|
|
87e7ef77eb | ||
|
|
27fdd8268a | ||
|
|
d4ea87b8b0 | ||
|
|
ec87eea20d | ||
|
|
e43ad202f4 | ||
|
|
104c36b450 | ||
|
|
f5d61d70f2 | ||
|
|
c76f5f478d | ||
|
|
49d1432b5a | ||
|
|
be157ef016 | ||
|
|
9f703203b6 | ||
|
|
516eeeff45 | ||
|
|
6caa679df6 | ||
|
|
2a87f42b32 | ||
|
|
f7c1e0f249 | ||
|
|
fe4c5433cf | ||
|
|
0e5e0c0fab | ||
|
|
f0fd6146c7 | ||
|
|
43061970c6 | ||
|
|
746023d9bb | ||
|
|
3102479dd9 | ||
|
|
c7a13c948c | ||
|
|
ec875ba321 | ||
|
|
db77bba802 | ||
|
|
5ea0a0ebf8 | ||
|
|
0130229236 | ||
|
|
da517fe6d1 | ||
|
|
95ff1e6c5e | ||
|
|
0f8adfd9b8 | ||
|
|
b514765354 | ||
|
|
3cbcd71a3a | ||
|
|
4c00f15f55 | ||
|
|
ea1d206b26 | ||
|
|
aa334aacbd | ||
|
|
1eda93ee08 | ||
|
|
fe0ac0a961 | ||
|
|
8740deb1f2 | ||
|
|
d71b762df5 |
207
README.md
207
README.md
@@ -1,5 +1,3 @@
|
||||
<!-- Hi there, fellow coder :) -->
|
||||
|
||||
<!-- BADGE LINKS -->
|
||||
[npm]: https://www.npmjs.com/package/youtubei.js
|
||||
[versions]: https://www.npmjs.com/package/youtubei.js?activeTab=versions
|
||||
@@ -10,45 +8,27 @@
|
||||
|
||||
<!-- OTHER LINKS -->
|
||||
[project]: https://github.com/LuanRT/YouTube.js
|
||||
[twitter]: https://twitter.com/lrt_nooneknows
|
||||
[twitter]: https://twitter.com/thesciencephile
|
||||
[discord]: https://discord.gg/syDu7Yks54
|
||||
[nodejs]: https://nodejs.org
|
||||
|
||||
<!-- INTRODUCTION -->
|
||||
<h1 align=center>
|
||||
YouTube.js
|
||||
</h1>
|
||||
<h1 align=center>YouTube.js</h1>
|
||||
|
||||
<p align=center>
|
||||
<i>
|
||||
A full-featured wrapper around the InnerTube API, which is what YouTube itself uses.
|
||||
</i>
|
||||
</p>
|
||||
<p align=center>A full-featured wrapper around the InnerTube API, which is what YouTube itself uses</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/LuanRT/YouTube.js/issues">
|
||||
Report Bug
|
||||
</a>
|
||||
·
|
||||
<a href="https://github.com/LuanRT/YouTube.js/issues">
|
||||
Request Feature
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- BADGES -->
|
||||
<div align="center">
|
||||
|
||||
[][actions]
|
||||
[][versions]
|
||||
[][versions]
|
||||
[][codefactor]
|
||||
[][npm]
|
||||
[][discord]
|
||||
[][say-thanks]
|
||||
<br>
|
||||
[][github-sponsors]
|
||||
|
||||
</div>
|
||||
|
||||
<!-- SPONSORS -->
|
||||
|
||||
<p align="center">
|
||||
<a><sub>Special thanks to:<sub></a>
|
||||
</p>
|
||||
@@ -57,7 +37,7 @@
|
||||
<body>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://serpapi.com/">
|
||||
<a href="https://serpapi.com/" target="_blank">
|
||||
<img width="80" alt="SerpApi" src="https://luanrt.is-a.dev/assets/img/serpapi.svg" />
|
||||
<br>
|
||||
<b>
|
||||
@@ -73,7 +53,6 @@
|
||||
|
||||
___
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
<details>
|
||||
<summary>Table of Contents</summary>
|
||||
<ol>
|
||||
@@ -95,23 +74,20 @@ ___
|
||||
<li><a href="#api">API</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#implementing-custom-functionality">Implementing custom functionality </a></li>
|
||||
<li><a href="#extending-the-library">Extending the library</a></li>
|
||||
<li><a href="#contributing">Contributing</a></li>
|
||||
<li><a href="#contributors">Contributors</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>
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
## About
|
||||
## Description
|
||||
|
||||
InnerTube is an API used across all YouTube clients, it was created to simplify[^1] the internal structure of the platform in a way that updates, tweaks, and experiments can be easily made. This library handles all the low-level communication with InnerTube, providing a simple, fast, and efficient way to interact with YouTube programmatically.
|
||||
InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform[^1]. This library handles all the low-level communication with InnerTube, providing a simple, and efficient way to interact with YouTube programmatically. It is designed to emulate an actual client as closely as possible, including how API responses are [parsed](https://github.com/LuanRT/YouTube.js/tree/main/src/parser#how-it-works).
|
||||
|
||||
If you have any questions or need help, feel free to contact us on our chat server [here](https://discord.gg/syDu7Yks54).
|
||||
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 -->
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -138,7 +114,6 @@ npm install github:LuanRT/YouTube.js
|
||||
|
||||
**TODO:** Deno install instructions (esm.sh possibly?)
|
||||
|
||||
<!-- USAGE -->
|
||||
## Usage
|
||||
Create an InnerTube instance:
|
||||
```ts
|
||||
@@ -245,7 +220,7 @@ const yt = await Innertube.create({
|
||||
* `Innertube`
|
||||
|
||||
<details>
|
||||
<summary>objects</summary>
|
||||
<summary>Objects</summary>
|
||||
<p>
|
||||
|
||||
* [.session](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/session.md)
|
||||
@@ -260,7 +235,7 @@ const yt = await Innertube.create({
|
||||
|
||||
|
||||
<details>
|
||||
<summary>methods</summary>
|
||||
<summary>Methods</summary>
|
||||
<p>
|
||||
|
||||
* [.getInfo(video_id, client?)](#getinfo)
|
||||
@@ -279,6 +254,7 @@ const yt = await Innertube.create({
|
||||
* [.getPlaylist(id)](#getplaylist)
|
||||
* [.getStreamingData(video_id, options)](#getstreamingdata)
|
||||
* [.download(video_id, options?)](#download)
|
||||
* [.resolveURL(url)](#resolveurl)
|
||||
* [.call(endpoint, args?)](#call)
|
||||
|
||||
</p>
|
||||
@@ -406,7 +382,32 @@ See [`./examples/comments`](https://github.com/LuanRT/YouTube.js/blob/main/examp
|
||||
### getHomeFeed()
|
||||
Retrieves YouTube's home feed.
|
||||
|
||||
**Returns**: `Promise.<FilterableFeed>`
|
||||
**Returns**: `Promise.<HomeFeed>`
|
||||
|
||||
<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()
|
||||
@@ -472,11 +473,17 @@ Retrieves contents for a given channel.
|
||||
<p>
|
||||
|
||||
- `<channel>#getVideos()`
|
||||
- `<channel>#getShorts()`
|
||||
- `<channel>#getLiveStreams()`
|
||||
- `<channel>#getPlaylists()`
|
||||
- `<channel>#getHome()`
|
||||
- `<channel>#getCommunity()`
|
||||
- `<channel>#getChannels()`
|
||||
- `<channel>#getAbout()`
|
||||
- `<channel>#search(query)`
|
||||
- `<channel>#getContinuation()`
|
||||
- `<channel>#filters`
|
||||
- `<channel>#page`
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -557,6 +564,16 @@ Downloads a given video.
|
||||
|
||||
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.
|
||||
@@ -567,66 +584,66 @@ Utility to call navigation endpoints.
|
||||
| --- | --- | --- |
|
||||
| endpoint | `NavigationEndpoint` | The target endpoint |
|
||||
| args? | `object` | Additional payload arguments |
|
||||
|
||||
## Extending the library
|
||||
|
||||
## Implementing custom functionality
|
||||
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.
|
||||
|
||||
Something cool about YouTube.js is that it is completely modular and easy to tinker with. 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, you may want to call an endpoint directly, that can be achieved with the `Actions` class:
|
||||
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';
|
||||
|
||||
const payload = {
|
||||
videoId: 'jLTOuvBTLxA',
|
||||
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.
|
||||
};
|
||||
(async () => {
|
||||
const yt = await Innertube.create();
|
||||
|
||||
const response = await yt.actions.execute('/player', payload);
|
||||
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.
|
||||
});
|
||||
|
||||
console.info(response);
|
||||
return videoInfo;
|
||||
}
|
||||
|
||||
const videoInfo = await getVideoInfo('jLTOuvBTLxA');
|
||||
console.info(videoInfo);
|
||||
})();
|
||||
```
|
||||
|
||||
Or maybe there's an interesting `NavigationEndpoint` in a parsed response and we want to call it to see what happens:
|
||||
Or perhaps there's a `NavigationEndpoint` in a parsed response and we want to call it to see what happens:
|
||||
|
||||
```ts
|
||||
// ...
|
||||
const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
|
||||
const albums = artist.sections[1].as(MusicCarouselShelf);
|
||||
import { Innertube, YTNodes } from 'youtubei.js';
|
||||
|
||||
// Say we have a button and want to “click” it
|
||||
const button = albums.as(MusicCarouselShelf).header?.more_content;
|
||||
|
||||
if (button) {
|
||||
// To do that, we can call its navigation endpoint:
|
||||
const page = await button.endpoint.call(yt.actions, 'YTMUSIC', true);
|
||||
console.info(page);
|
||||
}
|
||||
(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);
|
||||
console.info(page);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
### Parser
|
||||
|
||||
If you're working on an extension for the library or just want to have nicely typed and sanitized InnerTube responses for a project then have a look at our powerful parser!
|
||||
|
||||
<details>
|
||||
<summary>Example:</summary>
|
||||
<p>
|
||||
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 } from 'youtubei.js';
|
||||
|
||||
import SectionList from 'youtubei.js/dist/src/parser/classes/SectionList';
|
||||
import SingleColumnBrowseResults from 'youtubei.js/dist/src/parser/classes/SingleColumnBrowseResults';
|
||||
|
||||
import MusicVisualHeader from 'youtubei.js/dist/src/parser/classes/MusicVisualHeader';
|
||||
import MusicImmersiveHeader from 'youtubei.js/dist/src/parser/classes/MusicImmersiveHeader';
|
||||
import MusicCarouselShelf from 'youtubei.js/dist/src/parser/classes/MusicCarouselShelf';
|
||||
import MusicDescriptionShelf from 'youtubei.js/dist/src/parser/classes/MusicDescriptionShelf';
|
||||
import MusicShelf from 'youtubei.js/dist/src/parser/classes/MusicShelf';
|
||||
|
||||
import { Parser, YTNodes } from 'youtubei.js';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Artist page response from YouTube Music
|
||||
@@ -634,15 +651,18 @@ const data = readFileSync('./artist.json').toString();
|
||||
|
||||
const page = Parser.parseResponse(JSON.parse(data));
|
||||
|
||||
const header = page.header.item().as(MusicImmersiveHeader, MusicVisualHeader);
|
||||
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(SingleColumnBrowseResults).tabs.get({ selected: false });
|
||||
/**
|
||||
* 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');
|
||||
@@ -650,31 +670,26 @@ if (!tab)
|
||||
if (!tab.content)
|
||||
throw new Error('Target tab appears to be empty');
|
||||
|
||||
const sections = tab.content?.as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf, MusicShelf);
|
||||
const sections = tab.content?.as(YTNodes.SectionList).contents.array().as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
|
||||
|
||||
console.info('Sections:', sections);
|
||||
```
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
Detailed documentation can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/src/parser).
|
||||
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.
|
||||
|
||||
<!-- CONTRIBUTORS -->
|
||||
## Contributors
|
||||
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 -->
|
||||
## Contact
|
||||
|
||||
LuanRT - [@lrt_nooneknows][twitter] - luan.lrt4@gmail.com
|
||||
LuanRT - [@thesciencephile][twitter] - luan.lrt4@gmail.com
|
||||
|
||||
Project Link: [https://github.com/LuanRT/YouTube.js][project]
|
||||
|
||||
@@ -684,13 +699,11 @@ All trademarks, logos, and brand names are the property of their respective owne
|
||||
|
||||
Should you have any questions or concerns please contact me directly via email.
|
||||
|
||||
<!-- Footnotes -->
|
||||
[^1]: https://gizmodo.com/how-project-innertube-helped-pull-youtube-out-of-the-gu-1704946491
|
||||
|
||||
<!-- LICENSE -->
|
||||
## License
|
||||
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
|
||||
|
||||
<p align=" right">
|
||||
(<a href="#top">back to top</a>)
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -6,4 +6,6 @@ export * from './src/utils';
|
||||
export { YTNodes } from './src/parser/map';
|
||||
export { default as Parser } from './src/parser';
|
||||
export { default as Innertube } from './src/Innertube';
|
||||
export { default as Session } from './src/core/Session';
|
||||
export { default as Player } from './src/core/Player';
|
||||
export default Innertube;
|
||||
@@ -7,7 +7,7 @@ Handles direct interactions.
|
||||
* InteractionManager
|
||||
* [.like(video_id)](#like)
|
||||
* [.dislike(video_id)](#dislike)
|
||||
* [.removeLike(video_id)](#removelike)
|
||||
* [.removeRating(video_id)](#removerating)
|
||||
* [.subscribe(video_id)](#subscribe)
|
||||
* [.unsubscribe(video_id)](#unsubscribe)
|
||||
* [.comment(video_id, text)](#comment)
|
||||
@@ -36,7 +36,7 @@ Dislikes given video.
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="removelike"></a>
|
||||
<a name="removerating"></a>
|
||||
### removeLike(video_id)
|
||||
|
||||
Remover like/dislike.
|
||||
|
||||
@@ -155,20 +155,26 @@ Retrieves library.
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<library>#getPlaylists(args?)`
|
||||
- Retrieves the library's playlists.
|
||||
- `<library>#applyFilter(filter)`
|
||||
- Applies given filter to the library.
|
||||
|
||||
- `<library>#getAlbums(args?)`
|
||||
- Retrieves the library's albums.
|
||||
- `<library>#applySortFilter(filter)`
|
||||
- Applies given sort filter to the library items.
|
||||
|
||||
- `<library>#getArtists(args?)`
|
||||
- Retrieves the library's artists.
|
||||
- `<library>#getContinuation()`
|
||||
- Retrieves continuation of the library items.
|
||||
|
||||
- `<library>#getSongs(args?)`
|
||||
- Retrieves the library's songs.
|
||||
- `<library>#has_continuation`
|
||||
- Checks if continuation is available.
|
||||
|
||||
- `<library>#getRecentActivity(args)`
|
||||
- Retrieves recent activity.
|
||||
- `<library>#filters`
|
||||
- Returns available filters.
|
||||
|
||||
- `<library>#sort_filters`
|
||||
- Returns available sort filters.
|
||||
|
||||
- `<library>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
YouTube is constantly changing, so it is not rare to see YouTube crawlers/scrapers breaking every now and then.
|
||||
|
||||
Our parser, on the other hand, was written so that it behaves similarly to an official client, parsing and mapping renderers (a.k.a YTNodes) dynamically without hard-coding their path in the response. This way, whenever a new renderer pops up (e.g; YouTube adds a new feature / minor UI changes) the library will print a warning like this in the console:
|
||||
Our parser, on the other hand, was written so that it behaves similarly to an official client, parsing and mapping renderers (a.k.a YTNodes) dynamically without hard-coding their path in the response. This way, whenever a new renderer pops up (e.g; YouTube adds a new feature / minor UI changes) the library will print a warning similar to this:
|
||||
```
|
||||
InnertubeError: SomeRenderer not found!
|
||||
This is a bug, want to help us fix it? Follow the instructions at https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md or report it at https://github.com/LuanRT/YouTube.js/issues!
|
||||
@@ -17,7 +17,7 @@ This is a bug, want to help us fix it? Follow the instructions at https://github
|
||||
}
|
||||
```
|
||||
|
||||
This warning, however, **does not** throw an error. The parser itself will continue working normally, even if a parsing error occurs in an existing renderer parser.
|
||||
This warning **does not** throw an error. The parser itself will continue working normally, even if a parsing error occurs in an existing renderer parser.
|
||||
|
||||
## Adding a new renderer parser
|
||||
|
||||
|
||||
@@ -1,39 +1,47 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
|
||||
const channel = await yt.getChannel('UCX6OQ3DkcsbYNE6H8uQQuVA');
|
||||
|
||||
console.info('Viewing channel:', channel.header.author.name);
|
||||
console.info('Family Safe:', channel.metadata.is_family_safe);
|
||||
if (channel.header?.is(YTNodes.C4TabbedHeader)) {
|
||||
console.info('Viewing channel:', channel?.header?.author.name);
|
||||
console.info('Family Safe:', channel.metadata.is_family_safe);
|
||||
}
|
||||
|
||||
const about = await channel.getAbout();
|
||||
|
||||
console.info('Country:', about.country.toString());
|
||||
|
||||
console.info('\nLists the following videos:');
|
||||
console.info('\nVideos:');
|
||||
const videos = await channel.getVideos();
|
||||
|
||||
for (const video of videos.videos) {
|
||||
console.info('Video:', video.title.toString());
|
||||
}
|
||||
|
||||
console.info('\nLists the following playlists:');
|
||||
console.info('\nPopular videos:');
|
||||
const popular_videos = await videos.applyFilter('Popular');
|
||||
for (const video of popular_videos.videos) {
|
||||
console.info('Video:', video.title.toString());
|
||||
}
|
||||
|
||||
console.info('\nPlaylists:');
|
||||
const playlists = await channel.getPlaylists();
|
||||
|
||||
for (const playlist of playlists.playlists) {
|
||||
console.info('Playlist:', playlist.title.toString());
|
||||
}
|
||||
|
||||
console.info('\nLists the following channels:');
|
||||
console.info('\nChannels:');
|
||||
const channels = await channel.getChannels();
|
||||
|
||||
for (const channel of channels.channels) {
|
||||
console.info('Channel:', channel.author.name);
|
||||
}
|
||||
|
||||
console.info('\nLists the following community posts:');
|
||||
console.info('\nCommunity posts:');
|
||||
const posts = await channel.getCommunity();
|
||||
|
||||
for (const post of posts.posts) {
|
||||
|
||||
@@ -4,42 +4,42 @@ import { streamToIterable } from 'youtubei.js/dist/src/utils/Utils';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
|
||||
|
||||
const search = await yt.music.search('No Copyright Background Music', { type: 'album' });
|
||||
|
||||
|
||||
if (!search.results)
|
||||
throw new Error('Filter "type" must be used');
|
||||
|
||||
|
||||
const album = await yt.music.getAlbum(search.results[0].id as string);
|
||||
|
||||
|
||||
if (!album.contents)
|
||||
throw new Error('Album appears to be empty');
|
||||
|
||||
console.info(`Album "${album.header.title.toString()}" by ${album.header.author?.name}`, '\n');
|
||||
|
||||
|
||||
console.info(`Album "${album.header?.title.toString()}" by ${album.header?.author?.name}`, '\n');
|
||||
|
||||
for (const song of album.contents) {
|
||||
const stream = await yt.download(song.id as string, {
|
||||
type: 'audio', // audio, video or audio+video
|
||||
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
|
||||
format: 'mp4' // media container format
|
||||
});
|
||||
|
||||
|
||||
console.info(`Downloading ${song.title} (${song.id})`);
|
||||
|
||||
const dir = `./${album.header.title.toString()}`;
|
||||
|
||||
|
||||
const dir = `./${album.header?.title.toString()}`;
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir);
|
||||
}
|
||||
|
||||
|
||||
const file = createWriteStream(`${dir}/${song.title?.replace(/\//g, '')}.m4a`);
|
||||
|
||||
|
||||
for await (const chunk of streamToIterable(stream)) {
|
||||
file.write(chunk);
|
||||
}
|
||||
|
||||
|
||||
console.info(`${song.id} - Done!`, '\n');
|
||||
}
|
||||
|
||||
console.info(`Downloaded ${album.header.song_count}!`);
|
||||
|
||||
console.info(`Downloaded ${album.header?.song_count}!`);
|
||||
})();
|
||||
@@ -1,31 +1,25 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
|
||||
|
||||
import { LiveChatContinuation } from 'youtubei.js/dist/src/parser';
|
||||
|
||||
import LiveChat, { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
|
||||
|
||||
import Video from 'youtubei.js/dist/src/parser/classes/Video';
|
||||
import AddChatItemAction from 'youtubei.js/dist/src/parser/classes/livechat/AddChatItemAction';
|
||||
import MarkChatItemAsDeletedAction from 'youtubei.js/dist/src/parser/classes/livechat/MarkChatItemAsDeletedAction';
|
||||
|
||||
import LiveChatTextMessage from 'youtubei.js/dist/src/parser/classes/livechat/items/LiveChatTextMessage';
|
||||
import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/items/LiveChatPaidMessage';
|
||||
import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
|
||||
|
||||
const search = await yt.search('Lofi girl live');
|
||||
const info = await yt.getInfo(search.videos[0].as(Video).id);
|
||||
const info = await yt.getInfo(search.videos[0].as(YTNodes.Video).id);
|
||||
|
||||
const livechat = info.getLiveChat();
|
||||
|
||||
const livechat = await info.getLiveChat();
|
||||
|
||||
livechat.on('start', (initial_data: LiveChatContinuation) => {
|
||||
/**
|
||||
* Initial info is what you see when you first open a Live Chat — this is; inital actions (pinned messages, top donations..), account's info and so on.
|
||||
*/
|
||||
|
||||
|
||||
console.info(`Hey ${initial_data.viewer_name || 'N/A'}, welcome to Live Chat!`);
|
||||
});
|
||||
|
||||
|
||||
livechat.on('chat-update', (action: ChatAction) => {
|
||||
/**
|
||||
* An action represents what is being added to
|
||||
@@ -35,28 +29,28 @@ import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/it
|
||||
* Below are a few examples of how this can be used.
|
||||
*/
|
||||
|
||||
if (action.is(AddChatItemAction)) {
|
||||
const item = action.as(AddChatItemAction).item;
|
||||
|
||||
if (action.is(YTNodes.AddChatItemAction)) {
|
||||
const item = action.as(YTNodes.AddChatItemAction).item;
|
||||
|
||||
if (!item)
|
||||
return console.info('Action did not have an item.', action);
|
||||
|
||||
|
||||
const hours = new Date(item.hasKey('timestamp') ? item.timestamp : Date.now()).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
|
||||
switch (item.type) {
|
||||
case 'LiveChatTextMessage':
|
||||
console.info(
|
||||
`${hours} - ${item.as(LiveChatTextMessage).author?.name.toString()}:\n` +
|
||||
`${item.as(LiveChatTextMessage).message.toString()}\n`
|
||||
`${hours} - ${item.as(YTNodes.LiveChatTextMessage).author?.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatTextMessage).message.toString()}\n`
|
||||
);
|
||||
break;
|
||||
case 'LiveChatPaidMessage':
|
||||
console.info(
|
||||
`${hours} - ${item.as(LiveChatPaidMessage).author.name.toString()}:\n` +
|
||||
`${item.as(LiveChatPaidMessage).purchase_amount}\n`
|
||||
`${hours} - ${item.as(YTNodes.LiveChatPaidMessage).author.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatPaidMessage).purchase_amount}\n`
|
||||
);
|
||||
break;
|
||||
default:
|
||||
@@ -64,8 +58,8 @@ import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/it
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.is(MarkChatItemAsDeletedAction)) {
|
||||
|
||||
if (action.is(YTNodes.MarkChatItemAsDeletedAction)) {
|
||||
console.warn(`Message ${action.target_item_id} just got deleted and should be replaced with ${action.deleted_state_message.toString()}!`, '\n');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { Parser } from 'youtubei.js';
|
||||
|
||||
import SectionList from 'youtubei.js/dist/src/parser/classes/SectionList';
|
||||
import SingleColumnBrowseResults from 'youtubei.js/dist/src/parser/classes/SingleColumnBrowseResults';
|
||||
|
||||
import MusicVisualHeader from 'youtubei.js/dist/src/parser/classes/MusicVisualHeader';
|
||||
import MusicImmersiveHeader from 'youtubei.js/dist/src/parser/classes/MusicImmersiveHeader';
|
||||
import MusicCarouselShelf from 'youtubei.js/dist/src/parser/classes/MusicCarouselShelf';
|
||||
import MusicDescriptionShelf from 'youtubei.js/dist/src/parser/classes/MusicDescriptionShelf';
|
||||
import MusicShelf from 'youtubei.js/dist/src/parser/classes/MusicShelf';
|
||||
|
||||
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(MusicImmersiveHeader, MusicVisualHeader);
|
||||
const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);
|
||||
|
||||
console.info('Header:', header);
|
||||
|
||||
@@ -24,14 +13,14 @@ console.info('Header:', header);
|
||||
// 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(SingleColumnBrowseResults).tabs.get({ selected: false });
|
||||
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(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf, MusicShelf);
|
||||
|
||||
const sections = tab.content?.as(YTNodes.SectionList).contents.array().as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
|
||||
|
||||
console.info('Sections:', sections);
|
||||
2
index.ts
2
index.ts
@@ -23,4 +23,6 @@ export * from './src/utils';
|
||||
export { YTNodes } from './src/parser/map';
|
||||
export { default as Parser } from './src/parser';
|
||||
export { default as Innertube } from './src/Innertube';
|
||||
export { default as Session } from './src/core/Session';
|
||||
export { default as Player } from './src/core/Player';
|
||||
export default Innertube;
|
||||
2343
package-lock.json
generated
2343
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "2.3.2",
|
||||
"description": "Full-featured wrapper around YouTube's private API.",
|
||||
"version": "2.8.0",
|
||||
"description": "Full-featured wrapper around YouTube's private API. Supports YouTube, YouTube Music and YouTube Studio (WIP).",
|
||||
"main": "./dist/index.js",
|
||||
"browser": "./bundle/browser.js",
|
||||
"types": "./dist",
|
||||
@@ -71,6 +71,7 @@
|
||||
"youtube-dl",
|
||||
"youtube-downloader",
|
||||
"youtube-music",
|
||||
"youtube-studio",
|
||||
"innertubeapi",
|
||||
"innertube",
|
||||
"unofficial",
|
||||
|
||||
@@ -24,11 +24,11 @@ import { YTNodeConstructor } from './helpers';
|
||||
|
||||
${import_list.join('\n')}
|
||||
|
||||
const map: Record<string, YTNodeConstructor> = {
|
||||
export const YTNodes = {
|
||||
${json.join(',\n ')}
|
||||
};
|
||||
|
||||
export const YTNodes = map;
|
||||
const map: Record<string, YTNodeConstructor> = YTNodes;
|
||||
|
||||
/**
|
||||
* @param name - Name of the node to be parsed
|
||||
|
||||
201
src/Innertube.ts
201
src/Innertube.ts
@@ -1,63 +1,54 @@
|
||||
|
||||
import Session, { SessionOptions } from './core/Session';
|
||||
import type { ParsedResponse } from './parser';
|
||||
import type { ActionsResponse } from './core/Actions';
|
||||
|
||||
import Search from './parser/youtube/Search';
|
||||
import Channel from './parser/youtube/Channel';
|
||||
import Playlist from './parser/youtube/Playlist';
|
||||
import Library from './parser/youtube/Library';
|
||||
import History from './parser/youtube/History';
|
||||
import Comments from './parser/youtube/Comments';
|
||||
import NotificationsMenu from './parser/youtube/NotificationsMenu';
|
||||
import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo';
|
||||
import NavigationEndpoint from './parser/classes/NavigationEndpoint';
|
||||
import Channel from './parser/youtube/Channel';
|
||||
import Comments from './parser/youtube/Comments';
|
||||
import History from './parser/youtube/History';
|
||||
import Library from './parser/youtube/Library';
|
||||
import NotificationsMenu from './parser/youtube/NotificationsMenu';
|
||||
import Playlist from './parser/youtube/Playlist';
|
||||
import Search from './parser/youtube/Search';
|
||||
import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo';
|
||||
|
||||
import { ParsedResponse } from './parser';
|
||||
import { ActionsResponse } from './core/Actions';
|
||||
|
||||
import Feed from './core/Feed';
|
||||
import YTMusic from './core/Music';
|
||||
import Studio from './core/Studio';
|
||||
import AccountManager from './core/AccountManager';
|
||||
import PlaylistManager from './core/PlaylistManager';
|
||||
import Feed from './core/Feed';
|
||||
import InteractionManager from './core/InteractionManager';
|
||||
import FilterableFeed from './core/FilterableFeed';
|
||||
import YTMusic from './core/Music';
|
||||
import PlaylistManager from './core/PlaylistManager';
|
||||
import Studio from './core/Studio';
|
||||
import TabbedFeed from './core/TabbedFeed';
|
||||
import Constants from './utils/Constants';
|
||||
import HomeFeed from './parser/youtube/HomeFeed';
|
||||
import Proto from './proto/index';
|
||||
import Constants from './utils/Constants';
|
||||
|
||||
import { throwIfMissing, generateRandomString } from './utils/Utils';
|
||||
import type Actions from './core/Actions';
|
||||
import type Format from './parser/classes/misc/Format';
|
||||
|
||||
export type InnertubeConfig = SessionOptions
|
||||
import { generateRandomString, throwIfMissing } from './utils/Utils';
|
||||
|
||||
export type InnertubeConfig = SessionOptions;
|
||||
|
||||
export interface SearchFilters {
|
||||
/**
|
||||
* Filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
|
||||
*/
|
||||
upload_date?: 'any' | 'last_hour' | 'today' | 'this_week' | 'this_month' | 'this_year';
|
||||
/**
|
||||
* Filter results by type, can be: any | video | channel | playlist | movie
|
||||
*/
|
||||
type?: 'any' | 'video' | 'channel' | 'playlist' | 'movie';
|
||||
/**
|
||||
* Filter videos by duration, can be: any | short | medium | long
|
||||
*/
|
||||
duration?: 'any' | 'short' | 'medium' | 'long';
|
||||
/**
|
||||
* Filter video results by order, can be: relevance | rating | upload_date | view_count
|
||||
*/
|
||||
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' | 'TV_EMBEDDED';
|
||||
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED';
|
||||
|
||||
class Innertube {
|
||||
session;
|
||||
account;
|
||||
playlist;
|
||||
interact;
|
||||
music;
|
||||
studio;
|
||||
actions;
|
||||
session: Session;
|
||||
account: AccountManager;
|
||||
playlist: PlaylistManager;
|
||||
interact: InteractionManager;
|
||||
music: YTMusic;
|
||||
studio: Studio;
|
||||
actions: Actions;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.session = session;
|
||||
@@ -69,18 +60,22 @@ class Innertube {
|
||||
this.actions = this.session.actions;
|
||||
}
|
||||
|
||||
static async create(config: InnertubeConfig = {}) {
|
||||
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) {
|
||||
async getInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = await this.actions.getVideoInfo(video_id, cpn, client);
|
||||
const continuation = this.actions.next({ video_id });
|
||||
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);
|
||||
@@ -88,8 +83,12 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Retrieves basic video info.
|
||||
* @param video_id - The video id.
|
||||
* @param client - The client to use.
|
||||
*/
|
||||
async getBasicInfo(video_id: string, client?: InnerTubeClient) {
|
||||
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);
|
||||
|
||||
@@ -98,18 +97,27 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Searches a given query.
|
||||
* @param query - search query.
|
||||
* @param filters - search filters.
|
||||
* @param query - The search query.
|
||||
* @param filters - Search filters.
|
||||
*/
|
||||
async search(query: string, filters: SearchFilters = {}) {
|
||||
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
|
||||
throwIfMissing({ query });
|
||||
const response = await this.actions.search({ query, filters });
|
||||
|
||||
const args = {
|
||||
query,
|
||||
...{
|
||||
params: filters ? Proto.encodeSearchFilters(filters) : undefined
|
||||
}
|
||||
};
|
||||
|
||||
const response = await this.actions.execute('/search', args);
|
||||
|
||||
return new Search(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions for a given query.
|
||||
* @param query - the search query.
|
||||
* @param query - The search query.
|
||||
*/
|
||||
async getSearchSuggestions(query: string): Promise<string[]> {
|
||||
throwIfMissing({ query });
|
||||
@@ -134,33 +142,34 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Retrieves comments for a video.
|
||||
* @param video_id - the video id.
|
||||
* @param sort_by - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
|
||||
* @param video_id - The video id.
|
||||
* @param sort_by - Sorting options.
|
||||
*/
|
||||
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST') {
|
||||
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.next({ ctoken: payload });
|
||||
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() {
|
||||
const response = await this.actions.browse('FEwhat_to_watch');
|
||||
return new FilterableFeed(this.actions, response.data);
|
||||
async getHomeFeed(): Promise<HomeFeed> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
|
||||
return new HomeFeed(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account's library.
|
||||
*/
|
||||
async getLibrary() {
|
||||
const response = await this.actions.browse('FElibrary');
|
||||
async getLibrary(): Promise<Library> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FElibrary' });
|
||||
return new Library(response.data, this.actions);
|
||||
}
|
||||
|
||||
@@ -168,59 +177,66 @@ class Innertube {
|
||||
* Retrieves watch history.
|
||||
* Which can also be achieved with {@link getLibrary}.
|
||||
*/
|
||||
async getHistory() {
|
||||
const response = await this.actions.browse('FEhistory');
|
||||
async getHistory(): Promise<History> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
|
||||
return new History(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves trending content.
|
||||
*/
|
||||
async getTrending() {
|
||||
const response = await this.actions.browse('FEtrending');
|
||||
async getTrending(): Promise<TabbedFeed> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEtrending' });
|
||||
return new TabbedFeed(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves subscriptions feed.
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await this.actions.browse('FEsubscriptions');
|
||||
async getSubscriptionsFeed(): Promise<Feed> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions' });
|
||||
return new Feed(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves contents for a given channel.
|
||||
* @param id - channel id
|
||||
* @param id - Channel id
|
||||
*/
|
||||
async getChannel(id: string) {
|
||||
async getChannel(id: string): Promise<Channel> {
|
||||
throwIfMissing({ id });
|
||||
const response = await this.actions.browse(id);
|
||||
const response = await this.actions.execute('/browse', { browseId: id });
|
||||
return new Channel(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves notifications.
|
||||
*/
|
||||
async getNotifications() {
|
||||
const response = await this.actions.notifications('get_notification_menu');
|
||||
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() {
|
||||
const response = await this.actions.notifications('get_unseen_count');
|
||||
return response.data.unseenCount;
|
||||
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) {
|
||||
async getPlaylist(id: string): Promise<Playlist> {
|
||||
throwIfMissing({ id });
|
||||
const response = await this.actions.browse(`VL${id.replace(/VL/g, '')}`);
|
||||
|
||||
if (!id.startsWith('VL')) {
|
||||
id = `VL${id}`;
|
||||
}
|
||||
|
||||
const response = await this.actions.execute('/browse', { browseId: id });
|
||||
return new Playlist(this.actions, response.data);
|
||||
}
|
||||
|
||||
@@ -229,27 +245,44 @@ class Innertube {
|
||||
* 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 = {}) {
|
||||
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) {
|
||||
async download(video_id: string, options?: DownloadOptions): Promise<ReadableStream<Uint8Array>> {
|
||||
const info = await this.getBasicInfo(video_id, options?.client);
|
||||
return info.download(options);
|
||||
}
|
||||
|
||||
call(endpoint: NavigationEndpoint, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
|
||||
call(endpoint: NavigationEndpoint, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
|
||||
/**
|
||||
* 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 as NavigationEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to call an endpoint without having to use {@link Actions}.
|
||||
* @param endpoint -The endpoint to call.
|
||||
* @param args - Call arguments.
|
||||
*/
|
||||
call(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise<ParsedResponse>;
|
||||
call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise<ActionsResponse>;
|
||||
call(endpoint: NavigationEndpoint, args?: object): Promise<ActionsResponse | ParsedResponse> {
|
||||
return endpoint.callTest(this.actions, args);
|
||||
return endpoint.call(this.actions, args);
|
||||
}
|
||||
}
|
||||
|
||||
export default Innertube;
|
||||
export default Innertube;
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import Proto from '../proto/index';
|
||||
import Actions from './Actions';
|
||||
import type Actions from './Actions';
|
||||
import type { ActionsResponse } from './Actions';
|
||||
|
||||
import Analytics from '../parser/youtube/Analytics';
|
||||
import TimeWatched from '../parser/youtube/TimeWatched';
|
||||
import AccountInfo from '../parser/youtube/AccountInfo';
|
||||
import Settings from '../parser/youtube/Settings';
|
||||
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
|
||||
class AccountManager {
|
||||
#actions;
|
||||
channel;
|
||||
#actions: Actions;
|
||||
|
||||
channel: {
|
||||
editName: (new_name: string) => Promise<ActionsResponse>;
|
||||
editDescription: (new_description: string) => Promise<ActionsResponse>;
|
||||
getBasicAnalytics: () => Promise<Analytics>;
|
||||
};
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
@@ -16,13 +24,30 @@ class AccountManager {
|
||||
this.channel = {
|
||||
/**
|
||||
* Edits channel name.
|
||||
* @param new_name - The new channel name.
|
||||
*/
|
||||
editName: (new_name: string) => this.#actions.channel('channel/edit_name', { new_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) => this.#actions.channel('channel/edit_description', { 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.
|
||||
*/
|
||||
@@ -33,7 +58,10 @@ class AccountManager {
|
||||
/**
|
||||
* Retrieves channel info.
|
||||
*/
|
||||
async getInfo() {
|
||||
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);
|
||||
}
|
||||
@@ -41,7 +69,7 @@ class AccountManager {
|
||||
/**
|
||||
* Retrieves time watched statistics.
|
||||
*/
|
||||
async getTimeWatched() {
|
||||
async getTimeWatched(): Promise<TimeWatched> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'SPtime_watched',
|
||||
client: 'ANDROID'
|
||||
@@ -53,7 +81,7 @@ class AccountManager {
|
||||
/**
|
||||
* Opens YouTube settings.
|
||||
*/
|
||||
async getSettings() {
|
||||
async getSettings(): Promise<Settings> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'SPaccount_overview'
|
||||
});
|
||||
@@ -64,11 +92,16 @@ class AccountManager {
|
||||
/**
|
||||
* Retrieves basic channel analytics.
|
||||
*/
|
||||
async getAnalytics() {
|
||||
async getAnalytics(): Promise<Analytics> {
|
||||
const info = await this.getInfo();
|
||||
|
||||
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
|
||||
const response = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
|
||||
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'FEanalytics_screen',
|
||||
client: 'ANDROID',
|
||||
params
|
||||
});
|
||||
|
||||
return new Analytics(response);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,31 @@
|
||||
import Proto from '../proto/index';
|
||||
import Session from './Session';
|
||||
|
||||
import Parser, { ParsedResponse } from '../parser/index';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import type Session from './Session';
|
||||
|
||||
import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
|
||||
|
||||
export interface BrowseArgs {
|
||||
params?: string | null;
|
||||
is_ytm?: boolean;
|
||||
is_ctoken?: boolean;
|
||||
form_data?: {};
|
||||
client?: string;
|
||||
}
|
||||
|
||||
export interface EngageArgs {
|
||||
video_id?: string;
|
||||
channel_id?: string;
|
||||
comment_id?: string;
|
||||
comment_action?: string;
|
||||
params?: string;
|
||||
text?: string;
|
||||
target_language?: string;
|
||||
}
|
||||
|
||||
export interface AccountArgs {
|
||||
new_value?: string | boolean; // TODO: is this correct?
|
||||
setting_item_id?: string;
|
||||
client?: string;
|
||||
}
|
||||
|
||||
export interface SearchArgs {
|
||||
query?: string,
|
||||
options?: {
|
||||
period?: string,
|
||||
duration?: string,
|
||||
order?: string
|
||||
},
|
||||
client?: string,
|
||||
ctoken?: string,
|
||||
params?: string
|
||||
filters?: any // TODO: what is this type??
|
||||
}
|
||||
|
||||
export interface AxioslikeResponse {
|
||||
export interface ApiResponse {
|
||||
success: boolean;
|
||||
status_code: number;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export type ActionsResponse = Promise<AxioslikeResponse>;
|
||||
export type ActionsResponse = Promise<ApiResponse>;
|
||||
|
||||
class Actions {
|
||||
#session;
|
||||
#session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
get 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) {
|
||||
async #wrap(response: Response): Promise<ApiResponse> {
|
||||
return {
|
||||
success: response.ok,
|
||||
status_code: response.status,
|
||||
@@ -72,552 +33,14 @@ class Actions {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers `/browse` endpoint, mostly used to access
|
||||
* YouTube's sections such as the home feed, etc
|
||||
* and sometimes to retrieve continuations.
|
||||
*
|
||||
* @param id - browseId or a continuation token
|
||||
* @param args - additional arguments
|
||||
*/
|
||||
async browse(id: string, args: BrowseArgs = {}) {
|
||||
if (this.#needsLogin(id) && !this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
if (args.params)
|
||||
data.params = args.params;
|
||||
|
||||
if (args.is_ctoken) {
|
||||
data.continuation = id;
|
||||
} else {
|
||||
data.browseId = id;
|
||||
}
|
||||
|
||||
if (args.form_data) {
|
||||
data.formData = args.form_data;
|
||||
}
|
||||
|
||||
if (args.client) {
|
||||
data.client = args.client;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/browse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used to perform direct interactions
|
||||
* on YouTube.
|
||||
*/
|
||||
async engage(action: string, args: EngageArgs = {}) {
|
||||
if (!this.#session.logged_in && !args.hasOwnProperty('text'))
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'like/like':
|
||||
case 'like/dislike':
|
||||
case 'like/removelike':
|
||||
if (!hasKeys(args, 'video_id'))
|
||||
throw new MissingParamError('Arguments lacks video_id');
|
||||
|
||||
data.target = {};
|
||||
data.target.videoId = args.video_id;
|
||||
|
||||
if (args.params) {
|
||||
data.params = args.params;
|
||||
}
|
||||
break;
|
||||
case 'subscription/subscribe':
|
||||
case 'subscription/unsubscribe':
|
||||
if (!hasKeys(args, 'channel_id'))
|
||||
throw new MissingParamError('Arguments lacks channel_id');
|
||||
|
||||
data.channelIds = [ args.channel_id ];
|
||||
data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data.commentText = args.text;
|
||||
|
||||
if (!hasKeys(args, 'video_id'))
|
||||
throw new MissingParamError('Arguments lacks video_id');
|
||||
|
||||
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
|
||||
break;
|
||||
case 'comment/create_comment_reply':
|
||||
if (!hasKeys(args, 'comment_id', 'video_id', 'text'))
|
||||
throw new MissingParamError('Arguments lacks comment_id, video_id or text');
|
||||
|
||||
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
|
||||
data.commentText = args.text;
|
||||
break;
|
||||
case 'comment/perform_comment_action':
|
||||
const target_action = (() => {
|
||||
switch (args.comment_action) {
|
||||
case 'like':
|
||||
return Proto.encodeCommentActionParams(5, args);
|
||||
case 'dislike':
|
||||
return Proto.encodeCommentActionParams(4, args);
|
||||
case 'translate':
|
||||
return Proto.encodeCommentActionParams(22, args);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
})();
|
||||
data.actions = [ target_action ];
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints related to account management.
|
||||
*/
|
||||
async account(action: string, args: AccountArgs = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
switch (action) {
|
||||
case 'account/set_setting':
|
||||
data.newValue = {
|
||||
boolValue: args.new_value
|
||||
};
|
||||
data.settingItemId = args.setting_item_id;
|
||||
break;
|
||||
case 'account/accounts_list':
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used for search.
|
||||
*/
|
||||
async search(args: SearchArgs = {}) {
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
if (args.query) {
|
||||
data.query = args.query;
|
||||
}
|
||||
|
||||
if (args.ctoken) {
|
||||
data.continuation = args.ctoken;
|
||||
}
|
||||
|
||||
if (args.params) {
|
||||
data.params = args.params;
|
||||
}
|
||||
|
||||
if (args.filters) {
|
||||
if (args.client == 'YTMUSIC' && args.filters?.type && args.filters.type !== 'all') {
|
||||
data.params = Proto.encodeMusicSearchFilters(args.filters);
|
||||
} else {
|
||||
data.params = Proto.encodeSearchFilters(args.filters);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used fo Shorts' sound search.
|
||||
*/
|
||||
async searchSound(args: { query: string; }) {
|
||||
const data = {
|
||||
query: args.query,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/sfv/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel management endpoints.
|
||||
*/
|
||||
async channel(action: string, args: { new_name?: string; new_description?: string; client?: string; } = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = { client: args.client || 'ANDROID' };
|
||||
|
||||
switch (action) {
|
||||
case 'channel/edit_name':
|
||||
data.givenName = args.new_name;
|
||||
break;
|
||||
case 'channel/edit_description':
|
||||
data.description = args.new_description;
|
||||
break;
|
||||
case 'channel/get_profile_editor':
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used for playlist management.
|
||||
*/
|
||||
async playlist(action: string, args: {
|
||||
title?: string;
|
||||
ids?: string[];
|
||||
playlist_id?: string;
|
||||
action?: string;
|
||||
} = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'playlist/create':
|
||||
data.title = args.title;
|
||||
data.videoIds = args.ids;
|
||||
break;
|
||||
case 'playlist/delete':
|
||||
data.playlistId = args.playlist_id;
|
||||
break;
|
||||
case 'browse/edit_playlist':
|
||||
if (!hasKeys(args, 'ids'))
|
||||
throw new MissingParamError('Arguments lacks ids');
|
||||
data.playlistId = args.playlist_id;
|
||||
data.actions = args.ids.map((id) => {
|
||||
switch (args.action) {
|
||||
case 'ACTION_ADD_VIDEO':
|
||||
return {
|
||||
action: args.action,
|
||||
addedVideoId: id
|
||||
};
|
||||
case 'ACTION_REMOVE_VIDEO':
|
||||
return {
|
||||
action: args.action,
|
||||
setVideoId: id
|
||||
};
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used for notifications management.
|
||||
*/
|
||||
async notifications(action: string, args: {
|
||||
pref?: string;
|
||||
channel_id?: string;
|
||||
ctoken?: string;
|
||||
params?: string
|
||||
} = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'modify_channel_preference':
|
||||
if (!hasKeys(args, 'channel_id', 'pref'))
|
||||
throw new MissingParamError('Arguments lacks channel_id or pref');
|
||||
const pref_types = {
|
||||
PERSONALIZED: 1,
|
||||
ALL: 2,
|
||||
NONE: 3
|
||||
};
|
||||
if (!Object.keys(pref_types).includes(args.pref.toUpperCase()))
|
||||
throw new InnertubeError('Invalid preference type', args.pref);
|
||||
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase() as keyof typeof pref_types]);
|
||||
break;
|
||||
case 'get_notification_menu':
|
||||
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
|
||||
if (args.ctoken)
|
||||
data.ctoken = args.ctoken;
|
||||
break;
|
||||
case 'record_interactions':
|
||||
data.serializedRecordNotificationInteractionsRequest = args.params;
|
||||
break;
|
||||
case 'get_unseen_count':
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/notification/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers livechat endpoints.
|
||||
*/
|
||||
async livechat(action: string, args: {
|
||||
text?: string;
|
||||
video_id?: string;
|
||||
channel_id?: string;
|
||||
ctoken?: string;
|
||||
params?: string;
|
||||
client?: string;
|
||||
} = {}) {
|
||||
// TODO: should client be required?
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
switch (action) {
|
||||
case 'live_chat/get_live_chat':
|
||||
case 'live_chat/get_live_chat_replay':
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
case 'live_chat/send_message':
|
||||
if (!hasKeys(args, 'channel_id', 'video_id', 'text'))
|
||||
throw new MissingParamError('Arguments lacks channel_id, video_id or text');
|
||||
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
|
||||
data.clientMessageId = uuidv4();
|
||||
data.richMessage = {
|
||||
textSegments: [ {
|
||||
text: args.text
|
||||
} ]
|
||||
};
|
||||
break;
|
||||
case 'live_chat/get_item_context_menu':
|
||||
data.params = args.params;
|
||||
break;
|
||||
case 'live_chat/moderate':
|
||||
data.params = args.params;
|
||||
break;
|
||||
case 'updated_metadata':
|
||||
data.videoId = args.video_id;
|
||||
if (args.ctoken)
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used to retrieve video thumbnails.
|
||||
*/
|
||||
async thumbnails(args: { video_id: string; }) {
|
||||
const data = {
|
||||
client: 'ANDROID',
|
||||
videoId: args.video_id
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/thumbnails', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place Autocomplete endpoint, found it in the APK but
|
||||
* doesn't seem to be used anywhere on YouTube (maybe for ads?).
|
||||
*
|
||||
* Ex:
|
||||
* ```js
|
||||
* const places = await session.actions.geo('place_autocomplete', { input: 'San diego cafe' });
|
||||
* console.info(places.data);
|
||||
* ```
|
||||
*/
|
||||
async geo(action: string, args: { input: string; }) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data = {
|
||||
input: args.input,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch(`/geo/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used to report content.
|
||||
*/
|
||||
async flag(action: string, args: { action: string; params?: string; }) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'flag/flag':
|
||||
data.action = args.action;
|
||||
break;
|
||||
case 'flag/get_form':
|
||||
data.params = args.params;
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers specific YouTube Music endpoints.
|
||||
*/
|
||||
async music(action: string, args: { input?: string; }) {
|
||||
const data = {
|
||||
input: args.input || '',
|
||||
client: 'YTMUSIC'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch(`/music/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mostly used for pagination and specific operations.
|
||||
*/
|
||||
async next(args: { video_id?: string; ctoken?: string; client?: string; playlist_id?: string; params?: string } = {}) {
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
if (args.ctoken) {
|
||||
data.continuation = args.ctoken;
|
||||
}
|
||||
|
||||
if (args.video_id) {
|
||||
data.videoId = args.video_id;
|
||||
}
|
||||
|
||||
if (args.playlist_id) {
|
||||
data.playlistId = args.playlist_id;
|
||||
}
|
||||
|
||||
if (args.params) {
|
||||
data.params = args.params;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/next', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise<ActionsResponse> {
|
||||
const data: Record<string, any> = {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
@@ -626,7 +49,7 @@ class Actions {
|
||||
referer: 'https://www.youtube.com',
|
||||
currentUrl: `/watch?v=${id}`,
|
||||
autonavState: 'STATE_OFF',
|
||||
signatureTimestamp: this.#session.player.sts,
|
||||
signatureTimestamp: this.#session.player?.sts || 0,
|
||||
autoCaptionsDefaultOn: false,
|
||||
html5Preference: 'HTML5_PREF_WANTS',
|
||||
lactMilliseconds: '-1'
|
||||
@@ -661,33 +84,13 @@ class Actions {
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used to retrieve user mention suggestions.
|
||||
*/
|
||||
async getUserMentionSuggestions(args: { input: string; }) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data = {
|
||||
input: args.input,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/get_user_mention_suggestions', {
|
||||
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 }) {
|
||||
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');
|
||||
@@ -706,20 +109,20 @@ class Actions {
|
||||
|
||||
/**
|
||||
* Executes an API call.
|
||||
* @param action - endpoint
|
||||
* @param args - call arguments
|
||||
* @param action - The endpoint to call.
|
||||
* @param args - Call arguments
|
||||
*/
|
||||
async execute(action: string, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }) : Promise<ParsedResponse>;
|
||||
async execute(action: string, args: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise<ActionsResponse>;
|
||||
async execute(action: string, args: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse | ActionsResponse> {
|
||||
async execute(action: string, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise<ActionsResponse>;
|
||||
async execute(action: string, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse | ActionsResponse> {
|
||||
let data;
|
||||
|
||||
if (!args.protobuf) {
|
||||
if (args && !args.protobuf) {
|
||||
data = { ...args };
|
||||
|
||||
if (Reflect.has(data, 'browseId')) {
|
||||
if (this.#needsLogin(data.browseId) && !this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'override_endpoint'))
|
||||
@@ -755,23 +158,23 @@ class Actions {
|
||||
if (data?.client === 'YTMUSIC') {
|
||||
data.isAudioOnly = true;
|
||||
}
|
||||
} else {
|
||||
} else if (args) {
|
||||
data = args.serialized_data;
|
||||
}
|
||||
|
||||
const endpoint = Reflect.has(args, 'override_endpoint') ? args.override_endpoint : action;
|
||||
const endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : action;
|
||||
|
||||
const response = await this.#session.http.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: args.protobuf ? data : JSON.stringify(data),
|
||||
body: args?.protobuf ? data : JSON.stringify((data || {})),
|
||||
headers: {
|
||||
'Content-Type': args.protobuf ?
|
||||
'Content-Type': args?.protobuf ?
|
||||
'application/x-protobuf' :
|
||||
'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (args.parse) {
|
||||
if (args?.parse) {
|
||||
return Parser.parseResponse(await response.json());
|
||||
}
|
||||
|
||||
@@ -784,6 +187,8 @@ class Actions {
|
||||
'FEhistory',
|
||||
'FEsubscriptions',
|
||||
'FEmusic_listening_review',
|
||||
'FEmusic_library_landing',
|
||||
'SPaccount_overview',
|
||||
'SPaccount_notifications',
|
||||
'SPaccount_privacy',
|
||||
'SPtime_watched'
|
||||
@@ -791,5 +196,4 @@ class Actions {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: maybe do this inferrance in a more elegant way
|
||||
export default Actions;
|
||||
@@ -1,41 +1,40 @@
|
||||
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers';
|
||||
import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index';
|
||||
import { Memo, ObservedArray } from '../parser/helpers';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
import { concatMemos, InnertubeError } from '../utils/Utils';
|
||||
import type Actions from './Actions';
|
||||
|
||||
import Post from '../parser/classes/Post';
|
||||
import BackstagePost from '../parser/classes/BackstagePost';
|
||||
|
||||
import Channel from '../parser/classes/Channel';
|
||||
import CompactVideo from '../parser/classes/CompactVideo';
|
||||
|
||||
import GridChannel from '../parser/classes/GridChannel';
|
||||
import GridPlaylist from '../parser/classes/GridPlaylist';
|
||||
import GridVideo from '../parser/classes/GridVideo';
|
||||
|
||||
import Playlist from '../parser/classes/Playlist';
|
||||
import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo';
|
||||
import PlaylistVideo from '../parser/classes/PlaylistVideo';
|
||||
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import Post from '../parser/classes/Post';
|
||||
import ReelItem from '../parser/classes/ReelItem';
|
||||
import ReelShelf from '../parser/classes/ReelShelf';
|
||||
import RichShelf from '../parser/classes/RichShelf';
|
||||
import Shelf from '../parser/classes/Shelf';
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import Video from '../parser/classes/Video';
|
||||
|
||||
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction';
|
||||
import ContinuationItem from '../parser/classes/ContinuationItem';
|
||||
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults';
|
||||
import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults';
|
||||
import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo';
|
||||
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction';
|
||||
import ContinuationItem from '../parser/classes/ContinuationItem';
|
||||
|
||||
import Video from '../parser/classes/Video';
|
||||
import type MusicQueue from '../parser/classes/MusicQueue';
|
||||
import type RichGrid from '../parser/classes/RichGrid';
|
||||
import type SectionList from '../parser/classes/SectionList';
|
||||
|
||||
// TODO: add a way subdivide into sections and return subfeeds?
|
||||
class Feed {
|
||||
#page: ParsedResponse;
|
||||
#continuation?: ObservedArray<ContinuationItem>;
|
||||
#actions;
|
||||
#memo;
|
||||
#actions: Actions;
|
||||
#memo: Memo;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
if (data.on_response_received_actions || data.on_response_received_endpoints || already_parsed) {
|
||||
@@ -44,16 +43,14 @@ class Feed {
|
||||
this.#page = Parser.parseResponse(data);
|
||||
}
|
||||
|
||||
// Xxx: this can be extremely confusing — maybe refactor?
|
||||
const memo =
|
||||
this.#page.on_response_received_commands ?
|
||||
this.#page.on_response_received_commands_memo :
|
||||
this.#page.on_response_received_endpoints ?
|
||||
this.#page.on_response_received_endpoints_memo :
|
||||
this.#page.contents ?
|
||||
this.#page.contents_memo :
|
||||
this.#page.on_response_received_actions ?
|
||||
this.#page.on_response_received_actions_memo : undefined;
|
||||
const memo = concatMemos(
|
||||
this.#page.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');
|
||||
@@ -66,9 +63,10 @@ class Feed {
|
||||
* Get all videos on a given page via memo
|
||||
*/
|
||||
static getVideosFromMemo(memo: Memo) {
|
||||
return memo.getType<Video | GridVideo | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
|
||||
return memo.getType<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
|
||||
Video,
|
||||
GridVideo,
|
||||
ReelItem,
|
||||
CompactVideo,
|
||||
PlaylistVideo,
|
||||
PlaylistPanelVideo,
|
||||
@@ -118,7 +116,7 @@ class Feed {
|
||||
/**
|
||||
* Returns contents from the page.
|
||||
*/
|
||||
get contents() {
|
||||
get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand {
|
||||
const tab_content = this.#memo.getType(Tab)?.[0]?.content;
|
||||
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)?.[0];
|
||||
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)?.[0];
|
||||
@@ -137,13 +135,13 @@ class Feed {
|
||||
* Finds shelf by title.
|
||||
*/
|
||||
getShelf(title: string) {
|
||||
return this.shelves.find((shelf) => shelf.title.toString() === title);
|
||||
return this.shelves.get({ title });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns secondary contents from the page.
|
||||
*/
|
||||
get secondary_contents() {
|
||||
get secondary_contents(): SuperParsedResult<YTNode> | undefined {
|
||||
if (!this.#page.contents.is_node)
|
||||
return undefined;
|
||||
|
||||
@@ -155,21 +153,21 @@ class Feed {
|
||||
return node.secondary_contents;
|
||||
}
|
||||
|
||||
get actions() {
|
||||
get actions(): Actions {
|
||||
return this.#actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original page data
|
||||
*/
|
||||
get page() {
|
||||
get page(): ParsedResponse {
|
||||
return this.#page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the feed has continuation.
|
||||
*/
|
||||
get has_continuation() {
|
||||
get has_continuation(): boolean {
|
||||
return (this.#memo.get('ContinuationItem') || []).length > 0;
|
||||
}
|
||||
|
||||
@@ -183,7 +181,7 @@ class Feed {
|
||||
if (this.#continuation.length === 0)
|
||||
throw new InnertubeError('There are no continuations');
|
||||
|
||||
const response = await this.#continuation[0].endpoint.call(this.#actions, undefined, true);
|
||||
const response = await this.#continuation[0].endpoint.call(this.#actions, { parse: true });
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -197,7 +195,7 @@ class Feed {
|
||||
/**
|
||||
* Retrieves next batch of contents and returns a new {@link Feed} object.
|
||||
*/
|
||||
async getContinuation() {
|
||||
async getContinuation(): Promise<Feed> {
|
||||
const continuation_data = await this.getContinuationData();
|
||||
return new Feed(this.actions, continuation_data, true);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import ChipCloudChip from '../parser/classes/ChipCloudChip';
|
||||
import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar';
|
||||
import { ObservedArray } from '../parser/helpers';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
import Feed from './Feed';
|
||||
|
||||
import type { ObservedArray } from '../parser/helpers';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import type Actions from './Actions';
|
||||
|
||||
class FilterableFeed extends Feed {
|
||||
#chips?: ObservedArray<ChipCloudChip>;
|
||||
|
||||
@@ -13,9 +14,9 @@ class FilterableFeed extends Feed {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filters for the feed
|
||||
* Returns the filter chips.
|
||||
*/
|
||||
get filter_chips() {
|
||||
get filter_chips(): ObservedArray<ChipCloudChip> {
|
||||
if (this.#chips)
|
||||
return this.#chips || [];
|
||||
|
||||
@@ -30,21 +31,22 @@ class FilterableFeed extends Feed {
|
||||
return this.#chips || [];
|
||||
}
|
||||
|
||||
get filters() {
|
||||
/**
|
||||
* 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) {
|
||||
async getFilteredFeed(filter: string | ChipCloudChip): Promise<Feed> {
|
||||
let target_filter: ChipCloudChip | undefined;
|
||||
|
||||
if (typeof filter === 'string') {
|
||||
if (!this.filters.includes(filter))
|
||||
throw new InnertubeError('Filter not found', {
|
||||
available_filters: this.filters
|
||||
});
|
||||
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;
|
||||
@@ -54,10 +56,12 @@ class FilterableFeed extends Feed {
|
||||
|
||||
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, undefined, true);
|
||||
const response = await target_filter.endpoint?.call(this.actions, { parse: true });
|
||||
|
||||
return new Feed(this.actions, response, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Proto from '../proto';
|
||||
import type Actions from './Actions';
|
||||
import type { ApiResponse } from './Actions';
|
||||
import { throwIfMissing } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
|
||||
class InteractionManager {
|
||||
#actions;
|
||||
#actions: Actions;
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
@@ -10,55 +12,119 @@ class InteractionManager {
|
||||
|
||||
/**
|
||||
* Likes a given video.
|
||||
* @param video_id - The video ID
|
||||
*/
|
||||
async like(video_id: string) {
|
||||
async like(video_id: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/like', { 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) {
|
||||
async dislike(video_id: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/dislike', { 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 removeLike(video_id: string) {
|
||||
async removeRating(video_id: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/removelike', { 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) {
|
||||
async subscribe(channel_id: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ channel_id });
|
||||
const action = await this.#actions.engage('subscription/subscribe', { 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) {
|
||||
async unsubscribe(channel_id: string): Promise<ApiResponse>{
|
||||
throwIfMissing({ channel_id });
|
||||
const action = await this.#actions.engage('subscription/unsubscribe', { 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) {
|
||||
async comment(video_id: string, text: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ video_id, text });
|
||||
const action = await this.#actions.engage('comment/create_comment', { 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;
|
||||
}
|
||||
|
||||
@@ -71,12 +137,11 @@ class InteractionManager {
|
||||
async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) {
|
||||
throwIfMissing({ text, target_language });
|
||||
|
||||
const response = await await this.#actions.engage('comment/perform_comment_action', {
|
||||
video_id: args.video_id,
|
||||
comment_id: args.comment_id,
|
||||
target_language: target_language,
|
||||
comment_action: 'translate',
|
||||
text
|
||||
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;
|
||||
@@ -92,10 +157,29 @@ class InteractionManager {
|
||||
/**
|
||||
* 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') {
|
||||
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE'): Promise<ApiResponse> {
|
||||
throwIfMissing({ channel_id, type });
|
||||
const action = await this.#actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' });
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import Session from './Session';
|
||||
|
||||
import TrackInfo from '../parser/ytmusic/TrackInfo';
|
||||
import Search from '../parser/ytmusic/Search';
|
||||
import HomeFeed from '../parser/ytmusic/HomeFeed';
|
||||
import Explore from '../parser/ytmusic/Explore';
|
||||
import Library from '../parser/ytmusic/Library';
|
||||
import Artist from '../parser/ytmusic/Artist';
|
||||
import Album from '../parser/ytmusic/Album';
|
||||
import Artist from '../parser/ytmusic/Artist';
|
||||
import Explore from '../parser/ytmusic/Explore';
|
||||
import HomeFeed from '../parser/ytmusic/HomeFeed';
|
||||
import Library from '../parser/ytmusic/Library';
|
||||
import Playlist from '../parser/ytmusic/Playlist';
|
||||
import Recap from '../parser/ytmusic/Recap';
|
||||
import Search from '../parser/ytmusic/Search';
|
||||
import TrackInfo from '../parser/ytmusic/TrackInfo';
|
||||
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import Tabbed from '../parser/classes/Tabbed';
|
||||
import SingleColumnMusicWatchNextResults from '../parser/classes/SingleColumnMusicWatchNextResults';
|
||||
import WatchNextTabbedResults from '../parser/classes/WatchNextTabbedResults';
|
||||
import SectionList from '../parser/classes/SectionList';
|
||||
|
||||
import Message from '../parser/classes/Message';
|
||||
import MusicQueue from '../parser/classes/MusicQueue';
|
||||
import PlaylistPanel from '../parser/classes/PlaylistPanel';
|
||||
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf';
|
||||
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf';
|
||||
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection';
|
||||
import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo';
|
||||
import Message from '../parser/classes/Message';
|
||||
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf';
|
||||
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf';
|
||||
import MusicQueue from '../parser/classes/MusicQueue';
|
||||
import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem';
|
||||
import PlaylistPanel from '../parser/classes/PlaylistPanel';
|
||||
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection';
|
||||
import SectionList from '../parser/classes/SectionList';
|
||||
import Tab from '../parser/classes/Tab';
|
||||
|
||||
import { observe, ObservedArray, YTNode } from '../parser/helpers';
|
||||
import { InnertubeError, throwIfMissing, generateRandomString } from '../utils/Utils';
|
||||
import { observe } from '../parser/helpers';
|
||||
import Proto from '../proto';
|
||||
import { generateRandomString, InnertubeError, throwIfMissing } from '../utils/Utils';
|
||||
|
||||
import type { ObservedArray, YTNode } from '../parser/helpers';
|
||||
import type Actions from './Actions';
|
||||
import type Session from './Session';
|
||||
|
||||
class Music {
|
||||
#session;
|
||||
#actions;
|
||||
#session: Session;
|
||||
#actions: Actions;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
@@ -39,7 +39,7 @@ class Music {
|
||||
|
||||
/**
|
||||
* Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
|
||||
* @param target - video id or a list item.
|
||||
* @param target - Video id or a list item.
|
||||
*/
|
||||
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
|
||||
if (target instanceof MusicTwoRowItem) {
|
||||
@@ -51,7 +51,7 @@ class Music {
|
||||
throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target);
|
||||
}
|
||||
|
||||
async #fetchInfoFromVideoId(video_id: string) {
|
||||
async #fetchInfoFromVideoId(video_id: string): Promise<TrackInfo> {
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = this.#actions.execute('/player', {
|
||||
@@ -60,7 +60,7 @@ class Music {
|
||||
videoId: video_id,
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: this.#session.player.sts
|
||||
signatureTimestamp: this.#session.player?.sts || 0
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -74,7 +74,7 @@ class Music {
|
||||
return new TrackInfo(response, this.#actions, cpn);
|
||||
}
|
||||
|
||||
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined) {
|
||||
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): Promise<TrackInfo> {
|
||||
if (!list_item)
|
||||
throw new InnertubeError('List item cannot be undefined');
|
||||
|
||||
@@ -83,17 +83,17 @@ class Music {
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = list_item.endpoint.callTest(this.#actions, {
|
||||
const initial_info = list_item.endpoint.call(this.#actions, {
|
||||
cpn,
|
||||
client: 'YTMUSIC',
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: this.#session.player.sts
|
||||
signatureTimestamp: this.#session.player?.sts || 0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const continuation = list_item.endpoint.callTest(this.#actions, {
|
||||
const continuation = list_item.endpoint.call(this.#actions, {
|
||||
client: 'YTMUSIC',
|
||||
enablePersistentPlaylistPanel: true,
|
||||
override_endpoint: '/next'
|
||||
@@ -105,12 +105,26 @@ class Music {
|
||||
|
||||
/**
|
||||
* 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 response = await this.#actions.search({ query, filters, client: 'YTMUSIC' });
|
||||
|
||||
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, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' });
|
||||
}
|
||||
|
||||
@@ -118,7 +132,11 @@ class Music {
|
||||
* Retrieves the home feed.
|
||||
*/
|
||||
async getHomeFeed(): Promise<HomeFeed> {
|
||||
const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' });
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: 'FEmusic_home'
|
||||
});
|
||||
|
||||
return new HomeFeed(response, this.#actions);
|
||||
}
|
||||
|
||||
@@ -126,20 +144,30 @@ class Music {
|
||||
* Retrieves the Explore feed.
|
||||
*/
|
||||
async getExplore(): Promise<Explore> {
|
||||
const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' });
|
||||
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.
|
||||
* Retrieves the library.
|
||||
*/
|
||||
getLibrary() {
|
||||
return new Library(this.#actions);
|
||||
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 });
|
||||
@@ -147,12 +175,17 @@ class Music {
|
||||
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.browse(artist_id, { client: 'YTMUSIC' });
|
||||
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 });
|
||||
@@ -160,12 +193,17 @@ class Music {
|
||||
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.browse(album_id, { client: 'YTMUSIC' });
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: album_id
|
||||
});
|
||||
|
||||
return new Album(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves playlist.
|
||||
* @param playlist_id - The playlist id.
|
||||
*/
|
||||
async getPlaylist(playlist_id: string): Promise<Playlist> {
|
||||
throwIfMissing({ playlist_id });
|
||||
@@ -174,12 +212,18 @@ class Music {
|
||||
playlist_id = `VL${playlist_id}`;
|
||||
}
|
||||
|
||||
const response = await this.#actions.browse(playlist_id, { client: 'YTMUSIC' });
|
||||
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 });
|
||||
@@ -190,13 +234,9 @@ class Music {
|
||||
parse: true
|
||||
});
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
.as(Tabbed).contents.item()
|
||||
.as(WatchNextTabbedResults)
|
||||
.tabs.array().as(Tab);
|
||||
const tabs = data.contents_memo.getType(Tab);
|
||||
|
||||
const tab = tabs.get({ title: 'Up next' });
|
||||
const tab = tabs?.[0];
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
@@ -214,7 +254,7 @@ class Music {
|
||||
if (!automix_preview_video)
|
||||
throw new InnertubeError('Automix item not found');
|
||||
|
||||
const page = await automix_preview_video.playlist_video?.endpoint.callTest(this.#actions, {
|
||||
const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
|
||||
videoId: video_id,
|
||||
client: 'YTMUSIC',
|
||||
parse: true
|
||||
@@ -231,6 +271,7 @@ class Music {
|
||||
|
||||
/**
|
||||
* Retrieves related content.
|
||||
* @param video_id - The video id.
|
||||
*/
|
||||
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
|
||||
throwIfMissing({ video_id });
|
||||
@@ -241,29 +282,23 @@ class Music {
|
||||
parse: true
|
||||
});
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
.as(Tabbed).contents.item()
|
||||
.as(WatchNextTabbedResults)
|
||||
.tabs.array().as(Tab);
|
||||
const tabs = data.contents_memo.getType(Tab);
|
||||
|
||||
const tab = tabs.get({ title: 'Related' });
|
||||
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, 'YTMUSIC', true);
|
||||
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
|
||||
|
||||
if (!page)
|
||||
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
|
||||
|
||||
const shelves = page.contents.item().as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf);
|
||||
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 });
|
||||
@@ -274,26 +309,19 @@ class Music {
|
||||
parse: true
|
||||
});
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
.as(Tabbed).contents.item()
|
||||
.as(WatchNextTabbedResults)
|
||||
.tabs.array().as(Tab);
|
||||
const tabs = data.contents_memo.getType(Tab);
|
||||
|
||||
const tab = tabs.get({ title: 'Lyrics' });
|
||||
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, 'YTMUSIC', true);
|
||||
|
||||
if (!page)
|
||||
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
|
||||
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
|
||||
|
||||
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.array();
|
||||
const section_list = page.contents.item().as(SectionList).contents;
|
||||
return section_list.firstOfType(MusicDescriptionShelf);
|
||||
}
|
||||
|
||||
@@ -311,8 +339,9 @@ class Music {
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions for the given query.
|
||||
* @param query - The query.
|
||||
*/
|
||||
async getSearchSuggestions(query: string) {
|
||||
async getSearchSuggestions(query: string): Promise<ObservedArray<YTNode>> {
|
||||
const response = await this.#actions.execute('/music/get_search_suggestions', {
|
||||
parse: true,
|
||||
input: query,
|
||||
@@ -321,7 +350,7 @@ class Music {
|
||||
|
||||
const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection)?.[0];
|
||||
|
||||
if (!search_suggestions_section.contents.is_array)
|
||||
if (!search_suggestions_section?.contents.is_array)
|
||||
return observe([] as YTNode[]);
|
||||
|
||||
return search_suggestions_section?.contents.array();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Session from './Session';
|
||||
import Constants from '../utils/Constants';
|
||||
import { OAuthError, uuidv4 } from '../utils/Utils';
|
||||
import type Session from './Session';
|
||||
|
||||
export interface Credentials {
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ class OAuth {
|
||||
/**
|
||||
* Starts the auth flow in case no valid credentials are available.
|
||||
*/
|
||||
async init(credentials?: Credentials) {
|
||||
async init(credentials?: Credentials): Promise<void> {
|
||||
this.#credentials = credentials;
|
||||
|
||||
if (this.validateCredentials()) {
|
||||
@@ -55,13 +55,13 @@ class OAuth {
|
||||
}
|
||||
}
|
||||
|
||||
async cacheCredentials() {
|
||||
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() {
|
||||
async #loadCachedCredentials(): Promise<boolean> {
|
||||
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
|
||||
if (!data) return false;
|
||||
|
||||
@@ -82,14 +82,14 @@ class OAuth {
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeCache() {
|
||||
async removeCache(): Promise<void> {
|
||||
await this.#session.cache?.remove('youtubei_oauth_credentials');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server for a user code and verification URL.
|
||||
*/
|
||||
async #getUserCode() {
|
||||
async #getUserCode(): Promise<void> {
|
||||
this.#identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
@@ -117,7 +117,7 @@ class OAuth {
|
||||
/**
|
||||
* Polls the authorization server until access is granted by the user.
|
||||
*/
|
||||
#startPolling(device_code: string) {
|
||||
#startPolling(device_code: string): void {
|
||||
const poller = setInterval(async () => {
|
||||
const data = {
|
||||
...this.#identity,
|
||||
@@ -176,13 +176,13 @@ class OAuth {
|
||||
/**
|
||||
* Refresh access token if the same has expired.
|
||||
*/
|
||||
async refreshIfRequired() {
|
||||
async refreshIfRequired(): Promise<void> {
|
||||
if (this.has_access_token_expired) {
|
||||
await this.#refreshAccessToken();
|
||||
}
|
||||
}
|
||||
|
||||
async #refreshAccessToken() {
|
||||
async #refreshAccessToken(): Promise<void> {
|
||||
if (!this.#credentials) return;
|
||||
this.#identity = await this.#getClientIdentity();
|
||||
|
||||
@@ -215,7 +215,7 @@ class OAuth {
|
||||
});
|
||||
}
|
||||
|
||||
async revokeCredentials() {
|
||||
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), {
|
||||
@@ -226,7 +226,7 @@ class OAuth {
|
||||
/**
|
||||
* Retrieves client identity from YouTube TV.
|
||||
*/
|
||||
async #getClientIdentity() {
|
||||
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();
|
||||
@@ -241,7 +241,6 @@ class OAuth {
|
||||
.replace(/\n/g, '')
|
||||
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
|
||||
|
||||
// TODO: check this.
|
||||
const groups = client_identity?.groups;
|
||||
|
||||
if (!groups)
|
||||
@@ -250,7 +249,7 @@ class OAuth {
|
||||
return groups;
|
||||
}
|
||||
|
||||
get credentials() {
|
||||
get credentials(): Credentials | undefined {
|
||||
return this.#credentials;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
|
||||
import { FetchFunction } from '../utils/HTTPClient';
|
||||
import { getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils';
|
||||
|
||||
import Constants from '../utils/Constants';
|
||||
import UniversalCache from '../utils/Cache';
|
||||
|
||||
// See https://github.com/LuanRT/Jinter
|
||||
// See: https://github.com/LuanRT/Jinter
|
||||
import Jinter from 'jintr';
|
||||
|
||||
import type { FetchFunction } from '../utils/HTTPClient';
|
||||
|
||||
export default class Player {
|
||||
#nsig_sc;
|
||||
#sig_sc;
|
||||
@@ -23,7 +23,7 @@ export default class Player {
|
||||
this.#player_id = player_id;
|
||||
}
|
||||
|
||||
static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch) {
|
||||
static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch): Promise<Player> {
|
||||
const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
|
||||
const res = await fetch(url);
|
||||
|
||||
@@ -66,7 +66,7 @@ export default class Player {
|
||||
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
|
||||
}
|
||||
|
||||
decipher(url?: string, signature_cipher?: string, cipher?: string) {
|
||||
decipher(url?: string, signature_cipher?: string, cipher?: string): string {
|
||||
url = url || signature_cipher || cipher;
|
||||
|
||||
if (!url)
|
||||
@@ -108,7 +108,7 @@ export default class Player {
|
||||
return url_components.toString();
|
||||
}
|
||||
|
||||
static async fromCache(cache: UniversalCache, player_id: string) {
|
||||
static async fromCache(cache: UniversalCache, player_id: string): Promise<Player | null> {
|
||||
const buffer = await cache.get(player_id);
|
||||
|
||||
if (!buffer)
|
||||
@@ -134,13 +134,13 @@ export default class Player {
|
||||
return new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
|
||||
}
|
||||
|
||||
static async fromSource(cache: UniversalCache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) {
|
||||
static async fromSource(cache: UniversalCache | 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?: UniversalCache) {
|
||||
async cache(cache?: UniversalCache): Promise<void> {
|
||||
if (!cache) return;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
@@ -161,22 +161,22 @@ export default class Player {
|
||||
await cache.set(this.#player_id, new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
static extractSigTimestamp(data: string) {
|
||||
static extractSigTimestamp(data: string): number {
|
||||
return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0');
|
||||
}
|
||||
|
||||
static extractSigSourceCode(data: string) {
|
||||
static extractSigSourceCode(data: string): string {
|
||||
const calls = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
const obj_name = calls?.split('.')?.[0]?.replace(';', '');
|
||||
const functions = getStringBetweenStrings(data, `var ${obj_name}=`, '};');
|
||||
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);`;
|
||||
return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`;
|
||||
}
|
||||
|
||||
static extractNSigSourceCode(data: string) {
|
||||
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)
|
||||
@@ -185,23 +185,23 @@ export default class Player {
|
||||
return sc;
|
||||
}
|
||||
|
||||
get url() {
|
||||
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() {
|
||||
get sts(): number {
|
||||
return this.#sig_sc_timestamp;
|
||||
}
|
||||
|
||||
get nsig_sc() {
|
||||
get nsig_sc(): string {
|
||||
return this.#nsig_sc;
|
||||
}
|
||||
|
||||
get sig_sc() {
|
||||
get sig_sc(): string {
|
||||
return this.#sig_sc;
|
||||
}
|
||||
|
||||
static get LIBRARY_VERSION() {
|
||||
static get LIBRARY_VERSION(): number {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type Feed from './Feed';
|
||||
import type Actions from './Actions';
|
||||
import Playlist from '../parser/youtube/Playlist';
|
||||
import Actions from './Actions';
|
||||
import Feed from './Feed';
|
||||
|
||||
import { InnertubeError, throwIfMissing } from '../utils/Utils';
|
||||
|
||||
class PlaylistManager {
|
||||
#actions;
|
||||
#actions: Actions;
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
@@ -13,11 +13,20 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* 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[]) {
|
||||
async create(title: string, video_ids: string[]): Promise<{ success: boolean; status_code: number; playlist_id: string; data: any }> {
|
||||
throwIfMissing({ title, video_ids });
|
||||
|
||||
const response = await this.#actions.execute('/playlist/create', { title, ids: video_ids, parse: false });
|
||||
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,
|
||||
@@ -29,10 +38,14 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* Deletes a given playlist.
|
||||
* @param playlist_id - The playlist ID.
|
||||
*/
|
||||
async delete(playlist_id: string) {
|
||||
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 {
|
||||
@@ -45,10 +58,15 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* 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[]) {
|
||||
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) => ({
|
||||
@@ -66,11 +84,20 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* 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[]) {
|
||||
async removeVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
|
||||
throwIfMissing({ playlist_id, video_ids });
|
||||
|
||||
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
|
||||
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)
|
||||
@@ -115,11 +142,21 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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 });
|
||||
|
||||
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
|
||||
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)
|
||||
@@ -157,7 +194,10 @@ class PlaylistManager {
|
||||
movedSetVideoIdPredecessor: set_video_id_1
|
||||
});
|
||||
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', {
|
||||
...payload,
|
||||
parse: false
|
||||
});
|
||||
|
||||
return {
|
||||
playlist_id,
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import Player from './Player';
|
||||
import Actions from './Actions';
|
||||
import Constants from '../utils/Constants';
|
||||
import UniversalCache from '../utils/Cache';
|
||||
import Constants, { CLIENTS } from '../utils/Constants';
|
||||
import EventEmitterLike from '../utils/EventEmitterLike';
|
||||
import Actions from './Actions';
|
||||
import Player from './Player';
|
||||
|
||||
import HTTPClient, { FetchFunction } from '../utils/HTTPClient';
|
||||
import { DeviceCategory, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
|
||||
import { DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
|
||||
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth';
|
||||
import Proto from '../proto';
|
||||
|
||||
export enum ClientType {
|
||||
WEB = 'WEB',
|
||||
MUSIC = 'WEB_REMIX',
|
||||
ANDROID = 'ANDROID',
|
||||
ANDROID_MUSIC = 'ANDROID_MUSIC'
|
||||
ANDROID_MUSIC = 'ANDROID_MUSIC',
|
||||
ANDROID_CREATOR = 'ANDROID_CREATOR',
|
||||
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
client: {
|
||||
hl: string;
|
||||
gl: string;
|
||||
remoteHost: string;
|
||||
remoteHost?: string;
|
||||
screenDensityFloat: number;
|
||||
screenHeightPoints: number;
|
||||
screenPixelDensity: number;
|
||||
screenWidthPoints: number;
|
||||
visitorData: string;
|
||||
userAgent: string;
|
||||
clientName: string;
|
||||
@@ -32,15 +39,16 @@ export interface Context {
|
||||
clientFormFactor: string;
|
||||
userInterfaceTheme: string;
|
||||
timeZone: string;
|
||||
browserName: string;
|
||||
browserVersion: string;
|
||||
browserName?: string;
|
||||
browserVersion?: string;
|
||||
originalUrl: string;
|
||||
deviceMake: string;
|
||||
deviceModel: string;
|
||||
utcOffsetMinutes: number;
|
||||
};
|
||||
user: {
|
||||
lockedSafetyMode: false;
|
||||
enableSafetyMode: boolean;
|
||||
lockedSafetyMode: boolean;
|
||||
};
|
||||
thirdParty?: {
|
||||
embedUrl: string;
|
||||
@@ -51,30 +59,83 @@ export interface Context {
|
||||
}
|
||||
|
||||
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?: UniversalCache;
|
||||
/**
|
||||
* 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;
|
||||
#key;
|
||||
#context;
|
||||
#player;
|
||||
#api_version: string;
|
||||
#key: string;
|
||||
#context: Context;
|
||||
#account_index: number;
|
||||
#player?: Player;
|
||||
|
||||
oauth;
|
||||
http;
|
||||
logged_in;
|
||||
actions;
|
||||
cache;
|
||||
oauth: OAuth;
|
||||
http: HTTPClient;
|
||||
logged_in: boolean;
|
||||
actions: Actions;
|
||||
cache?: UniversalCache;
|
||||
|
||||
constructor(context: Context, api_key: string, api_version: string, player: Player, cookie?: string, fetch?: FetchFunction, cache?: UniversalCache) {
|
||||
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: UniversalCache) {
|
||||
super();
|
||||
this.#context = context;
|
||||
this.#account_index = account_index;
|
||||
this.#key = api_key;
|
||||
this.#api_version = api_version;
|
||||
this.#player = player;
|
||||
@@ -103,32 +164,69 @@ export default class Session extends EventEmitterLike {
|
||||
}
|
||||
|
||||
static async create(options: SessionOptions = {}) {
|
||||
const { context, api_key, api_version } = await Session.getSessionData(options.lang, options.device_category, options.client_type, options.timezone, options.fetch);
|
||||
return new Session(context, api_key, api_version, await Player.create(options.cache, options.fetch), options.cookie, options.fetch, options.cache);
|
||||
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 = 'en-US',
|
||||
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 = globalThis.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 = globalThis.fetch): Promise<SessionData> {
|
||||
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'accept-language': lang,
|
||||
'accept-language': options.lang || 'en-US',
|
||||
'user-agent': getRandomUserAgent('desktop'),
|
||||
'accept': '*/*',
|
||||
'referer': 'https://www.youtube.com/sw.js',
|
||||
'cookie': `PREF=tz=${tz.replace('/', '.')}`
|
||||
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new SessionError(`Failed to get session data: ${res.status}`);
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new SessionError(`Failed to retrieve session data: ${res.status}`);
|
||||
|
||||
const text = await res.text();
|
||||
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
|
||||
@@ -142,26 +240,31 @@ export default class Session extends EventEmitterLike {
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: device_info[0],
|
||||
gl: device_info[2],
|
||||
gl: options.location || device_info[2],
|
||||
remoteHost: device_info[3],
|
||||
visitorData: data[3],
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 1080,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1920,
|
||||
visitorData: device_info[13],
|
||||
userAgent: device_info[14],
|
||||
clientName: client_name,
|
||||
clientName: options.client_name,
|
||||
clientVersion: device_info[16],
|
||||
osName: device_info[17],
|
||||
osVersion: device_info[18],
|
||||
platform: device_category.toUpperCase(),
|
||||
platform: options.device_category.toUpperCase(),
|
||||
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
|
||||
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
|
||||
timeZone: device_info[79],
|
||||
timeZone: device_info[79] || options.time_zone,
|
||||
browserName: device_info[86],
|
||||
browserVersion: device_info[87],
|
||||
originalUrl: Constants.URLS.API.BASE,
|
||||
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: {
|
||||
@@ -172,6 +275,52 @@ export default class Session extends EventEmitterLike {
|
||||
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);
|
||||
@@ -203,9 +352,12 @@ export default class Session extends EventEmitterLike {
|
||||
});
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
/**
|
||||
* Signs out of the current account and revokes the credentials.
|
||||
*/
|
||||
async signOut(): Promise<Response | undefined> {
|
||||
if (!this.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const response = await this.oauth.revokeCredentials();
|
||||
this.logged_in = false;
|
||||
@@ -213,31 +365,41 @@ export default class Session extends EventEmitterLike {
|
||||
return response;
|
||||
}
|
||||
|
||||
get key() {
|
||||
/**
|
||||
* InnerTube API key.
|
||||
*/
|
||||
get key(): string {
|
||||
return this.#key;
|
||||
}
|
||||
|
||||
get api_version() {
|
||||
/**
|
||||
* InnerTube API version.
|
||||
*/
|
||||
get api_version(): string {
|
||||
return this.#api_version;
|
||||
}
|
||||
|
||||
get client_version() {
|
||||
get client_version(): string {
|
||||
return this.#context.client.clientVersion;
|
||||
}
|
||||
|
||||
get client_name() {
|
||||
get client_name(): string {
|
||||
return this.#context.client.clientName;
|
||||
}
|
||||
|
||||
get context() {
|
||||
get account_index(): number {
|
||||
return this.#account_index;
|
||||
}
|
||||
|
||||
get context(): Context {
|
||||
return this.#context;
|
||||
}
|
||||
|
||||
get player() {
|
||||
get player(): Player | undefined {
|
||||
return this.#player;
|
||||
}
|
||||
|
||||
get lang() {
|
||||
get lang(): string {
|
||||
return this.#context.client.hl;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import Proto from '../proto';
|
||||
import Session from './Session';
|
||||
import { AxioslikeResponse } from './Actions';
|
||||
import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
|
||||
import { Constants } from '../utils';
|
||||
import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
|
||||
|
||||
import type { ApiResponse } from './Actions';
|
||||
import type Session from './Session';
|
||||
|
||||
interface UploadResult {
|
||||
status: string;
|
||||
@@ -36,7 +37,7 @@ export interface UploadedVideoMetadata {
|
||||
}
|
||||
|
||||
class Studio {
|
||||
#session;
|
||||
#session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
@@ -50,7 +51,10 @@ class Studio {
|
||||
* const response = await yt.studio.setThumbnail(video_id, buffer);
|
||||
* ```
|
||||
*/
|
||||
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<AxioslikeResponse> {
|
||||
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.');
|
||||
|
||||
@@ -73,12 +77,15 @@ class Studio {
|
||||
* title: 'Artemis Mission',
|
||||
* description: 'A nicely written description...',
|
||||
* category: 27,
|
||||
* licence: 'creative_commons'
|
||||
* license: 'creative_commons'
|
||||
* // ...
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async updateVideoMetadata(video_id: string, metadata: VideoMetadata) {
|
||||
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', {
|
||||
@@ -97,7 +104,10 @@ class Studio {
|
||||
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
|
||||
* ```
|
||||
*/
|
||||
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<AxioslikeResponse> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
import Feed from './Feed';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
|
||||
import type Actions from './Actions';
|
||||
import type { ObservedArray } from '../parser/helpers';
|
||||
|
||||
class TabbedFeed extends Feed {
|
||||
#tabs;
|
||||
#actions;
|
||||
#tabs: ObservedArray<Tab>;
|
||||
#actions: Actions;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
@@ -13,11 +15,11 @@ class TabbedFeed extends Feed {
|
||||
this.#tabs = this.page.contents_memo.getType(Tab);
|
||||
}
|
||||
|
||||
get tabs() {
|
||||
get tabs(): string[] {
|
||||
return this.#tabs.map((tab) => tab.title.toString());
|
||||
}
|
||||
|
||||
async getTab(title: string) {
|
||||
async getTabByName(title: string): Promise<TabbedFeed> {
|
||||
const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
|
||||
|
||||
if (!tab)
|
||||
@@ -28,13 +30,24 @@ class TabbedFeed extends Feed {
|
||||
|
||||
const response = await tab.endpoint.call(this.#actions);
|
||||
|
||||
if (!response)
|
||||
throw new InnertubeError('Failed to call endpoint');
|
||||
return new TabbedFeed(this.#actions, response.data, false);
|
||||
}
|
||||
|
||||
async getTabByURL(url: string): Promise<TabbedFeed> {
|
||||
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(this.#actions, response.data, false);
|
||||
}
|
||||
|
||||
get title() {
|
||||
get title(): string | undefined {
|
||||
return this.page.contents_memo.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,21 @@ import { YTNode } from '../helpers';
|
||||
class Button extends YTNode {
|
||||
static type = 'Button';
|
||||
|
||||
text: string;
|
||||
text?: string;
|
||||
|
||||
label;
|
||||
tooltip;
|
||||
icon_type;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
icon_type?: string;
|
||||
is_disabled?: boolean;
|
||||
|
||||
endpoint: NavigationEndpoint;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.text = new Text(data.text).toString();
|
||||
|
||||
if (data.text) {
|
||||
this.text = new Text(data.text).toString();
|
||||
}
|
||||
|
||||
if (data.accessibility?.label) {
|
||||
this.label = data.accessibility?.label;
|
||||
@@ -30,6 +34,10 @@ class Button extends YTNode {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import Parser from '../index';
|
||||
import Author from './misc/Author';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
|
||||
import type Button from './Button';
|
||||
import type ChannelHeaderLinks from './ChannelHeaderLinks';
|
||||
import type SubscribeButton from './SubscribeButton';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class C4TabbedHeader extends YTNode {
|
||||
static type = 'C4TabbedHeader';
|
||||
|
||||
author;
|
||||
banner;
|
||||
tv_banner;
|
||||
mobile_banner;
|
||||
subscribers;
|
||||
sponsor_button;
|
||||
subscribe_button;
|
||||
header_links;
|
||||
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();
|
||||
@@ -23,13 +31,16 @@ class C4TabbedHeader extends YTNode {
|
||||
navigationEndpoint: data.navigationEndpoint
|
||||
}, data.badges, data.avatar);
|
||||
|
||||
this.banner = data.banner ? Thumbnail.fromResponse(data.banner) : [];
|
||||
this.tv_banner = data.tvBanner ? Thumbnail.fromResponse(data.tvBanner) : [];
|
||||
this.mobile_banner = data.mobileBanner ? Thumbnail.fromResponse(data.mobileBanner) : [];
|
||||
this.banner = Thumbnail.fromResponse(data.banner);
|
||||
this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
|
||||
this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);
|
||||
this.subscribers = new Text(data.subscriberCountText);
|
||||
this.sponsor_button = data.sponsorButton ? Parser.parseItem(data.sponsorButton) : undefined;
|
||||
this.subscribe_button = data.subscribeButton ? Parser.parseItem(data.subscribeButton) : undefined;
|
||||
this.header_links = data.headerLinks ? Parser.parse(data.headerLinks) : undefined;
|
||||
this.videos_count = new Text(data.videosCountText);
|
||||
this.sponsor_button = Parser.parseItem<Button>(data.sponsorButton);
|
||||
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
|
||||
this.header_links = Parser.parseItem<ChannelHeaderLinks>(data.headerLinks);
|
||||
this.channel_handle = new Text(data.channelHandleText);
|
||||
this.channel_id = data.channelId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
src/parser/classes/CarouselHeader.ts
Normal file
15
src/parser/classes/CarouselHeader.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class CarouselHeader extends YTNode {
|
||||
static type = 'CarouselHeader';
|
||||
|
||||
contents: YTNode[];
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
}
|
||||
}
|
||||
|
||||
export default CarouselHeader;
|
||||
25
src/parser/classes/CarouselItem.ts
Normal file
25
src/parser/classes/CarouselItem.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
|
||||
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;
|
||||
@@ -1,6 +1,11 @@
|
||||
import Parser from '..';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import Author from './misc/Author';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import Text from './misc/Text';
|
||||
|
||||
import type SubscribeButton from './SubscribeButton';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class Channel extends YTNode {
|
||||
@@ -10,7 +15,10 @@ class Channel extends YTNode {
|
||||
author: Author;
|
||||
subscribers: Text;
|
||||
videos: Text;
|
||||
long_byline: Text;
|
||||
short_byline: Text;
|
||||
endpoint: NavigationEndpoint;
|
||||
subscribe_button: SubscribeButton | null;
|
||||
description_snippet: Text;
|
||||
|
||||
constructor(data: any) {
|
||||
@@ -22,9 +30,13 @@ class Channel extends YTNode {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import Parser from '../index';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import Text from './misc/Text';
|
||||
import Parser from '../index';
|
||||
|
||||
import type Button from './Button';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class ChannelAboutFullMetadata extends YTNode {
|
||||
@@ -11,13 +15,20 @@ class ChannelAboutFullMetadata extends YTNode {
|
||||
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;
|
||||
buttons: Button[];
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -25,13 +36,20 @@ class ChannelAboutFullMetadata extends YTNode {
|
||||
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.parse(data.actionButtons);
|
||||
this.buttons = Parser.parseArray<Button>(data.actionButtons);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
src/parser/classes/Chapter.ts
Normal file
21
src/parser/classes/Chapter.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
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;
|
||||
27
src/parser/classes/CompactStation.ts
Normal file
27
src/parser/classes/CompactStation.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
|
||||
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;
|
||||
@@ -4,6 +4,8 @@ import Author from './misc/Author';
|
||||
import { timeToSeconds } from '../../utils/Utils';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import type Menu from './menus/Menu';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class CompactVideo extends YTNode {
|
||||
@@ -25,7 +27,7 @@ class CompactVideo extends YTNode {
|
||||
|
||||
thumbnail_overlays;
|
||||
endpoint: NavigationEndpoint;
|
||||
menu;
|
||||
menu: Menu | null;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -43,9 +45,9 @@ class CompactVideo extends YTNode {
|
||||
seconds: timeToSeconds(new Text(data.lengthText).toString())
|
||||
};
|
||||
|
||||
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
|
||||
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.menu = Parser.parse(data.menu);
|
||||
this.menu = Parser.parseItem<Menu>(data.menu);
|
||||
}
|
||||
|
||||
get best_thumbnail() {
|
||||
|
||||
19
src/parser/classes/DecoratedPlayerBar.ts
Normal file
19
src/parser/classes/DecoratedPlayerBar.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
import type Button from './Button';
|
||||
import type MultiMarkersPlayerBar from './MultiMarkersPlayerBar';
|
||||
|
||||
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;
|
||||
38
src/parser/classes/DefaultPromoPanel.ts
Normal file
38
src/parser/classes/DefaultPromoPanel.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
|
||||
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;
|
||||
41
src/parser/classes/ExpandableMetadata.ts
Normal file
41
src/parser/classes/ExpandableMetadata.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import Parser from '..';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
|
||||
import Button from './Button';
|
||||
import HorizontalCardList from './HorizontalCardList';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
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;
|
||||
@@ -15,7 +15,7 @@ class ExpandableTab extends YTNode {
|
||||
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.parse(data.content) : null;
|
||||
this.content = data.content ? Parser.parseItem(data.content) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ class ExpandedShelfContents extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.items = Parser.parse(data.items);
|
||||
this.items = Parser.parseArray(data.items);
|
||||
}
|
||||
|
||||
// XXX: alias for consistency
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Parser from '../index';
|
||||
import { YTNode } from '../helpers';
|
||||
import ChipCloudChip from './ChipCloudChip';
|
||||
|
||||
class FeedFilterChipBar extends YTNode {
|
||||
static type = 'FeedFilterChipBar';
|
||||
@@ -8,7 +9,7 @@ class FeedFilterChipBar extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.contents = Parser.parse(data.contents);
|
||||
this.contents = Parser.parseArray<ChipCloudChip>(data.contents, ChipCloudChip);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
src/parser/classes/GameCard.ts
Normal file
15
src/parser/classes/GameCard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class GameCard extends YTNode {
|
||||
static type = 'GameCard';
|
||||
|
||||
game;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.game = Parser.parseItem(data.game);
|
||||
}
|
||||
}
|
||||
|
||||
export default GameCard;
|
||||
26
src/parser/classes/GameDetails.ts
Normal file
26
src/parser/classes/GameDetails.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
|
||||
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;
|
||||
@@ -5,23 +5,34 @@ class Grid extends YTNode {
|
||||
static type = 'Grid';
|
||||
|
||||
items;
|
||||
is_collapsible: boolean;
|
||||
visible_row_count: string;
|
||||
target_id: string;
|
||||
is_collapsible?: boolean;
|
||||
visible_row_count?: string;
|
||||
target_id?: string;
|
||||
continuation: string | null;
|
||||
header?;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.items = Parser.parse(data.items);
|
||||
this.is_collapsible = data.isCollapsible;
|
||||
this.visible_row_count = data.visibleRowCount;
|
||||
this.target_id = data.targetId;
|
||||
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
|
||||
|
||||
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
|
||||
|
||||
@@ -3,6 +3,9 @@ import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import Author from './misc/Author';
|
||||
|
||||
import type Menu from './menus/Menu';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class GridVideo extends YTNode {
|
||||
@@ -14,12 +17,12 @@ class GridVideo extends YTNode {
|
||||
thumbnail_overlays;
|
||||
rich_thumbnail;
|
||||
published: Text;
|
||||
duration: Text | string;
|
||||
duration: Text | null;
|
||||
author: Author;
|
||||
views: Text;
|
||||
short_view_count: Text;
|
||||
endpoint: NavigationEndpoint;
|
||||
menu;
|
||||
menu: Menu | null;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -27,15 +30,15 @@ class GridVideo extends YTNode {
|
||||
this.id = data.videoId;
|
||||
this.title = new Text(data.title);
|
||||
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
|
||||
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
|
||||
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) : '';
|
||||
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.parse(data.menu);
|
||||
this.menu = Parser.parseItem<Menu>(data.menu);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
src/parser/classes/HeatMarker.ts
Normal file
18
src/parser/classes/HeatMarker.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
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;
|
||||
25
src/parser/classes/Heatmap.ts
Normal file
25
src/parser/classes/Heatmap.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Parser from '..';
|
||||
import type HeatMarker from './HeatMarker';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
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;
|
||||
@@ -4,7 +4,7 @@ import { YTNode } from '../helpers';
|
||||
class Panel {
|
||||
static type = 'Panel';
|
||||
|
||||
thumbnail: {
|
||||
thumbnail?: {
|
||||
image: {
|
||||
url: string;
|
||||
width: number;
|
||||
@@ -43,13 +43,15 @@ class Panel {
|
||||
};
|
||||
|
||||
constructor(data: any) {
|
||||
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
|
||||
};
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Parser from '../index';
|
||||
import { YTNode } from '../helpers';
|
||||
import SearchRefinementCard from './SearchRefinementCard';
|
||||
import Button from './Button';
|
||||
import MacroMarkersListItem from './MacroMarkersListItem';
|
||||
|
||||
class HorizontalCardList extends YTNode {
|
||||
static type = 'HorizontalCardList';
|
||||
@@ -11,10 +14,10 @@ class HorizontalCardList extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.cards = Parser.parse(data.cards);
|
||||
this.header = Parser.parse(data.header);
|
||||
this.previous_button = Parser.parse(data.previousButton);
|
||||
this.next_button = Parser.parse(data.nextButton);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class HorizontalList extends YTNode {
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.visible_item_count = data.visibleItemCount;
|
||||
this.items = Parser.parse(data.items);
|
||||
this.items = Parser.parseArray(data.items);
|
||||
}
|
||||
|
||||
// XXX: alias for consistency
|
||||
|
||||
38
src/parser/classes/InteractiveTabbedHeader.ts
Normal file
38
src/parser/classes/InteractiveTabbedHeader.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import Parser from '..';
|
||||
import { ObservedArray, YTNode } from '../helpers';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import SubscribeButton from './SubscribeButton';
|
||||
import MetadataBadge from './MetadataBadge';
|
||||
import Button from './Button';
|
||||
|
||||
class InteractiveTabbedHeader extends YTNode {
|
||||
static type = 'InteractiveTabbedHeader';
|
||||
|
||||
header_type: string;
|
||||
title: Text;
|
||||
description: Text;
|
||||
metadata: Text;
|
||||
badges: MetadataBadge[];
|
||||
box_art: Thumbnail[];
|
||||
banner: Thumbnail[];
|
||||
buttons: ObservedArray<SubscribeButton | Button>;
|
||||
auto_generated: Text;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
|
||||
this.header_type = data.type;
|
||||
this.title = new Text(data.title);
|
||||
this.description = new Text(data.description);
|
||||
this.metadata = new Text(data.metadata);
|
||||
this.badges = Parser.parseArray<MetadataBadge>(data.badges, MetadataBadge);
|
||||
this.box_art = Thumbnail.fromResponse(data.boxArt);
|
||||
this.banner = Thumbnail.fromResponse(data.banner);
|
||||
this.buttons = Parser.parseArray<SubscribeButton | Button>(data.buttons, [ SubscribeButton, Button ]);
|
||||
this.auto_generated = new Text(data.autoGenerated);
|
||||
}
|
||||
}
|
||||
|
||||
export default InteractiveTabbedHeader;
|
||||
@@ -3,17 +3,18 @@ import ItemSectionHeader from './ItemSectionHeader';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
import ItemSectionTabbedHeader from './ItemSectionTabbedHeader';
|
||||
import CommentsHeader from './comments/CommentsHeader';
|
||||
|
||||
class ItemSection extends YTNode {
|
||||
static type = 'ItemSection';
|
||||
|
||||
header: ItemSectionHeader | ItemSectionTabbedHeader | null;
|
||||
header: CommentsHeader | ItemSectionHeader | ItemSectionTabbedHeader | null;
|
||||
contents;
|
||||
target_id;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.header = Parser.parseItem<ItemSectionHeader | ItemSectionTabbedHeader>(data.header, [ ItemSectionHeader, ItemSectionTabbedHeader ]);
|
||||
this.header = Parser.parseItem<CommentsHeader | ItemSectionHeader | ItemSectionTabbedHeader>(data.header);
|
||||
this.contents = Parser.parse(data.contents, true);
|
||||
|
||||
if (data.targetId || data.sectionIdentifier) {
|
||||
|
||||
29
src/parser/classes/MacroMarkersListItem.ts
Normal file
29
src/parser/classes/MacroMarkersListItem.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class MacroMarkersListItem extends YTNode {
|
||||
static type = 'MacroMarkersListItem';
|
||||
|
||||
title: Text;
|
||||
time_description: Text;
|
||||
thumbnail: Thumbnail[];
|
||||
on_tap_endpoint: NavigationEndpoint;
|
||||
layout: string;
|
||||
is_highlighted: boolean;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
|
||||
this.title = new Text(data.title);
|
||||
this.time_description = new Text(data.timeDescription);
|
||||
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
|
||||
this.on_tap_endpoint = new NavigationEndpoint(data.onTap);
|
||||
this.layout = data.layout;
|
||||
this.is_highlighted = data.isHighlighted;
|
||||
}
|
||||
}
|
||||
|
||||
export default MacroMarkersListItem;
|
||||
@@ -5,7 +5,8 @@ class MetadataBadge extends YTNode {
|
||||
|
||||
icon_type?: string;
|
||||
style?: string;
|
||||
tooltip: string | null;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -18,7 +19,13 @@ class MetadataBadge extends YTNode {
|
||||
this.style = data.style;
|
||||
}
|
||||
|
||||
this.tooltip = data?.tooltip || data?.iconTooltip || null;
|
||||
if (data?.label) {
|
||||
this.style = data.label;
|
||||
}
|
||||
|
||||
if (data?.tooltip || data?.iconTooltip) {
|
||||
this.tooltip = data.tooltip || data.iconTooltip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
src/parser/classes/MultiMarkersPlayerBar.ts
Normal file
44
src/parser/classes/MultiMarkersPlayerBar.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import Parser from '..';
|
||||
import type Chapter from './Chapter';
|
||||
import type Heatmap from './Heatmap';
|
||||
|
||||
import { observe, ObservedArray, YTNode } from '../helpers';
|
||||
|
||||
class Marker extends YTNode {
|
||||
static type = 'Marker';
|
||||
|
||||
marker_key: string;
|
||||
value: {
|
||||
heatmap?: Heatmap | null;
|
||||
chapters?: Chapter[];
|
||||
};
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.marker_key = data.key;
|
||||
|
||||
this.value = {};
|
||||
|
||||
if (data.value.heatmap) {
|
||||
this.value.heatmap = Parser.parseItem<Heatmap>(data.value.heatmap);
|
||||
}
|
||||
|
||||
if (data.value.chapters) {
|
||||
this.value.chapters = Parser.parseArray<Chapter>(data.value.chapters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MultiMarkersPlayerBar extends YTNode {
|
||||
static type = 'MultiMarkersPlayerBar';
|
||||
|
||||
markers_map: ObservedArray<Marker>;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.markers_map = observe(data.markersMap?.map((marker: { key: string; value: { [key: string ]: any }}) => new Marker(marker)));
|
||||
}
|
||||
}
|
||||
|
||||
export { Marker };
|
||||
export default MultiMarkersPlayerBar;
|
||||
@@ -36,12 +36,12 @@ class MusicDetailHeader extends YTNode {
|
||||
this.thumbnails = Thumbnail.fromResponse(data.thumbnail.croppedSquareThumbnailRenderer.thumbnail);
|
||||
this.badges = Parser.parse(data.subtitleBadges);
|
||||
|
||||
const author = this.subtitle.runs?.find((run) => (run as TextRun)?.endpoint?.browse?.id.startsWith('UC'));
|
||||
const author = this.subtitle.runs?.find((run) => (run as TextRun)?.endpoint?.payload?.browseId.startsWith('UC'));
|
||||
|
||||
if (author) {
|
||||
this.author = {
|
||||
name: (author as TextRun).text,
|
||||
channel_id: (author as TextRun).endpoint?.browse?.id,
|
||||
channel_id: (author as TextRun).endpoint?.payload?.browseId,
|
||||
endpoint: (author as TextRun).endpoint
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,7 +80,9 @@ class MusicResponsiveListItem extends YTNode {
|
||||
|
||||
this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : null;
|
||||
|
||||
switch (this.endpoint?.browse?.page_type) {
|
||||
const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
|
||||
|
||||
switch (page_type) {
|
||||
case 'MUSIC_PAGE_TYPE_ALBUM':
|
||||
this.item_type = 'album';
|
||||
this.#parseAlbum();
|
||||
@@ -139,7 +141,7 @@ class MusicResponsiveListItem extends YTNode {
|
||||
}
|
||||
|
||||
#parseSong() {
|
||||
this.id = this.#playlist_item_data.video_id || this.endpoint?.watch?.video_id;
|
||||
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
|
||||
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
|
||||
const duration_text =
|
||||
@@ -151,21 +153,21 @@ class MusicResponsiveListItem extends YTNode {
|
||||
seconds: timeToSeconds(duration_text)
|
||||
});
|
||||
|
||||
const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun ||
|
||||
this.#flex_columns[2]?.key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun;
|
||||
const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('MPR')) as TextRun ||
|
||||
this.#flex_columns[2]?.key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('MPR')) as TextRun;
|
||||
if (album) {
|
||||
this.album = {
|
||||
id: album.endpoint?.browse?.id,
|
||||
id: album.endpoint?.payload?.browseId,
|
||||
name: album.text,
|
||||
endpoint: album.endpoint
|
||||
};
|
||||
}
|
||||
|
||||
const artists = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[];
|
||||
const artists = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun[];
|
||||
if (artists) {
|
||||
this.artists = artists.map((artist) => ({
|
||||
name: artist.text,
|
||||
channel_id: artist.endpoint?.browse?.id,
|
||||
channel_id: artist.endpoint?.payload?.browseId,
|
||||
endpoint: artist.endpoint
|
||||
}));
|
||||
}
|
||||
@@ -176,11 +178,11 @@ class MusicResponsiveListItem extends YTNode {
|
||||
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
this.views = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => run.text.match(/(.*?) views/))?.text;
|
||||
|
||||
const authors = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[];
|
||||
const authors = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun[];
|
||||
if (authors) {
|
||||
this.authors = authors.map((author) => ({
|
||||
name: author.text,
|
||||
channel_id: author.endpoint?.browse?.id,
|
||||
channel_id: author.endpoint?.payload?.browseId,
|
||||
endpoint: author.endpoint
|
||||
}));
|
||||
}
|
||||
@@ -194,7 +196,7 @@ class MusicResponsiveListItem extends YTNode {
|
||||
}
|
||||
|
||||
#parseArtist() {
|
||||
this.id = this.endpoint?.browse?.id;
|
||||
this.id = this.endpoint?.payload?.browseId;
|
||||
this.name = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
this.subtitle = this.#flex_columns[1].key('title').instanceof(Text);
|
||||
this.subscribers = this.subtitle.runs?.find((run) => (/^(\d*\.)?\d+[M|K]? subscribers?$/i).test(run.text))?.text || '';
|
||||
@@ -207,13 +209,13 @@ class MusicResponsiveListItem extends YTNode {
|
||||
}
|
||||
|
||||
#parseAlbum() {
|
||||
this.id = this.endpoint?.browse?.id;
|
||||
this.id = this.endpoint?.payload?.browseId;
|
||||
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
|
||||
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun;
|
||||
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun;
|
||||
author && (this.author = {
|
||||
name: author.text,
|
||||
channel_id: author.endpoint?.browse?.id,
|
||||
channel_id: author.endpoint?.payload?.browseId,
|
||||
endpoint: author.endpoint
|
||||
});
|
||||
|
||||
@@ -221,7 +223,7 @@ class MusicResponsiveListItem extends YTNode {
|
||||
}
|
||||
|
||||
#parsePlaylist() {
|
||||
this.id = this.endpoint?.browse?.id;
|
||||
this.id = this.endpoint?.payload?.browseId;
|
||||
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
|
||||
const item_count_run = this.#flex_columns[1].key('title')
|
||||
@@ -229,12 +231,12 @@ class MusicResponsiveListItem extends YTNode {
|
||||
|
||||
this.item_count = item_count_run ? item_count_run.text : undefined;
|
||||
|
||||
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun;
|
||||
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun;
|
||||
|
||||
if (author) {
|
||||
this.author = {
|
||||
name: author.text,
|
||||
channel_id: author.endpoint?.browse?.id,
|
||||
channel_id: author.endpoint?.payload?.browseId,
|
||||
endpoint: author.endpoint
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ class MusicSideAlignedItem extends YTNode {
|
||||
static type = 'MusicSideAlignedItem';
|
||||
|
||||
start_items?;
|
||||
end_items?;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -13,6 +14,10 @@ class MusicSideAlignedItem extends YTNode {
|
||||
if (data.startItems) {
|
||||
this.start_items = Parser.parseArray(data.startItems);
|
||||
}
|
||||
|
||||
if (data.endItems) {
|
||||
this.end_items = Parser.parseArray(data.endItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,13 +48,15 @@ class MusicTwoRowItem extends YTNode {
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
|
||||
this.id =
|
||||
this.endpoint?.browse?.id ||
|
||||
this.endpoint?.watch?.video_id;
|
||||
this.endpoint?.payload?.browseId ||
|
||||
this.endpoint?.payload?.videoId;
|
||||
|
||||
this.subtitle = new Text(data.subtitle);
|
||||
this.badges = Parser.parse(data.subtitleBadges);
|
||||
|
||||
switch (this.endpoint?.browse?.page_type) {
|
||||
const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
|
||||
|
||||
switch (page_type) {
|
||||
case 'MUSIC_PAGE_TYPE_ARTIST':
|
||||
this.item_type = 'artist';
|
||||
break;
|
||||
@@ -65,7 +67,7 @@ class MusicTwoRowItem extends YTNode {
|
||||
this.item_type = 'album';
|
||||
break;
|
||||
default:
|
||||
if (this.endpoint?.watch_playlist) {
|
||||
if (this.endpoint?.metadata?.api_url === '/next') {
|
||||
this.item_type = 'endpoint';
|
||||
} else if (this.subtitle.runs?.[0]) {
|
||||
if (this.subtitle.runs[0].text !== 'Song') {
|
||||
@@ -87,11 +89,11 @@ class MusicTwoRowItem extends YTNode {
|
||||
const item_count_run = this.subtitle.runs?.find((run) => run.text.match(/\d+ songs|song/));
|
||||
this.item_count = item_count_run ? (item_count_run as TextRun).text : null;
|
||||
} else if (this.item_type == 'album') {
|
||||
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
|
||||
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
|
||||
if (artists) {
|
||||
this.artists = artists.map((artist: any) => ({
|
||||
name: artist.text,
|
||||
channel_id: artist.endpoint.browse.id,
|
||||
channel_id: artist.endpoint?.payload?.browseId,
|
||||
endpoint: artist.endpoint
|
||||
}));
|
||||
}
|
||||
@@ -101,20 +103,20 @@ class MusicTwoRowItem extends YTNode {
|
||||
} else if (this.item_type == 'video') {
|
||||
this.views = this?.subtitle.runs?.find((run) => run?.text.match(/(.*?) views/))?.text || 'N/A';
|
||||
|
||||
const author = this.subtitle.runs?.find((run: any) => run.endpoint?.browse?.id?.startsWith('UC'));
|
||||
const author = this.subtitle.runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));
|
||||
if (author) {
|
||||
this.author = {
|
||||
name: (author as TextRun)?.text,
|
||||
channel_id: (author as TextRun)?.endpoint?.browse?.id,
|
||||
channel_id: (author as TextRun)?.endpoint?.payload?.browseId,
|
||||
endpoint: (author as TextRun)?.endpoint
|
||||
};
|
||||
}
|
||||
} else if (this.item_type == 'song') {
|
||||
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
|
||||
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
|
||||
if (artists) {
|
||||
this.artists = artists.map((artist: any) => ({
|
||||
name: (artist as TextRun)?.text,
|
||||
channel_id: (artist as TextRun)?.endpoint?.browse?.id,
|
||||
channel_id: (artist as TextRun)?.endpoint?.payload?.browseId,
|
||||
endpoint: (artist as TextRun)?.endpoint
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// TODO: refactor this
|
||||
import { YTNode } from '../helpers';
|
||||
import Parser, { ParsedResponse } from '../index';
|
||||
import Actions, { ActionsResponse } from '../../core/Actions';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
import CreatePlaylistDialog from './CreatePlaylistDialog';
|
||||
|
||||
class NavigationEndpoint extends YTNode {
|
||||
@@ -14,34 +14,9 @@ class NavigationEndpoint extends YTNode {
|
||||
url?: string;
|
||||
api_url?: string;
|
||||
page_type?: string;
|
||||
send_post?: boolean; // TODO: is this a boolean?
|
||||
send_post?: boolean;
|
||||
};
|
||||
|
||||
// TODO: these should be given proper types, currently infered
|
||||
browse?: {
|
||||
id: string,
|
||||
params: string | null,
|
||||
base_url: string | null,
|
||||
page_type: string | null,
|
||||
form_data?: {}
|
||||
};
|
||||
watch;
|
||||
search;
|
||||
subscribe;
|
||||
unsubscribe;
|
||||
like;
|
||||
perform_comment_action;
|
||||
offline_video;
|
||||
continuation;
|
||||
feedback;
|
||||
watch_playlist;
|
||||
playlist_edit;
|
||||
add_to_playlist;
|
||||
create_playlist;
|
||||
get_report_form;
|
||||
live_chat_item_context_menu;
|
||||
send_live_chat_vote;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
|
||||
@@ -85,150 +60,10 @@ class NavigationEndpoint extends YTNode {
|
||||
this.metadata.send_post = data.commandMetadata.webCommandMetadata.sendPost;
|
||||
}
|
||||
|
||||
if (data?.browseEndpoint) {
|
||||
const configs = data?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig;
|
||||
this.browse = {
|
||||
id: data?.browseEndpoint?.browseId || null,
|
||||
params: data?.browseEndpoint.params || null,
|
||||
base_url: data?.browseEndpoint?.canonicalBaseUrl || null,
|
||||
page_type: configs?.pageType || null
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.watchEndpoint) {
|
||||
const configs = data?.watchEndpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig;
|
||||
this.watch = {
|
||||
video_id: data?.watchEndpoint?.videoId,
|
||||
playlist_id: data?.watchEndpoint.playlistId || null,
|
||||
params: data?.watchEndpoint.params || null,
|
||||
index: data?.watchEndpoint.index || null,
|
||||
supported_onesie_config: data?.watchEndpoint?.watchEndpointSupportedOnesieConfig,
|
||||
music_video_type: configs?.musicVideoType || null
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.searchEndpoint) {
|
||||
this.search = {
|
||||
query: data.searchEndpoint.query,
|
||||
params: data.searchEndpoint.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.subscribeEndpoint) {
|
||||
this.subscribe = {
|
||||
channel_ids: data.subscribeEndpoint.channelIds,
|
||||
params: data.subscribeEndpoint.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.unsubscribeEndpoint) {
|
||||
this.unsubscribe = {
|
||||
channel_ids: data.unsubscribeEndpoint.channelIds,
|
||||
params: data.unsubscribeEndpoint.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.likeEndpoint) {
|
||||
this.like = {
|
||||
status: data.likeEndpoint.status,
|
||||
target: {
|
||||
video_id: data.likeEndpoint.target.videoId,
|
||||
playlist_id: data.likeEndpoint.target.playlistId
|
||||
},
|
||||
params:
|
||||
data.likeEndpoint?.removeLikeParams ||
|
||||
data.likeEndpoint?.likeParams ||
|
||||
data.likeEndpoint?.dislikeParams
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.performCommentActionEndpoint) {
|
||||
this.perform_comment_action = {
|
||||
action: data?.performCommentActionEndpoint.action
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.offlineVideoEndpoint) {
|
||||
this.offline_video = {
|
||||
video_id: data.offlineVideoEndpoint.videoId,
|
||||
on_add_command: {
|
||||
get_download_action: {
|
||||
video_id: data.offlineVideoEndpoint.videoId,
|
||||
params: data.offlineVideoEndpoint.onAddCommand.getDownloadActionCommand.params
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.continuationCommand) {
|
||||
this.continuation = {
|
||||
request: data?.continuationCommand?.request || null,
|
||||
token: data?.continuationCommand?.token || null
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.feedbackEndpoint) {
|
||||
this.feedback = {
|
||||
token: data.feedbackEndpoint.feedbackToken
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.watchPlaylistEndpoint) {
|
||||
this.watch_playlist = {
|
||||
playlist_id: data.watchPlaylistEndpoint?.playlistId,
|
||||
params: data.watchPlaylistEndpoint?.params || null
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.playlistEditEndpoint) {
|
||||
this.playlist_edit = {
|
||||
playlist_id: data.playlistEditEndpoint.playlistId,
|
||||
actions: data.playlistEditEndpoint.actions.map((item: any) => ({
|
||||
action: item.action,
|
||||
removed_video_id: item.removedVideoId
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.addToPlaylistEndpoint) {
|
||||
this.add_to_playlist = {
|
||||
video_id: data.addToPlaylistEndpoint.videoId
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.addToPlaylistServiceEndpoint) {
|
||||
this.add_to_playlist = {
|
||||
video_id: data.addToPlaylistServiceEndpoint.videoId
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.createPlaylistEndpoint) {
|
||||
if (data?.createPlaylistEndpoint.createPlaylistDialog) {
|
||||
this.dialog = Parser.parseItem(data?.createPlaylistEndpoint.createPlaylistDialog, CreatePlaylistDialog);
|
||||
}
|
||||
this.create_playlist = {
|
||||
// Nothing to put here - data.createPlaylistEndpoint has only one prop `createPlaylistDialog`
|
||||
// Which was already parsed and referred to by `this.dialog`. But still useful to have this as
|
||||
// A quick indicator of what the endpoint does.
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.getReportFormEndpoint) {
|
||||
this.get_report_form = {
|
||||
params: data.getReportFormEndpoint.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.liveChatItemContextMenuEndpoint) {
|
||||
this.live_chat_item_context_menu = {
|
||||
params: data?.liveChatItemContextMenuEndpoint?.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.sendLiveChatVoteEndpoint) {
|
||||
this.send_live_chat_vote = {
|
||||
params: data.sendLiveChatVoteEndpoint.params
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +76,8 @@ class NavigationEndpoint extends YTNode {
|
||||
return '/browse';
|
||||
case 'watchEndpoint':
|
||||
return '/player';
|
||||
case 'searchEndpoint':
|
||||
return '/search';
|
||||
case 'watchPlaylistEndpoint':
|
||||
return '/next';
|
||||
case 'liveChatItemContextMenuEndpoint':
|
||||
@@ -248,72 +85,15 @@ class NavigationEndpoint extends YTNode {
|
||||
}
|
||||
}
|
||||
|
||||
callTest(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
|
||||
callTest(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
|
||||
callTest(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<ParsedResponse | ActionsResponse> {
|
||||
call(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
|
||||
call(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
|
||||
call(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<ParsedResponse | ActionsResponse> {
|
||||
if (!actions)
|
||||
throw new Error('An active caller must be provided');
|
||||
if (!this.metadata.api_url)
|
||||
throw new Error('Expected an api_url, but none was found, this is a bug.');
|
||||
return actions.execute(this.metadata.api_url, { ...this.payload, ...args });
|
||||
}
|
||||
|
||||
// TODO: replace client with an enum or something
|
||||
async #call(actions: Actions, client?: string) {
|
||||
if (!actions)
|
||||
throw new Error('An active caller must be provided');
|
||||
|
||||
if (this.continuation) {
|
||||
switch (this.continuation.request) {
|
||||
case 'CONTINUATION_REQUEST_TYPE_BROWSE': {
|
||||
return await actions.browse(this.continuation.token, { is_ctoken: true });
|
||||
}
|
||||
case 'CONTINUATION_REQUEST_TYPE_SEARCH': {
|
||||
return await actions.search({ ctoken: this.continuation.token });
|
||||
}
|
||||
case 'CONTINUATION_REQUEST_TYPE_WATCH_NEXT': {
|
||||
return await actions.next({ ctoken: this.continuation.token });
|
||||
}
|
||||
default:
|
||||
throw new Error(`${this.continuation.request} not implemented`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.search) {
|
||||
return await actions.search({ query: this.search.query, params: this.search.params, client });
|
||||
}
|
||||
|
||||
if (this.browse) {
|
||||
return await actions.browse(this.browse.id, { ...this.browse, client });
|
||||
}
|
||||
|
||||
if (this.like) {
|
||||
if (!this.metadata.api_url)
|
||||
throw new Error('Like endpoint requires an api_url, but was not parsed from the response.');
|
||||
const response = await actions.engage(this.metadata.api_url, { video_id: this.like.target.video_id, params: this.like.params });
|
||||
return response;
|
||||
}
|
||||
|
||||
if (this.live_chat_item_context_menu) {
|
||||
if (!this.metadata.api_url)
|
||||
throw new Error('Live Chat Item Context Menu endpoint requires an api_url, but was not parsed from the response.');
|
||||
const response = await actions.livechat(this.metadata.api_url, {
|
||||
params: this.live_chat_item_context_menu.params
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
async call(actions: Actions, client: string | undefined, parse: true) : Promise<ParsedResponse | undefined>;
|
||||
async call(actions: Actions, client?: string, parse?: false) : Promise<ActionsResponse | undefined>;
|
||||
async call(actions: Actions, client?: string, parse?: boolean): Promise<ParsedResponse | ActionsResponse | undefined> {
|
||||
const result = await this.#call(actions, client);
|
||||
|
||||
if (parse && result)
|
||||
return Parser.parseResponse(result.data);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default NavigationEndpoint;
|
||||
@@ -34,6 +34,7 @@ class PlayerMicroformat extends YTNode {
|
||||
publish_date: string;
|
||||
upload_date: string;
|
||||
available_countries: string[];
|
||||
start_timestamp: Date | null;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -65,6 +66,7 @@ class PlayerMicroformat extends YTNode {
|
||||
this.publish_date = data.publishDate;
|
||||
this.upload_date = data.uploadDate;
|
||||
this.available_countries = data.availableCountries;
|
||||
this.start_timestamp = data.liveBroadcastDetails?.startTimestamp ? new Date(data.liveBroadcastDetails.startTimestamp) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import Menu from './menus/Menu';
|
||||
import Button from './Button';
|
||||
import WatchNextEndScreen from './WatchNextEndScreen';
|
||||
import PlayerOverlayAutoplay from './PlayerOverlayAutoplay';
|
||||
import type DecoratedPlayerBar from './DecoratedPlayerBar';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
@@ -16,6 +17,7 @@ class PlayerOverlay extends YTNode {
|
||||
fullscreen_engagement;
|
||||
actions;
|
||||
browser_media_session;
|
||||
decorated_player_bar;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -26,6 +28,7 @@ class PlayerOverlay extends YTNode {
|
||||
this.fullscreen_engagement = Parser.parse(data.fullscreenEngagement);
|
||||
this.actions = Parser.parseArray(data.actions);
|
||||
this.browser_media_session = Parser.parseItem(data.browserMediaSession);
|
||||
this.decorated_player_bar = Parser.parseItem<DecoratedPlayerBar>(data.decoratedPlayerBarRenderer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
src/parser/classes/PlaylistCustomThumbnail.ts
Normal file
15
src/parser/classes/PlaylistCustomThumbnail.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { YTNode } from '../helpers';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
|
||||
class PlaylistCustomThumbnail extends YTNode {
|
||||
static type = 'PlaylistCustomThumbnail';
|
||||
|
||||
thumbnail: Thumbnail[];
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
export default PlaylistCustomThumbnail;
|
||||
@@ -54,14 +54,14 @@ class PlaylistPanelVideo extends YTNode {
|
||||
seconds: timeToSeconds(new Text(data.lengthText).toString())
|
||||
};
|
||||
|
||||
const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.browse?.id.startsWith('MPR'));
|
||||
const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
|
||||
const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('MPR'));
|
||||
const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));
|
||||
|
||||
this.author = new Text(data.shortBylineText).toString();
|
||||
|
||||
if (album) {
|
||||
this.album = {
|
||||
id: (album as TextRun).endpoint?.browse?.id,
|
||||
id: (album as TextRun).endpoint?.payload?.browseId,
|
||||
name: (album as TextRun).text,
|
||||
year: new Text(data.longBylineText).runs?.slice(-1)[0].text,
|
||||
endpoint: (album as TextRun).endpoint
|
||||
@@ -71,13 +71,13 @@ class PlaylistPanelVideo extends YTNode {
|
||||
if (artists) {
|
||||
this.artists = artists.map((artist) => ({
|
||||
name: (artist as TextRun).text,
|
||||
channel_id: (artist as TextRun).endpoint?.browse?.id,
|
||||
channel_id: (artist as TextRun).endpoint?.payload?.browseId,
|
||||
endpoint: (artist as TextRun).endpoint
|
||||
}));
|
||||
}
|
||||
|
||||
this.badges = Parser.parse(data.badges);
|
||||
this.menu = Parser.parse(data.menu);
|
||||
this.badges = Parser.parseArray(data.badges);
|
||||
this.menu = Parser.parseItem(data.menu);
|
||||
this.set_video_id = data.playlistSetVideoId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class PlaylistSidebarPrimaryInfo extends YTNode {
|
||||
this.stats = data.stats.map((stat: any) => new Text(stat));
|
||||
this.thumbnail_renderer = Parser.parse(data.thumbnailRenderer);
|
||||
this.title = new Text(data.title);
|
||||
this.menu = data.menu && Parser.parse(data.menu);
|
||||
this.menu = Parser.parseItem(data.menu);
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.description = new Text(data.description);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import Parser from '../index';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import PlaylistAuthor from './misc/PlaylistAuthor';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import type Menu from './menus/Menu';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class PlaylistVideo extends YTNode {
|
||||
@@ -17,7 +19,7 @@ class PlaylistVideo extends YTNode {
|
||||
set_video_id: string | undefined;
|
||||
endpoint: NavigationEndpoint;
|
||||
is_playable: boolean;
|
||||
menu;
|
||||
menu: Menu | null;
|
||||
|
||||
duration: {
|
||||
text: string;
|
||||
@@ -35,7 +37,7 @@ class PlaylistVideo extends YTNode {
|
||||
this.set_video_id = data?.setVideoId;
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.is_playable = data.isPlayable;
|
||||
this.menu = Parser.parse(data.menu);
|
||||
this.menu = Parser.parseItem<Menu>(data.menu);
|
||||
this.duration = {
|
||||
text: new Text(data.lengthText).text,
|
||||
seconds: parseInt(data.lengthSeconds)
|
||||
|
||||
28
src/parser/classes/RecognitionShelf.ts
Normal file
28
src/parser/classes/RecognitionShelf.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
import Button from './Button';
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
|
||||
class RecognitionShelf extends YTNode {
|
||||
static type = 'RecognitionShelf';
|
||||
|
||||
title: Text;
|
||||
subtitle: Text;
|
||||
avatars: Thumbnail[];
|
||||
button: Button | null;
|
||||
surface: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
|
||||
this.title = new Text(data.title);
|
||||
this.subtitle = new Text(data.subtitle);
|
||||
this.avatars = data.avatars.map((avatar: any) => new Thumbnail(avatar));
|
||||
this.button = Parser.parseItem<Button>(data.button, Button);
|
||||
this.surface = data.surface;
|
||||
}
|
||||
}
|
||||
|
||||
export default RecognitionShelf;
|
||||
@@ -12,8 +12,8 @@ class RichGrid extends YTNode {
|
||||
super();
|
||||
// XXX: we don't parse the masthead since it is usually an advertisement
|
||||
// XXX: reflowOptions aren't parsed, I think its only used internally for layout
|
||||
this.header = Parser.parse(data.header);
|
||||
this.contents = Parser.parse(data.contents);
|
||||
this.header = Parser.parseItem(data.header);
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ class RichItem extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
// TODO: check this
|
||||
this.content = Parser.parse(data.content);
|
||||
this.content = Parser.parseItem(data.content);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@ class RichListHeader extends YTNode {
|
||||
static type = 'RichListHeader';
|
||||
|
||||
title: Text;
|
||||
subtitle: Text;
|
||||
title_style: string | undefined;
|
||||
icon_type: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.icon_type = data.icon.iconType;
|
||||
this.subtitle = new Text(data.subtitle);
|
||||
this.title_style = data?.titleStyle?.style;
|
||||
this.icon_type = data?.icon?.iconType;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ import { YTNode } from '../helpers';
|
||||
class RichSection extends YTNode {
|
||||
static type = 'RichSection';
|
||||
|
||||
contents;
|
||||
content;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.contents = Parser.parse(data.content);
|
||||
this.content = Parser.parseItem(data.content);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class RichShelf extends YTNode {
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.contents = Parser.parse(data.contents);
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
this.endpoint = data.endpoint ? new NavigationEndpoint(data.endpoint) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { YTNode } from '../helpers';
|
||||
class SectionList extends YTNode {
|
||||
static type = 'SectionList';
|
||||
|
||||
target_id;
|
||||
target_id?: string;
|
||||
contents;
|
||||
continuation;
|
||||
continuation?: string;
|
||||
header;
|
||||
|
||||
constructor(data: any) {
|
||||
@@ -15,7 +15,8 @@ class SectionList extends YTNode {
|
||||
this.target_id = data.targetId;
|
||||
}
|
||||
|
||||
this.contents = Parser.parse(data.contents);
|
||||
// TODO: this should be Parser#parseArray
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
|
||||
if (data.continuations) {
|
||||
if (data.continuations[0].nextContinuationData) {
|
||||
|
||||
@@ -7,10 +7,10 @@ class Shelf extends YTNode {
|
||||
static type = 'Shelf';
|
||||
|
||||
title: Text;
|
||||
endpoint;
|
||||
content;
|
||||
icon_type;
|
||||
menu;
|
||||
endpoint?: NavigationEndpoint;
|
||||
content: YTNode | null;
|
||||
icon_type?: string;
|
||||
menu?: YTNode | null;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -20,14 +20,14 @@ class Shelf extends YTNode {
|
||||
this.endpoint = new NavigationEndpoint(data.endpoint);
|
||||
}
|
||||
|
||||
this.content = Parser.parse(data.content) || null;
|
||||
this.content = Parser.parseItem(data.content) || null;
|
||||
|
||||
if (data.icon?.iconType) {
|
||||
this.icon_type = data.icon?.iconType;
|
||||
}
|
||||
|
||||
if (data.menu) {
|
||||
this.menu = Parser.parse(data.menu);
|
||||
this.menu = Parser.parseItem(data.menu);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
src/parser/classes/ThumbnailLandscapePortrait.ts
Normal file
17
src/parser/classes/ThumbnailLandscapePortrait.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { YTNode } from '../helpers';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
|
||||
class ThumbnailLandscapePortrait extends YTNode {
|
||||
static type = 'ThumbnailLandscapePortrait';
|
||||
|
||||
landscape: Thumbnail[];
|
||||
portrait: Thumbnail[];
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.landscape = Thumbnail.fromResponse(data.landscape);
|
||||
this.portrait = Thumbnail.fromResponse(data.portrait);
|
||||
}
|
||||
}
|
||||
|
||||
export default ThumbnailLandscapePortrait;
|
||||
23
src/parser/classes/TimedMarkerDecoration.ts
Normal file
23
src/parser/classes/TimedMarkerDecoration.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Text from './misc/Text';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class TimedMarkerDecoration extends YTNode {
|
||||
static type = 'TimedMarkerDecoration';
|
||||
|
||||
visible_time_range_start_millis: number;
|
||||
visible_time_range_end_millis: number;
|
||||
decoration_time_millis: number;
|
||||
label: Text;
|
||||
icon: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.visible_time_range_start_millis = data.visibleTimeRangeStartMillis;
|
||||
this.visible_time_range_end_millis = data.visibleTimeRangeEndMillis;
|
||||
this.decoration_time_millis = data.decorationTimeMillis;
|
||||
this.label = new Text(data.label);
|
||||
this.icon = data.icon;
|
||||
}
|
||||
}
|
||||
|
||||
export default TimedMarkerDecoration;
|
||||
29
src/parser/classes/TopicChannelDetails.ts
Normal file
29
src/parser/classes/TopicChannelDetails.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import SubscribeButton from './SubscribeButton';
|
||||
|
||||
class TopicChannelDetails extends YTNode {
|
||||
static type = 'TopicChannelDetails';
|
||||
|
||||
title: Text;
|
||||
avatar: Thumbnail[];
|
||||
subtitle: Text;
|
||||
subscribe_button: SubscribeButton | null;
|
||||
endpoint: NavigationEndpoint;
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
|
||||
this.title = new Text(data.title);
|
||||
this.avatar = Thumbnail.fromResponse(data.thumbnail);
|
||||
this.subtitle = new Text(data.title);
|
||||
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton, SubscribeButton);
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
export default TopicChannelDetails;
|
||||
@@ -10,9 +10,9 @@ class TwoColumnWatchNextResults extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.results = Parser.parse(data.results?.results.contents, true);
|
||||
this.secondary_results = Parser.parse(data.secondaryResults?.secondaryResults.results, true);
|
||||
this.conversation_bar = Parser.parse(data?.conversationBar);
|
||||
this.results = Parser.parseArray(data.results?.results.contents);
|
||||
this.secondary_results = Parser.parseArray(data.secondaryResults?.secondaryResults.results);
|
||||
this.conversation_bar = Parser.parseItem(data?.conversationBar);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Parser from '../index';
|
||||
import Text from './misc/Text';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class UniversalWatchCard extends YTNode {
|
||||
@@ -7,13 +8,17 @@ class UniversalWatchCard extends YTNode {
|
||||
header;
|
||||
call_to_action;
|
||||
sections;
|
||||
collapsed_label?: Text;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
// TODO: use parseItem / parseArray for these
|
||||
this.header = Parser.parse(data.header);
|
||||
this.call_to_action = Parser.parse(data.callToAction);
|
||||
this.sections = Parser.parse(data.sections);
|
||||
this.header = Parser.parseItem(data.header);
|
||||
this.call_to_action = Parser.parseItem(data.callToAction);
|
||||
this.sections = Parser.parseArray(data.sections);
|
||||
|
||||
if (data.collapsedLabel) {
|
||||
this.collapsed_label = new Text(data.collapsedLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class VerticalWatchCardList extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.items = Parser.parse(data.items);
|
||||
this.items = Parser.parseArray(data.items);
|
||||
this.contents = this.items; // XXX: alias for consistency
|
||||
this.view_all_text = new Text(data.viewAllText);
|
||||
this.view_all_endpoint = new NavigationEndpoint(data.viewAllEndpoint);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import Parser from '../index';
|
||||
import Parser from '..';
|
||||
import Text from './misc/Text';
|
||||
import Author from './misc/Author';
|
||||
import Menu from './menus/Menu';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import MetadataBadge from './MetadataBadge';
|
||||
import ExpandableMetadata from './ExpandableMetadata';
|
||||
|
||||
import { timeToSeconds } from '../../utils/Utils';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
@@ -17,11 +20,13 @@ class Video extends YTNode {
|
||||
text: Text;
|
||||
hover_text: Text;
|
||||
}[];
|
||||
expandable_metadata: ExpandableMetadata | null;
|
||||
|
||||
thumbnails: Thumbnail[];
|
||||
thumbnail_overlays;
|
||||
rich_thumbnail;
|
||||
author: Author;
|
||||
badges: MetadataBadge[];
|
||||
endpoint: NavigationEndpoint;
|
||||
published: Text;
|
||||
view_count: Text;
|
||||
@@ -35,7 +40,8 @@ class Video extends YTNode {
|
||||
|
||||
show_action_menu: boolean;
|
||||
is_watched: boolean;
|
||||
menu;
|
||||
menu: Menu | null;
|
||||
search_video_result_entity_key: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -53,10 +59,13 @@ class Video extends YTNode {
|
||||
hover_text: new Text(snippet.snippetHoverText)
|
||||
})) || [];
|
||||
|
||||
this.expandable_metadata = Parser.parseItem<ExpandableMetadata>(data.expandableMetadata);
|
||||
|
||||
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
|
||||
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
|
||||
this.rich_thumbnail = data.richThumbnail ? Parser.parse(data.richThumbnail) : null;
|
||||
this.rich_thumbnail = data.richThumbnail ? Parser.parseItem(data.richThumbnail) : null;
|
||||
this.author = new Author(data.ownerText, data.ownerBadges, data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail);
|
||||
this.badges = Parser.parseArray(data.badges, MetadataBadge);
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.published = new Text(data.publishedTimeText);
|
||||
this.view_count = new Text(data.viewCountText);
|
||||
@@ -75,6 +84,7 @@ class Video extends YTNode {
|
||||
this.show_action_menu = data.showActionMenu;
|
||||
this.is_watched = data.isWatched || false;
|
||||
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
|
||||
this.search_video_result_entity_key = data.searchVideoResultEntityKey;
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
@@ -84,22 +94,30 @@ class Video extends YTNode {
|
||||
return this.description_snippet?.toString() || '';
|
||||
}
|
||||
|
||||
/*
|
||||
Get is_live() {
|
||||
return this.badges.some((badge) => badge.style === 'BADGE_STYLE_TYPE_LIVE_NOW');
|
||||
get is_live(): boolean {
|
||||
return this.badges.some((badge) => {
|
||||
if (badge.label === 'BADGE_STYLE_TYPE_LIVE_NOW' || badge.style === 'LIVE')
|
||||
return true;
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
get is_upcoming(): boolean | undefined {
|
||||
return this.upcoming && this.upcoming > new Date();
|
||||
}
|
||||
|
||||
/*
|
||||
Get has_captions() {
|
||||
return this.badges.some((badge) => badge.label === 'CC');
|
||||
}*/
|
||||
get is_premiere(): boolean {
|
||||
return this.badges.some((badge) => badge.style === 'PREMIERE');
|
||||
}
|
||||
|
||||
get best_thumbnail(): Thumbnail | undefined{
|
||||
get is_4k(): boolean {
|
||||
return this.badges.some((badge) => badge.style === '4K');
|
||||
}
|
||||
|
||||
get has_captions(): boolean {
|
||||
return this.badges.some((badge) => badge.style === 'CC');
|
||||
}
|
||||
|
||||
get best_thumbnail(): Thumbnail | undefined {
|
||||
return this.thumbnails[0];
|
||||
}
|
||||
}
|
||||
|
||||
11
src/parser/classes/VideoCard.ts
Normal file
11
src/parser/classes/VideoCard.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import Video from './Video';
|
||||
|
||||
class VideoCard extends Video {
|
||||
static type = 'VideoCard';
|
||||
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default VideoCard;
|
||||
@@ -13,9 +13,9 @@ class WatchCardHeroVideo extends YTNode {
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.call_to_action_button = Parser.parse(data.callToActionButton);
|
||||
this.hero_image = Parser.parse(data.heroImage);
|
||||
this.label = data.accessibility.accessibilityData.label;
|
||||
this.call_to_action_button = Parser.parseItem(data.callToActionButton);
|
||||
this.hero_image = Parser.parseItem(data.heroImage);
|
||||
this.label = data.lengthText?.accessibility.accessibilityData.label || '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ class WatchCardSectionSequence extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.lists = Parser.parse(data.lists);
|
||||
this.lists = Parser.parseArray(data.lists);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class Comment extends YTNode {
|
||||
if (button.is_toggled)
|
||||
throw new InnertubeError('This comment is already liked', { comment_id: this.comment_id });
|
||||
|
||||
const response = await button.endpoint.callTest(this.#actions, { parse: false });
|
||||
const response = await button.endpoint.call(this.#actions, { parse: false });
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ class Comment extends YTNode {
|
||||
if (button.is_toggled)
|
||||
throw new InnertubeError('This comment is already disliked', { comment_id: this.comment_id });
|
||||
|
||||
const response = await button.endpoint.callTest(this.#actions, { parse: false });
|
||||
const response = await button.endpoint.call(this.#actions, { parse: false });
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ class Comment extends YTNode {
|
||||
commentText: text
|
||||
};
|
||||
|
||||
const response = await dialog_button.endpoint.callTest(this.#actions, payload);
|
||||
const response = await dialog_button.endpoint.call(this.#actions, payload);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class CommentThread extends YTNode {
|
||||
throw new InnertubeError('This comment has no replies.', { comment_id: this.comment?.comment_id });
|
||||
|
||||
const continuation = this.#replies.key('contents').parsed().array().get({ type: 'ContinuationItem' })?.as(ContinuationItem);
|
||||
const response = await continuation?.endpoint.callTest(this.#actions, { parse: true });
|
||||
const response = await continuation?.endpoint.call(this.#actions, { parse: true });
|
||||
|
||||
this.replies = response?.on_response_received_endpoints_memo?.getType(Comment).map((comment) => {
|
||||
comment.setActions(this.#actions);
|
||||
@@ -60,7 +60,7 @@ class CommentThread extends YTNode {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions not set for this CommentThread.');
|
||||
|
||||
const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).callTest(this.#actions, { parse: true });
|
||||
const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).call(this.#actions, { parse: true });
|
||||
|
||||
this.replies = response?.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
|
||||
comment.setActions(this.#actions);
|
||||
|
||||
@@ -5,19 +5,34 @@ import { YTNode } from '../../helpers';
|
||||
class CommentsEntryPointHeader extends YTNode {
|
||||
static type = 'CommentsEntryPointHeader';
|
||||
|
||||
header;
|
||||
comment_count;
|
||||
teaser_avatar;
|
||||
teaser_content;
|
||||
simplebox_placeholder;
|
||||
header?: Text;
|
||||
comment_count?: Text;
|
||||
teaser_avatar?: Thumbnail[];
|
||||
teaser_content?: Text;
|
||||
simplebox_placeholder?: Text;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.header = new Text(data.headerText);
|
||||
this.comment_count = new Text(data.commentCount);
|
||||
this.teaser_avatar = Thumbnail.fromResponse(data.teaserAvatar || data.simpleboxAvatar);
|
||||
this.teaser_content = new Text(data.teaserContent);
|
||||
this.simplebox_placeholder = new Text(data.simpleboxPlaceholder);
|
||||
|
||||
if (data.header) {
|
||||
this.header = new Text(data.headerText);
|
||||
}
|
||||
|
||||
if (data.commentCount) {
|
||||
this.comment_count = new Text(data.commentCount);
|
||||
}
|
||||
|
||||
if (data.teaserAvatar || data.simpleboxAvatar) {
|
||||
this.teaser_avatar = Thumbnail.fromResponse(data.teaserAvatar || data.simpleboxAvatar);
|
||||
}
|
||||
|
||||
if (data.teaserContent) {
|
||||
this.teaser_content = new Text(data.teaserContent);
|
||||
}
|
||||
|
||||
if (data.simpleboxPlaceholder) {
|
||||
this.simplebox_placeholder = new Text(data.simpleboxPlaceholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { YTNode } from '../../../helpers';
|
||||
import Text from '../../misc/Text';
|
||||
|
||||
class LiveChatRestrictedParticipation extends YTNode {
|
||||
message: Text;
|
||||
icon_type?: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.message = new Text(data.message);
|
||||
this.icon_type = data?.icon?.iconType;
|
||||
// TODO: parse onClickCommand
|
||||
}
|
||||
}
|
||||
|
||||
export default LiveChatRestrictedParticipation;
|
||||
@@ -8,7 +8,7 @@ class MusicMultiSelectMenuItem extends YTNode {
|
||||
title: string;
|
||||
form_item_entity_key: string;
|
||||
selected_icon_type: string;
|
||||
endpoint?: NavigationEndpoint;
|
||||
endpoint?: NavigationEndpoint | null;
|
||||
selected: boolean;
|
||||
|
||||
constructor(data: any) {
|
||||
@@ -17,19 +17,7 @@ class MusicMultiSelectMenuItem extends YTNode {
|
||||
this.title = new Text(data.title).text;
|
||||
this.form_item_entity_key = data.formItemEntityKey;
|
||||
this.selected_icon_type = data.selectedIcon?.iconType || null;
|
||||
const command = data.selectedCommand?.commandExecutorCommand?.commands?.find((command: any) => command.musicBrowseFormBinderCommand?.browseEndpoint);
|
||||
if (command) {
|
||||
/**
|
||||
* At this point, endpoint will still be missing `form_data` field which is required for
|
||||
* selection to take effect. This can only be obtained from the response data which
|
||||
* we don't have here. We shall delegate this task back to `Parser`.
|
||||
*/
|
||||
this.endpoint = new NavigationEndpoint(command.musicBrowseFormBinderCommand);
|
||||
}
|
||||
/**
|
||||
* Inferring selected state from existence of endpoint. `Parser` shall
|
||||
* update this with the definitive value obtained from response data.
|
||||
*/
|
||||
this.endpoint = data.selectedCommand ? new NavigationEndpoint(data.selectedCommand) : null;
|
||||
this.selected = !!this.endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ class Author {
|
||||
this.#nav_text = new NavigatableText(item);
|
||||
|
||||
this.id =
|
||||
(this.#nav_text.runs?.[0] as TextRun)?.endpoint?.browse?.id ||
|
||||
this.#nav_text?.endpoint?.browse?.id || 'N/A';
|
||||
(this.#nav_text.runs?.[0] as TextRun)?.endpoint?.payload?.browseId ||
|
||||
this.#nav_text?.endpoint?.payload?.browseId || 'N/A';
|
||||
|
||||
this.name = this.#nav_text.text || 'N/A';
|
||||
this.thumbnails = thumbs ? Thumbnail.fromResponse(thumbs) : [];
|
||||
@@ -32,9 +32,9 @@ class Author {
|
||||
this.is_verified_artist = this.badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || null;
|
||||
|
||||
this.url =
|
||||
(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.browse &&
|
||||
`${Constants.URLS.YT_BASE}${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.browse?.base_url || `/u/${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.browse?.id}`}` ||
|
||||
`${Constants.URLS.YT_BASE}${this.#nav_text?.endpoint?.browse?.base_url || `/u/${this.#nav_text?.endpoint?.browse?.id}`}` ||
|
||||
(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.metadata?.api_url === '/browse' &&
|
||||
`${Constants.URLS.YT_BASE}${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.payload?.canonicalBaseUrl || `/u/${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.payload?.browseId}`}` ||
|
||||
`${Constants.URLS.YT_BASE}${this.#nav_text?.endpoint?.payload?.canonicalBaseUrl || `/u/${this.#nav_text?.endpoint?.payload?.browseId}`}` ||
|
||||
null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import Player from '../../../core/Player';
|
||||
import { InnertubeError } from '../../../utils/Utils';
|
||||
|
||||
class Format {
|
||||
itag: string;
|
||||
itag: number;
|
||||
mime_type: string;
|
||||
bitrate;
|
||||
average_bitrate;
|
||||
width;
|
||||
height;
|
||||
bitrate: number;
|
||||
average_bitrate: number;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
init_range: {
|
||||
start: number;
|
||||
@@ -22,15 +23,15 @@ class Format {
|
||||
content_length: number;
|
||||
quality: string;
|
||||
quality_label: string | undefined;
|
||||
fps: string | undefined;
|
||||
fps: number | undefined;
|
||||
url: string;
|
||||
cipher: string | undefined;
|
||||
signature_cipher: string | undefined;
|
||||
audio_quality: string | undefined;
|
||||
approx_duration_ms: number;
|
||||
audio_sample_rate: number;
|
||||
audio_channels: string;
|
||||
loudness_db: string;
|
||||
audio_channels: number;
|
||||
loudness_db: number;
|
||||
has_audio: boolean;
|
||||
has_video: boolean;
|
||||
|
||||
@@ -73,7 +74,8 @@ class Format {
|
||||
* Decipher the streaming url of the format.
|
||||
* @returns Deciphered URL.
|
||||
*/
|
||||
decipher(player: Player): string {
|
||||
decipher(player: Player | undefined): string {
|
||||
if (!player) throw new InnertubeError('Cannot decipher format, this session appears to have no valid player.');
|
||||
return player.decipher(this.url, this.signature_cipher, this.cipher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ class NavigatableText extends Text {
|
||||
super(node);
|
||||
// TODO: is this needed? Text now supports this itself
|
||||
this.endpoint =
|
||||
node.runs?.[0]?.navigationEndpoint ?
|
||||
new NavigationEndpoint(node.runs[0].navigationEndpoint) :
|
||||
node.navigationEndpoint ?
|
||||
new NavigationEndpoint(node.navigationEndpoint) :
|
||||
node.titleNavigationEndpoint ?
|
||||
new NavigationEndpoint(node.titleNavigationEndpoint) : null;
|
||||
node?.runs?.[0]?.navigationEndpoint ?
|
||||
new NavigationEndpoint(node?.runs[0].navigationEndpoint) :
|
||||
node?.navigationEndpoint ?
|
||||
new NavigationEndpoint(node?.navigationEndpoint) :
|
||||
node?.titleNavigationEndpoint ?
|
||||
new NavigationEndpoint(node?.titleNavigationEndpoint) : null;
|
||||
}
|
||||
|
||||
toJSON(): NavigatableText {
|
||||
|
||||
@@ -3,9 +3,15 @@ import NavigationEndpoint from '../NavigationEndpoint';
|
||||
class TextRun {
|
||||
text: string;
|
||||
endpoint: NavigationEndpoint | undefined;
|
||||
bold: boolean;
|
||||
italics: boolean;
|
||||
strikethrough: boolean;
|
||||
|
||||
constructor(data: any) {
|
||||
this.text = data.text;
|
||||
this.bold = Boolean(data.bold);
|
||||
this.italics = Boolean(data.italics);
|
||||
this.strikethrough = Boolean(data.strikethrough);
|
||||
this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ class VideoDetails {
|
||||
view_count: number;
|
||||
author: string;
|
||||
is_private: boolean;
|
||||
is_live: boolean;
|
||||
is_live_content: boolean;
|
||||
is_upcoming: boolean;
|
||||
is_crawlable: boolean;
|
||||
|
||||
constructor(data: any) {
|
||||
@@ -29,7 +31,9 @@ class VideoDetails {
|
||||
this.view_count = parseInt(data.viewCount);
|
||||
this.author = data.author;
|
||||
this.is_private = !!data.isPrivate;
|
||||
this.is_live = !!data.isLive;
|
||||
this.is_live_content = !!data.isLiveContent;
|
||||
this.is_upcoming = !!data.isUpcoming;
|
||||
this.is_crawlable = !!data.isCrawlable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,10 @@ export type ObservedArray<T extends YTNode = YTNode> = Array<T> & {
|
||||
* Returns all objects that match the rule.
|
||||
*/
|
||||
getAll: (rule: object, del_items?: boolean) => T[];
|
||||
/**
|
||||
* Returns the first object to match the condition.
|
||||
*/
|
||||
matchCondition: (condition: (node: T) => boolean) => T | undefined;
|
||||
/**
|
||||
* Removes the item at the given index.
|
||||
*/
|
||||
@@ -371,6 +375,10 @@ export type ObservedArray<T extends YTNode = YTNode> = Array<T> & {
|
||||
* Get the first of a specific type
|
||||
*/
|
||||
firstOfType<R extends YTNode, K extends YTNodeConstructor<R>[]>(...types: K): InstanceType<K[number]> | undefined;
|
||||
/**
|
||||
* Get the first item
|
||||
*/
|
||||
first: () => T | undefined;
|
||||
/**
|
||||
* This is similar to filter but throws if there's a type mismatch.
|
||||
*/
|
||||
@@ -381,7 +389,7 @@ export type ObservedArray<T extends YTNode = YTNode> = Array<T> & {
|
||||
* Creates a trap to intercept property access
|
||||
* and add utilities to an object.
|
||||
*/
|
||||
export function observe<T extends YTNode>(obj: Array<T>) {
|
||||
export function observe<T extends YTNode>(obj: Array<T>): ObservedArray<T> {
|
||||
return new Proxy(obj, {
|
||||
get(target, prop) {
|
||||
if (prop == 'get') {
|
||||
@@ -412,6 +420,14 @@ export function observe<T extends YTNode>(obj: Array<T>) {
|
||||
);
|
||||
}
|
||||
|
||||
if (prop == 'matchCondition') {
|
||||
return (condition: (node: T) => boolean) => (
|
||||
target.find((obj) => {
|
||||
return condition(obj);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (prop == 'filterType') {
|
||||
return (...types: YTNodeConstructor<YTNode>[]) => {
|
||||
return observe(target.filter((node: YTNode) => {
|
||||
@@ -423,6 +439,7 @@ export function observe<T extends YTNode>(obj: Array<T>) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (prop == 'firstOfType') {
|
||||
return (...types: YTNodeConstructor<YTNode>[]) => {
|
||||
return target.find((node: YTNode) => {
|
||||
@@ -433,6 +450,10 @@ export function observe<T extends YTNode>(obj: Array<T>) {
|
||||
};
|
||||
}
|
||||
|
||||
if (prop == 'first') {
|
||||
return () => target[0];
|
||||
}
|
||||
|
||||
if (prop == 'as') {
|
||||
return (...types: YTNodeConstructor<YTNode>[]) => {
|
||||
return observe(target.map((node: YTNode) => {
|
||||
|
||||
@@ -1,21 +1,382 @@
|
||||
import type AudioOnlyPlayability from './classes/AudioOnlyPlayability';
|
||||
import type CardCollection from './classes/CardCollection';
|
||||
import type Endscreen from './classes/Endscreen';
|
||||
import type PlayerAnnotationsExpanded from './classes/PlayerAnnotationsExpanded';
|
||||
import type PlayerCaptionsTracklist from './classes/PlayerCaptionsTracklist';
|
||||
import type PlayerLiveStoryboardSpec from './classes/PlayerLiveStoryboardSpec';
|
||||
import type PlayerStoryboardSpec from './classes/PlayerStoryboardSpec';
|
||||
|
||||
import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem';
|
||||
import Format from './classes/misc/Format';
|
||||
import VideoDetails from './classes/misc/VideoDetails';
|
||||
import GetParserByName from './map';
|
||||
import Endscreen from './classes/Endscreen';
|
||||
import CardCollection from './classes/CardCollection';
|
||||
import NavigationEndpoint from './classes/NavigationEndpoint';
|
||||
|
||||
import PlayerStoryboardSpec from './classes/PlayerStoryboardSpec';
|
||||
import PlayerCaptionsTracklist from './classes/PlayerCaptionsTracklist';
|
||||
import PlayerLiveStoryboardSpec from './classes/PlayerLiveStoryboardSpec';
|
||||
import PlayerAnnotationsExpanded from './classes/PlayerAnnotationsExpanded';
|
||||
|
||||
import { InnertubeError, ParsingError } from '../utils/Utils';
|
||||
import { YTNode, YTNodeConstructor, SuperParsedResult, ObservedArray, observe, Memo } from './helpers';
|
||||
import { Memo, observe, ObservedArray, SuperParsedResult, YTNode, YTNodeConstructor } from './helpers';
|
||||
import GetParserByName from './map';
|
||||
|
||||
import package_json from '../../package.json';
|
||||
import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem';
|
||||
import AudioOnlyPlayability from './classes/AudioOnlyPlayability';
|
||||
|
||||
export type ParserError = { classname: string, classdata: any, err: any };
|
||||
export type ParserErrorHandler = (error: ParserError) => void;
|
||||
|
||||
export default class Parser {
|
||||
static #errorHandler: ParserErrorHandler = Parser.#printError;
|
||||
static #memo: Memo | null = null;
|
||||
|
||||
static setParserErrorHandler(handler: ParserErrorHandler) {
|
||||
this.#errorHandler = handler;
|
||||
}
|
||||
|
||||
static #clearMemo() {
|
||||
Parser.#memo = null;
|
||||
}
|
||||
|
||||
static #createMemo() {
|
||||
Parser.#memo = new Memo();
|
||||
}
|
||||
|
||||
static #addToMemo(classname: string, result: YTNode) {
|
||||
if (!Parser.#memo)
|
||||
return;
|
||||
|
||||
const list = Parser.#memo.get(classname);
|
||||
if (!list)
|
||||
return Parser.#memo.set(classname, [ result ]);
|
||||
|
||||
list.push(result);
|
||||
}
|
||||
|
||||
static #getMemo() {
|
||||
if (!Parser.#memo)
|
||||
throw new Error('Parser#getMemo() called before Parser#createMemo()');
|
||||
return Parser.#memo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses InnerTube response.
|
||||
* @param data - The response data.
|
||||
*/
|
||||
static parseResponse(data: any) {
|
||||
// Memoize the response objects by classname
|
||||
this.#createMemo();
|
||||
// TODO: should this parseItem?
|
||||
const contents = Parser.parse(data.contents);
|
||||
const contents_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const on_response_received_actions = data.onResponseReceivedActions ? Parser.parseRR(data.onResponseReceivedActions) : null;
|
||||
const on_response_received_actions_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const on_response_received_endpoints = data.onResponseReceivedEndpoints ? Parser.parseRR(data.onResponseReceivedEndpoints) : null;
|
||||
const on_response_received_endpoints_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const on_response_received_commands = data.onResponseReceivedCommands ? Parser.parseRR(data.onResponseReceivedCommands) : null;
|
||||
const on_response_received_commands_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const actions = data.actions ? Parser.parseActions(data.actions) : null;
|
||||
const actions_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const live_chat_item_context_menu_supported_renderers = data.liveChatItemContextMenuSupportedRenderers
|
||||
? Parser.parseItem(data.liveChatItemContextMenuSupportedRenderers)
|
||||
: null;
|
||||
const live_chat_item_context_menu_supported_renderers_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const header = data.header ? Parser.parse(data.header) : null;
|
||||
const header_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const sidebar = data.sidebar ? Parser.parseItem(data.sidebar) : null;
|
||||
const sidebar_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.applyMutations(contents_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations);
|
||||
|
||||
return {
|
||||
actions,
|
||||
actions_memo,
|
||||
contents,
|
||||
contents_memo,
|
||||
header,
|
||||
header_memo,
|
||||
sidebar,
|
||||
sidebar_memo,
|
||||
live_chat_item_context_menu_supported_renderers,
|
||||
live_chat_item_context_menu_supported_renderers_memo,
|
||||
on_response_received_actions,
|
||||
on_response_received_actions_memo,
|
||||
on_response_received_endpoints,
|
||||
on_response_received_endpoints_memo,
|
||||
on_response_received_commands,
|
||||
on_response_received_commands_memo,
|
||||
continuation: data.continuation ? Parser.parseC(data.continuation) : null,
|
||||
continuation_contents: data.continuationContents ? Parser.parseLC(data.continuationContents) : null,
|
||||
metadata: Parser.parse(data.metadata),
|
||||
microformat: data.microformat ? Parser.parseItem(data.microformat) : null,
|
||||
overlay: Parser.parseItem(data.overlay),
|
||||
refinements: data.refinements || null,
|
||||
estimated_results: data.estimatedResults ? parseInt(data.estimatedResults) : null,
|
||||
player_overlays: Parser.parse(data.playerOverlays),
|
||||
playback_tracking: data.playbackTracking ? {
|
||||
videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl as string,
|
||||
videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl as string
|
||||
} : null,
|
||||
playability_status: data.playabilityStatus ? {
|
||||
status: data.playabilityStatus.status as string,
|
||||
error_screen: Parser.parseItem(data.playabilityStatus.errorScreen),
|
||||
audio_only_playablility: Parser.parseItem<AudioOnlyPlayability>(data.playabilityStatus.audioOnlyPlayability),
|
||||
embeddable: !!data.playabilityStatus.playableInEmbed || false,
|
||||
reason: data.playabilityStatus?.reason || ''
|
||||
} : undefined,
|
||||
streaming_data: data.streamingData ? {
|
||||
expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000),
|
||||
formats: Parser.parseFormats(data.streamingData.formats),
|
||||
adaptive_formats: Parser.parseFormats(data.streamingData.adaptiveFormats),
|
||||
dash_manifest_url: data.streamingData?.dashManifestUrl as string || null,
|
||||
hls_manifest_url: data.streamingData?.hlsManifestUrl as string || null
|
||||
} : undefined,
|
||||
current_video_endpoint: data.currentVideoEndpoint ? new NavigationEndpoint(data.currentVideoEndpoint) : null,
|
||||
endpoint: data.endpoint ? new NavigationEndpoint(data.endpoint) : null,
|
||||
captions: Parser.parseItem<PlayerCaptionsTracklist>(data.captions),
|
||||
video_details: data.videoDetails ? new VideoDetails(data.videoDetails) : undefined,
|
||||
annotations: Parser.parseArray<PlayerAnnotationsExpanded>(data.annotations),
|
||||
storyboards: Parser.parseItem<PlayerStoryboardSpec | PlayerLiveStoryboardSpec>(data.storyboards),
|
||||
endscreen: Parser.parseItem<Endscreen>(data.endscreen),
|
||||
cards: Parser.parseItem<CardCollection>(data.cards)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single item.
|
||||
* @param data - The data to parse.
|
||||
* @param validTypes - YTNode types that are allowed to be parsed.
|
||||
*/
|
||||
static parseItem<T extends YTNode = YTNode>(data: any, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
|
||||
if (!data) return null;
|
||||
|
||||
const keys = Object.keys(data);
|
||||
|
||||
if (!keys.length)
|
||||
return null;
|
||||
|
||||
const classname = this.sanitizeClassName(keys[0]);
|
||||
|
||||
if (!this.shouldIgnore(classname)) {
|
||||
try {
|
||||
const TargetClass = GetParserByName(classname);
|
||||
|
||||
if (validTypes) {
|
||||
if (Array.isArray(validTypes)) {
|
||||
if (!validTypes.some((type) => type.type === TargetClass.type))
|
||||
throw new ParsingError(`Type mismatch, got ${classname} but expected one of ${validTypes.map((type) => type.type).join(', ')}`);
|
||||
} else if (TargetClass.type !== validTypes.type)
|
||||
throw new ParsingError(`Type mismatch, got ${classname} but expected ${validTypes.type}`);
|
||||
}
|
||||
|
||||
const result = new TargetClass(data[keys[0]]);
|
||||
this.#addToMemo(classname, result);
|
||||
|
||||
return result as T;
|
||||
} catch (err) {
|
||||
this.#errorHandler({ classname, classdata: data[keys[0]], err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an array of items.
|
||||
* @param data - The data to parse.
|
||||
* @param validTypes - YTNode types that are allowed to be parsed.
|
||||
*/
|
||||
static parseArray<T extends YTNode = YTNode>(data: any[], validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
|
||||
if (Array.isArray(data)) {
|
||||
const results: T[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
const result = this.parseItem(item, validTypes);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return observe(results);
|
||||
} else if (!data) {
|
||||
return observe([] as T[]);
|
||||
}
|
||||
throw new ParsingError('Expected array but got a single item');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an item or an array of items.
|
||||
* @param data - The data to parse.
|
||||
* @param requireArray - Whether the data should be parsed as an array.
|
||||
* @param validTypes - YTNode types that are allowed to be parsed.
|
||||
*/
|
||||
static parse<T extends YTNode = YTNode>(data: any, requireArray: true, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]): ObservedArray<T> | null;
|
||||
static parse<T extends YTNode = YTNode>(data: any, requireArray?: false | undefined, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]): SuperParsedResult<T>;
|
||||
static parse<T extends YTNode = YTNode>(data: any, requireArray?: boolean, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
|
||||
if (!data) return null;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const results: T[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
const result = this.parseItem(item, validTypes);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const res = observe(results);
|
||||
|
||||
return requireArray ? res : new SuperParsedResult(observe(results));
|
||||
} else if (requireArray) {
|
||||
throw new ParsingError('Expected array but got a single item');
|
||||
}
|
||||
|
||||
return new SuperParsedResult(this.parseItem(data, validTypes));
|
||||
}
|
||||
|
||||
static parseC(data: any) {
|
||||
if (data.timedContinuationData)
|
||||
return new TimedContinuation(data.timedContinuationData);
|
||||
}
|
||||
|
||||
static parseLC(data: any) {
|
||||
if (data.sectionListContinuation)
|
||||
return new SectionListContinuation(data.sectionListContinuation);
|
||||
if (data.liveChatContinuation)
|
||||
return new LiveChatContinuation(data.liveChatContinuation);
|
||||
if (data.musicPlaylistShelfContinuation)
|
||||
return new MusicPlaylistShelfContinuation(data.musicPlaylistShelfContinuation);
|
||||
if (data.musicShelfContinuation)
|
||||
return new MusicShelfContinuation(data.musicShelfContinuation);
|
||||
if (data.gridContinuation)
|
||||
return new GridContinuation(data.gridContinuation);
|
||||
if (data.playlistPanelContinuation)
|
||||
return new PlaylistPanelContinuation(data.playlistPanelContinuation);
|
||||
}
|
||||
|
||||
static parseRR(actions: any[]) {
|
||||
return observe(actions.map((action: any) => {
|
||||
if (action.reloadContinuationItemsCommand)
|
||||
return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand);
|
||||
if (action.appendContinuationItemsAction)
|
||||
return new AppendContinuationItemsAction(action.appendContinuationItemsAction);
|
||||
}).filter((item) => item) as (ReloadContinuationItemsCommand | AppendContinuationItemsAction)[]);
|
||||
}
|
||||
|
||||
static parseActions(data: any) {
|
||||
if (Array.isArray(data)) {
|
||||
return Parser.parse(data.map((action) => {
|
||||
delete action.clickTrackingParams;
|
||||
return action;
|
||||
}));
|
||||
}
|
||||
return new SuperParsedResult(Parser.parseItem(data));
|
||||
}
|
||||
|
||||
static parseFormats(formats: any[]) {
|
||||
return formats?.map((format) => new Format(format)) || [];
|
||||
}
|
||||
|
||||
static applyMutations(memo: Memo, mutations: Array<any>) {
|
||||
// Apply mutations to MusicMultiSelectMenuItems
|
||||
const music_multi_select_menu_items = memo.getType(MusicMultiSelectMenuItem);
|
||||
|
||||
if (music_multi_select_menu_items.length > 0 && !mutations) {
|
||||
console.warn(
|
||||
new InnertubeError(
|
||||
'Mutation data required for processing MusicMultiSelectMenuItems, but none found.\n' +
|
||||
`This is a bug, please report it at ${package_json.bugs.url}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const missing_or_invalid_mutations = [];
|
||||
|
||||
for (const menu_item of music_multi_select_menu_items) {
|
||||
const mutation = mutations
|
||||
.find((mutation) => mutation.payload?.musicFormBooleanChoice?.id === menu_item.form_item_entity_key);
|
||||
|
||||
const choice = mutation?.payload.musicFormBooleanChoice;
|
||||
|
||||
if (choice?.selected !== undefined && choice?.opaqueToken) {
|
||||
menu_item.selected = choice.selected;
|
||||
} else {
|
||||
missing_or_invalid_mutations.push(`'${menu_item.title}'`);
|
||||
}
|
||||
}
|
||||
if (missing_or_invalid_mutations.length > 0) {
|
||||
console.warn(
|
||||
new InnertubeError(
|
||||
`Mutation data missing or invalid for ${missing_or_invalid_mutations.length} out of ${music_multi_select_menu_items.length} MusicMultiSelectMenuItems. ` +
|
||||
`The titles of the failed items are: ${missing_or_invalid_mutations.join(', ')}.\n` +
|
||||
`This is a bug, please report it at ${package_json.bugs.url}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static #printError({ classname, classdata, err }: ParserError) {
|
||||
if (err.code == 'MODULE_NOT_FOUND') {
|
||||
return console.warn(
|
||||
new InnertubeError(
|
||||
`${classname} not found!\n` +
|
||||
`This is a bug, want to help us fix it? Follow the instructions at ${package_json.homepage.split('#')[0]}/blob/main/docs/updating-the-parser.md or report it at ${package_json.bugs.url}!`, classdata
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
new InnertubeError(
|
||||
`Something went wrong at ${classname}!\n` +
|
||||
`This is a bug, please report it at ${package_json.bugs.url}`, { stack: err.stack }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static sanitizeClassName(input: string) {
|
||||
return (input.charAt(0).toUpperCase() + input.slice(1))
|
||||
.replace(/Renderer|Model/g, '')
|
||||
.replace(/Radio/g, 'Mix').trim();
|
||||
}
|
||||
|
||||
static ignore_list = new Set<string>([
|
||||
'AdSlot',
|
||||
'DisplayAd',
|
||||
'SearchPyv',
|
||||
'MealbarPromo',
|
||||
'BackgroundPromo',
|
||||
'PromotedSparklesWeb',
|
||||
'RunAttestationCommand',
|
||||
'CompactPromotedVideo',
|
||||
'StatementBanner'
|
||||
]);
|
||||
|
||||
static shouldIgnore(classname: string) {
|
||||
return this.ignore_list.has(classname);
|
||||
}
|
||||
}
|
||||
|
||||
export type ParsedResponse = ReturnType<typeof Parser.parseResponse>;
|
||||
|
||||
// Continuation nodes
|
||||
|
||||
export class AppendContinuationItemsAction extends YTNode {
|
||||
static readonly type = 'appendContinuationItemsAction';
|
||||
@@ -50,7 +411,9 @@ export class SectionListContinuation extends YTNode {
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.contents = Parser.parse(data.contents, true);
|
||||
this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;
|
||||
this.continuation =
|
||||
data.continuations?.[0]?.nextContinuationData?.continuation ||
|
||||
data.continuations?.[0]?.reloadContinuationData?.continuation || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,321 +531,4 @@ export class LiveChatContinuation extends YTNode {
|
||||
|
||||
this.viewer_name = data.viewerName;
|
||||
}
|
||||
}
|
||||
|
||||
export default class Parser {
|
||||
static #memo: Memo | null = null;
|
||||
|
||||
static #clearMemo() {
|
||||
Parser.#memo = null;
|
||||
}
|
||||
|
||||
static #createMemo() {
|
||||
Parser.#memo = new Memo();
|
||||
}
|
||||
|
||||
static #addToMemo(classname: string, result: YTNode) {
|
||||
if (!Parser.#memo)
|
||||
return;
|
||||
|
||||
const list = Parser.#memo.get(classname);
|
||||
if (!list)
|
||||
return Parser.#memo.set(classname, [ result ]);
|
||||
|
||||
list.push(result);
|
||||
}
|
||||
|
||||
static #getMemo() {
|
||||
if (!Parser.#memo)
|
||||
throw new Error('Parser#getMemo() called before Parser#createMemo()');
|
||||
return Parser.#memo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses InnerTube response.
|
||||
*/
|
||||
static parseResponse(data: any) {
|
||||
// Memoize the response objects by classname
|
||||
this.#createMemo();
|
||||
// TODO: is this parseItem?
|
||||
const contents = Parser.parse(data.contents);
|
||||
const contents_memo = this.#getMemo();
|
||||
// End of memoization
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const on_response_received_actions = data.onResponseReceivedActions ? Parser.parseRR(data.onResponseReceivedActions) : null;
|
||||
const on_response_received_actions_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const on_response_received_endpoints = data.onResponseReceivedEndpoints ? Parser.parseRR(data.onResponseReceivedEndpoints) : null;
|
||||
const on_response_received_endpoints_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const on_response_received_commands = data.onResponseReceivedCommands ? Parser.parseRR(data.onResponseReceivedCommands) : null;
|
||||
const on_response_received_commands_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const actions = data.actions ? Parser.parseActions(data.actions) : null;
|
||||
const actions_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.#createMemo();
|
||||
const live_chat_item_context_menu_supported_renderers = data.liveChatItemContextMenuSupportedRenderers
|
||||
? Parser.parseItem(data.liveChatItemContextMenuSupportedRenderers)
|
||||
: null;
|
||||
const live_chat_item_context_menu_supported_renderers_memo = this.#getMemo();
|
||||
this.#clearMemo();
|
||||
|
||||
this.applyMutations(contents_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations);
|
||||
|
||||
return {
|
||||
actions,
|
||||
actions_memo,
|
||||
contents,
|
||||
contents_memo,
|
||||
live_chat_item_context_menu_supported_renderers,
|
||||
live_chat_item_context_menu_supported_renderers_memo,
|
||||
on_response_received_actions,
|
||||
on_response_received_actions_memo,
|
||||
on_response_received_endpoints,
|
||||
on_response_received_endpoints_memo,
|
||||
on_response_received_commands,
|
||||
on_response_received_commands_memo,
|
||||
continuation: data.continuation ? Parser.parseC(data.continuation) : null,
|
||||
continuation_contents: data.continuationContents ? Parser.parseLC(data.continuationContents) : null,
|
||||
metadata: Parser.parse(data.metadata),
|
||||
header: Parser.parse(data.header),
|
||||
microformat: data.microformat ? Parser.parseItem(data.microformat) : null,
|
||||
sidebar: Parser.parseItem(data.sidebar),
|
||||
overlay: Parser.parseItem(data.overlay),
|
||||
refinements: data.refinements || null,
|
||||
estimated_results: data.estimatedResults ? parseInt(data.estimatedResults) : null,
|
||||
player_overlays: Parser.parse(data.playerOverlays),
|
||||
playback_tracking: data.playbackTracking ? {
|
||||
videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl,
|
||||
videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl
|
||||
} : null,
|
||||
playability_status: data.playabilityStatus ? {
|
||||
status: data.playabilityStatus.status as string,
|
||||
error_screen: Parser.parseItem(data.playabilityStatus.errorScreen),
|
||||
audio_only_playablility: Parser.parseItem<AudioOnlyPlayability>(data.playabilityStatus.audioOnlyPlayability, AudioOnlyPlayability),
|
||||
embeddable: !!data.playabilityStatus.playableInEmbed || false,
|
||||
reason: data.playabilityStatus?.reason || ''
|
||||
} : undefined,
|
||||
streaming_data: data.streamingData ? {
|
||||
expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000),
|
||||
formats: Parser.parseFormats(data.streamingData.formats),
|
||||
adaptive_formats: Parser.parseFormats(data.streamingData.adaptiveFormats),
|
||||
dash_manifest_url: data.streamingData?.dashManifestUrl || null,
|
||||
hls_manifest_url: data.streamingData?.hlsManifestUrl || null
|
||||
} : undefined,
|
||||
current_video_endpoint: data.currentVideoEndpoint ? new NavigationEndpoint(data.currentVideoEndpoint) : null,
|
||||
captions: Parser.parseItem<PlayerCaptionsTracklist>(data.captions, PlayerCaptionsTracklist),
|
||||
video_details: data.videoDetails ? new VideoDetails(data.videoDetails) : undefined,
|
||||
annotations: Parser.parseArray<PlayerAnnotationsExpanded>(data.annotations, PlayerAnnotationsExpanded),
|
||||
storyboards: Parser.parseItem<PlayerStoryboardSpec | PlayerLiveStoryboardSpec>(data.storyboards, [ PlayerStoryboardSpec, PlayerLiveStoryboardSpec ]),
|
||||
endscreen: Parser.parseItem<Endscreen>(data.endscreen, Endscreen),
|
||||
cards: Parser.parseItem<CardCollection>(data.cards, CardCollection)
|
||||
};
|
||||
}
|
||||
|
||||
static parseC(data: any) {
|
||||
if (data.timedContinuationData)
|
||||
return new TimedContinuation(data.timedContinuationData);
|
||||
}
|
||||
|
||||
static parseLC(data: any) {
|
||||
if (data.sectionListContinuation)
|
||||
return new SectionListContinuation(data.sectionListContinuation);
|
||||
if (data.liveChatContinuation)
|
||||
return new LiveChatContinuation(data.liveChatContinuation);
|
||||
if (data.musicPlaylistShelfContinuation)
|
||||
return new MusicPlaylistShelfContinuation(data.musicPlaylistShelfContinuation);
|
||||
if (data.musicShelfContinuation)
|
||||
return new MusicShelfContinuation(data.musicShelfContinuation);
|
||||
if (data.gridContinuation)
|
||||
return new GridContinuation(data.gridContinuation);
|
||||
if (data.playlistPanelContinuation)
|
||||
return new PlaylistPanelContinuation(data.playlistPanelContinuation);
|
||||
}
|
||||
|
||||
static parseRR(actions: any[]) {
|
||||
return observe(actions.map((action: any) => {
|
||||
if (action.reloadContinuationItemsCommand)
|
||||
return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand);
|
||||
if (action.appendContinuationItemsAction)
|
||||
return new AppendContinuationItemsAction(action.appendContinuationItemsAction);
|
||||
}).filter((item) => item) as (ReloadContinuationItemsCommand | AppendContinuationItemsAction)[]);
|
||||
}
|
||||
|
||||
static parseActions(data: any) {
|
||||
if (Array.isArray(data)) {
|
||||
return Parser.parse(data.map((action) => {
|
||||
delete action.clickTrackingParams;
|
||||
return action;
|
||||
}));
|
||||
}
|
||||
return new SuperParsedResult(Parser.parseItem(data));
|
||||
}
|
||||
|
||||
static parseFormats(formats: any[]) {
|
||||
return formats?.map((format) => new Format(format)) || [];
|
||||
}
|
||||
|
||||
static parseItem<T extends YTNode = YTNode>(data: any, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
|
||||
if (!data) return null;
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const classname = this.sanitizeClassName(keys[0]);
|
||||
|
||||
if (!this.shouldIgnore(classname)) {
|
||||
try {
|
||||
const TargetClass = GetParserByName(classname);
|
||||
|
||||
if (validTypes) {
|
||||
if (Array.isArray(validTypes)) {
|
||||
if (!validTypes.some((type) => type.type === TargetClass.type))
|
||||
throw new ParsingError(`Type mismatch, got ${classname} but expected one of ${validTypes.map((type) => type.type).join(', ')}`);
|
||||
} else if (TargetClass.type !== validTypes.type)
|
||||
throw new ParsingError(`Type mismatch, got ${classname} but expected ${validTypes.type}`);
|
||||
}
|
||||
|
||||
const result = new TargetClass(data[keys[0]]);
|
||||
this.#addToMemo(classname, result);
|
||||
|
||||
return result as T;
|
||||
} catch (err) {
|
||||
this.printError({ classname, classdata: data[keys[0]], err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static parseArray<T extends YTNode = YTNode>(data: any[], validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
|
||||
if (Array.isArray(data)) {
|
||||
const results: T[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
const result = this.parseItem(item, validTypes);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return observe(results);
|
||||
} else if (!data) {
|
||||
return observe([] as T[]);
|
||||
}
|
||||
throw new ParsingError('Expected array but got a single item');
|
||||
}
|
||||
|
||||
static parse<T extends YTNode = YTNode>(data: any, requireArray: true, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) : ObservedArray<T> | null;
|
||||
static parse<T extends YTNode = YTNode>(data: any, requireArray?: false | undefined, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) : SuperParsedResult<T>;
|
||||
static parse<T extends YTNode = YTNode>(data: any, requireArray?: boolean, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
|
||||
if (!data) return null;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const results: T[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
const result = this.parseItem(item, validTypes);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const res = observe(results);
|
||||
|
||||
return requireArray ? res : new SuperParsedResult(observe(results));
|
||||
} else if (requireArray) {
|
||||
throw new ParsingError('Expected array but got a single item');
|
||||
}
|
||||
|
||||
return new SuperParsedResult(this.parseItem(data, validTypes));
|
||||
}
|
||||
|
||||
static applyMutations(memo: Memo, mutations: Array<any>) {
|
||||
// Apply mutations to MusicMultiSelectMenuItems
|
||||
const musicMultiSelectMenuItems = memo.getType(MusicMultiSelectMenuItem);
|
||||
if (musicMultiSelectMenuItems.length > 0 && !mutations) {
|
||||
console.warn(
|
||||
new InnertubeError(
|
||||
'Mutation data required for processing MusicMultiSelectMenuItems, but none found.\n' +
|
||||
`This is a bug, please report it at ${package_json.bugs.url}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const missingOrInvalidMutations = [];
|
||||
for (const menuItem of musicMultiSelectMenuItems) {
|
||||
const mutation = mutations.find((mutation) => mutation.payload?.musicFormBooleanChoice?.id === menuItem.form_item_entity_key);
|
||||
const choice = mutation?.payload.musicFormBooleanChoice;
|
||||
if (choice?.selected !== undefined && choice?.opaqueToken) {
|
||||
menuItem.selected = choice.selected;
|
||||
if (menuItem.endpoint?.browse) {
|
||||
menuItem.endpoint.browse.form_data = {
|
||||
selectedValues: [ choice.opaqueToken ]
|
||||
};
|
||||
}
|
||||
} else {
|
||||
missingOrInvalidMutations.push(`'${menuItem.title}'`);
|
||||
}
|
||||
}
|
||||
if (missingOrInvalidMutations.length > 0) {
|
||||
console.warn(
|
||||
new InnertubeError(
|
||||
`Mutation data missing or invalid for ${missingOrInvalidMutations.length} out of ${musicMultiSelectMenuItems.length} MusicMultiSelectMenuItems. ` +
|
||||
`The titles of the failed items are: ${missingOrInvalidMutations.join(', ')}.\n` +
|
||||
`This is a bug, please report it at ${package_json.bugs.url}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static printError({ classname, classdata, err }: { classname: string, classdata: any, err: any }) {
|
||||
if (err.code == 'MODULE_NOT_FOUND') {
|
||||
return console.warn(
|
||||
new InnertubeError(
|
||||
`${classname} not found!\n` +
|
||||
`This is a bug, want to help us fix it? Follow the instructions at ${package_json.homepage.split('#')[0]}/blob/main/docs/updating-the-parser.md or report it at ${package_json.bugs.url}!`, classdata
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
new InnertubeError(
|
||||
`Something went wrong at ${classname}!\n` +
|
||||
`This is a bug, please report it at ${package_json.bugs.url}`, { stack: err.stack }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static sanitizeClassName(input: string) {
|
||||
return (input.charAt(0).toUpperCase() + input.slice(1))
|
||||
.replace(/Renderer|Model/g, '')
|
||||
.replace(/Radio/g, 'Mix').trim();
|
||||
}
|
||||
|
||||
static ignore_list = new Set<string>([
|
||||
'DisplayAd',
|
||||
'SearchPyv',
|
||||
'MealbarPromo',
|
||||
'BackgroundPromo',
|
||||
'PromotedSparklesWeb',
|
||||
'RunAttestationCommand',
|
||||
'CompactPromotedVideo',
|
||||
'StatementBanner'
|
||||
]);
|
||||
|
||||
static shouldIgnore(classname: string) {
|
||||
return this.ignore_list.has(classname);
|
||||
}
|
||||
}
|
||||
|
||||
export type ParsedResponse = ReturnType<typeof Parser.parseResponse>;
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import { default as C4TabbedHeader } from './classes/C4TabbedHeader';
|
||||
import { default as CallToActionButton } from './classes/CallToActionButton';
|
||||
import { default as Card } from './classes/Card';
|
||||
import { default as CardCollection } from './classes/CardCollection';
|
||||
import { default as CarouselHeader } from './classes/CarouselHeader';
|
||||
import { default as CarouselItem } from './classes/CarouselItem';
|
||||
import { default as Channel } from './classes/Channel';
|
||||
import { default as ChannelAboutFullMetadata } from './classes/ChannelAboutFullMetadata';
|
||||
import { default as ChannelFeaturedContent } from './classes/ChannelFeaturedContent';
|
||||
@@ -37,6 +39,7 @@ import { default as ChannelMobileHeader } from './classes/ChannelMobileHeader';
|
||||
import { default as ChannelOptions } from './classes/ChannelOptions';
|
||||
import { default as ChannelThumbnailWithLink } from './classes/ChannelThumbnailWithLink';
|
||||
import { default as ChannelVideoPlayer } from './classes/ChannelVideoPlayer';
|
||||
import { default as Chapter } from './classes/Chapter';
|
||||
import { default as ChildVideo } from './classes/ChildVideo';
|
||||
import { default as ChipCloud } from './classes/ChipCloud';
|
||||
import { default as ChipCloudChip } from './classes/ChipCloudChip';
|
||||
@@ -54,11 +57,14 @@ import { default as CommentThread } from './classes/comments/CommentThread';
|
||||
import { default as CompactLink } from './classes/CompactLink';
|
||||
import { default as CompactMix } from './classes/CompactMix';
|
||||
import { default as CompactPlaylist } from './classes/CompactPlaylist';
|
||||
import { default as CompactStation } from './classes/CompactStation';
|
||||
import { default as CompactVideo } from './classes/CompactVideo';
|
||||
import { default as ConfirmDialog } from './classes/ConfirmDialog';
|
||||
import { default as ContinuationItem } from './classes/ContinuationItem';
|
||||
import { default as CopyLink } from './classes/CopyLink';
|
||||
import { default as CreatePlaylistDialog } from './classes/CreatePlaylistDialog';
|
||||
import { default as DecoratedPlayerBar } from './classes/DecoratedPlayerBar';
|
||||
import { default as DefaultPromoPanel } from './classes/DefaultPromoPanel';
|
||||
import { default as DidYouMean } from './classes/DidYouMean';
|
||||
import { default as DownloadButton } from './classes/DownloadButton';
|
||||
import { default as Dropdown } from './classes/Dropdown';
|
||||
@@ -69,20 +75,26 @@ import { default as Endscreen } from './classes/Endscreen';
|
||||
import { default as EndscreenElement } from './classes/EndscreenElement';
|
||||
import { default as EndScreenPlaylist } from './classes/EndScreenPlaylist';
|
||||
import { default as EndScreenVideo } from './classes/EndScreenVideo';
|
||||
import { default as ExpandableMetadata } from './classes/ExpandableMetadata';
|
||||
import { default as ExpandableTab } from './classes/ExpandableTab';
|
||||
import { default as ExpandedShelfContents } from './classes/ExpandedShelfContents';
|
||||
import { default as FeedFilterChipBar } from './classes/FeedFilterChipBar';
|
||||
import { default as FeedTabbedHeader } from './classes/FeedTabbedHeader';
|
||||
import { default as GameCard } from './classes/GameCard';
|
||||
import { default as GameDetails } from './classes/GameDetails';
|
||||
import { default as Grid } from './classes/Grid';
|
||||
import { default as GridChannel } from './classes/GridChannel';
|
||||
import { default as GridHeader } from './classes/GridHeader';
|
||||
import { default as GridPlaylist } from './classes/GridPlaylist';
|
||||
import { default as GridVideo } from './classes/GridVideo';
|
||||
import { default as Heatmap } from './classes/Heatmap';
|
||||
import { default as HeatMarker } from './classes/HeatMarker';
|
||||
import { default as HighlightsCarousel } from './classes/HighlightsCarousel';
|
||||
import { default as HistorySuggestion } from './classes/HistorySuggestion';
|
||||
import { default as HorizontalCardList } from './classes/HorizontalCardList';
|
||||
import { default as HorizontalList } from './classes/HorizontalList';
|
||||
import { default as IconLink } from './classes/IconLink';
|
||||
import { default as InteractiveTabbedHeader } from './classes/InteractiveTabbedHeader';
|
||||
import { default as ItemSection } from './classes/ItemSection';
|
||||
import { default as ItemSectionHeader } from './classes/ItemSectionHeader';
|
||||
import { default as ItemSectionTab } from './classes/ItemSectionTab';
|
||||
@@ -101,6 +113,7 @@ import { default as LiveChatPaidMessage } from './classes/livechat/items/LiveCha
|
||||
import { default as LiveChatPaidSticker } from './classes/livechat/items/LiveChatPaidSticker';
|
||||
import { default as LiveChatPlaceholderItem } from './classes/livechat/items/LiveChatPlaceholderItem';
|
||||
import { default as LiveChatProductItem } from './classes/livechat/items/LiveChatProductItem';
|
||||
import { default as LiveChatRestrictedParticipation } from './classes/livechat/items/LiveChatRestrictedParticipation';
|
||||
import { default as LiveChatTextMessage } from './classes/livechat/items/LiveChatTextMessage';
|
||||
import { default as LiveChatTickerPaidMessageItem } from './classes/livechat/items/LiveChatTickerPaidMessageItem';
|
||||
import { default as LiveChatTickerPaidStickerItem } from './classes/livechat/items/LiveChatTickerPaidStickerItem';
|
||||
@@ -130,6 +143,7 @@ import { default as LiveChatItemList } from './classes/LiveChatItemList';
|
||||
import { default as LiveChatMessageInput } from './classes/LiveChatMessageInput';
|
||||
import { default as LiveChatParticipant } from './classes/LiveChatParticipant';
|
||||
import { default as LiveChatParticipantsList } from './classes/LiveChatParticipantsList';
|
||||
import { default as MacroMarkersListItem } from './classes/MacroMarkersListItem';
|
||||
import { default as Menu } from './classes/menus/Menu';
|
||||
import { default as MenuNavigationItem } from './classes/menus/MenuNavigationItem';
|
||||
import { default as MenuServiceItem } from './classes/menus/MenuServiceItem';
|
||||
@@ -152,6 +166,7 @@ import { default as MicroformatData } from './classes/MicroformatData';
|
||||
import { default as Mix } from './classes/Mix';
|
||||
import { default as Movie } from './classes/Movie';
|
||||
import { default as MovingThumbnail } from './classes/MovingThumbnail';
|
||||
import { default as MultiMarkersPlayerBar } from './classes/MultiMarkersPlayerBar';
|
||||
import { default as MusicCarouselShelf } from './classes/MusicCarouselShelf';
|
||||
import { default as MusicCarouselShelfBasicHeader } from './classes/MusicCarouselShelfBasicHeader';
|
||||
import { default as MusicDescriptionShelf } from './classes/MusicDescriptionShelf';
|
||||
@@ -189,6 +204,7 @@ import { default as PlayerOverlay } from './classes/PlayerOverlay';
|
||||
import { default as PlayerOverlayAutoplay } from './classes/PlayerOverlayAutoplay';
|
||||
import { default as PlayerStoryboardSpec } from './classes/PlayerStoryboardSpec';
|
||||
import { default as Playlist } from './classes/Playlist';
|
||||
import { default as PlaylistCustomThumbnail } from './classes/PlaylistCustomThumbnail';
|
||||
import { default as PlaylistHeader } from './classes/PlaylistHeader';
|
||||
import { default as PlaylistInfoCardContent } from './classes/PlaylistInfoCardContent';
|
||||
import { default as PlaylistMetadata } from './classes/PlaylistMetadata';
|
||||
@@ -207,6 +223,7 @@ import { default as ProfileColumn } from './classes/ProfileColumn';
|
||||
import { default as ProfileColumnStats } from './classes/ProfileColumnStats';
|
||||
import { default as ProfileColumnStatsEntry } from './classes/ProfileColumnStatsEntry';
|
||||
import { default as ProfileColumnUserInfo } from './classes/ProfileColumnUserInfo';
|
||||
import { default as RecognitionShelf } from './classes/RecognitionShelf';
|
||||
import { default as ReelItem } from './classes/ReelItem';
|
||||
import { default as ReelShelf } from './classes/ReelShelf';
|
||||
import { default as RelatedChipCloud } from './classes/RelatedChipCloud';
|
||||
@@ -245,6 +262,7 @@ import { default as Tab } from './classes/Tab';
|
||||
import { default as Tabbed } from './classes/Tabbed';
|
||||
import { default as TabbedSearchResults } from './classes/TabbedSearchResults';
|
||||
import { default as TextHeader } from './classes/TextHeader';
|
||||
import { default as ThumbnailLandscapePortrait } from './classes/ThumbnailLandscapePortrait';
|
||||
import { default as ThumbnailOverlayBottomPanel } from './classes/ThumbnailOverlayBottomPanel';
|
||||
import { default as ThumbnailOverlayEndorsement } from './classes/ThumbnailOverlayEndorsement';
|
||||
import { default as ThumbnailOverlayHoverText } from './classes/ThumbnailOverlayHoverText';
|
||||
@@ -257,10 +275,12 @@ import { default as ThumbnailOverlayResumePlayback } from './classes/ThumbnailOv
|
||||
import { default as ThumbnailOverlaySidePanel } from './classes/ThumbnailOverlaySidePanel';
|
||||
import { default as ThumbnailOverlayTimeStatus } from './classes/ThumbnailOverlayTimeStatus';
|
||||
import { default as ThumbnailOverlayToggleButton } from './classes/ThumbnailOverlayToggleButton';
|
||||
import { default as TimedMarkerDecoration } from './classes/TimedMarkerDecoration';
|
||||
import { default as TitleAndButtonListHeader } from './classes/TitleAndButtonListHeader';
|
||||
import { default as ToggleButton } from './classes/ToggleButton';
|
||||
import { default as ToggleMenuServiceItem } from './classes/ToggleMenuServiceItem';
|
||||
import { default as Tooltip } from './classes/Tooltip';
|
||||
import { default as TopicChannelDetails } from './classes/TopicChannelDetails';
|
||||
import { default as TwoColumnBrowseResults } from './classes/TwoColumnBrowseResults';
|
||||
import { default as TwoColumnSearchResults } from './classes/TwoColumnSearchResults';
|
||||
import { default as TwoColumnWatchNextResults } from './classes/TwoColumnWatchNextResults';
|
||||
@@ -268,6 +288,7 @@ import { default as UniversalWatchCard } from './classes/UniversalWatchCard';
|
||||
import { default as VerticalList } from './classes/VerticalList';
|
||||
import { default as VerticalWatchCardList } from './classes/VerticalWatchCardList';
|
||||
import { default as Video } from './classes/Video';
|
||||
import { default as VideoCard } from './classes/VideoCard';
|
||||
import { default as VideoInfoCardContent } from './classes/VideoInfoCardContent';
|
||||
import { default as VideoOwner } from './classes/VideoOwner';
|
||||
import { default as VideoPrimaryInfo } from './classes/VideoPrimaryInfo';
|
||||
@@ -279,7 +300,7 @@ import { default as WatchCardSectionSequence } from './classes/WatchCardSectionS
|
||||
import { default as WatchNextEndScreen } from './classes/WatchNextEndScreen';
|
||||
import { default as WatchNextTabbedResults } from './classes/WatchNextTabbedResults';
|
||||
|
||||
const map: Record<string, YTNodeConstructor> = {
|
||||
export const YTNodes = {
|
||||
AccountChannel,
|
||||
AccountItemSection,
|
||||
AccountItemSectionHeader,
|
||||
@@ -306,6 +327,8 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
CallToActionButton,
|
||||
Card,
|
||||
CardCollection,
|
||||
CarouselHeader,
|
||||
CarouselItem,
|
||||
Channel,
|
||||
ChannelAboutFullMetadata,
|
||||
ChannelFeaturedContent,
|
||||
@@ -315,6 +338,7 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
ChannelOptions,
|
||||
ChannelThumbnailWithLink,
|
||||
ChannelVideoPlayer,
|
||||
Chapter,
|
||||
ChildVideo,
|
||||
ChipCloud,
|
||||
ChipCloudChip,
|
||||
@@ -332,11 +356,14 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
CompactLink,
|
||||
CompactMix,
|
||||
CompactPlaylist,
|
||||
CompactStation,
|
||||
CompactVideo,
|
||||
ConfirmDialog,
|
||||
ContinuationItem,
|
||||
CopyLink,
|
||||
CreatePlaylistDialog,
|
||||
DecoratedPlayerBar,
|
||||
DefaultPromoPanel,
|
||||
DidYouMean,
|
||||
DownloadButton,
|
||||
Dropdown,
|
||||
@@ -347,20 +374,26 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
EndscreenElement,
|
||||
EndScreenPlaylist,
|
||||
EndScreenVideo,
|
||||
ExpandableMetadata,
|
||||
ExpandableTab,
|
||||
ExpandedShelfContents,
|
||||
FeedFilterChipBar,
|
||||
FeedTabbedHeader,
|
||||
GameCard,
|
||||
GameDetails,
|
||||
Grid,
|
||||
GridChannel,
|
||||
GridHeader,
|
||||
GridPlaylist,
|
||||
GridVideo,
|
||||
Heatmap,
|
||||
HeatMarker,
|
||||
HighlightsCarousel,
|
||||
HistorySuggestion,
|
||||
HorizontalCardList,
|
||||
HorizontalList,
|
||||
IconLink,
|
||||
InteractiveTabbedHeader,
|
||||
ItemSection,
|
||||
ItemSectionHeader,
|
||||
ItemSectionTab,
|
||||
@@ -379,6 +412,7 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
LiveChatPaidSticker,
|
||||
LiveChatPlaceholderItem,
|
||||
LiveChatProductItem,
|
||||
LiveChatRestrictedParticipation,
|
||||
LiveChatTextMessage,
|
||||
LiveChatTickerPaidMessageItem,
|
||||
LiveChatTickerPaidStickerItem,
|
||||
@@ -408,6 +442,7 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
LiveChatMessageInput,
|
||||
LiveChatParticipant,
|
||||
LiveChatParticipantsList,
|
||||
MacroMarkersListItem,
|
||||
Menu,
|
||||
MenuNavigationItem,
|
||||
MenuServiceItem,
|
||||
@@ -430,6 +465,7 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
Mix,
|
||||
Movie,
|
||||
MovingThumbnail,
|
||||
MultiMarkersPlayerBar,
|
||||
MusicCarouselShelf,
|
||||
MusicCarouselShelfBasicHeader,
|
||||
MusicDescriptionShelf,
|
||||
@@ -467,6 +503,7 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
PlayerOverlayAutoplay,
|
||||
PlayerStoryboardSpec,
|
||||
Playlist,
|
||||
PlaylistCustomThumbnail,
|
||||
PlaylistHeader,
|
||||
PlaylistInfoCardContent,
|
||||
PlaylistMetadata,
|
||||
@@ -485,6 +522,7 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
ProfileColumnStats,
|
||||
ProfileColumnStatsEntry,
|
||||
ProfileColumnUserInfo,
|
||||
RecognitionShelf,
|
||||
ReelItem,
|
||||
ReelShelf,
|
||||
RelatedChipCloud,
|
||||
@@ -523,6 +561,7 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
Tabbed,
|
||||
TabbedSearchResults,
|
||||
TextHeader,
|
||||
ThumbnailLandscapePortrait,
|
||||
ThumbnailOverlayBottomPanel,
|
||||
ThumbnailOverlayEndorsement,
|
||||
ThumbnailOverlayHoverText,
|
||||
@@ -535,10 +574,12 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
ThumbnailOverlaySidePanel,
|
||||
ThumbnailOverlayTimeStatus,
|
||||
ThumbnailOverlayToggleButton,
|
||||
TimedMarkerDecoration,
|
||||
TitleAndButtonListHeader,
|
||||
ToggleButton,
|
||||
ToggleMenuServiceItem,
|
||||
Tooltip,
|
||||
TopicChannelDetails,
|
||||
TwoColumnBrowseResults,
|
||||
TwoColumnSearchResults,
|
||||
TwoColumnWatchNextResults,
|
||||
@@ -546,6 +587,7 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
VerticalList,
|
||||
VerticalWatchCardList,
|
||||
Video,
|
||||
VideoCard,
|
||||
VideoInfoCardContent,
|
||||
VideoOwner,
|
||||
VideoPrimaryInfo,
|
||||
@@ -558,7 +600,7 @@ const map: Record<string, YTNodeConstructor> = {
|
||||
WatchNextTabbedResults
|
||||
};
|
||||
|
||||
export const YTNodes = map;
|
||||
const map: Record<string, YTNodeConstructor> = YTNodes;
|
||||
|
||||
/**
|
||||
* @param name - Name of the node to be parsed
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import Parser, { ParsedResponse } from '..';
|
||||
import { AxioslikeResponse } from '../../core/Actions';
|
||||
import type { ApiResponse } from '../../core/Actions';
|
||||
|
||||
import AccountSectionList from '../classes/AccountSectionList';
|
||||
import AccountItemSection from '../classes/AccountItemSection';
|
||||
import AccountChannel from '../classes/AccountChannel';
|
||||
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
|
||||
class AccountInfo {
|
||||
#page;
|
||||
#page: ParsedResponse;
|
||||
|
||||
contents: AccountItemSection | null;
|
||||
footers: AccountChannel | null;
|
||||
|
||||
constructor(response: AxioslikeResponse) {
|
||||
constructor(response: ApiResponse) {
|
||||
this.#page = Parser.parseResponse(response.data);
|
||||
|
||||
const account_section_list = this.#page.contents.array().as(AccountSectionList)[0];
|
||||
const account_section_list = this.#page.contents.array().as(AccountSectionList)?.[0];
|
||||
|
||||
if (!account_section_list)
|
||||
throw new InnertubeError('Account section list not found');
|
||||
|
||||
this.contents = account_section_list.contents;
|
||||
this.footers = account_section_list.footers;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import Parser, { ParsedResponse } from '..';
|
||||
import { AxioslikeResponse } from '../../core/Actions';
|
||||
import type { ApiResponse } from '../../core/Actions';
|
||||
import Element from '../classes/Element';
|
||||
|
||||
class Analytics {
|
||||
#page;
|
||||
#page: ParsedResponse;
|
||||
sections;
|
||||
|
||||
constructor(response: AxioslikeResponse) {
|
||||
constructor(response: ApiResponse) {
|
||||
this.#page = Parser.parseResponse(response.data);
|
||||
this.sections = this.#page.contents_memo?.get('Element')
|
||||
?.map((el) => el.as(Element).model?.item());
|
||||
this.sections = this.#page.contents_memo?.getType(Element).map((el) => el.model?.item());
|
||||
}
|
||||
|
||||
get page(): ParsedResponse {
|
||||
|
||||
@@ -1,56 +1,107 @@
|
||||
import Actions from '../../core/Actions';
|
||||
import type Actions from '../../core/Actions';
|
||||
import TabbedFeed from '../../core/TabbedFeed';
|
||||
import C4TabbedHeader from '../classes/C4TabbedHeader';
|
||||
import CarouselHeader from '../classes/CarouselHeader';
|
||||
import ChannelAboutFullMetadata from '../classes/ChannelAboutFullMetadata';
|
||||
import ChannelMetadata from '../classes/ChannelMetadata';
|
||||
import InteractiveTabbedHeader from '../classes/InteractiveTabbedHeader';
|
||||
import MicroformatData from '../classes/MicroformatData';
|
||||
import SubscribeButton from '../classes/SubscribeButton';
|
||||
import Tab from '../classes/Tab';
|
||||
|
||||
class Channel extends TabbedFeed {
|
||||
header;
|
||||
import Feed from '../../core/Feed';
|
||||
import FilterableFeed from '../../core/FilterableFeed';
|
||||
import ChipCloudChip from '../classes/ChipCloudChip';
|
||||
import ExpandableTab from '../classes/ExpandableTab';
|
||||
import FeedFilterChipBar from '../classes/FeedFilterChipBar';
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
|
||||
import type { AppendContinuationItemsAction, ReloadContinuationItemsCommand } from '..';
|
||||
|
||||
export default class Channel extends TabbedFeed {
|
||||
header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader;
|
||||
metadata;
|
||||
sponsor_button;
|
||||
subscribe_button;
|
||||
current_tab;
|
||||
subscribe_button?: SubscribeButton;
|
||||
current_tab?: Tab | ExpandableTab;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
|
||||
this.header = this.page.header.item().as(C4TabbedHeader);
|
||||
const metadata = this.page.metadata.item().as(ChannelMetadata);
|
||||
this.header = this.page.header?.item()?.as(C4TabbedHeader, CarouselHeader, InteractiveTabbedHeader);
|
||||
|
||||
const metadata = this.page.metadata?.item().as(ChannelMetadata);
|
||||
const microformat = this.page.microformat?.as(MicroformatData);
|
||||
|
||||
if (!metadata && !this.page.contents)
|
||||
throw new InnertubeError('Invalid channel', this);
|
||||
|
||||
this.metadata = { ...metadata, ...(microformat || {}) };
|
||||
this.sponsor_button = this.header.sponsor_button;
|
||||
this.subscribe_button = this.header.subscribe_button;
|
||||
|
||||
const tab = this.page.contents.item().key('tabs').parsed().array().filterType(Tab).get({ selected: true });
|
||||
this.subscribe_button = this.page.header_memo.getType(SubscribeButton)?.[0];
|
||||
|
||||
this.current_tab = tab;
|
||||
this.current_tab = this.page.contents.item().key('tabs').parsed().array().filterType(Tab, ExpandableTab).get({ selected: true });
|
||||
}
|
||||
|
||||
async getVideos() {
|
||||
const tab = await this.getTab('Videos');
|
||||
/**
|
||||
* Applies given filter to the list. Use {@link filters} to get available filters.
|
||||
* @param filter - The filter to apply
|
||||
*/
|
||||
async applyFilter(filter: string | ChipCloudChip): Promise<FilteredChannelList> {
|
||||
let target_filter: ChipCloudChip | undefined;
|
||||
|
||||
const filter_chipbar = this.memo.getType(FeedFilterChipBar)?.[0];
|
||||
|
||||
if (typeof filter === 'string') {
|
||||
target_filter = filter_chipbar?.contents.get({ text: filter });
|
||||
if (!target_filter)
|
||||
throw new InnertubeError(`Filter ${filter} not found`, { available_filters: this.filters });
|
||||
} else if (filter instanceof ChipCloudChip) {
|
||||
target_filter = filter;
|
||||
}
|
||||
|
||||
if (!target_filter)
|
||||
throw new InnertubeError('Invalid filter', filter);
|
||||
|
||||
const page = await target_filter.endpoint?.call(this.actions, { parse: true });
|
||||
return new FilteredChannelList(this.actions, page, true);
|
||||
}
|
||||
|
||||
get filters(): string[] {
|
||||
return this.memo.getType(FeedFilterChipBar)?.[0]?.contents.filterType(ChipCloudChip).map((chip) => chip.text) || [];
|
||||
}
|
||||
|
||||
async getHome(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('featured');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
async getPlaylists() {
|
||||
const tab = await this.getTab('Playlists');
|
||||
async getVideos(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('videos');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
async getHome() {
|
||||
const tab = await this.getTab('Home');
|
||||
async getShorts(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('shorts');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
async getCommunity() {
|
||||
const tab = await this.getTab('Community');
|
||||
async getLiveStreams(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('streams');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
async getChannels() {
|
||||
const tab = await this.getTab('Channels');
|
||||
async getPlaylists(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('playlists');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
async getCommunity(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('community');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
async getChannels(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('channels');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
@@ -58,10 +109,92 @@ class Channel extends TabbedFeed {
|
||||
* Retrieves the channel about page.
|
||||
* Note that this does not return a new {@link Channel} object.
|
||||
*/
|
||||
async getAbout() {
|
||||
const tab = await this.getTab('About');
|
||||
async getAbout(): Promise<ChannelAboutFullMetadata> {
|
||||
const tab = await this.getTabByURL('about');
|
||||
return tab.memo.getType(ChannelAboutFullMetadata)?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches within the channel.
|
||||
*/
|
||||
async search(query: string): Promise<Channel> {
|
||||
const tab = this.memo.getType(ExpandableTab)?.[0];
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Search tab not found', this);
|
||||
|
||||
const page = await tab.endpoint?.call(this.actions, { query, parse: true });
|
||||
|
||||
return new Channel(this.actions, page, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrives list continuation.
|
||||
*/
|
||||
async getContinuation(): Promise<ChannelListContinuation> {
|
||||
const page = await super.getContinuationData();
|
||||
return new ChannelListContinuation(this.actions, page, true);
|
||||
}
|
||||
}
|
||||
|
||||
export default Channel;
|
||||
export class ChannelListContinuation extends Feed {
|
||||
contents: ReloadContinuationItemsCommand | AppendContinuationItemsAction | undefined;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
this.contents =
|
||||
this.page.on_response_received_actions?.[0] ||
|
||||
this.page.on_response_received_endpoints?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves list continuation.
|
||||
*/
|
||||
async getContinuation(): Promise<ChannelListContinuation> {
|
||||
const page = await super.getContinuationData();
|
||||
return new ChannelListContinuation(this.actions, page, true);
|
||||
}
|
||||
}
|
||||
|
||||
export class FilteredChannelList extends FilterableFeed {
|
||||
applied_filter?: ChipCloudChip;
|
||||
contents;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
|
||||
this.applied_filter = this.memo.getType(ChipCloudChip).get({ is_selected: true });
|
||||
|
||||
// Removes the filter chipbar from the actions list
|
||||
if (
|
||||
this.page.on_response_received_actions &&
|
||||
this.page.on_response_received_actions.length > 1
|
||||
) {
|
||||
this.page.on_response_received_actions.shift();
|
||||
}
|
||||
|
||||
this.contents = this.page.on_response_received_actions?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies given filter to the list.
|
||||
* @param filter - The filter to apply
|
||||
*/
|
||||
async applyFilter(filter: string | ChipCloudChip): Promise<FilteredChannelList> {
|
||||
const feed = await super.getFilteredFeed(filter);
|
||||
return new FilteredChannelList(this.actions, feed.page, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves list continuation.
|
||||
*/
|
||||
async getContinuation(): Promise<FilteredChannelList> {
|
||||
const page = await super.getContinuationData();
|
||||
|
||||
// Keep the filters
|
||||
page?.on_response_received_actions_memo.set('FeedFilterChipBar', this.memo.getType(FeedFilterChipBar));
|
||||
page?.on_response_received_actions_memo.set('ChipCloudChip', this.memo.getType(ChipCloudChip));
|
||||
|
||||
return new FilteredChannelList(this.actions, page, true);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user