mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-14 10:02:16 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e7ef77eb | ||
|
|
27fdd8268a | ||
|
|
d4ea87b8b0 | ||
|
|
ec87eea20d | ||
|
|
e43ad202f4 | ||
|
|
104c36b450 | ||
|
|
f5d61d70f2 | ||
|
|
c76f5f478d | ||
|
|
49d1432b5a | ||
|
|
be157ef016 | ||
|
|
9f703203b6 | ||
|
|
516eeeff45 | ||
|
|
6caa679df6 | ||
|
|
2a87f42b32 | ||
|
|
f7c1e0f249 | ||
|
|
fe4c5433cf | ||
|
|
0e5e0c0fab | ||
|
|
f0fd6146c7 | ||
|
|
43061970c6 | ||
|
|
746023d9bb | ||
|
|
3102479dd9 | ||
|
|
c7a13c948c | ||
|
|
ec875ba321 | ||
|
|
db77bba802 | ||
|
|
5ea0a0ebf8 | ||
|
|
0130229236 | ||
|
|
da517fe6d1 | ||
|
|
95ff1e6c5e | ||
|
|
0f8adfd9b8 | ||
|
|
b514765354 | ||
|
|
3cbcd71a3a | ||
|
|
4c00f15f55 | ||
|
|
ea1d206b26 | ||
|
|
aa334aacbd | ||
|
|
1eda93ee08 | ||
|
|
fe0ac0a961 | ||
|
|
8740deb1f2 | ||
|
|
d71b762df5 | ||
|
|
dc14d3785f | ||
|
|
088f909515 | ||
|
|
2a78d77aa3 | ||
|
|
1b2862c00f | ||
|
|
477c030084 | ||
|
|
19d579df13 | ||
|
|
5313c57783 | ||
|
|
190f7681be | ||
|
|
6e027bcc85 | ||
|
|
6b531dd0ea | ||
|
|
92f24076db | ||
|
|
a9eba7ca62 | ||
|
|
2f56c15ecc | ||
|
|
95e0479745 | ||
|
|
556c7cd6e8 | ||
|
|
a4a88419ef | ||
|
|
aefecd061e | ||
|
|
7485726f1e | ||
|
|
9e703abe3a | ||
|
|
affbe84284 | ||
|
|
fcbdae3e34 | ||
|
|
059c858021 | ||
|
|
4ecd3360e0 | ||
|
|
08e9527931 | ||
|
|
a9f03a1523 | ||
|
|
c8980c7985 | ||
|
|
2e5688f235 | ||
|
|
dcf2b720a0 | ||
|
|
a90f5eb853 | ||
|
|
c6482e07b9 | ||
|
|
2de77c8f2c | ||
|
|
2aaa209906 | ||
|
|
ab028ba1ec | ||
|
|
f2f48af1bc | ||
|
|
3a7da21fd1 | ||
|
|
89794d65da | ||
|
|
91847ae3cc | ||
|
|
eb44b71939 | ||
|
|
88ebb5e2ae | ||
|
|
b237b6af4e | ||
|
|
9e618cc576 | ||
|
|
daf95cfe87 | ||
|
|
bc03c91df9 | ||
|
|
e00be25bf4 | ||
|
|
c9856a8359 | ||
|
|
4b29ad74de | ||
|
|
60730a5531 |
@@ -12,10 +12,7 @@ ___
|
||||
* [Create a PR](#changes-2)
|
||||
* [Run tests](#test)
|
||||
* [Lint your code](#lint)
|
||||
* [Build for node](#build-1)
|
||||
* [Bundle for browsers](#build-2)
|
||||
* [Compile proto file](#build-3)
|
||||
* [Build parser map](#build-4)
|
||||
* [Build](#build)
|
||||
|
||||
## Issues
|
||||
|
||||
@@ -25,7 +22,7 @@ If you find a problem, search if an issue already exists. If a related issue doe
|
||||
|
||||
<a id="issue-2"></a>
|
||||
#### Solve an issue
|
||||
Scan through the existing issues to find one that interests you. You can narrow down the search using labels as filters. If you find an issue to work on, you are welcome to open a PR with a fix.
|
||||
Scan through the existing issues to find one that interests you. You can narrow down the search using labels as filters. If you find an issue to work on, you are welcome to open a PR with a fix. Documentation updates and grammar fixes are also appreciated!
|
||||
|
||||
<a id="changes"></a>
|
||||
## Make changes
|
||||
@@ -62,42 +59,23 @@ npm run test
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Or
|
||||
|
||||
```bash
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
<a id="build-1"></a>
|
||||
#### Build for Node
|
||||
<a id="build"></a>
|
||||
#### Build
|
||||
|
||||
```bash
|
||||
# Node
|
||||
npm run build:node
|
||||
```
|
||||
|
||||
<a id="build-2"></a>
|
||||
#### Build for browsers
|
||||
|
||||
```bash
|
||||
# Browser
|
||||
npm run build:browser
|
||||
```
|
||||
Or:
|
||||
```bash
|
||||
npm run build:browser:prod
|
||||
```
|
||||
|
||||
<a id="build-3"></a>
|
||||
#### Compile proto file
|
||||
# Protobuf
|
||||
npm run build:proto
|
||||
|
||||
```bash
|
||||
// TODO
|
||||
```
|
||||
|
||||
<a id="build-4"></a>
|
||||
#### Build parser map
|
||||
|
||||
```bash
|
||||
# Parser map
|
||||
npm run build:parser-map
|
||||
```
|
||||
```
|
||||
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,43 +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]
|
||||
[][npm]
|
||||
[][discord]
|
||||
[][say-thanks]
|
||||
|
||||
<br>
|
||||
[][github-sponsors]
|
||||
|
||||
</div>
|
||||
|
||||
<!-- SPONSORS -->
|
||||
|
||||
<p align="center">
|
||||
<a><sub>Special thanks to:<sub></a>
|
||||
</p>
|
||||
@@ -55,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>
|
||||
@@ -71,7 +53,6 @@
|
||||
|
||||
___
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
<details>
|
||||
<summary>Table of Contents</summary>
|
||||
<ol>
|
||||
@@ -93,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 across all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform[^1]. This library handles all the low-level communication with InnerTube, providing a simple, and efficient way to interact with YouTube programmatically. It is designed to emulate an actual client as closely as possible, including how API responses are parsed.
|
||||
|
||||
If you have any questions or need help, feel free to 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
|
||||
@@ -136,14 +114,13 @@ npm install github:LuanRT/YouTube.js
|
||||
|
||||
**TODO:** Deno install instructions (esm.sh possibly?)
|
||||
|
||||
<!-- USAGE -->
|
||||
## Usage
|
||||
Create an InnerTube instance:
|
||||
```ts
|
||||
// const { Innertube } = require('youtubei.js');
|
||||
import { Innertube } from 'youtubei.js';
|
||||
const youtube = await Innertube.create();
|
||||
```
|
||||
```****
|
||||
|
||||
## Browser Usage
|
||||
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in [`examples/browser/proxy/deno.ts`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/proxy/deno.ts).
|
||||
@@ -243,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)
|
||||
@@ -258,7 +235,7 @@ const yt = await Innertube.create({
|
||||
|
||||
|
||||
<details>
|
||||
<summary>methods</summary>
|
||||
<summary>Methods</summary>
|
||||
<p>
|
||||
|
||||
* [.getInfo(video_id, client?)](#getinfo)
|
||||
@@ -292,7 +269,7 @@ Retrieves video info, including playback data and even layout elements such as m
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | The id of the video |
|
||||
| client? | `InnerTubeClient` | `WEB`, `ANDROID` or `YTMUSIC` |
|
||||
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC`, `YTMUSIC_ANDROID` or `TV_EMBEDDED` |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
@@ -347,7 +324,7 @@ Suitable for cases where you only need basic video metadata. Also, it is faster
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | The id of the video |
|
||||
| client? | `InnerTubeClient` | `WEB`, `ANDROID` or `YTMUSIC` |
|
||||
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC_ANDROID`, `YTMUSIC`, `TV_EMBEDDED` |
|
||||
|
||||
<a name="search"></a>
|
||||
### search(query, filters?)
|
||||
@@ -404,7 +381,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()
|
||||
@@ -470,11 +472,16 @@ Retrieves contents for a given channel.
|
||||
<p>
|
||||
|
||||
- `<channel>#getVideos()`
|
||||
- `<channel>#getShorts()`
|
||||
- `<channel>#getLiveStreams()`
|
||||
- `<channel>#getPlaylists()`
|
||||
- `<channel>#getHome()`
|
||||
- `<channel>#getCommunity()`
|
||||
- `<channel>#getChannels()`
|
||||
- `<channel>#getAbout()`
|
||||
- `<channel>#getContinuation()`
|
||||
- `<channel>#filters`
|
||||
- `<channel>#page`
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -527,6 +534,14 @@ Retrieves playlist contents.
|
||||
### getStreamingData(video_id, options)
|
||||
Returns deciphered streaming data.
|
||||
|
||||
**Note:**
|
||||
It is recommended to retrieve streaming data from a `VideoInfo`/`TrackInfo` object instead if you want to select formats manually, example:
|
||||
```ts
|
||||
const info = await yt.getBasicInfo('somevideoid');
|
||||
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||
console.info('Playback url:', url);
|
||||
```
|
||||
|
||||
**Returns**: `Promise.<object>`
|
||||
|
||||
| Param | Type | Description |
|
||||
@@ -557,66 +572,64 @@ 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, WEB
|
||||
parse: true // tells YouTube.js to parse the response, this is not sent to InnerTube.
|
||||
};
|
||||
const yt = await Innertube.create();
|
||||
|
||||
const response = await yt.actions.execute('/player', payload);
|
||||
async function getVideoInfo(videoId: string) {
|
||||
const payload = {
|
||||
// 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);
|
||||
const videoInfo = await yt.actions.execute('/player', payload);
|
||||
|
||||
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';
|
||||
|
||||
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;
|
||||
|
||||
// Say we have a button and want to “click” it
|
||||
const button = albums.as(MusicCarouselShelf).header?.more_content;
|
||||
|
||||
if (button) {
|
||||
// To do that, we can call its navigation endpoint:
|
||||
const page = await button.endpoint.call(yt.actions, 'YTMUSIC', true);
|
||||
// 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
|
||||
@@ -624,15 +637,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');
|
||||
@@ -640,31 +656,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]
|
||||
|
||||
@@ -674,13 +685,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;
|
||||
@@ -5,7 +5,7 @@ YouTube Music class.
|
||||
## API
|
||||
|
||||
* Music
|
||||
* [.getInfo(video_id)](#getinfo)
|
||||
* [.getInfo(target)](#getinfo)
|
||||
* [.search(query, filters?)](#search)
|
||||
* [.getHomeFeed()](#gethomefeed)
|
||||
* [.getExplore()](#getexplore)
|
||||
@@ -14,13 +14,13 @@ YouTube Music class.
|
||||
* [.getAlbum(album_id)](#getalbum)
|
||||
* [.getPlaylist(playlist_id)](#getplaylist)
|
||||
* [.getLyrics(video_id)](#getlyrics)
|
||||
* [.getUpNext(video_id)](#getupnext)
|
||||
* [.getUpNext(video_id, automix?)](#getupnext)
|
||||
* [.getRelated(video_id)](#getrelated)
|
||||
* [.getRecap()](#getrecap)
|
||||
* [.getSearchSuggestions(query)](#getsearchsuggestions)
|
||||
|
||||
<a name="getinfo"></a>
|
||||
### getInfo(video_id)
|
||||
### getInfo(target)
|
||||
|
||||
Retrieves track info.
|
||||
|
||||
@@ -28,7 +28,29 @@ Retrieves track info.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| target | `string` or `MusicTwoRowItem` | video id or list item |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<info>#getTab(title)`
|
||||
- Retrieves contents of the given tab.
|
||||
|
||||
- `<info>#getUpNext(automix?)`
|
||||
- Retrieves up next.
|
||||
|
||||
- `<info>#getRelated()`
|
||||
- Retrieves related content.
|
||||
|
||||
- `<info>#getLyrics()`
|
||||
- Retrieves song lyrics.
|
||||
|
||||
- `<info>#available_tabs`
|
||||
- Returns available tabs.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="search"></a>
|
||||
### search(query, filters?)
|
||||
@@ -127,9 +149,35 @@ Retrieves “Explore” feed.
|
||||
|
||||
Retrieves library.
|
||||
|
||||
**Returns:** `Promise.<Library>`
|
||||
**Returns:** `Library`
|
||||
|
||||
<!-- TODO: document Library's methods and getters. -->
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<library>#applyFilter(filter)`
|
||||
- Applies given filter to the library.
|
||||
|
||||
- `<library>#applySortFilter(filter)`
|
||||
- Applies given sort filter to the library items.
|
||||
|
||||
- `<library>#getContinuation()`
|
||||
- Retrieves continuation of the library items.
|
||||
|
||||
- `<library>#has_continuation`
|
||||
- Checks if continuation is available.
|
||||
|
||||
- `<library>#filters`
|
||||
- Returns available filters.
|
||||
|
||||
- `<library>#sort_filters`
|
||||
- Returns available sort filters.
|
||||
|
||||
- `<library>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getartist"></a>
|
||||
### getArtist(artist_id)
|
||||
@@ -211,14 +259,14 @@ Retrieves given playlist.
|
||||
|
||||
Retrieves song lyrics.
|
||||
|
||||
**Returns:** `Promise.<{ text: string; footer: object; }>`
|
||||
**Returns:** `Promise.<MusicDescriptionShelf | undefined>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="getupnext"></a>
|
||||
### getUpNext(video_id)
|
||||
### getUpNext(video_id, automix?)
|
||||
|
||||
Retrieves up next content.
|
||||
|
||||
@@ -227,6 +275,7 @@ Retrieves up next content.
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| automix? | `boolean` | if automix should be fetched |
|
||||
|
||||
<a name="getrelated"></a>
|
||||
### getRelated(video_id)
|
||||
|
||||
@@ -41,7 +41,7 @@ InnerTube API key.
|
||||
**Returns:** `string`
|
||||
|
||||
<a name="api_version"></a>
|
||||
### key
|
||||
### api_version
|
||||
|
||||
InnerTube API version.
|
||||
|
||||
@@ -80,4 +80,4 @@ Player script object.
|
||||
|
||||
Client language.
|
||||
|
||||
**Returns:** `string`
|
||||
**Returns:** `string`
|
||||
|
||||
@@ -6,6 +6,7 @@ YouTube Studio class (WIP).
|
||||
|
||||
* Studio
|
||||
* [.setThumbnail(video_id, buffer)](#setthumbnail)
|
||||
* [.updateVideoMetadata(video_id, metadata)](#updatemetadata)
|
||||
* [.upload(file, metadata)](#upload)
|
||||
|
||||
<a name="setthumbnail"></a>
|
||||
@@ -20,6 +21,18 @@ Uploads a custom thumbnail and sets it for a video.
|
||||
| video_id | `string` | Video id |
|
||||
| buffer | `Uint8Array` | Thumbnail buffer |
|
||||
|
||||
<a name="updatemetadata"></a>
|
||||
### updateVideoMetadata(video_id, metadata)
|
||||
|
||||
Updates given video's metadata.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| metadata | `VideoMetadata` | Video metadata |
|
||||
|
||||
<a name="upload"></a>
|
||||
### upload(file, metadata)
|
||||
|
||||
@@ -30,4 +43,4 @@ Uploads a video to YouTube.
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| file | `BodyInit` | Video file |
|
||||
| metadata | `VideoMetadata` | Video metadata |
|
||||
| metadata | `UploadedVideoMetadata` | Video metadata |
|
||||
54
docs/updating-the-parser.md
Normal file
54
docs/updating-the-parser.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Updating the parser
|
||||
|
||||
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:
|
||||
```
|
||||
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!
|
||||
at Parser.printError (...)
|
||||
at Parser.parseItem (...)
|
||||
at Parser.parseArray (...) {
|
||||
info: {
|
||||
// renderer data, can be used as a reference to implement the renderer parser
|
||||
},
|
||||
date: 2022-05-22T22:16:06.831Z,
|
||||
version: '2.2.3'
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Adding a new renderer parser
|
||||
|
||||
Thanks to the modularity of the parser, a renderer can be implemented by simply adding a new file anywhere in the [classes directory](../src/parser/classes)!
|
||||
|
||||
For example, say we found a new renderer named `verticalListRenderer`, to let the parser know it exists we would have to create a file with the following structure:
|
||||
|
||||
> `../classes/VerticalList.ts`
|
||||
```ts
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class VerticalList extends YTNode {
|
||||
static type = 'VerticalList';
|
||||
|
||||
header;
|
||||
contents;
|
||||
|
||||
constructor(data: any) {
|
||||
// parse the data here, ex;
|
||||
this.header = Parser.parseItem(data.header);
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
}
|
||||
}
|
||||
|
||||
export default VerticalList;
|
||||
```
|
||||
|
||||
Then update the parser map:
|
||||
```bash
|
||||
npm run build:parser-map
|
||||
```
|
||||
|
||||
And that's it!
|
||||
@@ -6,17 +6,52 @@ YouTube.js works in the browser!
|
||||
|
||||
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in `examples/browser/proxy/deno.ts`.
|
||||
|
||||
Once the proxy is set up you need to tell Innertube about it when instantiating it.
|
||||
We'll use our own fetch implementation to proxy requests through our server. This is a simple example, but you can use any fetch implementation you want.
|
||||
|
||||
```ts
|
||||
import { Innertube } from "youtubei.js/build/browser";
|
||||
|
||||
const yt = await Innertube.create({
|
||||
browser_proxy: {
|
||||
host: "localhost",
|
||||
schema: 'http',
|
||||
}
|
||||
})
|
||||
fetch: async (input, init) => {
|
||||
// url
|
||||
const url = typeof input === 'string'
|
||||
? new URL(input)
|
||||
: input instanceof URL
|
||||
? input
|
||||
: new URL(input.url);
|
||||
|
||||
// transform the url for use with our proxy
|
||||
url.searchParams.set('__host', url.host);
|
||||
url.host = 'localhost:8080';
|
||||
url.protocol = 'http';
|
||||
|
||||
const headers = init?.headers
|
||||
? new Headers(init.headers)
|
||||
: input instanceof Request
|
||||
? input.headers
|
||||
: new Headers();
|
||||
|
||||
// now serialize the headers
|
||||
url.searchParams.set('__headers', JSON.stringify([...headers]));
|
||||
|
||||
// copy over the request
|
||||
const request = new Request(
|
||||
url,
|
||||
input instanceof Request ? input : undefined,
|
||||
);
|
||||
|
||||
headers.delete('user-agent');
|
||||
|
||||
// fetch the url
|
||||
return fetch(request, init ? {
|
||||
...init,
|
||||
headers
|
||||
} : {
|
||||
headers
|
||||
});
|
||||
},
|
||||
cache: new UniversalCache(),
|
||||
});
|
||||
```
|
||||
|
||||
after that you can use the library as normal.
|
||||
|
||||
@@ -18,7 +18,7 @@ const handler = async (request: Request): Promise<Response> => {
|
||||
'Access-Control-Allow-Origin': request.headers.get('origin') || '*',
|
||||
'Access-Control-Allow-Methods': '*',
|
||||
'Access-Control-Allow-Headers':
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-origin, x-youtube-client-version, Accept-Language, Range',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-origin, x-youtube-client-version, Accept-Language, Range, Referer',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
}),
|
||||
@@ -45,7 +45,7 @@ const handler = async (request: Request): Promise<Response> => {
|
||||
JSON.parse(url.searchParams.get('__headers') || '{}'),
|
||||
);
|
||||
copyHeader('range', request_headers, request.headers);
|
||||
copyHeader('user-agent', request_headers, request.headers);
|
||||
!request_headers.has('user-agent') && copyHeader('user-agent', request_headers, request.headers);
|
||||
url.searchParams.delete('__headers');
|
||||
|
||||
// Make the request to YouTube
|
||||
@@ -62,6 +62,8 @@ const handler = async (request: Request): Promise<Response> => {
|
||||
copyHeader('content-length', headers, fetchRes.headers);
|
||||
copyHeader('content-type', headers, fetchRes.headers);
|
||||
copyHeader('content-disposition', headers, fetchRes.headers);
|
||||
copyHeader('accept-ranges', headers, fetchRes.headers);
|
||||
copyHeader('content-range', headers, fetchRes.headers);
|
||||
|
||||
// add cors headers
|
||||
headers.set(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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();
|
||||
|
||||
|
||||
@@ -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}!`);
|
||||
})();
|
||||
@@ -21,6 +21,7 @@ const livechat = await info.getLiveChat();
|
||||
* [.ev](#ev) ⇒ `EventEmitter`
|
||||
* [.start](#start) ⇒ `function`
|
||||
* [.stop](#stop) ⇒ `function`
|
||||
* [.getItemMenu](#getitemmenu) ⇒ `function`
|
||||
* [.sendMessage](#sendmessage) ⇒ `function`
|
||||
|
||||
<a name="ev"></a>
|
||||
@@ -58,6 +59,16 @@ Starts the Live Chat.
|
||||
### stop()
|
||||
Stops the Live Chat.
|
||||
|
||||
<a name="getitemmenu"></a>
|
||||
### getItemMenu(item)
|
||||
Retrieves given chat item's menu.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| item | `object` | Chat item |
|
||||
|
||||
**Returns:** `Promise<ItemMenu>`
|
||||
|
||||
<a name="sendmessage"></a>
|
||||
### sendMessage(text)
|
||||
Sends a message.
|
||||
|
||||
@@ -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);
|
||||
7
index.ts
7
index.ts
@@ -10,6 +10,11 @@ if (getRuntime() === 'node') {
|
||||
Reflect.set(globalThis, 'Response', undici.Response);
|
||||
Reflect.set(globalThis, 'FormData', undici.FormData);
|
||||
Reflect.set(globalThis, 'File', undici.File);
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
const { ReadableStream } = require('node:stream/web');
|
||||
Reflect.set(globalThis, 'ReadableStream', ReadableStream);
|
||||
} catch { /* do nothing */ }
|
||||
}
|
||||
|
||||
import Innertube from './src/Innertube';
|
||||
@@ -18,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;
|
||||
2283
package-lock.json
generated
2283
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "2.0.0",
|
||||
"description": "Full-featured wrapper around YouTube's private API.",
|
||||
"version": "2.5.2",
|
||||
"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",
|
||||
@@ -12,7 +12,8 @@
|
||||
"contributors": [
|
||||
"Wykerd (https://github.com/wykerd/)",
|
||||
"MasterOfBob777 (https://github.com/MasterOfBob777)",
|
||||
"patrickkfkan (https://github.com/patrickkfkan)"
|
||||
"patrickkfkan (https://github.com/patrickkfkan)",
|
||||
"akkadaska (https://github.com/akkadaska)"
|
||||
],
|
||||
"directories": {
|
||||
"test": "./test",
|
||||
@@ -39,16 +40,16 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@protobuf-ts/runtime": "^2.7.0",
|
||||
"jintr": "^0.1.9",
|
||||
"jintr": "^0.3.1",
|
||||
"linkedom": "^0.14.12",
|
||||
"undici": "^5.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"@protobuf-ts/plugin": "^2.7.0",
|
||||
"@types/jest": "^28.1.7",
|
||||
"@types/node": "^17.0.45",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"esbuild": "^0.14.49",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.16",
|
||||
@@ -69,17 +70,19 @@
|
||||
"youtubedl",
|
||||
"youtube-dl",
|
||||
"youtube-downloader",
|
||||
"innertube",
|
||||
"youtube-music",
|
||||
"youtube-studio",
|
||||
"innertubeapi",
|
||||
"innertube",
|
||||
"unofficial",
|
||||
"downloader",
|
||||
"livechat",
|
||||
"studio",
|
||||
"upload",
|
||||
"ytmusic",
|
||||
"dislike",
|
||||
"search",
|
||||
"comment",
|
||||
"music",
|
||||
"like",
|
||||
"api"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,38 +17,26 @@ import { ActionsResponse } from './core/Actions';
|
||||
import Feed from './core/Feed';
|
||||
import YTMusic from './core/Music';
|
||||
import Studio from './core/Studio';
|
||||
import HomeFeed from './parser/youtube/HomeFeed';
|
||||
import AccountManager from './core/AccountManager';
|
||||
import PlaylistManager from './core/PlaylistManager';
|
||||
import InteractionManager from './core/InteractionManager';
|
||||
import FilterableFeed from './core/FilterableFeed';
|
||||
import TabbedFeed from './core/TabbedFeed';
|
||||
import Constants from './utils/Constants';
|
||||
import Proto from './proto/index';
|
||||
|
||||
import { throwIfMissing, generateRandomString } from './utils/Utils';
|
||||
|
||||
export type InnertubeConfig = SessionOptions
|
||||
export type InnertubeConfig = SessionOptions;
|
||||
|
||||
export interface SearchFilters {
|
||||
/**
|
||||
* Filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
|
||||
*/
|
||||
upload_date?: 'any' | 'last_hour' | 'today' | 'this_week' | 'this_month' | 'this_year';
|
||||
/**
|
||||
* Filter results by type, can be: any | video | channel | playlist | movie
|
||||
*/
|
||||
type?: 'any' | 'video' | 'channel' | 'playlist' | 'movie';
|
||||
/**
|
||||
* Filter videos by duration, can be: any | short | medium | long
|
||||
*/
|
||||
duration?: 'any' | 'short' | 'medium' | 'long';
|
||||
/**
|
||||
* Filter video results by order, can be: relevance | rating | upload_date | view_count
|
||||
*/
|
||||
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'
|
||||
}
|
||||
|
||||
export type InnerTubeClient = 'ANDROID' | 'YTMUSIC_ANDROID' | 'WEB' | 'YTMUSIC';
|
||||
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'TV_EMBEDDED';
|
||||
|
||||
class Innertube {
|
||||
session;
|
||||
@@ -75,12 +63,14 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Retrieves video info.
|
||||
* @param video_id - The video id.
|
||||
* @param client - The client to use.
|
||||
*/
|
||||
async getInfo(video_id: string, client?: InnerTubeClient) {
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = await this.actions.getVideoInfo(video_id, cpn, client);
|
||||
const continuation = this.actions.next({ video_id });
|
||||
const initial_info = this.actions.getVideoInfo(video_id, cpn, client);
|
||||
const continuation = this.actions.execute('/next', { videoId: video_id });
|
||||
|
||||
const response = await Promise.all([ initial_info, continuation ]);
|
||||
return new VideoInfo(response, this.actions, this.session.player, cpn);
|
||||
@@ -88,6 +78,8 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Retrieves basic video info.
|
||||
* @param video_id - The video id.
|
||||
* @param client - The client to use.
|
||||
*/
|
||||
async getBasicInfo(video_id: string, client?: InnerTubeClient) {
|
||||
const cpn = generateRandomString(16);
|
||||
@@ -98,18 +90,27 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Searches a given query.
|
||||
* @param query - search query.
|
||||
* @param filters - search filters.
|
||||
* @param query - The search query.
|
||||
* @param filters - Search filters.
|
||||
*/
|
||||
async search(query: string, filters: SearchFilters = {}) {
|
||||
throwIfMissing({ query });
|
||||
const response = await this.actions.search({ query, filters });
|
||||
|
||||
const args = {
|
||||
query,
|
||||
...{
|
||||
params: filters ? Proto.encodeSearchFilters(filters) : undefined
|
||||
}
|
||||
};
|
||||
|
||||
const response = await this.actions.execute('/search', args);
|
||||
|
||||
return new Search(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions for a given query.
|
||||
* @param query - the search query.
|
||||
* @param query - The search query.
|
||||
*/
|
||||
async getSearchSuggestions(query: string): Promise<string[]> {
|
||||
throwIfMissing({ query });
|
||||
@@ -134,8 +135,8 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Retrieves comments for a video.
|
||||
* @param video_id - the video id.
|
||||
* @param sort_by - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
|
||||
* @param video_id - The video id.
|
||||
* @param sort_by - Sorting options.
|
||||
*/
|
||||
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST') {
|
||||
throwIfMissing({ video_id });
|
||||
@@ -144,7 +145,8 @@ class Innertube {
|
||||
sort_by: sort_by || 'TOP_COMMENTS'
|
||||
});
|
||||
|
||||
const response = await this.actions.next({ ctoken: payload });
|
||||
const response = await this.actions.execute('/next', { continuation: payload });
|
||||
|
||||
return new Comments(this.actions, response.data);
|
||||
}
|
||||
|
||||
@@ -152,15 +154,15 @@ class Innertube {
|
||||
* Retrieves YouTube's home feed (aka recommendations).
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await this.actions.browse('FEwhat_to_watch');
|
||||
return new FilterableFeed(this.actions, response.data);
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
|
||||
return new HomeFeed(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account's library.
|
||||
*/
|
||||
async getLibrary() {
|
||||
const response = await this.actions.browse('FElibrary');
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FElibrary' });
|
||||
return new Library(response.data, this.actions);
|
||||
}
|
||||
|
||||
@@ -169,7 +171,7 @@ class Innertube {
|
||||
* Which can also be achieved with {@link getLibrary}.
|
||||
*/
|
||||
async getHistory() {
|
||||
const response = await this.actions.browse('FEhistory');
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
|
||||
return new History(this.actions, response.data);
|
||||
}
|
||||
|
||||
@@ -177,7 +179,7 @@ class Innertube {
|
||||
* Retrieves trending content.
|
||||
*/
|
||||
async getTrending() {
|
||||
const response = await this.actions.browse('FEtrending');
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEtrending' });
|
||||
return new TabbedFeed(this.actions, response.data);
|
||||
}
|
||||
|
||||
@@ -185,7 +187,7 @@ class Innertube {
|
||||
* Retrieves subscriptions feed.
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await this.actions.browse('FEsubscriptions');
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions' });
|
||||
return new Feed(this.actions, response.data);
|
||||
}
|
||||
|
||||
@@ -195,7 +197,7 @@ class Innertube {
|
||||
*/
|
||||
async getChannel(id: string) {
|
||||
throwIfMissing({ id });
|
||||
const response = await this.actions.browse(id);
|
||||
const response = await this.actions.execute('/browse', { browseId: id });
|
||||
return new Channel(this.actions, response.data);
|
||||
}
|
||||
|
||||
@@ -203,16 +205,17 @@ class Innertube {
|
||||
* Retrieves notifications.
|
||||
*/
|
||||
async getNotifications() {
|
||||
const response = await this.actions.notifications('get_notification_menu');
|
||||
const response = await this.actions.execute('/notification/get_notification_menu', { notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' });
|
||||
return new NotificationsMenu(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves unseen notifications count.
|
||||
*/
|
||||
async getUnseenNotificationsCount() {
|
||||
const response = await this.actions.notifications('get_unseen_count');
|
||||
return response.data.unseenCount;
|
||||
async getUnseenNotificationsCount(): Promise<number> {
|
||||
const response = await this.actions.execute('/notification/get_unseen_count');
|
||||
// TODO: properly parse this
|
||||
return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,7 +223,12 @@ class Innertube {
|
||||
*/
|
||||
async getPlaylist(id: string) {
|
||||
throwIfMissing({ id });
|
||||
const response = await this.actions.browse(`VL${id.replace(/VL/g, '')}`);
|
||||
|
||||
if (!id.startsWith('VL')) {
|
||||
id = `VL${id}`;
|
||||
}
|
||||
|
||||
const response = await this.actions.execute('/browse', { browseId: id });
|
||||
return new Playlist(this.actions, response.data);
|
||||
}
|
||||
|
||||
@@ -245,11 +253,16 @@ class Innertube {
|
||||
return info.download(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to call an endpoint without having to use {@link Actions}.
|
||||
* @param endpoint -The endpoint to call.
|
||||
* @param args - Call arguments.
|
||||
*/
|
||||
call(endpoint: NavigationEndpoint, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
|
||||
call(endpoint: NavigationEndpoint, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
|
||||
call(endpoint: NavigationEndpoint, args?: object): Promise<ActionsResponse | ParsedResponse> {
|
||||
return endpoint.callTest(this.actions, args);
|
||||
return endpoint.call(this.actions, args);
|
||||
}
|
||||
}
|
||||
|
||||
export default Innertube;
|
||||
export default Innertube;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -16,13 +17,30 @@ class AccountManager {
|
||||
this.channel = {
|
||||
/**
|
||||
* Edits channel name.
|
||||
* @param new_name - The new channel name.
|
||||
*/
|
||||
editName: (new_name: string) => this.#actions.channel('channel/edit_name', { new_name }),
|
||||
editName: (new_name: string) => {
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
return this.#actions.execute('/channel/edit_name', {
|
||||
givenName: new_name,
|
||||
client: 'ANDROID'
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Edits channel description.
|
||||
*
|
||||
* @param new_description - The new description.
|
||||
*/
|
||||
editDescription: (new_description: string) => this.#actions.channel('channel/edit_description', { new_description }),
|
||||
editDescription: (new_description: string) => {
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
return this.#actions.execute('/channel/edit_description', {
|
||||
givenDescription: new_description,
|
||||
client: 'ANDROID'
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Retrieves basic channel analytics.
|
||||
*/
|
||||
@@ -34,6 +52,9 @@ class AccountManager {
|
||||
* Retrieves channel info.
|
||||
*/
|
||||
async getInfo() {
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const response = await this.#actions.execute('/account/accounts_list', { client: 'ANDROID' });
|
||||
return new AccountInfo(response);
|
||||
}
|
||||
@@ -68,7 +89,12 @@ class AccountManager {
|
||||
const info = await this.getInfo();
|
||||
|
||||
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
|
||||
const response = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
|
||||
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'FEanalytics_screen',
|
||||
client: 'ANDROID',
|
||||
params
|
||||
});
|
||||
|
||||
return new Analytics(response);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,14 @@
|
||||
import Proto from '../proto/index';
|
||||
import Session from './Session';
|
||||
|
||||
import Parser, { ParsedResponse } from '../parser/index';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
|
||||
import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
|
||||
|
||||
export interface BrowseArgs {
|
||||
params?: string | null;
|
||||
is_ytm?: boolean;
|
||||
is_ctoken?: boolean;
|
||||
form_data?: {};
|
||||
client?: string;
|
||||
}
|
||||
|
||||
export interface EngageArgs {
|
||||
video_id?: string;
|
||||
channel_id?: string;
|
||||
comment_id?: string;
|
||||
comment_action?: string;
|
||||
params?: string;
|
||||
text?: string;
|
||||
target_language?: string;
|
||||
}
|
||||
|
||||
export interface AccountArgs {
|
||||
new_value?: string | boolean; // TODO: is this correct?
|
||||
setting_item_id?: string;
|
||||
client?: string;
|
||||
}
|
||||
|
||||
export interface SearchArgs {
|
||||
query?: string,
|
||||
options?: {
|
||||
period?: string,
|
||||
duration?: string,
|
||||
order?: string
|
||||
},
|
||||
client?: string,
|
||||
ctoken?: string,
|
||||
params?: string
|
||||
filters?: any // TODO: what is this type??
|
||||
}
|
||||
|
||||
export interface AxioslikeResponse {
|
||||
export interface ApiResponse {
|
||||
success: boolean;
|
||||
status_code: number;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export type ActionsResponse = Promise<AxioslikeResponse>;
|
||||
export type ActionsResponse = Promise<ApiResponse>;
|
||||
|
||||
class Actions {
|
||||
#session;
|
||||
@@ -63,562 +23,24 @@ class Actions {
|
||||
|
||||
/**
|
||||
* Mimmics the Axios API using Fetch's Response object.
|
||||
* @param response - The response object.
|
||||
*/
|
||||
async #wrap(response: Response, protobuf?: boolean) {
|
||||
async #wrap(response: Response) {
|
||||
return {
|
||||
success: response.ok,
|
||||
status_code: response.status,
|
||||
data: protobuf ? await response.text() : JSON.parse(await response.text())
|
||||
data: JSON.parse(await response.text())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers `/browse` endpoint, mostly used to access
|
||||
* YouTube's sections such as the home feed, etc
|
||||
* and sometimes to retrieve continuations.
|
||||
*
|
||||
* @param id - browseId or a continuation token
|
||||
* @param args - additional arguments
|
||||
*/
|
||||
async browse(id: string, args: BrowseArgs = {}) {
|
||||
if (this.#needsLogin(id) && !this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
if (args.params)
|
||||
data.params = args.params;
|
||||
|
||||
if (args.is_ctoken) {
|
||||
data.continuation = id;
|
||||
} else {
|
||||
data.browseId = id;
|
||||
}
|
||||
|
||||
if (args.form_data) {
|
||||
data.formData = args.form_data;
|
||||
}
|
||||
|
||||
if (args.client) {
|
||||
data.client = args.client;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/browse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used to perform direct interactions
|
||||
* on YouTube.
|
||||
*/
|
||||
async engage(action: string, args: EngageArgs = {}) {
|
||||
if (!this.#session.logged_in && !args.hasOwnProperty('text'))
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'like/like':
|
||||
case 'like/dislike':
|
||||
case 'like/removelike':
|
||||
if (!hasKeys(args, 'video_id'))
|
||||
throw new MissingParamError('Arguments lacks video_id');
|
||||
|
||||
data.target = {};
|
||||
data.target.videoId = args.video_id;
|
||||
|
||||
if (args.params) {
|
||||
data.params = args.params;
|
||||
}
|
||||
break;
|
||||
case 'subscription/subscribe':
|
||||
case 'subscription/unsubscribe':
|
||||
if (!hasKeys(args, 'channel_id'))
|
||||
throw new MissingParamError('Arguments lacks channel_id');
|
||||
|
||||
data.channelIds = [ args.channel_id ];
|
||||
data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data.commentText = args.text;
|
||||
|
||||
if (!hasKeys(args, 'video_id'))
|
||||
throw new MissingParamError('Arguments lacks video_id');
|
||||
|
||||
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
|
||||
break;
|
||||
case 'comment/create_comment_reply':
|
||||
if (!hasKeys(args, 'comment_id', 'video_id', 'text'))
|
||||
throw new MissingParamError('Arguments lacks comment_id, video_id or text');
|
||||
|
||||
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
|
||||
data.commentText = args.text;
|
||||
break;
|
||||
case 'comment/perform_comment_action':
|
||||
const target_action = (() => {
|
||||
switch (args.comment_action) {
|
||||
case 'like':
|
||||
return Proto.encodeCommentActionParams(5, args);
|
||||
case 'dislike':
|
||||
return Proto.encodeCommentActionParams(4, args);
|
||||
case 'translate':
|
||||
return Proto.encodeCommentActionParams(22, args);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
})();
|
||||
data.actions = [ target_action ];
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints related to account management.
|
||||
*/
|
||||
async account(action: string, args: AccountArgs = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
switch (action) {
|
||||
case 'account/set_setting':
|
||||
data.newValue = {
|
||||
boolValue: args.new_value
|
||||
};
|
||||
data.settingItemId = args.setting_item_id;
|
||||
break;
|
||||
case 'account/accounts_list':
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used for search.
|
||||
*/
|
||||
async search(args: SearchArgs = {}) {
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
if (args.query) {
|
||||
data.query = args.query;
|
||||
}
|
||||
|
||||
if (args.ctoken) {
|
||||
data.continuation = args.ctoken;
|
||||
}
|
||||
|
||||
if (args.params) {
|
||||
data.params = args.params;
|
||||
}
|
||||
|
||||
if (args.filters) {
|
||||
if (args.client == 'YTMUSIC' && args.filters?.type && args.filters.type !== 'all') {
|
||||
data.params = Proto.encodeMusicSearchFilters(args.filters);
|
||||
} else {
|
||||
data.params = Proto.encodeSearchFilters(args.filters);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used fo Shorts' sound search.
|
||||
*/
|
||||
async searchSound(args: { query: string; }) {
|
||||
const data = {
|
||||
query: args.query,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/sfv/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel management endpoints.
|
||||
*/
|
||||
async channel(action: string, args: { new_name?: string; new_description?: string; client?: string; } = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = { client: args.client || 'ANDROID' };
|
||||
|
||||
switch (action) {
|
||||
case 'channel/edit_name':
|
||||
data.givenName = args.new_name;
|
||||
break;
|
||||
case 'channel/edit_description':
|
||||
data.description = args.new_description;
|
||||
break;
|
||||
case 'channel/get_profile_editor':
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used for playlist management.
|
||||
*/
|
||||
async playlist(action: string, args: {
|
||||
title?: string;
|
||||
ids?: string[];
|
||||
playlist_id?: string;
|
||||
action?: string;
|
||||
} = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'playlist/create':
|
||||
data.title = args.title;
|
||||
data.videoIds = args.ids;
|
||||
break;
|
||||
case 'playlist/delete':
|
||||
data.playlistId = args.playlist_id;
|
||||
break;
|
||||
case 'browse/edit_playlist':
|
||||
if (!hasKeys(args, 'ids'))
|
||||
throw new MissingParamError('Arguments lacks ids');
|
||||
data.playlistId = args.playlist_id;
|
||||
data.actions = args.ids.map((id) => {
|
||||
switch (args.action) {
|
||||
case 'ACTION_ADD_VIDEO':
|
||||
return {
|
||||
action: args.action,
|
||||
addedVideoId: id
|
||||
};
|
||||
case 'ACTION_REMOVE_VIDEO':
|
||||
return {
|
||||
action: args.action,
|
||||
setVideoId: id
|
||||
};
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used for notifications management.
|
||||
*/
|
||||
async notifications(action: string, args: {
|
||||
pref?: string;
|
||||
channel_id?: string;
|
||||
ctoken?: string;
|
||||
params?: string
|
||||
} = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'modify_channel_preference':
|
||||
if (!hasKeys(args, 'channel_id', 'pref'))
|
||||
throw new MissingParamError('Arguments lacks channel_id or pref');
|
||||
const pref_types = {
|
||||
PERSONALIZED: 1,
|
||||
ALL: 2,
|
||||
NONE: 3
|
||||
};
|
||||
if (!Object.keys(pref_types).includes(args.pref.toUpperCase()))
|
||||
throw new InnertubeError('Invalid preference type', args.pref);
|
||||
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase() as keyof typeof pref_types]);
|
||||
break;
|
||||
case 'get_notification_menu':
|
||||
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
|
||||
if (args.ctoken)
|
||||
data.ctoken = args.ctoken;
|
||||
break;
|
||||
case 'record_interactions':
|
||||
data.serializedRecordNotificationInteractionsRequest = args.params;
|
||||
break;
|
||||
case 'get_unseen_count':
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/notification/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers livechat endpoints.
|
||||
*/
|
||||
async livechat(action: string, args: {
|
||||
text?: string;
|
||||
video_id?: string;
|
||||
channel_id?: string;
|
||||
ctoken?: string;
|
||||
params?: string;
|
||||
client?: string;
|
||||
} = {}) {
|
||||
// TODO: should client be required?
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
switch (action) {
|
||||
case 'live_chat/get_live_chat':
|
||||
case 'live_chat/get_live_chat_replay':
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
case 'live_chat/send_message':
|
||||
if (!hasKeys(args, 'channel_id', 'video_id', 'text'))
|
||||
throw new MissingParamError('Arguments lacks channel_id, video_id or text');
|
||||
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
|
||||
data.clientMessageId = uuidv4();
|
||||
data.richMessage = {
|
||||
textSegments: [ {
|
||||
text: args.text
|
||||
} ]
|
||||
};
|
||||
break;
|
||||
case 'live_chat/get_item_context_menu':
|
||||
// Note: this is currently broken due to a recent refactor
|
||||
// TODO: this should be implemented
|
||||
break;
|
||||
case 'live_chat/moderate':
|
||||
data.params = args.params;
|
||||
break;
|
||||
case 'updated_metadata':
|
||||
data.videoId = args.video_id;
|
||||
if (args.ctoken)
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used to retrieve video thumbnails.
|
||||
*/
|
||||
async thumbnails(args: { video_id: string; }) {
|
||||
const data = {
|
||||
client: 'ANDROID',
|
||||
videoId: args.video_id
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/thumbnails', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place Autocomplete endpoint, found it in the APK but
|
||||
* doesn't seem to be used anywhere on YouTube (maybe for ads?).
|
||||
*
|
||||
* Ex:
|
||||
* ```js
|
||||
* const places = await session.actions.geo('place_autocomplete', { input: 'San diego cafe' });
|
||||
* console.info(places.data);
|
||||
* ```
|
||||
*/
|
||||
async geo(action: string, args: { input: string; }) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data = {
|
||||
input: args.input,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch(`/geo/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used to report content.
|
||||
*/
|
||||
async flag(action: string, args: { action: string; params?: string; }) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'flag/flag':
|
||||
data.action = args.action;
|
||||
break;
|
||||
case 'flag/get_form':
|
||||
data.params = args.params;
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers specific YouTube Music endpoints.
|
||||
*/
|
||||
async music(action: string, args: { input?: string; }) {
|
||||
const data = {
|
||||
input: args.input || '',
|
||||
client: 'YTMUSIC'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch(`/music/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mostly used for pagination and specific operations.
|
||||
*/
|
||||
async next(args: { video_id?: string; ctoken?: string; client?: string; playlist_id?: string; params?: string } = {}) {
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
if (args.ctoken) {
|
||||
data.continuation = args.ctoken;
|
||||
}
|
||||
|
||||
if (args.video_id) {
|
||||
data.videoId = args.video_id;
|
||||
}
|
||||
|
||||
if (args.playlist_id) {
|
||||
data.playlistId = args.playlist_id;
|
||||
}
|
||||
|
||||
if (args.params) {
|
||||
data.params = args.params;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/next', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to retrieve video info.
|
||||
* @param id - The video ID.
|
||||
* @param cpn - Content Playback Nonce.
|
||||
* @param client - The client to use.
|
||||
* @param playlist_id - The playlist ID.
|
||||
*/
|
||||
async getVideoInfo(id: string, cpn?: string, client?: string) {
|
||||
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string) {
|
||||
const data: Record<string, any> = {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
@@ -627,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'
|
||||
@@ -647,6 +69,10 @@ class Actions {
|
||||
data.cpn = cpn;
|
||||
}
|
||||
|
||||
if (playlist_id) {
|
||||
data.playlistId = playlist_id;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/player', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
@@ -658,31 +84,11 @@ class Actions {
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used to retrieve user mention suggestions.
|
||||
*/
|
||||
async getUserMentionSuggestions(args: { input: string; }) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data = {
|
||||
input: args.input,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/get_user_mention_suggestions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes calls to the playback tracking API.
|
||||
* @param url - The URL to call.
|
||||
* @param client - The client to use.
|
||||
* @param params - Call parameters.
|
||||
*/
|
||||
async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }) {
|
||||
const s_url = new URL(url);
|
||||
@@ -703,22 +109,25 @@ class Actions {
|
||||
|
||||
/**
|
||||
* Executes an API call.
|
||||
* @param action - endpoint
|
||||
* @param args - call arguments
|
||||
* @param action - The endpoint to call.
|
||||
* @param args - Call arguments
|
||||
*/
|
||||
async execute(action: string, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }) : Promise<ParsedResponse>;
|
||||
async execute(action: string, args: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise<ActionsResponse>;
|
||||
async execute(action: string, args: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse | ActionsResponse> {
|
||||
async execute(action: string, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise<ActionsResponse>;
|
||||
async execute(action: string, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse | ActionsResponse> {
|
||||
let data;
|
||||
|
||||
if (!args.protobuf) {
|
||||
if (args && !args.protobuf) {
|
||||
data = { ...args };
|
||||
|
||||
if (Reflect.has(data, 'browseId')) {
|
||||
if (this.#needsLogin(data.browseId) && !this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'override_endpoint'))
|
||||
delete data.override_endpoint;
|
||||
|
||||
if (Reflect.has(data, 'parse'))
|
||||
delete data.parse;
|
||||
|
||||
@@ -745,25 +154,31 @@ class Actions {
|
||||
data.continuation = data.token;
|
||||
delete data.token;
|
||||
}
|
||||
} else {
|
||||
|
||||
if (data?.client === 'YTMUSIC') {
|
||||
data.isAudioOnly = true;
|
||||
}
|
||||
} else if (args) {
|
||||
data = args.serialized_data;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(action, {
|
||||
const endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : action;
|
||||
|
||||
const response = await this.#session.http.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: args.protobuf ? data : JSON.stringify(data),
|
||||
body: args?.protobuf ? data : JSON.stringify((data || {})),
|
||||
headers: {
|
||||
'Content-Type': args.protobuf ?
|
||||
'Content-Type': args?.protobuf ?
|
||||
'application/x-protobuf' :
|
||||
'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (args.parse) {
|
||||
if (args?.parse) {
|
||||
return Parser.parseResponse(await response.json());
|
||||
}
|
||||
|
||||
return this.#wrap(response, args.protobuf);
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
#needsLogin(id: string) {
|
||||
@@ -772,6 +187,8 @@ class Actions {
|
||||
'FEhistory',
|
||||
'FEsubscriptions',
|
||||
'FEmusic_listening_review',
|
||||
'FEmusic_library_landing',
|
||||
'SPaccount_overview',
|
||||
'SPaccount_notifications',
|
||||
'SPaccount_privacy',
|
||||
'SPtime_watched'
|
||||
@@ -779,5 +196,4 @@ class Actions {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: maybe do this inferrance in a more elegant way
|
||||
export default Actions;
|
||||
@@ -1,6 +1,6 @@
|
||||
import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index';
|
||||
import { Memo, ObservedArray } from '../parser/helpers';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import { concatMemos, InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
|
||||
import Post from '../parser/classes/Post';
|
||||
@@ -29,8 +29,8 @@ import AppendContinuationItemsAction from '../parser/classes/actions/AppendConti
|
||||
import ContinuationItem from '../parser/classes/ContinuationItem';
|
||||
|
||||
import Video from '../parser/classes/Video';
|
||||
import ReelItem from '../parser/classes/ReelItem';
|
||||
|
||||
// TODO: add a way subdivide into sections and return subfeeds?
|
||||
class Feed {
|
||||
#page: ParsedResponse;
|
||||
#continuation?: ObservedArray<ContinuationItem>;
|
||||
@@ -44,16 +44,14 @@ class Feed {
|
||||
this.#page = Parser.parseResponse(data);
|
||||
}
|
||||
|
||||
// Xxx: this can be extremely confusing — maybe refactor?
|
||||
const memo =
|
||||
this.#page.on_response_received_commands ?
|
||||
this.#page.on_response_received_commands_memo :
|
||||
this.#page.on_response_received_endpoints ?
|
||||
this.#page.on_response_received_endpoints_memo :
|
||||
this.#page.contents ?
|
||||
this.#page.contents_memo :
|
||||
this.#page.on_response_received_actions ?
|
||||
this.#page.on_response_received_actions_memo : undefined;
|
||||
const memo = concatMemos(
|
||||
this.#page.contents_memo,
|
||||
this.#page.on_response_received_commands_memo,
|
||||
this.#page.on_response_received_endpoints_memo,
|
||||
this.#page.on_response_received_actions_memo,
|
||||
this.#page.sidebar_memo,
|
||||
this.#page.header_memo
|
||||
);
|
||||
|
||||
if (!memo)
|
||||
throw new InnertubeError('No memo found in feed');
|
||||
@@ -66,9 +64,10 @@ class Feed {
|
||||
* Get all videos on a given page via memo
|
||||
*/
|
||||
static getVideosFromMemo(memo: Memo) {
|
||||
return memo.getType<Video | GridVideo | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
|
||||
return memo.getType<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
|
||||
Video,
|
||||
GridVideo,
|
||||
ReelItem,
|
||||
CompactVideo,
|
||||
PlaylistVideo,
|
||||
PlaylistPanelVideo,
|
||||
@@ -118,7 +117,7 @@ class Feed {
|
||||
/**
|
||||
* Returns contents from the page.
|
||||
*/
|
||||
get contents() {
|
||||
get page_contents() {
|
||||
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];
|
||||
@@ -183,7 +182,7 @@ class Feed {
|
||||
if (this.#continuation.length === 0)
|
||||
throw new InnertubeError('There are no continuations');
|
||||
|
||||
const response = await this.#continuation[0].endpoint.call(this.#actions, undefined, true);
|
||||
const response = await this.#continuation[0].endpoint.call(this.#actions, { parse: true });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class FilterableFeed extends Feed {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filters for the feed
|
||||
* Returns the filter chips.
|
||||
*/
|
||||
get filter_chips() {
|
||||
if (this.#chips)
|
||||
@@ -30,6 +30,9 @@ class FilterableFeed extends Feed {
|
||||
return this.#chips || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available filters.
|
||||
*/
|
||||
get filters() {
|
||||
return this.filter_chips.map((chip) => chip.text.toString()) || [];
|
||||
}
|
||||
@@ -42,9 +45,7 @@ class FilterableFeed extends Feed {
|
||||
|
||||
if (typeof filter === 'string') {
|
||||
if (!this.filters.includes(filter))
|
||||
throw new InnertubeError('Filter not found', {
|
||||
available_filters: this.filters
|
||||
});
|
||||
throw new InnertubeError('Filter not found', { available_filters: this.filters });
|
||||
target_filter = this.filter_chips.find((chip) => chip.text.toString() === filter);
|
||||
} else if (filter.type === 'ChipCloudChip') {
|
||||
target_filter = filter;
|
||||
@@ -54,10 +55,12 @@ class FilterableFeed extends Feed {
|
||||
|
||||
if (!target_filter)
|
||||
throw new InnertubeError('Filter not found');
|
||||
|
||||
if (target_filter.is_selected)
|
||||
return this;
|
||||
|
||||
const response = await target_filter.endpoint?.call(this.actions, undefined, true);
|
||||
const response = await target_filter.endpoint?.call(this.actions, { parse: true });
|
||||
|
||||
return new Feed(this.actions, response, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { throwIfMissing } from '../utils/Utils';
|
||||
import Proto from '../proto';
|
||||
import Actions from './Actions';
|
||||
import { throwIfMissing } from '../utils/Utils';
|
||||
|
||||
class InteractionManager {
|
||||
#actions;
|
||||
@@ -10,55 +11,119 @@ class InteractionManager {
|
||||
|
||||
/**
|
||||
* Likes a given video.
|
||||
* @param video_id - The video ID
|
||||
*/
|
||||
async like(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/like', { video_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/like/like', {
|
||||
client: 'ANDROID',
|
||||
target: {
|
||||
videoId: video_id
|
||||
}
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dislikes a given video.
|
||||
* @param video_id - The video ID
|
||||
*/
|
||||
async dislike(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/dislike', { video_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/like/dislike', {
|
||||
client: 'ANDROID',
|
||||
target: {
|
||||
videoId: video_id
|
||||
}
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a like/dislike.
|
||||
* @param video_id - The video ID
|
||||
*/
|
||||
async removeLike(video_id: string) {
|
||||
async removeRating(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/removelike', { video_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/like/removelike', {
|
||||
client: 'ANDROID',
|
||||
target: {
|
||||
videoId: video_id
|
||||
}
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a given channel.
|
||||
* @param channel_id - The channel ID
|
||||
*/
|
||||
async subscribe(channel_id: string) {
|
||||
throwIfMissing({ channel_id });
|
||||
const action = await this.#actions.engage('subscription/subscribe', { channel_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/subscription/subscribe', {
|
||||
client: 'ANDROID',
|
||||
channelIds: [ channel_id ],
|
||||
params: 'EgIIAhgA'
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from a given channel.
|
||||
* @param channel_id - The channel ID
|
||||
*/
|
||||
async unsubscribe(channel_id: string) {
|
||||
throwIfMissing({ channel_id });
|
||||
const action = await this.#actions.engage('subscription/unsubscribe', { channel_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/subscription/unsubscribe', {
|
||||
client: 'ANDROID',
|
||||
channelIds: [ channel_id ],
|
||||
params: 'CgIIAhgA'
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a comment on a given video.
|
||||
* @param video_id - The video ID
|
||||
* @param text - The comment text
|
||||
*/
|
||||
async comment(video_id: string, text: string) {
|
||||
throwIfMissing({ video_id, text });
|
||||
const action = await this.#actions.engage('comment/create_comment', { video_id, text });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/comment/create_comment', {
|
||||
client: 'ANDROID',
|
||||
commentText: text,
|
||||
createCommentParams: Proto.encodeCommentParams(video_id)
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
@@ -71,12 +136,11 @@ class InteractionManager {
|
||||
async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) {
|
||||
throwIfMissing({ text, target_language });
|
||||
|
||||
const response = await await this.#actions.engage('comment/perform_comment_action', {
|
||||
video_id: args.video_id,
|
||||
comment_id: args.comment_id,
|
||||
target_language: target_language,
|
||||
comment_action: 'translate',
|
||||
text
|
||||
const target_action = Proto.encodeCommentActionParams(22, { text, target_language, ...args });
|
||||
|
||||
const response = await this.#actions.execute('/comment/perform_comment_action', {
|
||||
client: 'ANDROID',
|
||||
actions: [ target_action ]
|
||||
});
|
||||
|
||||
const mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload;
|
||||
@@ -92,10 +156,29 @@ class InteractionManager {
|
||||
/**
|
||||
* Changes notification preferences for a given channel.
|
||||
* Only works with channels you are subscribed to.
|
||||
* @param channel_id - The channel ID.
|
||||
* @param type - The notification type.
|
||||
*/
|
||||
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE') {
|
||||
throwIfMissing({ channel_id, type });
|
||||
const action = await this.#actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const pref_types = {
|
||||
PERSONALIZED: 1,
|
||||
ALL: 2,
|
||||
NONE: 3
|
||||
};
|
||||
|
||||
if (!Object.keys(pref_types).includes(type.toUpperCase()))
|
||||
throw new Error(`Invalid notification preference type: ${type}`);
|
||||
|
||||
const action = await this.#actions.execute('/notification/modify_channel_preference', {
|
||||
client: 'ANDROID',
|
||||
params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types])
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -11,39 +10,95 @@ import Album from '../parser/ytmusic/Album';
|
||||
import Playlist from '../parser/ytmusic/Playlist';
|
||||
import Recap from '../parser/ytmusic/Recap';
|
||||
|
||||
import Parser from '../parser/index';
|
||||
import { observe, YTNode } from '../parser/helpers';
|
||||
|
||||
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 Message from '../parser/classes/Message';
|
||||
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 MusicTwoRowItem from '../parser/classes/MusicTwoRowItem';
|
||||
|
||||
import { observe, ObservedArray, YTNode } from '../parser/helpers';
|
||||
import { InnertubeError, throwIfMissing, generateRandomString } from '../utils/Utils';
|
||||
import Proto from '../proto';
|
||||
|
||||
class Music {
|
||||
#session;
|
||||
#actions;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
this.#actions = session.actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves track info.
|
||||
* Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
|
||||
* @param target - Video id or a list item.
|
||||
*/
|
||||
async getInfo(video_id: string) {
|
||||
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
|
||||
if (target instanceof MusicTwoRowItem) {
|
||||
return this.#fetchInfoFromListItem(target);
|
||||
} else if (typeof target === 'string') {
|
||||
return this.#fetchInfoFromVideoId(target);
|
||||
}
|
||||
|
||||
throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target);
|
||||
}
|
||||
|
||||
async #fetchInfoFromVideoId(video_id: string) {
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = await this.#actions.getVideoInfo(video_id, cpn, 'YTMUSIC');
|
||||
const continuation = this.#actions.execute('/next', { client: 'YTMUSIC', videoId: video_id });
|
||||
const initial_info = this.#actions.execute('/player', {
|
||||
cpn,
|
||||
client: 'YTMUSIC',
|
||||
videoId: video_id,
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: this.#session.player?.sts || 0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const continuation = this.#actions.execute('/next', {
|
||||
client: 'YTMUSIC',
|
||||
videoId: video_id
|
||||
});
|
||||
|
||||
const response = await Promise.all([ initial_info, continuation ]);
|
||||
return new TrackInfo(response, this.#actions, cpn);
|
||||
}
|
||||
|
||||
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined) {
|
||||
if (!list_item)
|
||||
throw new InnertubeError('List item cannot be undefined');
|
||||
|
||||
if (!list_item.endpoint)
|
||||
throw new Error('This item does not have an endpoint.');
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = list_item.endpoint.call(this.#actions, {
|
||||
cpn,
|
||||
client: 'YTMUSIC',
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: this.#session.player?.sts || 0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const continuation = list_item.endpoint.call(this.#actions, {
|
||||
client: 'YTMUSIC',
|
||||
enablePersistentPlaylistPanel: true,
|
||||
override_endpoint: '/next'
|
||||
});
|
||||
|
||||
const response = await Promise.all([ initial_info, continuation ]);
|
||||
return new TrackInfo(response, this.#actions, cpn);
|
||||
@@ -51,126 +106,134 @@ class Music {
|
||||
|
||||
/**
|
||||
* Searches on YouTube Music.
|
||||
* @param query - Search query.
|
||||
* @param filters - Search filters.
|
||||
*/
|
||||
async search(query: string, filters: {
|
||||
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
|
||||
} = {}) {
|
||||
} = {}): Promise<Search> {
|
||||
throwIfMissing({ query });
|
||||
const response = await this.#actions.search({ query, filters, client: 'YTMUSIC' });
|
||||
|
||||
const payload: {
|
||||
query: string;
|
||||
client: string;
|
||||
params?: string;
|
||||
} = { query, client: 'YTMUSIC' };
|
||||
|
||||
if (filters.type && filters.type !== 'all') {
|
||||
payload.params = Proto.encodeMusicSearchFilters(filters);
|
||||
}
|
||||
|
||||
const response = await this.#actions.execute('/search', payload);
|
||||
|
||||
return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the home feed.
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' });
|
||||
async getHomeFeed(): Promise<HomeFeed> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: 'FEmusic_home'
|
||||
});
|
||||
|
||||
return new HomeFeed(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Explore feed.
|
||||
*/
|
||||
async getExplore() {
|
||||
const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' });
|
||||
async getExplore(): Promise<Explore> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: 'FEmusic_explore'
|
||||
});
|
||||
|
||||
return new Explore(response);
|
||||
// TODO: return new Explore(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Library.
|
||||
* Retrieves the library.
|
||||
*/
|
||||
getLibrary() {
|
||||
return new Library(this.#actions);
|
||||
async getLibrary(): Promise<Library> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: 'FEmusic_library_landing'
|
||||
});
|
||||
|
||||
return new Library(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves artist's info & content.
|
||||
* @param artist_id - The artist id.
|
||||
*/
|
||||
async getArtist(artist_id: string) {
|
||||
async getArtist(artist_id: string): Promise<Artist> {
|
||||
throwIfMissing({ artist_id });
|
||||
|
||||
if (!artist_id.startsWith('UC'))
|
||||
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
|
||||
throw new InnertubeError('Invalid artist id', artist_id);
|
||||
|
||||
const response = await this.#actions.browse(artist_id, { client: 'YTMUSIC' });
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: artist_id
|
||||
});
|
||||
|
||||
return new Artist(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves album.
|
||||
* @param album_id - The album id.
|
||||
*/
|
||||
async getAlbum(album_id: string) {
|
||||
async getAlbum(album_id: string): Promise<Album> {
|
||||
throwIfMissing({ album_id });
|
||||
|
||||
if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
|
||||
throw new InnertubeError('Invalid album id', album_id);
|
||||
|
||||
const response = await this.#actions.browse(album_id, { client: 'YTMUSIC' });
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: album_id
|
||||
});
|
||||
|
||||
return new Album(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves playlist.
|
||||
* @param playlist_id - The playlist id.
|
||||
*/
|
||||
async getPlaylist(playlist_id: string) {
|
||||
async getPlaylist(playlist_id: string): Promise<Playlist> {
|
||||
throwIfMissing({ playlist_id });
|
||||
|
||||
if (!playlist_id.startsWith('VL')) {
|
||||
playlist_id = `VL${playlist_id}`;
|
||||
}
|
||||
|
||||
const response = await this.#actions.browse(playlist_id, { client: 'YTMUSIC' });
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: playlist_id
|
||||
});
|
||||
|
||||
return new Playlist(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves song lyrics.
|
||||
*/
|
||||
async getLyrics(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
|
||||
|
||||
const data = Parser.parseResponse(response.data);
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
.as(Tabbed).contents.item()
|
||||
.as(WatchNextTabbedResults)
|
||||
.tabs.array().as(Tab);
|
||||
|
||||
const tab = tabs.get({ title: 'Lyrics' });
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
|
||||
|
||||
if (!page)
|
||||
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
|
||||
|
||||
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 description_shelf = section_list.firstOfType(MusicDescriptionShelf);
|
||||
|
||||
return {
|
||||
text: description_shelf?.description.toString(),
|
||||
footer: description_shelf?.footer
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves up next.
|
||||
* @param video_id - The video id.
|
||||
* @param automix - Whether to enable automix.
|
||||
*/
|
||||
async getUpNext(video_id: string) {
|
||||
async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
|
||||
|
||||
const data = Parser.parseResponse(response.data);
|
||||
const data = await this.#actions.execute('/next', {
|
||||
videoId: video_id,
|
||||
client: 'YTMUSIC',
|
||||
parse: true
|
||||
});
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
@@ -188,20 +251,41 @@ class Music {
|
||||
if (!music_queue || !music_queue.content)
|
||||
throw new InnertubeError('Music queue was empty, the given id is probably invalid.', music_queue);
|
||||
|
||||
const playlist_panel = music_queue.content.item().as(PlaylistPanel);
|
||||
const playlist_panel = music_queue.content.as(PlaylistPanel);
|
||||
|
||||
if (!playlist_panel.playlist_id && automix) {
|
||||
const automix_preview_video = playlist_panel.contents.firstOfType(AutomixPreviewVideo);
|
||||
|
||||
if (!automix_preview_video)
|
||||
throw new InnertubeError('Automix item not found');
|
||||
|
||||
const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
|
||||
videoId: video_id,
|
||||
client: 'YTMUSIC',
|
||||
parse: true
|
||||
});
|
||||
|
||||
if (!page)
|
||||
throw new InnertubeError('Could not fetch automix');
|
||||
|
||||
return page.contents_memo.getType(PlaylistPanel)?.[0];
|
||||
}
|
||||
|
||||
return playlist_panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves related content.
|
||||
* @param video_id - The video id.
|
||||
*/
|
||||
async getRelated(video_id: string) {
|
||||
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
|
||||
|
||||
const data = Parser.parseResponse(response.data);
|
||||
const data = await this.#actions.execute('/next', {
|
||||
videoId: video_id,
|
||||
client: 'YTMUSIC',
|
||||
parse: true
|
||||
});
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
@@ -214,17 +298,50 @@ class Music {
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
|
||||
|
||||
if (!page)
|
||||
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
|
||||
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
|
||||
|
||||
const shelves = page.contents.item().as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf);
|
||||
|
||||
return shelves;
|
||||
}
|
||||
|
||||
async getRecap() {
|
||||
/**
|
||||
* Retrieves song lyrics.
|
||||
* @param video_id - The video id.
|
||||
*/
|
||||
async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const data = await this.#actions.execute('/next', {
|
||||
videoId: video_id,
|
||||
client: 'YTMUSIC',
|
||||
parse: true
|
||||
});
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
.as(Tabbed).contents.item()
|
||||
.as(WatchNextTabbedResults)
|
||||
.tabs.array().as(Tab);
|
||||
|
||||
const tab = tabs.get({ title: 'Lyrics' });
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
|
||||
|
||||
if (page.contents.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();
|
||||
return section_list.firstOfType(MusicDescriptionShelf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves recap.
|
||||
*/
|
||||
async getRecap(): Promise<Recap> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'FEmusic_listening_review',
|
||||
client: 'YTMUSIC_ANDROID'
|
||||
@@ -235,6 +352,7 @@ class Music {
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions for the given query.
|
||||
* @param query - The query.
|
||||
*/
|
||||
async getSearchSuggestions(query: string) {
|
||||
const response = await this.#actions.execute('/music/get_search_suggestions', {
|
||||
@@ -245,7 +363,7 @@ class Music {
|
||||
|
||||
const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection)?.[0];
|
||||
|
||||
if (!search_suggestions_section.contents.is_array)
|
||||
if (!search_suggestions_section?.contents.is_array)
|
||||
return observe([] as YTNode[]);
|
||||
|
||||
return search_suggestions_section?.contents.array();
|
||||
|
||||
@@ -241,7 +241,6 @@ class OAuth {
|
||||
.replace(/\n/g, '')
|
||||
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
|
||||
|
||||
// TODO: check this.
|
||||
const groups = client_identity?.groups;
|
||||
|
||||
if (!groups)
|
||||
|
||||
@@ -166,20 +166,21 @@ export default class Player {
|
||||
}
|
||||
|
||||
static extractSigSourceCode(data: string) {
|
||||
const funcs = getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const calls = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
const obj_name = calls?.split(/\.|\[/)?.[0]?.replace(';', '')?.trim();
|
||||
const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};');
|
||||
|
||||
if (!funcs || !calls)
|
||||
throw new PlayerError('Failed to extract signature decipher algorithm');
|
||||
if (!functions || !calls)
|
||||
console.warn(new PlayerError('Failed to extract signature decipher algorithm'));
|
||||
|
||||
return `function descramble_sig(a) { a = a.split(""); ${funcs}}${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) {
|
||||
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)
|
||||
throw new PlayerError('Failed to extract n-token decipher algorithm');
|
||||
console.warn(new PlayerError('Failed to extract n-token decipher algorithm'));
|
||||
|
||||
return sc;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,20 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* Creates a playlist.
|
||||
* @param title - The title of the playlist.
|
||||
* @param video_ids - An array of video IDs to add to the playlist.
|
||||
*/
|
||||
async create(title: string, video_ids: string[]) {
|
||||
throwIfMissing({ title, video_ids });
|
||||
|
||||
const response = await this.#actions.execute('/playlist/create', { title, ids: video_ids, parse: false });
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const response = await this.#actions.execute('/playlist/create', {
|
||||
title,
|
||||
ids: video_ids,
|
||||
parse: false
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
@@ -29,10 +38,14 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* Deletes a given playlist.
|
||||
* @param playlist_id - The playlist ID.
|
||||
*/
|
||||
async delete(playlist_id: string) {
|
||||
throwIfMissing({ playlist_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id });
|
||||
|
||||
return {
|
||||
@@ -45,10 +58,15 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* Adds videos to a given playlist.
|
||||
* @param playlist_id - The playlist ID.
|
||||
* @param video_ids - An array of video IDs to add to the playlist.
|
||||
*/
|
||||
async addVideos(playlist_id: string, video_ids: string[]) {
|
||||
throwIfMissing({ playlist_id, video_ids });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', {
|
||||
playlistId: playlist_id,
|
||||
actions: video_ids.map((id) => ({
|
||||
@@ -66,11 +84,20 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* Removes videos from a given playlist.
|
||||
* @param playlist_id - The playlist ID.
|
||||
* @param video_ids - An array of video IDs to remove from the playlist.
|
||||
*/
|
||||
async removeVideos(playlist_id: string, video_ids: string[]) {
|
||||
throwIfMissing({ playlist_id, video_ids });
|
||||
|
||||
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const info = await this.#actions.execute('/browse', {
|
||||
browseId: `VL${playlist_id}`,
|
||||
parse: true
|
||||
});
|
||||
|
||||
const playlist = new Playlist(this.#actions, info, true);
|
||||
|
||||
if (!playlist.info.is_editable)
|
||||
@@ -115,11 +142,21 @@ class PlaylistManager {
|
||||
|
||||
/**
|
||||
* Moves a video to a new position within a given playlist.
|
||||
* @param playlist_id - The playlist ID.
|
||||
* @param moved_video_id - The video ID to move.
|
||||
* @param predecessor_video_id - The video ID to move the moved video before.
|
||||
*/
|
||||
async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string) {
|
||||
throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id });
|
||||
|
||||
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const info = await this.#actions.execute('/browse', {
|
||||
browseId: `VL${playlist_id}`,
|
||||
parse: true
|
||||
});
|
||||
|
||||
const playlist = new Playlist(this.#actions, info, true);
|
||||
|
||||
if (!playlist.info.is_editable)
|
||||
@@ -157,7 +194,10 @@ class PlaylistManager {
|
||||
movedSetVideoIdPredecessor: set_video_id_1
|
||||
});
|
||||
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', {
|
||||
...payload,
|
||||
parse: false
|
||||
});
|
||||
|
||||
return {
|
||||
playlist_id,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import Player from './Player';
|
||||
import Proto from '../proto/index';
|
||||
import Actions from './Actions';
|
||||
import Constants from '../utils/Constants';
|
||||
import UniversalCache from '../utils/Cache';
|
||||
import EventEmitterLike from '../utils/EventEmitterLike';
|
||||
|
||||
import HTTPClient, { FetchFunction } from '../utils/HTTPClient';
|
||||
import { DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
|
||||
import { DeviceCategory, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
|
||||
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth';
|
||||
|
||||
export enum ClientType {
|
||||
@@ -21,10 +20,15 @@ export interface Context {
|
||||
hl: string;
|
||||
gl: string;
|
||||
remoteHost: string;
|
||||
screenDensityFloat: number;
|
||||
screenHeightPoints: number;
|
||||
screenPixelDensity: number;
|
||||
screenWidthPoints: number;
|
||||
visitorData: string;
|
||||
userAgent: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
clientScreen?: string,
|
||||
androidSdkVersion?: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
@@ -42,6 +46,9 @@ export interface Context {
|
||||
user: {
|
||||
lockedSafetyMode: false;
|
||||
};
|
||||
thirdParty?: {
|
||||
embedUrl: string;
|
||||
};
|
||||
request: {
|
||||
useSsl: true;
|
||||
};
|
||||
@@ -49,6 +56,7 @@ export interface Context {
|
||||
|
||||
export interface SessionOptions {
|
||||
lang?: string;
|
||||
account_index?: number;
|
||||
device_category?: DeviceCategory;
|
||||
client_type?: ClientType;
|
||||
timezone?: string;
|
||||
@@ -61,6 +69,7 @@ export default class Session extends EventEmitterLike {
|
||||
#api_version;
|
||||
#key;
|
||||
#context;
|
||||
#account_index;
|
||||
#player;
|
||||
|
||||
oauth;
|
||||
@@ -69,9 +78,10 @@ export default class Session extends EventEmitterLike {
|
||||
actions;
|
||||
cache;
|
||||
|
||||
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;
|
||||
@@ -100,12 +110,20 @@ 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.account_index,
|
||||
options.device_category,
|
||||
options.client_type,
|
||||
options.timezone,
|
||||
options.fetch
|
||||
);
|
||||
return new Session(context, api_key, api_version, account_index, await Player.create(options.cache, options.fetch), options.cookie, options.fetch, options.cache);
|
||||
}
|
||||
|
||||
static async getSessionData(
|
||||
lang = 'en-US',
|
||||
account_index = 0,
|
||||
device_category: DeviceCategory = 'desktop',
|
||||
client_name: ClientType = ClientType.WEB,
|
||||
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
@@ -136,16 +154,16 @@ export default class Session extends EventEmitterLike {
|
||||
|
||||
const [ [ device_info ], api_key ] = ytcfg;
|
||||
|
||||
const id = generateRandomString(11);
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const visitor_data = Proto.encodeVisitorData(id, timestamp);
|
||||
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: device_info[0],
|
||||
gl: device_info[2],
|
||||
remoteHost: device_info[3],
|
||||
visitorData: visitor_data,
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 720,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1280,
|
||||
visitorData: device_info[13],
|
||||
userAgent: device_info[14],
|
||||
clientName: client_name,
|
||||
clientVersion: device_info[16],
|
||||
@@ -157,7 +175,7 @@ export default class Session extends EventEmitterLike {
|
||||
timeZone: device_info[79],
|
||||
browserName: device_info[86],
|
||||
browserVersion: device_info[87],
|
||||
originalUrl: Constants.URLS.API.BASE,
|
||||
originalUrl: Constants.URLS.YT_BASE,
|
||||
deviceMake: device_info[11],
|
||||
deviceModel: device_info[12],
|
||||
utcOffsetMinutes: new Date().getTimezoneOffset()
|
||||
@@ -170,7 +188,7 @@ export default class Session extends EventEmitterLike {
|
||||
}
|
||||
};
|
||||
|
||||
return { context, api_key, api_version };
|
||||
return { context, api_key, api_version, account_index };
|
||||
}
|
||||
|
||||
async signIn(credentials?: Credentials): Promise<void> {
|
||||
@@ -206,7 +224,7 @@ export default class Session extends EventEmitterLike {
|
||||
|
||||
async signOut() {
|
||||
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;
|
||||
@@ -230,6 +248,10 @@ export default class Session extends EventEmitterLike {
|
||||
return this.#context.client.clientName;
|
||||
}
|
||||
|
||||
get account_index() {
|
||||
return this.#account_index;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.#context;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import Proto from '../proto';
|
||||
import Session from './Session';
|
||||
import { AxioslikeResponse } from './Actions';
|
||||
import { ApiResponse } from './Actions';
|
||||
import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
|
||||
import { Constants } from '../utils';
|
||||
|
||||
export interface UploadResult {
|
||||
interface UploadResult {
|
||||
status: string;
|
||||
scottyResourceId: string;
|
||||
}
|
||||
|
||||
export interface InitialUploadData {
|
||||
interface InitialUploadData {
|
||||
frontend_upload_id: string;
|
||||
upload_id: string;
|
||||
upload_url: string;
|
||||
@@ -18,6 +18,17 @@ export interface InitialUploadData {
|
||||
}
|
||||
|
||||
export interface VideoMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
category?: number;
|
||||
license?: string;
|
||||
age_restricted?: boolean;
|
||||
made_for_kids?: boolean;
|
||||
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
|
||||
}
|
||||
|
||||
export interface UploadedVideoMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
|
||||
@@ -39,7 +50,10 @@ class Studio {
|
||||
* const response = await yt.studio.setThumbnail(video_id, buffer);
|
||||
* ```
|
||||
*/
|
||||
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<AxioslikeResponse> {
|
||||
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
if (!video_id || !buffer)
|
||||
throw new MissingParamError('One or more parameters are missing.');
|
||||
|
||||
@@ -53,6 +67,34 @@ class Studio {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates given video's metadata.
|
||||
* @example
|
||||
* ```ts
|
||||
* const response = await yt.studio.updateVideoMetadata('videoid', {
|
||||
* tags: [ 'astronomy', 'NASA', 'APOD' ],
|
||||
* title: 'Artemis Mission',
|
||||
* description: 'A nicely written description...',
|
||||
* category: 27,
|
||||
* license: 'creative_commons'
|
||||
* // ...
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async updateVideoMetadata(video_id: string, metadata: VideoMetadata) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);
|
||||
|
||||
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
|
||||
protobuf: true,
|
||||
serialized_data: payload
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a video to YouTube.
|
||||
* @example
|
||||
@@ -61,7 +103,10 @@ class Studio {
|
||||
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
|
||||
* ```
|
||||
*/
|
||||
async upload(file: BodyInit, metadata: VideoMetadata = {}): Promise<AxioslikeResponse> {
|
||||
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<ApiResponse> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const initial_data = await this.#getInitialUploadData();
|
||||
const upload_result = await this.#uploadVideo(initial_data.upload_url, file);
|
||||
|
||||
@@ -128,7 +173,7 @@ class Studio {
|
||||
return data;
|
||||
}
|
||||
|
||||
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: VideoMetadata) {
|
||||
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadata) {
|
||||
const metadata_payload = {
|
||||
resourceId: {
|
||||
scottyResourceId: {
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
# Parser
|
||||
|
||||
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data. Also [drastically improves](https://github.com/LuanRT/YouTube.js/blob/main/lib/parser/youtube/Library.js#L44) how API calls are made and handled.
|
||||
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data. Also [drastically improves](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube/Library.ts#L69) how API calls are made and handled.
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#api">API</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#usage">Usage</a>
|
||||
<ul>
|
||||
<li><a href="#observedarray">ObservedArray</a></li>
|
||||
<li><a href="#superparsedresponse">SuperParsedResponse</a></li>
|
||||
<li><a href="#ytnode">YTNode</a></li>
|
||||
<li><a href="#memo">Memo</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#adding-new-nodes">Adding new nodes</a></li>
|
||||
<li><a href="#how-it-works">How it works</a></li>
|
||||
</ol>
|
||||
|
||||
___
|
||||
|
||||
## API
|
||||
|
||||
@@ -20,7 +39,7 @@ Responsible for parsing individual nodes.
|
||||
| --- | --- | --- |
|
||||
| data | `any` | The data |
|
||||
| requireArray | `?boolean` | Whether the response should be an array |
|
||||
| validTypes | `YTNodeConstructor<T> | YTNodeConstructor<T>[] | undefined` | The types of YTNodes are allowed |
|
||||
| validTypes | `YTNodeConstructor<T> \| YTNodeConstructor<T>[] \| undefined` | Types of `YTNode` allowed |
|
||||
|
||||
When `requireArray` is `true`, the response will be an `ObservedArray<YTNodes>`.
|
||||
|
||||
@@ -43,6 +62,8 @@ Unlike `parse`, this can be used to parse the entire response object.
|
||||
| --- | --- | --- |
|
||||
| data | `object` | Raw InnerTube response |
|
||||
|
||||
## Usage
|
||||
|
||||
## ObservedArray
|
||||
You may use `ObservedArray<T extends YTNode>` as a normal array, but it provides additional methods for typesafe access and casting.
|
||||
|
||||
@@ -58,13 +79,13 @@ const firstVideo = feed.firstOfType(GridVideo);
|
||||
// We may cast the whole array to a GridVideo[] and throw if we have any non-GridVideo elements:
|
||||
const allVideos = feed.as(GridVideo);
|
||||
|
||||
// There's some extra methods for ObservedArray<T extends YTNode>
|
||||
// There are some extra methods for ObservedArray<T extends YTNode>
|
||||
// which we use internally but not documented here (yet).
|
||||
// see the source code for more details.
|
||||
```
|
||||
|
||||
## SuperParsedResponse
|
||||
Represents a parsed response in an unknown state. Either a `YTNode` or a `ObservedArray<YTNode>` or `null`.
|
||||
Represents a parsed response in an unknown state. Either a `YTNode` or an `ObservedArray<YTNode>` or `null`.
|
||||
|
||||
You will need to assert the type and unwrap the response to get the actual value.
|
||||
|
||||
@@ -89,7 +110,7 @@ const is_null = response.is_null;
|
||||
## YTNode
|
||||
All renderers returned by InnerTube are converted to this generic class and then extended for the specific renderers.
|
||||
|
||||
This class is allows us a typesafe way to use data returned by the InnerTube API.
|
||||
This class is what allows us a typesafe way to use data returned by the InnerTube API.
|
||||
|
||||
Here's how to use this class to access returned data:
|
||||
|
||||
@@ -116,14 +137,14 @@ if (node.is(TwoColumnSearchResults, VideoList)) {
|
||||
```
|
||||
|
||||
### Accessing properties without casting
|
||||
Sometimes multiple nodes have the same properties and we don't want to check the type of the node before accessing the property, for example the property "contents" is used by many node types, and we may add more in the future, as such we want to only assert the property instead of casting to a specific type.
|
||||
Sometimes multiple nodes have the same properties and we don't want to check the type of the node before accessing the property, for example, the property "contents" is used by many node types, and we may add more in the future, as such we want to only assert the property instead of casting to a specific type.
|
||||
|
||||
```ts
|
||||
// Accesing a property on a node which you aren't sure if it exists.
|
||||
// Accessing a property on a node which you aren't sure if it exists.
|
||||
const prop = node.key("contents");
|
||||
// This returns the value wrapped into a `Maybe` type
|
||||
// which you can use to find the type of the value
|
||||
// note however, this throws an error if the key doesn't exist
|
||||
// note, however, this throws an error if the key doesn't exist
|
||||
// we may want to check for the key before accessing it.
|
||||
if (node.hasKey("contents")) {
|
||||
const prop = node.key("contents");
|
||||
@@ -146,7 +167,7 @@ if (prop.isInstanceof(Text)) {
|
||||
});
|
||||
}
|
||||
|
||||
// There's some special methods for using with the parser —
|
||||
// There are some special methods for using with the parser —
|
||||
// such as getting the value as a YTNode.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isNode()) {
|
||||
@@ -171,7 +192,7 @@ const prop = node.key("contents");
|
||||
if (prop.isObserved()) {
|
||||
const array = prop.observed();
|
||||
|
||||
// Now we may use the all the ObservedArray methods as normal,
|
||||
// Now we may use all the ObservedArray methods as normal,
|
||||
// like finding nodes of a certain type for example.
|
||||
const results = array.filterType(GridVideo);
|
||||
}
|
||||
@@ -187,7 +208,7 @@ if (prop.isParsed()) {
|
||||
const videos = results.filterType(Video);
|
||||
}
|
||||
|
||||
// Sometimes we just want to debug something and not interested in finding the type.
|
||||
// Sometimes we just want to debug something and are not interested in finding the type.
|
||||
// This will, however, warn you when being used.
|
||||
const prop = node.key("contents");
|
||||
const value = prop.any();
|
||||
@@ -200,7 +221,7 @@ if (prop.isArray()) {
|
||||
// This will return Maybe[]
|
||||
}
|
||||
|
||||
// Or if you want zero typesafety you can use the `array` method.
|
||||
// Or if you want zero type safety you can use the `array` method.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isArray()) {
|
||||
const array = prop.array();
|
||||
@@ -221,13 +242,16 @@ const videos = response.contents_memo.getType(Video);
|
||||
|
||||
`Memo` extends `Map<string, YTNode[]>` and can be used as a normal `Map` too if you want.
|
||||
|
||||
## Adding new nodes
|
||||
Instructions can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md).
|
||||
|
||||
## How it works
|
||||
|
||||
If you decompile a YouTube client and analize it for a while you will notice that it has classes named `protos/youtube/api/innertube/MusicItemRenderer`, `protos/youtube/api/innertube/SectionListRenderer`, etc.
|
||||
If you decompile a YouTube client and analyze it for a while you will notice that it has classes named `protos/youtube/api/innertube/MusicItemRenderer`, `protos/youtube/api/innertube/SectionListRenderer`, etc.
|
||||
|
||||
These classes are used to parse objects from the response (which consists of protobuf messages) and also build requests. The website works in a similar way, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
|
||||
These classes are used to parse objects from the response, map them into models and generate the UI. The website works similarly, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
|
||||
|
||||
Here we're taking a similar approach, the parser goes through all the renderers and parses their inner element(s). The final result is a nicely structured JSON, and on top of that it also parses navigation endpoints which allows us to make an API call with all required parameters in one line and even emulate client actions (eg; clicking a button).
|
||||
Here we're taking a similar approach, the parser goes through all the renderers and parses their inner element(s). The final result is a nicely structured JSON, and on top of that, it also parses navigation endpoints which allow us to make an API call with all required parameters in one line and even emulate client actions (eg; clicking a button).
|
||||
|
||||
Here is your average, arguably ugly InnerTube response:
|
||||
<details>
|
||||
|
||||
14
src/parser/classes/AudioOnlyPlayability.ts
Normal file
14
src/parser/classes/AudioOnlyPlayability.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class AudioOnlyPlayability extends YTNode {
|
||||
static type = 'AudioOnlyPlayability';
|
||||
|
||||
audio_only_availability: string;
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.audio_only_availability = data.audioOnlyAvailability;
|
||||
}
|
||||
}
|
||||
|
||||
export default AudioOnlyPlayability;
|
||||
18
src/parser/classes/BrowserMediaSession.ts
Normal file
18
src/parser/classes/BrowserMediaSession.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class BrowserMediaSession extends YTNode {
|
||||
static type = 'BrowserMediaSession';
|
||||
|
||||
album;
|
||||
thumbnails;
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.album = new Text(data.album);
|
||||
this.thumbnails = Thumbnail.fromResponse(data.thumbnailDetails);
|
||||
}
|
||||
}
|
||||
|
||||
export default BrowserMediaSession;
|
||||
@@ -6,8 +6,8 @@ class Card extends YTNode {
|
||||
|
||||
teaser;
|
||||
content;
|
||||
card_id: string;
|
||||
feature: string;
|
||||
card_id: string | null;
|
||||
feature: string | null;
|
||||
|
||||
cue_ranges: {
|
||||
start_card_active_ms: string;
|
||||
@@ -18,10 +18,10 @@ class Card extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.teaser = Parser.parse(data.teaser);
|
||||
this.content = Parser.parse(data.content);
|
||||
this.card_id = data.cardId;
|
||||
this.feature = data.feature;
|
||||
this.teaser = Parser.parseItem(data.teaser);
|
||||
this.content = Parser.parseItem(data.content);
|
||||
this.card_id = data.cardId || null;
|
||||
this.feature = data.feature || null;
|
||||
|
||||
this.cue_ranges = data.cueRanges.map((cr: any) => ({
|
||||
start_card_active_ms: cr.startCardActiveMs,
|
||||
@@ -32,4 +32,4 @@ class Card extends YTNode {
|
||||
}
|
||||
}
|
||||
|
||||
export default Card;
|
||||
export default Card;
|
||||
|
||||
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;
|
||||
26
src/parser/classes/CollaboratorInfoCardContent.ts
Normal file
26
src/parser/classes/CollaboratorInfoCardContent.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class CollaboratorInfoCardContent extends YTNode {
|
||||
static type = 'CollaboratorInfoCardContent';
|
||||
|
||||
channel_avatar: Thumbnail[];
|
||||
custom_text: Text;
|
||||
channel_name: Text;
|
||||
subscriber_count: Text;
|
||||
endpoint: NavigationEndpoint;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.channel_avatar = Thumbnail.fromResponse(data.channelAvatar);
|
||||
this.custom_text = new Text(data.customText);
|
||||
this.channel_name = new Text(data.channelName);
|
||||
this.subscriber_count = new Text(data.subscriberCountText);
|
||||
this.endpoint = new NavigationEndpoint(data.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
export default CollaboratorInfoCardContent;
|
||||
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;
|
||||
24
src/parser/classes/ConfirmDialog.ts
Normal file
24
src/parser/classes/ConfirmDialog.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import Parser from '..';
|
||||
import Text from './misc/Text';
|
||||
import Button from './Button';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class ConfirmDialog extends YTNode {
|
||||
static type = 'ConfirmDialog';
|
||||
|
||||
title: Text;
|
||||
confirm_button: Button | null;
|
||||
cancel_button: Button | null;
|
||||
dialog_messages: Text[];
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.confirm_button = Parser.parseItem<Button>(data.confirmButton, Button);
|
||||
this.cancel_button = Parser.parseItem<Button>(data.cancelButton, Button);
|
||||
this.dialog_messages = data.dialogMessages.map((txt: any) => new Text(txt));
|
||||
}
|
||||
}
|
||||
|
||||
export default ConfirmDialog;
|
||||
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;
|
||||
@@ -8,7 +8,7 @@ class DropdownItem extends YTNode {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
value?: number | string;
|
||||
iconType?: string;
|
||||
icon_type?: string;
|
||||
description?: string;
|
||||
endpoint?: NavigationEndpoint;
|
||||
|
||||
@@ -29,7 +29,7 @@ class DropdownItem extends YTNode {
|
||||
}
|
||||
|
||||
if (data.icon?.iconType) {
|
||||
this.iconType = data.icon?.iconType;
|
||||
this.icon_type = data.icon?.iconType;
|
||||
}
|
||||
|
||||
if (data.descriptionText) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Parser from '../index';
|
||||
import { YTNode } from '../helpers';
|
||||
import ChipCloudChip from './ChipCloudChip';
|
||||
|
||||
class FeedFilterChipBar extends YTNode {
|
||||
static type = 'FeedFilterChipBar';
|
||||
@@ -8,7 +9,7 @@ class FeedFilterChipBar extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.contents = Parser.parse(data.contents);
|
||||
this.contents = Parser.parseArray<ChipCloudChip>(data.contents, ChipCloudChip);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
src/parser/classes/GameCard.ts
Normal file
15
src/parser/classes/GameCard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class GameCard extends YTNode {
|
||||
static type = 'GameCard';
|
||||
|
||||
game;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.game = Parser.parseItem(data.game);
|
||||
}
|
||||
}
|
||||
|
||||
export default GameCard;
|
||||
26
src/parser/classes/GameDetails.ts
Normal file
26
src/parser/classes/GameDetails.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
|
||||
class GameDetails extends YTNode {
|
||||
static type = 'GameDetails';
|
||||
|
||||
title: Text;
|
||||
box_art: Thumbnail[];
|
||||
box_art_overlay_text: Text;
|
||||
endpoint: NavigationEndpoint;
|
||||
is_official_box_art: boolean;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.box_art = Thumbnail.fromResponse(data.boxArt);
|
||||
this.box_art_overlay_text = new Text(data.boxArtOverlayText);
|
||||
this.endpoint = new NavigationEndpoint(data.endpoint);
|
||||
this.is_official_box_art = data.isOfficialBoxArt;
|
||||
}
|
||||
}
|
||||
|
||||
export default GameDetails;
|
||||
@@ -5,23 +5,34 @@ class Grid extends YTNode {
|
||||
static type = 'Grid';
|
||||
|
||||
items;
|
||||
is_collapsible: boolean;
|
||||
visible_row_count: string;
|
||||
target_id: string;
|
||||
is_collapsible?: boolean;
|
||||
visible_row_count?: string;
|
||||
target_id?: string;
|
||||
continuation: string | null;
|
||||
header?;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.items = Parser.parse(data.items);
|
||||
this.is_collapsible = data.isCollapsible;
|
||||
this.visible_row_count = data.visibleRowCount;
|
||||
this.target_id = data.targetId;
|
||||
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
|
||||
|
||||
this.items = Parser.parseArray(data.items);
|
||||
|
||||
if (data.header) {
|
||||
this.header = Parser.parse(data.header);
|
||||
}
|
||||
|
||||
if (data.isCollapsible) {
|
||||
this.is_collapsible = data.isCollapsible;
|
||||
}
|
||||
|
||||
if (data.visibleRowCount) {
|
||||
this.visible_row_count = data.visibleRowCount;
|
||||
}
|
||||
|
||||
if (data.targetId) {
|
||||
this.target_id = data.targetId;
|
||||
}
|
||||
|
||||
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
|
||||
}
|
||||
|
||||
// XXX: alias for consistency
|
||||
|
||||
@@ -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,7 @@
|
||||
import Parser from '../index';
|
||||
import { YTNode } from '../helpers';
|
||||
import SearchRefinementCard from './SearchRefinementCard';
|
||||
import Button from './Button';
|
||||
|
||||
class HorizontalCardList extends YTNode {
|
||||
static type = 'HorizontalCardList';
|
||||
@@ -11,10 +13,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>(data.cards, SearchRefinementCard);
|
||||
this.header = Parser.parseItem(data.header);
|
||||
this.previous_button = Parser.parseItem<Button>(data.previousButton, Button);
|
||||
this.next_button = Parser.parseItem<Button>(data.nextButton, Button);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
|
||||
19
src/parser/classes/LiveChatDialog.ts
Normal file
19
src/parser/classes/LiveChatDialog.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Parser from '..';
|
||||
import Text from './misc/Text';
|
||||
import Button from './Button';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class LiveChatDialog extends YTNode {
|
||||
static type = 'LiveChatDialog';
|
||||
|
||||
confirm_button: Button | null;
|
||||
dialog_messages: Text[];
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.confirm_button = Parser.parseItem<Button>(data.confirmButton, Button);
|
||||
this.dialog_messages = data.dialogMessages.map((el: any) => new Text(el));
|
||||
}
|
||||
}
|
||||
|
||||
export default LiveChatDialog;
|
||||
15
src/parser/classes/MetadataScreen.ts
Normal file
15
src/parser/classes/MetadataScreen.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class MetadataScreen extends YTNode {
|
||||
static type = 'MetadataScreen';
|
||||
|
||||
section_list;
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.section_list = Parser.parseItem(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default MetadataScreen;
|
||||
@@ -36,12 +36,12 @@ class MusicDetailHeader extends YTNode {
|
||||
this.thumbnails = Thumbnail.fromResponse(data.thumbnail.croppedSquareThumbnailRenderer.thumbnail);
|
||||
this.badges = Parser.parse(data.subtitleBadges);
|
||||
|
||||
const author = this.subtitle.runs?.find((run) => (run as TextRun)?.endpoint?.browse?.id.startsWith('UC'));
|
||||
const author = this.subtitle.runs?.find((run) => (run as TextRun)?.endpoint?.payload?.browseId.startsWith('UC'));
|
||||
|
||||
if (author) {
|
||||
this.author = {
|
||||
name: (author as TextRun).text,
|
||||
channel_id: (author as TextRun).endpoint?.browse?.id,
|
||||
channel_id: (author as TextRun).endpoint?.payload?.browseId,
|
||||
endpoint: (author as TextRun).endpoint
|
||||
};
|
||||
}
|
||||
|
||||
16
src/parser/classes/MusicDownloadStateBadge.ts
Normal file
16
src/parser/classes/MusicDownloadStateBadge.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class MusicDownloadStateBadge extends YTNode {
|
||||
static type = 'MusicDownloadStateBadge';
|
||||
|
||||
playlist_id: string;
|
||||
supported_download_states: string[];
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.playlist_id = data.playlistId;
|
||||
this.supported_download_states = data.supportedDownloadStates;
|
||||
}
|
||||
}
|
||||
|
||||
export default MusicDownloadStateBadge;
|
||||
@@ -1,4 +1,5 @@
|
||||
import Parser from '../index';
|
||||
import MusicPlayButton from './MusicPlayButton';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class MusicItemThumbnailOverlay extends YTNode {
|
||||
@@ -10,7 +11,7 @@ class MusicItemThumbnailOverlay extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.content = Parser.parse(data.content);
|
||||
this.content = Parser.parseItem<MusicPlayButton>(data.content, MusicPlayButton);
|
||||
this.content_position = data.contentPosition;
|
||||
this.display_style = data.displayStyle;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import Parser from '../index';
|
||||
import PlaylistPanel from './PlaylistPanel';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class MusicQueue extends YTNode {
|
||||
static type = 'MusicQueue';
|
||||
|
||||
content;
|
||||
content: PlaylistPanel | null;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.content = Parser.parse(data.content);
|
||||
this.content = Parser.parseItem<PlaylistPanel>(data.content, PlaylistPanel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
|
||||
import Parser from '../index';
|
||||
import Text from './misc/Text';
|
||||
import { timeToSeconds } from '../../utils/Utils';
|
||||
import TextRun from './misc/TextRun';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay';
|
||||
import MusicResponsiveListItemFlexColumn from './MusicResponsiveListItemFlexColumn';
|
||||
import MusicResponsiveListItemFixedColumn from './MusicResponsiveListItemFixedColumn';
|
||||
import Menu from './menus/Menu';
|
||||
|
||||
import { timeToSeconds } from '../../utils/Utils';
|
||||
import { YTNode } from '../helpers';
|
||||
import TextRun from './misc/TextRun';
|
||||
|
||||
class MusicResponsiveListItem extends YTNode {
|
||||
static type = 'MusicResponsiveListItem';
|
||||
@@ -66,8 +70,8 @@ class MusicResponsiveListItem extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.#flex_columns = Parser.parseArray(data.flexColumns);
|
||||
this.#fixed_columns = Parser.parseArray(data.fixedColumns);
|
||||
this.#flex_columns = Parser.parseArray<MusicResponsiveListItemFlexColumn>(data.flexColumns, MusicResponsiveListItemFlexColumn);
|
||||
this.#fixed_columns = Parser.parseArray<MusicResponsiveListItemFixedColumn>(data.fixedColumns, MusicResponsiveListItemFixedColumn);
|
||||
|
||||
this.#playlist_item_data = {
|
||||
video_id: data?.playlistItemData?.videoId || null,
|
||||
@@ -76,7 +80,9 @@ class MusicResponsiveListItem extends YTNode {
|
||||
|
||||
this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : null;
|
||||
|
||||
switch (this.endpoint?.browse?.page_type) {
|
||||
const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
|
||||
|
||||
switch (page_type) {
|
||||
case 'MUSIC_PAGE_TYPE_ALBUM':
|
||||
this.item_type = 'album';
|
||||
this.#parseAlbum();
|
||||
@@ -109,8 +115,8 @@ class MusicResponsiveListItem extends YTNode {
|
||||
|
||||
this.thumbnails = data.thumbnail ? Thumbnail.fromResponse(data.thumbnail.musicThumbnailRenderer?.thumbnail) : [];
|
||||
this.badges = Parser.parseArray(data.badges);
|
||||
this.menu = Parser.parse(data.menu);
|
||||
this.overlay = Parser.parse(data.overlay);
|
||||
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
|
||||
this.overlay = Parser.parseItem<MusicItemThumbnailOverlay>(data.overlay, MusicItemThumbnailOverlay);
|
||||
}
|
||||
|
||||
#parseOther() {
|
||||
@@ -135,7 +141,7 @@ class MusicResponsiveListItem extends YTNode {
|
||||
}
|
||||
|
||||
#parseSong() {
|
||||
this.id = this.#playlist_item_data.video_id || this.endpoint?.watch?.video_id;
|
||||
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
|
||||
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
|
||||
const duration_text =
|
||||
@@ -147,21 +153,21 @@ class MusicResponsiveListItem extends YTNode {
|
||||
seconds: timeToSeconds(duration_text)
|
||||
});
|
||||
|
||||
const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun ||
|
||||
this.#flex_columns[2]?.key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun;
|
||||
const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('MPR')) as TextRun ||
|
||||
this.#flex_columns[2]?.key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('MPR')) as TextRun;
|
||||
if (album) {
|
||||
this.album = {
|
||||
id: album.endpoint?.browse?.id,
|
||||
id: album.endpoint?.payload?.browseId,
|
||||
name: album.text,
|
||||
endpoint: album.endpoint
|
||||
};
|
||||
}
|
||||
|
||||
const artists = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[];
|
||||
const artists = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun[];
|
||||
if (artists) {
|
||||
this.artists = artists.map((artist) => ({
|
||||
name: artist.text,
|
||||
channel_id: artist.endpoint?.browse?.id,
|
||||
channel_id: artist.endpoint?.payload?.browseId,
|
||||
endpoint: artist.endpoint
|
||||
}));
|
||||
}
|
||||
@@ -172,11 +178,11 @@ class MusicResponsiveListItem extends YTNode {
|
||||
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
this.views = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => run.text.match(/(.*?) views/))?.text;
|
||||
|
||||
const authors = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[];
|
||||
const authors = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun[];
|
||||
if (authors) {
|
||||
this.authors = authors.map((author) => ({
|
||||
name: author.text,
|
||||
channel_id: author.endpoint?.browse?.id,
|
||||
channel_id: author.endpoint?.payload?.browseId,
|
||||
endpoint: author.endpoint
|
||||
}));
|
||||
}
|
||||
@@ -190,7 +196,7 @@ class MusicResponsiveListItem extends YTNode {
|
||||
}
|
||||
|
||||
#parseArtist() {
|
||||
this.id = this.endpoint?.browse?.id;
|
||||
this.id = this.endpoint?.payload?.browseId;
|
||||
this.name = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
this.subtitle = this.#flex_columns[1].key('title').instanceof(Text);
|
||||
this.subscribers = this.subtitle.runs?.find((run) => (/^(\d*\.)?\d+[M|K]? subscribers?$/i).test(run.text))?.text || '';
|
||||
@@ -203,13 +209,13 @@ class MusicResponsiveListItem extends YTNode {
|
||||
}
|
||||
|
||||
#parseAlbum() {
|
||||
this.id = this.endpoint?.browse?.id;
|
||||
this.id = this.endpoint?.payload?.browseId;
|
||||
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
|
||||
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun;
|
||||
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun;
|
||||
author && (this.author = {
|
||||
name: author.text,
|
||||
channel_id: author.endpoint?.browse?.id,
|
||||
channel_id: author.endpoint?.payload?.browseId,
|
||||
endpoint: author.endpoint
|
||||
});
|
||||
|
||||
@@ -217,7 +223,7 @@ class MusicResponsiveListItem extends YTNode {
|
||||
}
|
||||
|
||||
#parsePlaylist() {
|
||||
this.id = this.endpoint?.browse?.id;
|
||||
this.id = this.endpoint?.payload?.browseId;
|
||||
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
|
||||
|
||||
const item_count_run = this.#flex_columns[1].key('title')
|
||||
@@ -225,12 +231,12 @@ class MusicResponsiveListItem extends YTNode {
|
||||
|
||||
this.item_count = item_count_run ? item_count_run.text : undefined;
|
||||
|
||||
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun;
|
||||
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun;
|
||||
|
||||
if (author) {
|
||||
this.author = {
|
||||
name: author.text,
|
||||
channel_id: author.endpoint?.browse?.id,
|
||||
channel_id: author.endpoint?.payload?.browseId,
|
||||
endpoint: author.endpoint
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ class MusicSideAlignedItem extends YTNode {
|
||||
static type = 'MusicSideAlignedItem';
|
||||
|
||||
start_items?;
|
||||
end_items?;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -13,6 +14,10 @@ class MusicSideAlignedItem extends YTNode {
|
||||
if (data.startItems) {
|
||||
this.start_items = Parser.parseArray(data.startItems);
|
||||
}
|
||||
|
||||
if (data.endItems) {
|
||||
this.end_items = Parser.parseArray(data.endItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import Text from './misc/Text';
|
||||
import TextRun from './misc/TextRun';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay';
|
||||
import Menu from './menus/Menu';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class MusicTwoRowItem extends YTNode {
|
||||
@@ -45,13 +48,15 @@ class MusicTwoRowItem extends YTNode {
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
|
||||
this.id =
|
||||
this.endpoint?.browse?.id ||
|
||||
this.endpoint?.watch?.video_id;
|
||||
this.endpoint?.payload?.browseId ||
|
||||
this.endpoint?.payload?.videoId;
|
||||
|
||||
this.subtitle = new Text(data.subtitle);
|
||||
this.badges = Parser.parse(data.subtitleBadges);
|
||||
|
||||
switch (this.endpoint?.browse?.page_type) {
|
||||
const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
|
||||
|
||||
switch (page_type) {
|
||||
case 'MUSIC_PAGE_TYPE_ARTIST':
|
||||
this.item_type = 'artist';
|
||||
break;
|
||||
@@ -62,7 +67,7 @@ class MusicTwoRowItem extends YTNode {
|
||||
this.item_type = 'album';
|
||||
break;
|
||||
default:
|
||||
if (this.endpoint?.watch_playlist) {
|
||||
if (this.endpoint?.metadata?.api_url === '/next') {
|
||||
this.item_type = 'endpoint';
|
||||
} else if (this.subtitle.runs?.[0]) {
|
||||
if (this.subtitle.runs[0].text !== 'Song') {
|
||||
@@ -84,11 +89,11 @@ class MusicTwoRowItem extends YTNode {
|
||||
const item_count_run = this.subtitle.runs?.find((run) => run.text.match(/\d+ songs|song/));
|
||||
this.item_count = item_count_run ? (item_count_run as TextRun).text : null;
|
||||
} else if (this.item_type == 'album') {
|
||||
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
|
||||
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
|
||||
if (artists) {
|
||||
this.artists = artists.map((artist: any) => ({
|
||||
name: artist.text,
|
||||
channel_id: artist.endpoint.browse.id,
|
||||
channel_id: artist.endpoint?.payload?.browseId,
|
||||
endpoint: artist.endpoint
|
||||
}));
|
||||
}
|
||||
@@ -98,28 +103,28 @@ class MusicTwoRowItem extends YTNode {
|
||||
} else if (this.item_type == 'video') {
|
||||
this.views = this?.subtitle.runs?.find((run) => run?.text.match(/(.*?) views/))?.text || 'N/A';
|
||||
|
||||
const author = this.subtitle.runs?.find((run: any) => run.endpoint?.browse?.id?.startsWith('UC'));
|
||||
const author = this.subtitle.runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));
|
||||
if (author) {
|
||||
this.author = {
|
||||
name: (author as TextRun)?.text,
|
||||
channel_id: (author as TextRun)?.endpoint?.browse?.id,
|
||||
channel_id: (author as TextRun)?.endpoint?.payload?.browseId,
|
||||
endpoint: (author as TextRun)?.endpoint
|
||||
};
|
||||
}
|
||||
} else if (this.item_type == 'song') {
|
||||
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
|
||||
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
|
||||
if (artists) {
|
||||
this.artists = artists.map((artist: any) => ({
|
||||
name: (artist as TextRun)?.text,
|
||||
channel_id: (artist as TextRun)?.endpoint?.browse?.id,
|
||||
channel_id: (artist as TextRun)?.endpoint?.payload?.browseId,
|
||||
endpoint: (artist as TextRun)?.endpoint
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
this.thumbnail = Thumbnail.fromResponse(data.thumbnailRenderer.musicThumbnailRenderer.thumbnail);
|
||||
this.thumbnail_overlay = Parser.parse(data.thumbnailOverlay);
|
||||
this.menu = Parser.parse(data.menu);
|
||||
this.thumbnail_overlay = Parser.parseItem<MusicItemThumbnailOverlay>(data.thumbnailOverlay, MusicItemThumbnailOverlay);
|
||||
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// TODO: refactor this
|
||||
import { YTNode } from '../helpers';
|
||||
import Parser, { ParsedResponse } from '../index';
|
||||
import Actions, { ActionsResponse } from '../../core/Actions';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
import CreatePlaylistDialog from './CreatePlaylistDialog';
|
||||
|
||||
class NavigationEndpoint extends YTNode {
|
||||
@@ -14,34 +14,9 @@ class NavigationEndpoint extends YTNode {
|
||||
url?: string;
|
||||
api_url?: string;
|
||||
page_type?: string;
|
||||
send_post?: boolean; // TODO: is this a boolean?
|
||||
send_post?: boolean;
|
||||
};
|
||||
|
||||
// TODO: these should be given proper types, currently infered
|
||||
browse?: {
|
||||
id: string,
|
||||
params: string | null,
|
||||
base_url: string | null,
|
||||
page_type: string | null,
|
||||
form_data?: {}
|
||||
};
|
||||
watch;
|
||||
search;
|
||||
subscribe;
|
||||
unsubscribe;
|
||||
like;
|
||||
perform_comment_action;
|
||||
offline_video;
|
||||
continuation;
|
||||
feedback;
|
||||
watch_playlist;
|
||||
playlist_edit;
|
||||
add_to_playlist;
|
||||
create_playlist;
|
||||
get_report_form;
|
||||
live_chat_item_context_menu;
|
||||
send_live_chat_vote;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
|
||||
@@ -85,156 +60,10 @@ class NavigationEndpoint extends YTNode {
|
||||
this.metadata.send_post = data.commandMetadata.webCommandMetadata.sendPost;
|
||||
}
|
||||
|
||||
if (data?.browseEndpoint) {
|
||||
const configs = data?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig;
|
||||
this.browse = {
|
||||
id: data?.browseEndpoint?.browseId || null,
|
||||
params: data?.browseEndpoint.params || null,
|
||||
base_url: data?.browseEndpoint?.canonicalBaseUrl || null,
|
||||
page_type: configs?.pageType || null
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.watchEndpoint) {
|
||||
const configs = data?.watchEndpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig;
|
||||
this.watch = {
|
||||
video_id: data?.watchEndpoint?.videoId,
|
||||
playlist_id: data?.watchEndpoint.playlistId || null,
|
||||
params: data?.watchEndpoint.params || null,
|
||||
index: data?.watchEndpoint.index || null,
|
||||
supported_onesie_config: data?.watchEndpoint?.watchEndpointSupportedOnesieConfig,
|
||||
music_video_type: configs?.musicVideoType || null
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.searchEndpoint) {
|
||||
this.search = {
|
||||
query: data.searchEndpoint.query,
|
||||
params: data.searchEndpoint.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.subscribeEndpoint) {
|
||||
this.subscribe = {
|
||||
channel_ids: data.subscribeEndpoint.channelIds,
|
||||
params: data.subscribeEndpoint.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.unsubscribeEndpoint) {
|
||||
this.unsubscribe = {
|
||||
channel_ids: data.unsubscribeEndpoint.channelIds,
|
||||
params: data.unsubscribeEndpoint.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.likeEndpoint) {
|
||||
this.like = {
|
||||
status: data.likeEndpoint.status,
|
||||
target: {
|
||||
video_id: data.likeEndpoint.target.videoId,
|
||||
playlist_id: data.likeEndpoint.target.playlistId
|
||||
},
|
||||
params:
|
||||
data.likeEndpoint?.removeLikeParams ||
|
||||
data.likeEndpoint?.likeParams ||
|
||||
data.likeEndpoint?.dislikeParams
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.performCommentActionEndpoint) {
|
||||
this.perform_comment_action = {
|
||||
action: data?.performCommentActionEndpoint.action
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.offlineVideoEndpoint) {
|
||||
this.offline_video = {
|
||||
video_id: data.offlineVideoEndpoint.videoId,
|
||||
on_add_command: {
|
||||
get_download_action: {
|
||||
video_id: data.offlineVideoEndpoint.videoId,
|
||||
params: data.offlineVideoEndpoint.onAddCommand.getDownloadActionCommand.params
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.continuationCommand) {
|
||||
this.continuation = {
|
||||
request: data?.continuationCommand?.request || null,
|
||||
token: data?.continuationCommand?.token || null
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.feedbackEndpoint) {
|
||||
this.feedback = {
|
||||
token: data.feedbackEndpoint.feedbackToken
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.watchPlaylistEndpoint) {
|
||||
this.watch_playlist = {
|
||||
playlist_id: data.watchPlaylistEndpoint?.playlistId,
|
||||
params: data.watchPlaylistEndpoint?.params || null
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.playlistEditEndpoint) {
|
||||
this.playlist_edit = {
|
||||
playlist_id: data.playlistEditEndpoint.playlistId,
|
||||
actions: data.playlistEditEndpoint.actions.map((item: any) => ({
|
||||
action: item.action,
|
||||
removed_video_id: item.removedVideoId
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.addToPlaylistEndpoint) {
|
||||
this.add_to_playlist = {
|
||||
video_id: data.addToPlaylistEndpoint.videoId
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.addToPlaylistServiceEndpoint) {
|
||||
this.add_to_playlist = {
|
||||
video_id: data.addToPlaylistServiceEndpoint.videoId
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.createPlaylistEndpoint) {
|
||||
if (data?.createPlaylistEndpoint.createPlaylistDialog) {
|
||||
this.dialog = Parser.parseItem(data?.createPlaylistEndpoint.createPlaylistDialog, CreatePlaylistDialog);
|
||||
}
|
||||
this.create_playlist = {
|
||||
// Nothing to put here - data.createPlaylistEndpoint has only one prop `createPlaylistDialog`
|
||||
// Which was already parsed and referred to by `this.dialog`. But still useful to have this as
|
||||
// A quick indicator of what the endpoint does.
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.getReportFormEndpoint) {
|
||||
this.get_report_form = {
|
||||
params: data.getReportFormEndpoint.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.liveChatItemContextMenuEndpoint) {
|
||||
this.live_chat_item_context_menu = {
|
||||
params: data?.liveChatItemContextMenuEndpoint?.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.sendLiveChatVoteEndpoint) {
|
||||
this.send_live_chat_vote = {
|
||||
params: data.sendLiveChatVoteEndpoint.params
|
||||
};
|
||||
}
|
||||
|
||||
if (data?.liveChatItemContextMenuEndpoint) {
|
||||
this.live_chat_item_context_menu = {
|
||||
params: data.liveChatItemContextMenuEndpoint.params
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,66 +74,26 @@ class NavigationEndpoint extends YTNode {
|
||||
switch (name) {
|
||||
case 'browseEndpoint':
|
||||
return '/browse';
|
||||
case 'watchEndpoint':
|
||||
return '/player';
|
||||
case 'searchEndpoint':
|
||||
return '/search';
|
||||
case 'watchPlaylistEndpoint':
|
||||
return '/next';
|
||||
case 'liveChatItemContextMenuEndpoint':
|
||||
return 'live_chat/get_item_context_menu';
|
||||
}
|
||||
}
|
||||
|
||||
callTest(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
|
||||
callTest(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
|
||||
callTest(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<ParsedResponse | ActionsResponse> {
|
||||
call(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
|
||||
call(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
|
||||
call(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<ParsedResponse | ActionsResponse> {
|
||||
if (!actions)
|
||||
throw new Error('An active caller must be provided');
|
||||
if (!this.metadata.api_url)
|
||||
throw new Error('Expected an api_url, but none was found, this is a bug.');
|
||||
return actions.execute(this.metadata.api_url, { ...this.payload, ...args });
|
||||
}
|
||||
|
||||
// TODO: replace client with an enum or something
|
||||
async #call(actions: Actions, client?: string) {
|
||||
if (!actions)
|
||||
throw new Error('An active caller must be provided');
|
||||
|
||||
if (this.continuation) {
|
||||
switch (this.continuation.request) {
|
||||
case 'CONTINUATION_REQUEST_TYPE_BROWSE': {
|
||||
return await actions.browse(this.continuation.token, { is_ctoken: true });
|
||||
}
|
||||
case 'CONTINUATION_REQUEST_TYPE_SEARCH': {
|
||||
return await actions.search({ ctoken: this.continuation.token });
|
||||
}
|
||||
case 'CONTINUATION_REQUEST_TYPE_WATCH_NEXT': {
|
||||
return await actions.next({ ctoken: this.continuation.token });
|
||||
}
|
||||
default:
|
||||
throw new Error(`${this.continuation.request} not implemented`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.search) {
|
||||
return await actions.search({ query: this.search.query, params: this.search.params, client });
|
||||
}
|
||||
|
||||
if (this.browse) {
|
||||
return await actions.browse(this.browse.id, { ...this.browse, client });
|
||||
}
|
||||
|
||||
if (this.like) {
|
||||
if (!this.metadata.api_url)
|
||||
throw new Error('Like endpoint requires an api_url, but was not parsed from the response.');
|
||||
const response = await actions.engage(this.metadata.api_url, { video_id: this.like.target.video_id, params: this.like.params });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
async call(actions: Actions, client: string | undefined, parse: true) : Promise<ParsedResponse | undefined>;
|
||||
async call(actions: Actions, client?: string, parse?: false) : Promise<ActionsResponse | undefined>;
|
||||
async call(actions: Actions, client?: string, parse?: boolean): Promise<ParsedResponse | ActionsResponse | undefined> {
|
||||
const result = await this.#call(actions, client);
|
||||
|
||||
if (parse && result)
|
||||
return Parser.parseResponse(result.data);
|
||||
|
||||
return this.#call(actions, client);
|
||||
}
|
||||
}
|
||||
|
||||
export default NavigationEndpoint;
|
||||
@@ -1,6 +1,8 @@
|
||||
import Parser from '../index';
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import Button from './Button';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class PlayerErrorMessage extends YTNode {
|
||||
@@ -8,17 +10,17 @@ class PlayerErrorMessage extends YTNode {
|
||||
|
||||
subreason: Text;
|
||||
reason: Text;
|
||||
proceed_button;
|
||||
proceed_button: Button | null;
|
||||
thumbnails: Thumbnail[];
|
||||
icon_type: string;
|
||||
icon_type: string | null;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.subreason = new Text(data.subreason);
|
||||
this.reason = new Text(data.reason);
|
||||
this.proceed_button = Parser.parse(data.proceedButton);
|
||||
this.proceed_button = Parser.parseItem<Button>(data.proceedButton, Button);
|
||||
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
|
||||
this.icon_type = data.icon.iconType;
|
||||
this.icon_type = data.icon?.iconType || null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ class PlayerOverlay extends YTNode {
|
||||
share_button;
|
||||
add_to_menu;
|
||||
fullscreen_engagement;
|
||||
actions;
|
||||
browser_media_session;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
@@ -22,6 +24,8 @@ class PlayerOverlay extends YTNode {
|
||||
this.share_button = Parser.parseItem<Button>(data.shareButton, Button);
|
||||
this.add_to_menu = Parser.parseItem<Menu>(data.addToMenu, Menu);
|
||||
this.fullscreen_engagement = Parser.parse(data.fullscreenEngagement);
|
||||
this.actions = Parser.parseArray(data.actions);
|
||||
this.browser_media_session = Parser.parseItem(data.browserMediaSession);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
@@ -4,6 +4,7 @@ import PlaylistPanelVideo from './PlaylistPanelVideo';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
import AutomixPreviewVideo from './AutomixPreviewVideo';
|
||||
import PlaylistPanelVideoWrapper from './PlaylistPanelVideoWrapper';
|
||||
|
||||
class PlaylistPanel extends YTNode {
|
||||
static type = 'PlaylistPanel';
|
||||
@@ -22,7 +23,7 @@ class PlaylistPanel extends YTNode {
|
||||
super();
|
||||
this.title = data.title;
|
||||
this.title_text = new Text(data.titleText);
|
||||
this.contents = Parser.parseArray<PlaylistPanelVideo | AutomixPreviewVideo>(data.contents, [ PlaylistPanelVideo, AutomixPreviewVideo ]);
|
||||
this.contents = Parser.parseArray<PlaylistPanelVideoWrapper | PlaylistPanelVideo | AutomixPreviewVideo>(data.contents);
|
||||
this.playlist_id = data.playlistId;
|
||||
this.is_infinite = data.isInfinite;
|
||||
this.continuation = data.continuations?.[0]?.nextRadioContinuationData?.continuation || data.continuations?.[0]?.nextContinuationData?.continuation;
|
||||
|
||||
@@ -54,14 +54,14 @@ class PlaylistPanelVideo extends YTNode {
|
||||
seconds: timeToSeconds(new Text(data.lengthText).toString())
|
||||
};
|
||||
|
||||
const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.browse?.id.startsWith('MPR'));
|
||||
const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
|
||||
const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('MPR'));
|
||||
const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));
|
||||
|
||||
this.author = new Text(data.shortBylineText).toString();
|
||||
|
||||
if (album) {
|
||||
this.album = {
|
||||
id: (album as TextRun).endpoint?.browse?.id,
|
||||
id: (album as TextRun).endpoint?.payload?.browseId,
|
||||
name: (album as TextRun).text,
|
||||
year: new Text(data.longBylineText).runs?.slice(-1)[0].text,
|
||||
endpoint: (album as TextRun).endpoint
|
||||
@@ -71,7 +71,7 @@ class PlaylistPanelVideo extends YTNode {
|
||||
if (artists) {
|
||||
this.artists = artists.map((artist) => ({
|
||||
name: (artist as TextRun).text,
|
||||
channel_id: (artist as TextRun).endpoint?.browse?.id,
|
||||
channel_id: (artist as TextRun).endpoint?.payload?.browseId,
|
||||
endpoint: (artist as TextRun).endpoint
|
||||
}));
|
||||
}
|
||||
|
||||
18
src/parser/classes/PlaylistPanelVideoWrapper.ts
Normal file
18
src/parser/classes/PlaylistPanelVideoWrapper.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
import PlaylistPanelVideo from './PlaylistPanelVideo';
|
||||
|
||||
class PlaylistPanelVideoWrapper extends YTNode {
|
||||
static type = 'PlaylistPanelVideoWrapper';
|
||||
|
||||
primary: PlaylistPanelVideo | null;
|
||||
counterpart: Array<PlaylistPanelVideo | null>;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.primary = Parser.parseItem<PlaylistPanelVideo>(data.primaryRenderer);
|
||||
this.counterpart = data.counterpart?.map((item: any) => Parser.parseItem<PlaylistPanelVideo>(item.counterpartRenderer)) || [];
|
||||
}
|
||||
}
|
||||
|
||||
export default PlaylistPanelVideoWrapper;
|
||||
@@ -8,7 +8,7 @@ class ProfileColumnStats extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.items = Parser.parse(data.items);
|
||||
this.items = Parser.parseArray(data.items);
|
||||
}
|
||||
|
||||
// XXX: alias for consistency
|
||||
@@ -17,4 +17,4 @@ class ProfileColumnStats extends YTNode {
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfileColumnStats;
|
||||
export default ProfileColumnStats;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class SectionList extends YTNode {
|
||||
this.target_id = data.targetId;
|
||||
}
|
||||
|
||||
// TODO: this should be Parser#parseArray
|
||||
this.contents = Parser.parse(data.contents);
|
||||
|
||||
if (data.continuations) {
|
||||
|
||||
18
src/parser/classes/SegmentedLikeDislikeButton.ts
Normal file
18
src/parser/classes/SegmentedLikeDislikeButton.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Parser from '..';
|
||||
import ToggleButton from './ToggleButton';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class SegmentedLikeDislikeButton extends YTNode {
|
||||
static type = 'SegmentedLikeDislikeButton';
|
||||
|
||||
like_button: ToggleButton | null;
|
||||
dislike_button: ToggleButton | null;
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.like_button = Parser.parseItem<ToggleButton>(data.likeButton, ToggleButton);
|
||||
this.dislike_button = Parser.parseItem<ToggleButton>(data.dislikeButton, ToggleButton);
|
||||
}
|
||||
}
|
||||
|
||||
export default SegmentedLikeDislikeButton;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Parser from '../index';
|
||||
import Text from './misc/Text';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import SubscriptionNotificationToggleButton from './SubscriptionNotificationToggleButton';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class SubscribeButton extends YTNode {
|
||||
@@ -14,7 +15,7 @@ class SubscribeButton extends YTNode {
|
||||
show_preferences: boolean;
|
||||
subscribed_text: Text;
|
||||
unsubscribed_text: Text;
|
||||
notification_preference_button;
|
||||
notification_preference_button: SubscriptionNotificationToggleButton | null;
|
||||
endpoint: NavigationEndpoint;
|
||||
|
||||
constructor(data: any) {
|
||||
@@ -27,7 +28,7 @@ class SubscribeButton extends YTNode {
|
||||
this.show_preferences = data.showPreferences;
|
||||
this.subscribed_text = new Text(data.subscribedButtonText);
|
||||
this.unsubscribed_text = new Text(data.unsubscribedButtonText);
|
||||
this.notification_preference_button = Parser.parse(data.notificationPreferenceButton);
|
||||
this.notification_preference_button = Parser.parseItem<SubscriptionNotificationToggleButton>(data.notificationPreferenceButton, SubscriptionNotificationToggleButton);
|
||||
this.endpoint = new NavigationEndpoint(data.serviceEndpoints?.[0] || data.onSubscribeEndpoints?.[0]);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
15
src/parser/classes/TitleAndButtonListHeader.ts
Normal file
15
src/parser/classes/TitleAndButtonListHeader.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Text from './misc/Text';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class TitleAndButtonListHeader extends YTNode {
|
||||
static type = 'TitleAndButtonListHeader';
|
||||
|
||||
title: Text;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
}
|
||||
}
|
||||
|
||||
export default TitleAndButtonListHeader;
|
||||
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;
|
||||
@@ -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,8 +1,10 @@
|
||||
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 { timeToSeconds } from '../../utils/Utils';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
@@ -53,8 +55,8 @@ class Video extends YTNode {
|
||||
})) || [];
|
||||
|
||||
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
|
||||
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
|
||||
this.rich_thumbnail = data.richThumbnail && Parser.parse(data.richThumbnail);
|
||||
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
|
||||
this.rich_thumbnail = data.richThumbnail ? Parser.parseItem(data.richThumbnail) : null;
|
||||
this.author = new Author(data.ownerText, data.ownerBadges, data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail);
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.published = new Text(data.publishedTimeText);
|
||||
@@ -73,7 +75,7 @@ class Video extends YTNode {
|
||||
|
||||
this.show_action_menu = data.showActionMenu;
|
||||
this.is_watched = data.isWatched || false;
|
||||
this.menu = Parser.parse(data.menu);
|
||||
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
|
||||
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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import Parser from '../index';
|
||||
import Text from './misc/Text';
|
||||
import Button from './Button';
|
||||
import VideoOwner from './VideoOwner';
|
||||
import SubscribeButton from './SubscribeButton';
|
||||
import MetadataRowContainer from './MetadataRowContainer';
|
||||
import { YTNode } from '../helpers';
|
||||
@@ -8,7 +9,7 @@ import { YTNode } from '../helpers';
|
||||
class VideoSecondaryInfo extends YTNode {
|
||||
static type = 'VideoSecondaryInfo';
|
||||
|
||||
owner; // TODO: VideoOwner?
|
||||
owner: VideoOwner | null;// TODO: VideoOwner?
|
||||
description: Text;
|
||||
subscribe_button;
|
||||
metadata: MetadataRowContainer | null;
|
||||
@@ -19,7 +20,7 @@ class VideoSecondaryInfo extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.owner = Parser.parse(data.owner);
|
||||
this.owner = Parser.parseItem<VideoOwner>(data.owner);
|
||||
this.description = new Text(data.description);
|
||||
this.subscribe_button = Parser.parseItem<SubscribeButton | Button>(data.subscribeButton, [ SubscribeButton, Button ]);
|
||||
this.metadata = Parser.parseItem<MetadataRowContainer>(data.metadataRowContainer, MetadataRowContainer);
|
||||
|
||||
@@ -13,9 +13,9 @@ class WatchCardHeroVideo extends YTNode {
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.call_to_action_button = Parser.parse(data.callToActionButton);
|
||||
this.hero_image = Parser.parse(data.heroImage);
|
||||
this.label = data.accessibility.accessibilityData.label;
|
||||
this.call_to_action_button = Parser.parseItem(data.callToActionButton);
|
||||
this.hero_image = Parser.parseItem(data.heroImage);
|
||||
this.label = data.lengthText?.accessibility.accessibilityData.label || '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ class WatchCardSectionSequence extends YTNode {
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.lists = Parser.parse(data.lists);
|
||||
this.lists = Parser.parseArray(data.lists);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class Comment extends YTNode {
|
||||
if (button.is_toggled)
|
||||
throw new InnertubeError('This comment is already liked', { comment_id: this.comment_id });
|
||||
|
||||
const response = await button.endpoint.callTest(this.#actions, { parse: false });
|
||||
const response = await button.endpoint.call(this.#actions, { parse: false });
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ class Comment extends YTNode {
|
||||
if (button.is_toggled)
|
||||
throw new InnertubeError('This comment is already disliked', { comment_id: this.comment_id });
|
||||
|
||||
const response = await button.endpoint.callTest(this.#actions, { parse: false });
|
||||
const response = await button.endpoint.call(this.#actions, { parse: false });
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ class Comment extends YTNode {
|
||||
commentText: text
|
||||
};
|
||||
|
||||
const response = await dialog_button.endpoint.callTest(this.#actions, payload);
|
||||
const response = await dialog_button.endpoint.call(this.#actions, payload);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class CommentThread extends YTNode {
|
||||
throw new InnertubeError('This comment has no replies.', { comment_id: this.comment?.comment_id });
|
||||
|
||||
const continuation = this.#replies.key('contents').parsed().array().get({ type: 'ContinuationItem' })?.as(ContinuationItem);
|
||||
const response = await continuation?.endpoint.callTest(this.#actions, { parse: true });
|
||||
const response = await continuation?.endpoint.call(this.#actions, { parse: true });
|
||||
|
||||
this.replies = response?.on_response_received_endpoints_memo?.getType(Comment).map((comment) => {
|
||||
comment.setActions(this.#actions);
|
||||
@@ -60,7 +60,7 @@ class CommentThread extends YTNode {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions not set for this CommentThread.');
|
||||
|
||||
const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).callTest(this.#actions, { parse: true });
|
||||
const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).call(this.#actions, { parse: true });
|
||||
|
||||
this.replies = response?.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
|
||||
comment.setActions(this.#actions);
|
||||
|
||||
14
src/parser/classes/livechat/RemoveChatItemAction.ts
Normal file
14
src/parser/classes/livechat/RemoveChatItemAction.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { YTNode } from '../../helpers';
|
||||
|
||||
class RemoveChatItemAction extends YTNode {
|
||||
static type = 'RemoveChatItemAction';
|
||||
|
||||
target_item_id: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.target_item_id = data.targetItemId;
|
||||
}
|
||||
}
|
||||
|
||||
export default RemoveChatItemAction;
|
||||
14
src/parser/classes/livechat/RemoveChatItemByAuthorAction.ts
Normal file
14
src/parser/classes/livechat/RemoveChatItemByAuthorAction.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { YTNode } from '../../helpers';
|
||||
|
||||
class RemoveChatItemByAuthorAction extends YTNode {
|
||||
static type = 'RemoveChatItemByAuthorAction';
|
||||
|
||||
external_channel_id: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.external_channel_id = data.externalChannelId;
|
||||
}
|
||||
}
|
||||
|
||||
export default RemoveChatItemByAuthorAction;
|
||||
30
src/parser/classes/livechat/items/LiveChatAutoModMessage.ts
Normal file
30
src/parser/classes/livechat/items/LiveChatAutoModMessage.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import Text from '../../misc/Text';
|
||||
import Parser from '../../../index';
|
||||
import { ObservedArray, YTNode } from '../../../helpers';
|
||||
import NavigationEndpoint from '../../NavigationEndpoint';
|
||||
import Button from '../../Button';
|
||||
|
||||
class LiveChatAutoModMessage extends YTNode {
|
||||
static type = 'LiveChatAutoModMessage';
|
||||
|
||||
auto_moderated_item;
|
||||
header_text: Text;
|
||||
|
||||
menu_endpoint?: NavigationEndpoint;
|
||||
moderation_buttons: ObservedArray<Button>;
|
||||
timestamp: number;
|
||||
id: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
|
||||
this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
|
||||
this.moderation_buttons = Parser.parseArray<Button>(data.moderationButtons, [ Button ]);
|
||||
this.auto_moderated_item = Parser.parse(data.autoModeratedItem);
|
||||
this.header_text = new Text(data.headerText);
|
||||
this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
|
||||
this.id = data.id;
|
||||
}
|
||||
}
|
||||
|
||||
export default LiveChatAutoModMessage;
|
||||
@@ -2,6 +2,7 @@ import Text from '../../misc/Text';
|
||||
import Thumbnail from '../../misc/Thumbnail';
|
||||
import NavigationEndpoint from '../../NavigationEndpoint';
|
||||
import MetadataBadge from '../../MetadataBadge';
|
||||
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
|
||||
import Parser from '../../../index';
|
||||
import { YTNode } from '../../../helpers';
|
||||
|
||||
@@ -14,7 +15,7 @@ class LiveChatPaidMessage extends YTNode {
|
||||
id: string;
|
||||
name: Text;
|
||||
thumbnails: Thumbnail[];
|
||||
badges: MetadataBadge[];
|
||||
badges: LiveChatAuthorBadge[] | MetadataBadge[];
|
||||
is_moderator: boolean | null;
|
||||
is_verified: boolean | null;
|
||||
is_verified_artist: boolean | null;
|
||||
@@ -38,13 +39,13 @@ class LiveChatPaidMessage extends YTNode {
|
||||
id: data.authorExternalChannelId,
|
||||
name: new Text(data.authorName),
|
||||
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
|
||||
badges: Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge),
|
||||
badges: Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]),
|
||||
is_moderator: null,
|
||||
is_verified: null,
|
||||
is_verified_artist: null
|
||||
};
|
||||
|
||||
const badges = Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge);
|
||||
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
|
||||
|
||||
this.author.badges = badges;
|
||||
this.author.is_moderator = badges?.some((badge: any) => badge.icon_type == 'MODERATOR') || null;
|
||||
|
||||
@@ -23,6 +23,7 @@ class LiveChatPaidSticker extends YTNode {
|
||||
sticker: Thumbnail[];
|
||||
purchase_amount: string;
|
||||
context_menu: NavigationEndpoint;
|
||||
menu_endpoint?: NavigationEndpoint;
|
||||
timestamp: number;
|
||||
|
||||
constructor(data: any) {
|
||||
@@ -42,7 +43,8 @@ class LiveChatPaidSticker extends YTNode {
|
||||
this.author_name_text_color = data.authorNameTextColor;
|
||||
this.sticker = Thumbnail.fromResponse(data.sticker);
|
||||
this.purchase_amount = new Text(data.purchaseAmountText).toString();
|
||||
this.context_menu = new NavigationEndpoint(data.contextMenuEndpoint);
|
||||
this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
|
||||
this.context_menu = this.menu_endpoint;
|
||||
this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import Text from '../../misc/Text';
|
||||
import Thumbnail from '../../misc/Thumbnail';
|
||||
import NavigationEndpoint from '../../NavigationEndpoint';
|
||||
import MetadataBadge from '../../MetadataBadge';
|
||||
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
|
||||
import Parser from '../../../index';
|
||||
|
||||
import { YTNode } from '../../../helpers';
|
||||
import { ObservedArray, YTNode } from '../../../helpers';
|
||||
import Button from '../../Button';
|
||||
|
||||
class LiveChatTextMessage extends YTNode {
|
||||
static type = 'LiveChatTextMessage';
|
||||
@@ -14,13 +16,14 @@ class LiveChatTextMessage extends YTNode {
|
||||
id: string;
|
||||
name: Text;
|
||||
thumbnails: Thumbnail[];
|
||||
badges: MetadataBadge[];
|
||||
badges: LiveChatAuthorBadge[] | MetadataBadge[];
|
||||
is_moderator: boolean | null;
|
||||
is_verified: boolean | null;
|
||||
is_verified_artist: boolean | null;
|
||||
};
|
||||
|
||||
menu_endpoint?: NavigationEndpoint;
|
||||
inline_action_buttons: ObservedArray<Button>;
|
||||
timestamp: number;
|
||||
id: string;
|
||||
|
||||
@@ -32,13 +35,13 @@ class LiveChatTextMessage extends YTNode {
|
||||
id: data.authorExternalChannelId,
|
||||
name: new Text(data.authorName),
|
||||
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
|
||||
badges: [] as MetadataBadge[],
|
||||
badges: [] as LiveChatAuthorBadge[] | [] as MetadataBadge[],
|
||||
is_moderator: null,
|
||||
is_verified: null,
|
||||
is_verified_artist: null
|
||||
};
|
||||
|
||||
const badges = Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge);
|
||||
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
|
||||
|
||||
this.author.badges = badges;
|
||||
this.author.is_moderator = badges ? badges.some((badge) => badge.icon_type == 'MODERATOR') : null;
|
||||
@@ -46,6 +49,7 @@ class LiveChatTextMessage extends YTNode {
|
||||
this.author.is_verified_artist = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') : null;
|
||||
|
||||
this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
|
||||
this.inline_action_buttons = Parser.parseArray<Button>(data.inlineActionButtons, [ Button ]);
|
||||
this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
|
||||
this.id = data.id;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Text from '../../misc/Text';
|
||||
import Thumbnail from '../../misc/Thumbnail';
|
||||
import NavigationEndpoint from '../../NavigationEndpoint';
|
||||
import MetadataBadge from '../../MetadataBadge';
|
||||
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
|
||||
import Parser from '../../../index';
|
||||
|
||||
import { YTNode } from '../../../helpers';
|
||||
@@ -12,7 +13,7 @@ class LiveChatTickerPaidMessageItem extends YTNode {
|
||||
author: {
|
||||
id: string;
|
||||
thumbnails: Thumbnail[];
|
||||
badges: MetadataBadge[];
|
||||
badges: LiveChatAuthorBadge[] | MetadataBadge[];
|
||||
is_moderator: boolean | null;
|
||||
is_verified: boolean | null;
|
||||
is_verified_artist: boolean | null;
|
||||
@@ -31,13 +32,13 @@ class LiveChatTickerPaidMessageItem extends YTNode {
|
||||
this.author = {
|
||||
id: data.authorExternalChannelId,
|
||||
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
|
||||
badges: Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge),
|
||||
badges: Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]),
|
||||
is_moderator: null,
|
||||
is_verified: null,
|
||||
is_verified_artist: null
|
||||
};
|
||||
|
||||
const badges = Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge);
|
||||
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
|
||||
|
||||
this.author.badges = badges;
|
||||
this.author.is_moderator = badges?.some((badge) => badge.icon_type == 'MODERATOR') || null;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Text from '../../misc/Text';
|
||||
import Thumbnail from '../../misc/Thumbnail';
|
||||
import NavigationEndpoint from '../../NavigationEndpoint';
|
||||
import MetadataBadge from '../../MetadataBadge';
|
||||
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
|
||||
import Parser from '../../../index';
|
||||
|
||||
import { YTNode } from '../../../helpers';
|
||||
|
||||
class LiveChatTickerPaidStickerItem extends YTNode {
|
||||
static type = 'LiveChatTickerPaidStickerItem';
|
||||
|
||||
author: {
|
||||
id: string;
|
||||
thumbnails: Thumbnail[];
|
||||
badges: LiveChatAuthorBadge[] | MetadataBadge[];
|
||||
is_moderator: boolean | null;
|
||||
is_verified: boolean | null;
|
||||
is_verified_artist: boolean | null;
|
||||
};
|
||||
|
||||
amount: Text;
|
||||
duration_sec: string; // Or number?
|
||||
full_duration_sec: string;
|
||||
show_item;
|
||||
show_item_endpoint: NavigationEndpoint;
|
||||
id: string;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
|
||||
this.author = {
|
||||
id: data.authorExternalChannelId,
|
||||
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
|
||||
badges: Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]),
|
||||
is_moderator: null,
|
||||
is_verified: null,
|
||||
is_verified_artist: null
|
||||
};
|
||||
|
||||
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
|
||||
|
||||
this.author.badges = badges;
|
||||
this.author.is_moderator = badges?.some((badge) => badge.icon_type == 'MODERATOR') || null;
|
||||
this.author.is_verified = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || null;
|
||||
this.author.is_verified_artist = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || null;
|
||||
this.amount = new Text(data.amount);
|
||||
this.duration_sec = data.durationSec;
|
||||
this.full_duration_sec = data.fullDurationSec;
|
||||
this.show_item = Parser.parse(data.showItemEndpoint.showLiveChatItemEndpoint.renderer);
|
||||
this.show_item_endpoint = new NavigationEndpoint(data.showItemEndpoint);
|
||||
this.id = data.id;
|
||||
}
|
||||
}
|
||||
|
||||
export default LiveChatTickerPaidStickerItem;
|
||||
@@ -8,7 +8,7 @@ class MusicMultiSelectMenuItem extends YTNode {
|
||||
title: string;
|
||||
form_item_entity_key: string;
|
||||
selected_icon_type: string;
|
||||
endpoint?: NavigationEndpoint;
|
||||
endpoint?: NavigationEndpoint | null;
|
||||
selected: boolean;
|
||||
|
||||
constructor(data: any) {
|
||||
@@ -17,19 +17,7 @@ class MusicMultiSelectMenuItem extends YTNode {
|
||||
this.title = new Text(data.title).text;
|
||||
this.form_item_entity_key = data.formItemEntityKey;
|
||||
this.selected_icon_type = data.selectedIcon?.iconType || null;
|
||||
const command = data.selectedCommand?.commandExecutorCommand?.commands?.find((command: any) => command.musicBrowseFormBinderCommand?.browseEndpoint);
|
||||
if (command) {
|
||||
/**
|
||||
* At this point, endpoint will still be missing `form_data` field which is required for
|
||||
* selection to take effect. This can only be obtained from the response data which
|
||||
* we don't have here. We shall delegate this task back to `Parser`.
|
||||
*/
|
||||
this.endpoint = new NavigationEndpoint(command.musicBrowseFormBinderCommand);
|
||||
}
|
||||
/**
|
||||
* Inferring selected state from existence of endpoint. `Parser` shall
|
||||
* update this with the definitive value obtained from response data.
|
||||
*/
|
||||
this.endpoint = data.selectedCommand ? new NavigationEndpoint(data.selectedCommand) : null;
|
||||
this.selected = !!this.endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ class Author {
|
||||
this.#nav_text = new NavigatableText(item);
|
||||
|
||||
this.id =
|
||||
(this.#nav_text.runs?.[0] as TextRun)?.endpoint?.browse?.id ||
|
||||
this.#nav_text?.endpoint?.browse?.id || 'N/A';
|
||||
(this.#nav_text.runs?.[0] as TextRun)?.endpoint?.payload?.browseId ||
|
||||
this.#nav_text?.endpoint?.payload?.browseId || 'N/A';
|
||||
|
||||
this.name = this.#nav_text.text || 'N/A';
|
||||
this.thumbnails = thumbs ? Thumbnail.fromResponse(thumbs) : [];
|
||||
@@ -32,9 +32,9 @@ class Author {
|
||||
this.is_verified_artist = this.badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || null;
|
||||
|
||||
this.url =
|
||||
(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.browse &&
|
||||
`${Constants.URLS.YT_BASE}${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.browse?.base_url || `/u/${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.browse?.id}`}` ||
|
||||
`${Constants.URLS.YT_BASE}${this.#nav_text?.endpoint?.browse?.base_url || `/u/${this.#nav_text?.endpoint?.browse?.id}`}` ||
|
||||
(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.metadata?.api_url === '/browse' &&
|
||||
`${Constants.URLS.YT_BASE}${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.payload?.canonicalBaseUrl || `/u/${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.payload?.browseId}`}` ||
|
||||
`${Constants.URLS.YT_BASE}${this.#nav_text?.endpoint?.payload?.canonicalBaseUrl || `/u/${this.#nav_text?.endpoint?.payload?.browseId}`}` ||
|
||||
null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Player from '../../../core/Player';
|
||||
import { InnertubeError } from '../../../utils/Utils';
|
||||
|
||||
class Format {
|
||||
itag: string;
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user