mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 17:42:18 +00:00
Compare commits
63 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 |
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, { parse: true, client: 'YTMUSIC' });
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
823
package-lock.json
generated
823
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "2.3.3",
|
||||
"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
|
||||
|
||||
127
src/Innertube.ts
127
src/Innertube.ts
@@ -1,51 +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';
|
||||
|
||||
import { generateRandomString, throwIfMissing } from './utils/Utils';
|
||||
|
||||
export type InnertubeConfig = SessionOptions;
|
||||
|
||||
export interface SearchFilters {
|
||||
upload_date?: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year',
|
||||
type?: 'all' | 'video' | 'channel' | 'playlist' | 'movie',
|
||||
duration?: 'all' | 'short' | 'medium' | 'long',
|
||||
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count'
|
||||
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;
|
||||
@@ -57,7 +60,7 @@ 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));
|
||||
}
|
||||
|
||||
@@ -66,10 +69,12 @@ class Innertube {
|
||||
* @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 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 ]);
|
||||
@@ -81,7 +86,9 @@ class Innertube {
|
||||
* @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);
|
||||
|
||||
@@ -93,7 +100,7 @@ class Innertube {
|
||||
* @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 args = {
|
||||
@@ -138,7 +145,7 @@ class Innertube {
|
||||
* @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, {
|
||||
@@ -153,15 +160,15 @@ class Innertube {
|
||||
/**
|
||||
* Retrieves YouTube's home feed (aka recommendations).
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
async getHomeFeed(): Promise<HomeFeed> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
|
||||
return new FilterableFeed(this.actions, response.data);
|
||||
return new HomeFeed(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account's library.
|
||||
*/
|
||||
async getLibrary() {
|
||||
async getLibrary(): Promise<Library> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FElibrary' });
|
||||
return new Library(response.data, this.actions);
|
||||
}
|
||||
@@ -170,7 +177,7 @@ class Innertube {
|
||||
* Retrieves watch history.
|
||||
* Which can also be achieved with {@link getLibrary}.
|
||||
*/
|
||||
async getHistory() {
|
||||
async getHistory(): Promise<History> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
|
||||
return new History(this.actions, response.data);
|
||||
}
|
||||
@@ -178,7 +185,7 @@ class Innertube {
|
||||
/**
|
||||
* Retrieves trending content.
|
||||
*/
|
||||
async getTrending() {
|
||||
async getTrending(): Promise<TabbedFeed> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEtrending' });
|
||||
return new TabbedFeed(this.actions, response.data);
|
||||
}
|
||||
@@ -186,16 +193,16 @@ class Innertube {
|
||||
/**
|
||||
* Retrieves subscriptions feed.
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
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.execute('/browse', { browseId: id });
|
||||
return new Channel(this.actions, response.data);
|
||||
@@ -204,7 +211,7 @@ class Innertube {
|
||||
/**
|
||||
* Retrieves notifications.
|
||||
*/
|
||||
async getNotifications() {
|
||||
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);
|
||||
}
|
||||
@@ -212,15 +219,17 @@ class Innertube {
|
||||
/**
|
||||
* Retrieves unseen notifications count.
|
||||
*/
|
||||
async getUnseenNotificationsCount() {
|
||||
async getUnseenNotificationsCount(): Promise<number> {
|
||||
const response = await this.actions.execute('/notification/get_unseen_count');
|
||||
return response.data.unseenCount;
|
||||
// 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 });
|
||||
|
||||
if (!id.startsWith('VL')) {
|
||||
@@ -236,29 +245,41 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: { [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.call(this.actions, args);
|
||||
}
|
||||
|
||||
@@ -1,15 +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;
|
||||
@@ -21,7 +28,7 @@ class AccountManager {
|
||||
*/
|
||||
editName: (new_name: string) => {
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
return this.#actions.execute('/channel/edit_name', {
|
||||
givenName: new_name,
|
||||
@@ -34,7 +41,7 @@ class AccountManager {
|
||||
*/
|
||||
editDescription: (new_description: string) => {
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
return this.#actions.execute('/channel/edit_description', {
|
||||
givenDescription: new_description,
|
||||
@@ -51,9 +58,9 @@ class AccountManager {
|
||||
/**
|
||||
* Retrieves channel info.
|
||||
*/
|
||||
async getInfo() {
|
||||
async getInfo(): Promise<AccountInfo> {
|
||||
if (!this.#actions.session.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.#actions.execute('/account/accounts_list', { client: 'ANDROID' });
|
||||
return new AccountInfo(response);
|
||||
@@ -62,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'
|
||||
@@ -74,7 +81,7 @@ class AccountManager {
|
||||
/**
|
||||
* Opens YouTube settings.
|
||||
*/
|
||||
async getSettings() {
|
||||
async getSettings(): Promise<Settings> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'SPaccount_overview'
|
||||
});
|
||||
@@ -85,7 +92,7 @@ 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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Session from './Session';
|
||||
import Parser, { ParsedResponse } from '../parser/index';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import type Session from './Session';
|
||||
|
||||
export interface ApiResponse {
|
||||
success: boolean;
|
||||
@@ -11,13 +11,13 @@ export interface ApiResponse {
|
||||
export type ActionsResponse = Promise<ApiResponse>;
|
||||
|
||||
class Actions {
|
||||
#session;
|
||||
#session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
get session() {
|
||||
get session(): Session {
|
||||
return this.#session;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class Actions {
|
||||
* 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,
|
||||
@@ -40,7 +40,7 @@ class Actions {
|
||||
* @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: {
|
||||
@@ -49,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'
|
||||
@@ -90,7 +90,7 @@ class Actions {
|
||||
* @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');
|
||||
@@ -122,7 +122,7 @@ class Actions {
|
||||
|
||||
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'))
|
||||
@@ -166,7 +166,7 @@ class Actions {
|
||||
|
||||
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 ?
|
||||
'application/x-protobuf' :
|
||||
@@ -188,6 +188,7 @@ class Actions {
|
||||
'FEsubscriptions',
|
||||
'FEmusic_listening_review',
|
||||
'FEmusic_library_landing',
|
||||
'SPaccount_overview',
|
||||
'SPaccount_notifications',
|
||||
'SPaccount_privacy',
|
||||
'SPtime_watched'
|
||||
|
||||
@@ -1,40 +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 { concatMemos, InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
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';
|
||||
|
||||
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) {
|
||||
@@ -63,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,
|
||||
@@ -115,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];
|
||||
@@ -134,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;
|
||||
|
||||
@@ -152,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;
|
||||
}
|
||||
|
||||
@@ -194,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,6 +56,7 @@ class FilterableFeed extends Feed {
|
||||
|
||||
if (!target_filter)
|
||||
throw new InnertubeError('Filter not found');
|
||||
|
||||
if (target_filter.is_selected)
|
||||
return this;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Proto from '../proto';
|
||||
import Actions from './Actions';
|
||||
import type Actions from './Actions';
|
||||
import type { ApiResponse } from './Actions';
|
||||
import { throwIfMissing } from '../utils/Utils';
|
||||
|
||||
class InteractionManager {
|
||||
#actions;
|
||||
#actions: Actions;
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
@@ -13,11 +14,11 @@ 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 });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You are not signed in');
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/like/like', {
|
||||
client: 'ANDROID',
|
||||
@@ -33,11 +34,11 @@ class InteractionManager {
|
||||
* 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 });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You are not signed in');
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/like/dislike', {
|
||||
client: 'ANDROID',
|
||||
@@ -53,11 +54,11 @@ class InteractionManager {
|
||||
* Removes a like/dislike.
|
||||
* @param video_id - The video ID
|
||||
*/
|
||||
async removeRating(video_id: string) {
|
||||
async removeRating(video_id: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You are not signed in');
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/like/removelike', {
|
||||
client: 'ANDROID',
|
||||
@@ -73,11 +74,11 @@ class InteractionManager {
|
||||
* 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 });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You are not signed in');
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/subscription/subscribe', {
|
||||
client: 'ANDROID',
|
||||
@@ -92,11 +93,11 @@ class InteractionManager {
|
||||
* 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 });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You are not signed in');
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/subscription/unsubscribe', {
|
||||
client: 'ANDROID',
|
||||
@@ -112,11 +113,11 @@ class InteractionManager {
|
||||
* @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 });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You are not signed in');
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/comment/create_comment', {
|
||||
client: 'ANDROID',
|
||||
@@ -159,11 +160,11 @@ class InteractionManager {
|
||||
* @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 });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You are not signed in');
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const pref_types = {
|
||||
PERSONALIZED: 1,
|
||||
@@ -175,7 +176,7 @@ class InteractionManager {
|
||||
throw new Error(`Invalid notification preference type: ${type}`);
|
||||
|
||||
const action = await this.#actions.execute('/notification/modify_channel_preference', {
|
||||
client: 'ANDROID',
|
||||
client: 'WEB',
|
||||
params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types])
|
||||
});
|
||||
|
||||
|
||||
@@ -1,37 +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;
|
||||
@@ -52,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', {
|
||||
@@ -61,7 +60,7 @@ class Music {
|
||||
videoId: video_id,
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: this.#session.player.sts
|
||||
signatureTimestamp: this.#session.player?.sts || 0
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -75,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');
|
||||
|
||||
@@ -89,7 +88,7 @@ class Music {
|
||||
client: 'YTMUSIC',
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: this.#session.player.sts
|
||||
signatureTimestamp: this.#session.player?.sts || 0
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -235,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.');
|
||||
@@ -287,20 +282,16 @@ 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, { client: 'YTMUSIC', parse: true });
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -318,13 +309,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: '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.');
|
||||
@@ -334,7 +321,7 @@ class Music {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -354,7 +341,7 @@ 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,
|
||||
|
||||
@@ -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();
|
||||
@@ -249,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;
|
||||
@@ -16,11 +16,11 @@ class PlaylistManager {
|
||||
* @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 });
|
||||
|
||||
if (!this.#actions.session.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.#actions.execute('/playlist/create', {
|
||||
title,
|
||||
@@ -40,11 +40,11 @@ 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 are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id });
|
||||
|
||||
@@ -61,11 +61,11 @@ class PlaylistManager {
|
||||
* @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 are not signed 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,
|
||||
@@ -87,11 +87,11 @@ class PlaylistManager {
|
||||
* @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 });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const info = await this.#actions.execute('/browse', {
|
||||
browseId: `VL${playlist_id}`,
|
||||
@@ -146,11 +146,11 @@ class PlaylistManager {
|
||||
* @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 });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const info = await this.#actions.execute('/browse', {
|
||||
browseId: `VL${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,18 +240,22 @@ 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],
|
||||
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.YT_BASE,
|
||||
@@ -162,6 +264,7 @@ export default class Session extends EventEmitterLike {
|
||||
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 { ApiResponse } 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;
|
||||
@@ -52,7 +53,7 @@ class Studio {
|
||||
*/
|
||||
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
|
||||
if (!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 (!video_id || !buffer)
|
||||
throw new MissingParamError('One or more parameters are missing.');
|
||||
@@ -81,9 +82,9 @@ class Studio {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
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 are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);
|
||||
|
||||
@@ -105,7 +106,7 @@ class Studio {
|
||||
*/
|
||||
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<ApiResponse> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed 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;
|
||||
@@ -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;
|
||||
@@ -76,6 +76,8 @@ class NavigationEndpoint extends YTNode {
|
||||
return '/browse';
|
||||
case 'watchEndpoint':
|
||||
return '/player';
|
||||
case 'searchEndpoint':
|
||||
return '/search';
|
||||
case 'watchPlaylistEndpoint':
|
||||
return '/next';
|
||||
case 'liveChatItemContextMenuEndpoint':
|
||||
|
||||
@@ -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;
|
||||
@@ -76,8 +76,8 @@ class PlaylistPanelVideo extends YTNode {
|
||||
}));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -170,342 +531,4 @@ export class LiveChatContinuation extends YTNode {
|
||||
|
||||
this.viewer_name = data.viewerName;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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();
|
||||
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,
|
||||
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.#errorHandler({ 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 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>([
|
||||
'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,12 +1,14 @@
|
||||
import Parser, { ParsedResponse } from '..';
|
||||
import { ApiResponse } 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;
|
||||
@@ -14,7 +16,10 @@ class AccountInfo {
|
||||
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 { ApiResponse } from '../../core/Actions';
|
||||
import type { ApiResponse } from '../../core/Actions';
|
||||
import Element from '../classes/Element';
|
||||
|
||||
class Analytics {
|
||||
#page;
|
||||
#page: ParsedResponse;
|
||||
sections;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Parser, { ParsedResponse } from '..';
|
||||
import Actions, { ActionsResponse } from '../../core/Actions';
|
||||
import type Actions from '../../core/Actions';
|
||||
import type { ApiResponse } from '../../core/Actions';
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
|
||||
import Button from '../classes/Button';
|
||||
@@ -11,10 +12,10 @@ import ContinuationItem from '../classes/ContinuationItem';
|
||||
class Comments {
|
||||
#page: ParsedResponse;
|
||||
#actions: Actions;
|
||||
#continuation: ContinuationItem | undefined;
|
||||
#continuation?: ContinuationItem;
|
||||
|
||||
header: CommentsHeader | undefined;
|
||||
contents;
|
||||
header?: CommentsHeader;
|
||||
contents: CommentThread[];
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
this.#page = already_parsed ? data : Parser.parseResponse(data);
|
||||
@@ -25,7 +26,7 @@ class Comments {
|
||||
if (!contents)
|
||||
throw new InnertubeError('Comments page did not have any content.');
|
||||
|
||||
this.header = contents[0].contents?.get({ type: 'CommentsHeader' })?.as(CommentsHeader);
|
||||
this.header = contents[0].contents?.firstOfType(CommentsHeader);
|
||||
|
||||
const threads: CommentThread[] = contents[1].contents?.filterType(CommentThread) || [];
|
||||
|
||||
@@ -35,14 +36,14 @@ class Comments {
|
||||
return thread;
|
||||
}) as CommentThread[];
|
||||
|
||||
this.#continuation = contents[1].contents?.get({ type: 'ContinuationItem' })?.as(ContinuationItem);
|
||||
this.#continuation = contents[1].contents?.firstOfType(ContinuationItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a top-level comment.
|
||||
* @param text - Comment text.
|
||||
*/
|
||||
async createComment(text: string): Promise<ActionsResponse> {
|
||||
async createComment(text: string): Promise<ApiResponse> {
|
||||
if (!this.header)
|
||||
throw new InnertubeError('Page header is missing. Cannot create comment.');
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import Actions from '../../core/Actions';
|
||||
|
||||
import type Actions from '../../core/Actions';
|
||||
import Feed from '../../core/Feed';
|
||||
import ItemSection from '../classes/ItemSection';
|
||||
import BrowseFeedActions from '../classes/BrowseFeedActions';
|
||||
|
||||
// TODO: make feed actions usable
|
||||
class History extends Feed {
|
||||
sections;
|
||||
feed_actions;
|
||||
sections: ItemSection[];
|
||||
feed_actions: BrowseFeedActions;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
this.sections = this.memo.get('ItemSection') as ItemSection[];
|
||||
this.feed_actions = this.memo.get('BrowseFeedActions')?.[0]?.as(BrowseFeedActions) || ([] as BrowseFeedActions[]);
|
||||
this.sections = this.memo.getType(ItemSection);
|
||||
this.feed_actions = this.memo.getType(BrowseFeedActions)?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
42
src/parser/youtube/HomeFeed.ts
Normal file
42
src/parser/youtube/HomeFeed.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type Actions from '../../core/Actions';
|
||||
import FilterableFeed from '../../core/FilterableFeed';
|
||||
import ChipCloudChip from '../classes/ChipCloudChip';
|
||||
import FeedTabbedHeader from '../classes/FeedTabbedHeader';
|
||||
import RichGrid from '../classes/RichGrid';
|
||||
|
||||
import type { AppendContinuationItemsAction, ReloadContinuationItemsCommand } from '..';
|
||||
|
||||
export default class HomeFeed extends FilterableFeed {
|
||||
contents: RichGrid | AppendContinuationItemsAction | ReloadContinuationItemsCommand;
|
||||
header: FeedTabbedHeader;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
this.header = this.memo.getType<FeedTabbedHeader>(FeedTabbedHeader)?.[0];
|
||||
this.contents =
|
||||
this.memo.getType<RichGrid>(RichGrid)?.[0] ||
|
||||
this.page.on_response_received_actions?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies given filter to the feed. Use {@link filters} to get available filters.
|
||||
* @param filter - Filter to apply.
|
||||
*/
|
||||
async applyFilter(filter: string | ChipCloudChip): Promise<HomeFeed> {
|
||||
const feed = await super.getFilteredFeed(filter);
|
||||
return new HomeFeed(this.actions, feed.page, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves next batch of contents.
|
||||
*/
|
||||
async getContinuation(): Promise<HomeFeed> {
|
||||
const feed = await super.getContinuation();
|
||||
|
||||
// Keep the page header
|
||||
feed.page.header = this.page.header;
|
||||
feed.page.header_memo.set(this.header.type, [ this.header ]);
|
||||
|
||||
return new HomeFeed(this.actions, feed.page, true);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import Actions from '../../core/Actions';
|
||||
|
||||
import Button from '../classes/Button';
|
||||
import Menu from '../classes/menus/Menu';
|
||||
import MenuServiceItem from '../classes/menus/MenuServiceItem';
|
||||
import NavigationEndpoint from '../classes/NavigationEndpoint';
|
||||
import Button from '../classes/Button';
|
||||
|
||||
import { ParsedResponse } from '..';
|
||||
import type Actions from '../../core/Actions';
|
||||
import type { ParsedResponse } from '..';
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
import { ObservedArray, YTNode } from '../helpers';
|
||||
import type { ObservedArray, YTNode } from '../helpers';
|
||||
|
||||
class ItemMenu {
|
||||
#page: ParsedResponse;
|
||||
|
||||
@@ -1,67 +1,53 @@
|
||||
import Parser, { ParsedResponse } from '..';
|
||||
import Actions, { ApiResponse } from '../../core/Actions';
|
||||
import type Actions from '../../core/Actions';
|
||||
import type { ApiResponse } from '../../core/Actions';
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
|
||||
import Feed from '../../core/Feed';
|
||||
import History from './History';
|
||||
import Playlist from './Playlist';
|
||||
|
||||
import Tab from '../classes/Tab';
|
||||
import Menu from '../classes/menus/Menu';
|
||||
import Shelf from '../classes/Shelf';
|
||||
import Button from '../classes/Button';
|
||||
import SectionList from '../classes/SectionList';
|
||||
import ItemSection from '../classes/ItemSection';
|
||||
import TwoColumnBrowseResults from '../classes/TwoColumnBrowseResults';
|
||||
|
||||
import ProfileColumn from '../classes/ProfileColumn';
|
||||
import ProfileColumnStats from '../classes/ProfileColumnStats';
|
||||
import ProfileColumnUserInfo from '../classes/ProfileColumnUserInfo';
|
||||
|
||||
class Library {
|
||||
#actions;
|
||||
#page;
|
||||
#actions: Actions;
|
||||
#page: ParsedResponse;
|
||||
|
||||
profile: {
|
||||
stats?: ProfileColumnStats;
|
||||
user_info?: ProfileColumnUserInfo;
|
||||
};
|
||||
|
||||
profile;
|
||||
sections;
|
||||
|
||||
constructor(response: ApiResponse, actions: Actions) {
|
||||
this.#actions = actions;
|
||||
this.#page = Parser.parseResponse(response);
|
||||
|
||||
const two_col = this.#page.contents.item().as(TwoColumnBrowseResults);
|
||||
|
||||
if (!two_col)
|
||||
throw new InnertubeError('Response did not have a TwoColumnBrowseResults.');
|
||||
|
||||
const tab = two_col.tabs.array().as(Tab).get({ selected: true });
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const stats = two_col.secondary_contents.item().as(ProfileColumn).items.array().get({ type: 'ProfileColumnStats' })?.as(ProfileColumnStats) || null;
|
||||
const user_info = two_col.secondary_contents.item().as(ProfileColumn).items.array().get({ type: 'ProfileColumnUserInfo' })?.as(ProfileColumnUserInfo) || null;
|
||||
const stats = this.#page.contents_memo.getType(ProfileColumnStats)?.[0];
|
||||
const user_info = this.#page.contents_memo.getType(ProfileColumnUserInfo)?.[0];
|
||||
|
||||
this.profile = { stats, user_info };
|
||||
|
||||
if (!tab.content)
|
||||
throw new InnertubeError('Target tab did not have any content.');
|
||||
const shelves = this.#page.contents_memo.getType(Shelf);
|
||||
|
||||
const shelves = tab.content.as(SectionList).contents.array().as(ItemSection).map((is: ItemSection) => is.contents?.firstOfType(Shelf));
|
||||
|
||||
this.sections = shelves.map((shelf: any) => ({
|
||||
this.sections = shelves.map((shelf: Shelf) => ({
|
||||
type: shelf.icon_type,
|
||||
title: shelf.title,
|
||||
contents: shelf.content?.item().items.array() || [],
|
||||
contents: shelf.content?.key('items').array() || [],
|
||||
getAll: () => this.#getAll(shelf)
|
||||
}));
|
||||
}
|
||||
|
||||
async #getAll(shelf: Shelf): Promise<Playlist | History | Feed> {
|
||||
if (!shelf.menu?.item().as(Menu).hasKey('top_level_buttons'))
|
||||
if (!shelf.menu?.as(Menu).hasKey('top_level_buttons'))
|
||||
throw new InnertubeError(`The ${shelf.title.text} shelf doesn't have more items`);
|
||||
|
||||
const button = await shelf.menu.item().as(Menu).top_level_buttons.get({ text: 'See all' });
|
||||
const button = shelf.menu.as(Menu).top_level_buttons.firstOfType(Button);
|
||||
|
||||
if (!button)
|
||||
throw new InnertubeError('Did not find target button.');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Parser, { LiveChatContinuation, ParsedResponse } from '../index';
|
||||
import EventEmitter from '../../utils/EventEmitterLike';
|
||||
import Parser, { LiveChatContinuation, ParsedResponse } from '../index';
|
||||
import VideoInfo from './VideoInfo';
|
||||
|
||||
import AddChatItemAction from '../classes/livechat/AddChatItemAction';
|
||||
@@ -10,11 +10,11 @@ import ReplaceChatItemAction from '../classes/livechat/ReplaceChatItemAction';
|
||||
import ReplayChatItemAction from '../classes/livechat/ReplayChatItemAction';
|
||||
import ShowLiveChatActionPanelAction from '../classes/livechat/ShowLiveChatActionPanelAction';
|
||||
|
||||
import UpdateTitleAction from '../classes/livechat/UpdateTitleAction';
|
||||
import UpdateDescriptionAction from '../classes/livechat/UpdateDescriptionAction';
|
||||
import UpdateViewershipAction from '../classes/livechat/UpdateViewershipAction';
|
||||
import UpdateDateTextAction from '../classes/livechat/UpdateDateTextAction';
|
||||
import UpdateDescriptionAction from '../classes/livechat/UpdateDescriptionAction';
|
||||
import UpdateTitleAction from '../classes/livechat/UpdateTitleAction';
|
||||
import UpdateToggleButtonTextAction from '../classes/livechat/UpdateToggleButtonTextAction';
|
||||
import UpdateViewershipAction from '../classes/livechat/UpdateViewershipAction';
|
||||
|
||||
import AddBannerToLiveChatCommand from '../classes/livechat/AddBannerToLiveChatCommand';
|
||||
import RemoveBannerForLiveChatCommand from '../classes/livechat/RemoveBannerForLiveChatCommand';
|
||||
@@ -22,16 +22,18 @@ import ShowLiveChatTooltipCommand from '../classes/livechat/ShowLiveChatTooltipC
|
||||
|
||||
import Proto from '../../proto/index';
|
||||
import { InnertubeError, uuidv4 } from '../../utils/Utils';
|
||||
import { ObservedArray, YTNode } from '../helpers';
|
||||
import type { ObservedArray, YTNode } from '../helpers';
|
||||
|
||||
import LiveChatTextMessage from '../classes/livechat/items/LiveChatTextMessage';
|
||||
import LiveChatPaidMessage from '../classes/livechat/items/LiveChatPaidMessage';
|
||||
import LiveChatPaidSticker from '../classes/livechat/items/LiveChatPaidSticker';
|
||||
import Button from '../classes/Button';
|
||||
import LiveChatAutoModMessage from '../classes/livechat/items/LiveChatAutoModMessage';
|
||||
import LiveChatMembershipItem from '../classes/livechat/items/LiveChatMembershipItem';
|
||||
import LiveChatPaidMessage from '../classes/livechat/items/LiveChatPaidMessage';
|
||||
import LiveChatPaidSticker from '../classes/livechat/items/LiveChatPaidSticker';
|
||||
import LiveChatTextMessage from '../classes/livechat/items/LiveChatTextMessage';
|
||||
import LiveChatViewerEngagementMessage from '../classes/livechat/items/LiveChatViewerEngagementMessage';
|
||||
import ItemMenu from './ItemMenu';
|
||||
import Button from '../classes/Button';
|
||||
|
||||
import type Actions from '../../core/Actions';
|
||||
|
||||
export type ChatAction =
|
||||
AddChatItemAction | AddBannerToLiveChatCommand | AddLiveChatTickerItemAction |
|
||||
@@ -49,9 +51,9 @@ export interface LiveMetadata {
|
||||
}
|
||||
|
||||
class LiveChat extends EventEmitter {
|
||||
#actions;
|
||||
#video_info;
|
||||
#continuation;
|
||||
#actions: Actions;
|
||||
#video_info: VideoInfo;
|
||||
#continuation?: string;
|
||||
#mcontinuation?: string;
|
||||
|
||||
initial_info?: LiveChatContinuation;
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
import Parser from '..';
|
||||
import Actions, { ApiResponse } from '../../core/Actions';
|
||||
import Parser, { ParsedResponse } from '..';
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
|
||||
import Notification from '../classes/Notification';
|
||||
import SimpleMenuHeader from '../classes/menus/SimpleMenuHeader';
|
||||
import ContinuationItem from '../classes/ContinuationItem';
|
||||
import SimpleMenuHeader from '../classes/menus/SimpleMenuHeader';
|
||||
import Notification from '../classes/Notification';
|
||||
|
||||
import type Actions from '../../core/Actions';
|
||||
import type { ApiResponse } from '../../core/Actions';
|
||||
|
||||
class NotificationsMenu {
|
||||
#page;
|
||||
#actions;
|
||||
#page: ParsedResponse;
|
||||
#actions: Actions;
|
||||
|
||||
header;
|
||||
contents;
|
||||
header: SimpleMenuHeader;
|
||||
contents: Notification[];
|
||||
|
||||
constructor(actions: Actions, response: ApiResponse) {
|
||||
this.#actions = actions;
|
||||
this.#page = Parser.parseResponse(response.data);
|
||||
|
||||
this.header = this.#page.actions_memo.get('SimpleMenuHeader')?.[0]?.as(SimpleMenuHeader) || null;
|
||||
this.contents = this.#page.actions_memo.get('Notification') as Notification[];
|
||||
this.header = this.#page.actions_memo.getType(SimpleMenuHeader)?.[0];
|
||||
this.contents = this.#page.actions_memo.getType(Notification);
|
||||
}
|
||||
|
||||
async getContinuation(): Promise<NotificationsMenu> {
|
||||
const continuation = this.#page.actions_memo.get('ContinuationItem')?.[0].as(ContinuationItem);
|
||||
const continuation = this.#page.actions_memo.getType(ContinuationItem)?.[0];
|
||||
|
||||
if (!continuation)
|
||||
throw new InnertubeError('Continuation not found');
|
||||
|
||||
const response = await continuation.endpoint.call(this.#actions, { parse: false });
|
||||
|
||||
return new NotificationsMenu(this.#actions, response);
|
||||
}
|
||||
|
||||
get page(): ParsedResponse {
|
||||
return this.#page;
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationsMenu;
|
||||
@@ -1,4 +1,3 @@
|
||||
import Actions from '../../core/Actions';
|
||||
import Feed from '../../core/Feed';
|
||||
|
||||
import Thumbnail from '../classes/misc/Thumbnail';
|
||||
@@ -7,11 +6,14 @@ import VideoOwner from '../classes/VideoOwner';
|
||||
import PlaylistMetadata from '../classes/PlaylistMetadata';
|
||||
import PlaylistSidebarPrimaryInfo from '../classes/PlaylistSidebarPrimaryInfo';
|
||||
import PlaylistSidebarSecondaryInfo from '../classes/PlaylistSidebarSecondaryInfo';
|
||||
import PlaylistCustomThumbnail from '../classes/PlaylistCustomThumbnail';
|
||||
import PlaylistVideoThumbnail from '../classes/PlaylistVideoThumbnail';
|
||||
import PlaylistHeader from '../classes/PlaylistHeader';
|
||||
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
|
||||
import type Actions from '../../core/Actions';
|
||||
|
||||
class Playlist extends Feed {
|
||||
info;
|
||||
menu;
|
||||
@@ -30,8 +32,8 @@ class Playlist extends Feed {
|
||||
this.info = {
|
||||
...this.page.metadata.item().as(PlaylistMetadata),
|
||||
...{
|
||||
author: secondary_info?.owner.item().as(VideoOwner).author,
|
||||
thumbnails: primary_info?.thumbnail_renderer.item().as(PlaylistVideoThumbnail).thumbnail as Thumbnail[],
|
||||
author: secondary_info?.owner.item().as(VideoOwner).author ?? header?.author,
|
||||
thumbnails: primary_info?.thumbnail_renderer.item().as(PlaylistVideoThumbnail, PlaylistCustomThumbnail).thumbnail as Thumbnail[],
|
||||
total_items: this.#getStat(0, primary_info),
|
||||
views: this.#getStat(1, primary_info),
|
||||
last_updated: this.#getStat(2, primary_info),
|
||||
@@ -42,7 +44,7 @@ class Playlist extends Feed {
|
||||
}
|
||||
};
|
||||
|
||||
this.menu = primary_info?.menu.item();
|
||||
this.menu = primary_info?.menu;
|
||||
this.endpoint = primary_info?.endpoint;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +1,46 @@
|
||||
import Actions from '../../core/Actions';
|
||||
import { observe, ObservedArray, YTNode } from '../helpers';
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
|
||||
import Feed from '../../core/Feed';
|
||||
import SectionList from '../classes/SectionList';
|
||||
import ItemSection from '../classes/ItemSection';
|
||||
import HorizontalCardList from '../classes/HorizontalCardList';
|
||||
import RichListHeader from '../classes/RichListHeader';
|
||||
import ItemSection from '../classes/ItemSection';
|
||||
import SearchRefinementCard from '../classes/SearchRefinementCard';
|
||||
import TwoColumnSearchResults from '../classes/TwoColumnSearchResults';
|
||||
import SectionList from '../classes/SectionList';
|
||||
import UniversalWatchCard from '../classes/UniversalWatchCard';
|
||||
import WatchCardHeroVideo from '../classes/WatchCardHeroVideo';
|
||||
import WatchCardSectionSequence from '../classes/WatchCardSectionSequence';
|
||||
|
||||
class Search extends Feed {
|
||||
results: ObservedArray<YTNode> | null | undefined;
|
||||
refinements;
|
||||
estimated_results;
|
||||
watch_card;
|
||||
refinement_cards;
|
||||
import type Actions from '../../core/Actions';
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
import type { ObservedArray, YTNode } from '../helpers';
|
||||
|
||||
export default class Search extends Feed {
|
||||
results?: ObservedArray<YTNode> | null;
|
||||
refinements: string[];
|
||||
estimated_results: number | null;
|
||||
watch_card: UniversalWatchCard | null;
|
||||
refinement_cards?: HorizontalCardList | null;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
|
||||
const contents =
|
||||
this.page.contents?.item().as(TwoColumnSearchResults).primary_contents?.item().as(SectionList).contents.array() ||
|
||||
this.page.contents_memo.getType(SectionList)?.[0]?.contents ||
|
||||
this.page.on_response_received_commands?.[0].contents;
|
||||
|
||||
const secondary_contents_maybe = this.page.contents?.item().key('secondary_contents');
|
||||
const secondary_contents = secondary_contents_maybe?.isParsed() ? secondary_contents_maybe.parsed().item().key('contents').parsed().array() : undefined;
|
||||
|
||||
this.results = contents.firstOfType(ItemSection)?.contents;
|
||||
|
||||
const card_list = this.results?.get({ type: 'HorizontalCardList' }, true)?.as(HorizontalCardList);
|
||||
const universal_watch_card = secondary_contents?.firstOfType(UniversalWatchCard);
|
||||
|
||||
this.refinements = this.page.refinements || [];
|
||||
this.estimated_results = this.page.estimated_results;
|
||||
|
||||
this.watch_card = {
|
||||
header: universal_watch_card?.header.item() || null,
|
||||
call_to_action: universal_watch_card?.call_to_action.item().as(WatchCardHeroVideo) || null,
|
||||
sections: universal_watch_card?.sections.array().filterType(WatchCardSectionSequence) || []
|
||||
};
|
||||
|
||||
this.refinement_cards = {
|
||||
header: card_list?.header.item().as(RichListHeader) || null,
|
||||
cards: card_list?.cards.array().filterType(SearchRefinementCard) || observe([] as SearchRefinementCard[])
|
||||
};
|
||||
this.watch_card = this.page?.contents_memo.getType(UniversalWatchCard)?.[0];
|
||||
this.refinement_cards = this.results?.get({ type: 'HorizontalCardList' }, true)?.as(HorizontalCardList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies given refinement card and returns a new {@link Search} object.
|
||||
* Applies given refinement card and returns a new {@link Search} object. Use {@link refinement_card_queries} to get a list of available refinement cards.
|
||||
*/
|
||||
async selectRefinementCard(card: SearchRefinementCard | string) {
|
||||
async selectRefinementCard(card: SearchRefinementCard | string): Promise<Search> {
|
||||
let target_card: SearchRefinementCard | undefined;
|
||||
|
||||
if (typeof card === 'string') {
|
||||
target_card = this.refinement_cards.cards.get({ query: card });
|
||||
if (!this.refinement_cards) throw new InnertubeError('No refinement cards found.');
|
||||
target_card = this.refinement_cards?.cards.get({ query: card })?.as(SearchRefinementCard);
|
||||
if (!target_card)
|
||||
throw new InnertubeError(`Refinement card "${card}" not found`, { available_cards: this.refinement_card_queries });
|
||||
} else if (card.type === 'SearchRefinementCard') {
|
||||
@@ -71,8 +54,11 @@ class Search extends Feed {
|
||||
return new Search(this.actions, page, true);
|
||||
}
|
||||
|
||||
get refinement_card_queries() {
|
||||
return this.refinement_cards.cards.map((card) => card.query);
|
||||
/**
|
||||
* Returns a list of refinement card queries.
|
||||
*/
|
||||
get refinement_card_queries(): string[] {
|
||||
return this.refinement_cards?.cards.as(SearchRefinementCard).map((card) => card.query) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +68,4 @@ class Search extends Feed {
|
||||
const continuation = await this.getContinuationData();
|
||||
return new Search(this.actions, continuation, true);
|
||||
}
|
||||
}
|
||||
|
||||
export default Search;
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
import Parser from '..';
|
||||
import Actions, { ApiResponse } from '../../core/Actions';
|
||||
import Parser, { ParsedResponse } from '..';
|
||||
import { InnertubeError } from '../../utils/Utils';
|
||||
|
||||
import ItemSection from '../classes/ItemSection';
|
||||
import SectionList from '../classes/SectionList';
|
||||
import Tab from '../classes/Tab';
|
||||
import TwoColumnBrowseResults from '../classes/TwoColumnBrowseResults';
|
||||
import SectionList from '../classes/SectionList';
|
||||
import ItemSection from '../classes/ItemSection';
|
||||
|
||||
import CompactLink from '../classes/CompactLink';
|
||||
import PageIntroduction from '../classes/PageIntroduction';
|
||||
import SettingsOptions from '../classes/SettingsOptions';
|
||||
import SettingsSwitch from '../classes/SettingsSwitch';
|
||||
import SettingsSidebar from '../classes/SettingsSidebar';
|
||||
import SettingsSwitch from '../classes/SettingsSwitch';
|
||||
|
||||
import type Actions from '../../core/Actions';
|
||||
import type { ApiResponse } from '../../core/Actions';
|
||||
|
||||
class Settings {
|
||||
#page;
|
||||
#actions;
|
||||
#page: ParsedResponse;
|
||||
#actions: Actions;
|
||||
|
||||
sidebar: SettingsSidebar | null | undefined;
|
||||
introduction: PageIntroduction | null | undefined;
|
||||
@@ -31,9 +34,9 @@ class Settings {
|
||||
if (!tab)
|
||||
throw new InnertubeError('Target tab not found');
|
||||
|
||||
const contents = tab.content?.as(SectionList).contents.array().as(ItemSection);
|
||||
const contents = tab.content?.as(SectionList).contents.as(ItemSection);
|
||||
|
||||
this.introduction = contents?.shift()?.contents?.get({ type: 'PageIntroduction' })?.as(PageIntroduction);
|
||||
this.introduction = contents?.shift()?.contents?.firstOfType(PageIntroduction);
|
||||
|
||||
this.sections = contents?.map((el: ItemSection) => ({
|
||||
title: el.header?.title.toString() || null,
|
||||
@@ -44,14 +47,21 @@ class Settings {
|
||||
/**
|
||||
* Selects an item from the sidebar menu. Use {@link sidebar_items} to see available items.
|
||||
*/
|
||||
async selectSidebarItem(name: string) {
|
||||
async selectSidebarItem(target_item: string | CompactLink): Promise<Settings> {
|
||||
if (!this.sidebar)
|
||||
throw new InnertubeError('Sidebar not available');
|
||||
|
||||
const item = this.sidebar.items.get({ title: name });
|
||||
let item: CompactLink | undefined;
|
||||
|
||||
if (!item)
|
||||
throw new InnertubeError(`Item "${name}" not found`, { available_items: this.sidebar_items });
|
||||
if (typeof target_item === 'string') {
|
||||
item = this.sidebar.items.get({ title: target_item });
|
||||
if (!item)
|
||||
throw new InnertubeError(`Item "${target_item}" not found`, { available_items: this.sidebar_items });
|
||||
} else if (target_item?.is(CompactLink)) {
|
||||
item = target_item;
|
||||
} else {
|
||||
throw new InnertubeError('Invalid item', { target_item });
|
||||
}
|
||||
|
||||
const response = await item.endpoint.call(this.#actions, { parse: false });
|
||||
|
||||
@@ -61,7 +71,7 @@ class Settings {
|
||||
/**
|
||||
* Finds a setting by name and returns it. Use {@link setting_options} to see available options.
|
||||
*/
|
||||
getSettingOption(name: string) {
|
||||
getSettingOption(name: string): SettingsSwitch {
|
||||
if (!this.sections)
|
||||
throw new InnertubeError('Sections not available');
|
||||
|
||||
@@ -113,6 +123,10 @@ class Settings {
|
||||
|
||||
return this.sidebar.items.map((item) => item.title.toString());
|
||||
}
|
||||
|
||||
get page(): ParsedResponse {
|
||||
return this.#page;
|
||||
}
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user