mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 17:42:18 +00:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e29019a07 | ||
|
|
6765f4e0d7 | ||
|
|
3048f70f60 | ||
|
|
090539b28f | ||
|
|
6d0bc89be1 | ||
|
|
a5f62093a1 | ||
|
|
a352ddeb9d | ||
|
|
0f8f92a28a | ||
|
|
7d03469e64 | ||
|
|
62ac2f6f32 | ||
|
|
142a7d0428 | ||
|
|
efa7205723 | ||
|
|
84f90aaf29 | ||
|
|
858cdd197c | ||
|
|
5a8fd3ad37 | ||
|
|
a19511de24 | ||
|
|
bd9f6ac64c | ||
|
|
e5aab9a9b3 | ||
|
|
d6fa134c3d | ||
|
|
fe953072a2 | ||
|
|
055fa33403 | ||
|
|
14c3a06d40 | ||
|
|
67376afae6 | ||
|
|
4cbaa7983f | ||
|
|
9802483233 | ||
|
|
2980a608b6 | ||
|
|
b6cecb10f5 | ||
|
|
040a091639 | ||
|
|
3939405cc6 | ||
|
|
978ab1ed29 | ||
|
|
5cdb9e1e2f | ||
|
|
15f3b5fdba | ||
|
|
384b80ee41 | ||
|
|
b588554ce1 | ||
|
|
583fd9f8d7 | ||
|
|
7953296580 | ||
|
|
cf29664d37 | ||
|
|
4015a5e560 | ||
|
|
184df79b3a | ||
|
|
000f3f0915 | ||
|
|
8372b3d22f | ||
|
|
b9d50daa57 | ||
|
|
031ffb696e | ||
|
|
8e942ada3b | ||
|
|
aa3f34c428 | ||
|
|
c82bb70180 | ||
|
|
766045049d | ||
|
|
b6ce5f903f | ||
|
|
6bb2086875 | ||
|
|
810665407e | ||
|
|
1b00e2c6ce | ||
|
|
ea82beaa10 | ||
|
|
0ba8c54257 | ||
|
|
7315fca1b4 | ||
|
|
0602dd2c3d | ||
|
|
13321888e8 | ||
|
|
d48b9d0946 | ||
|
|
592ddac30f | ||
|
|
1ec2ea85e2 | ||
|
|
064436cef3 | ||
|
|
4022d7aa89 | ||
|
|
cd69ce73c1 | ||
|
|
1c08bfe113 | ||
|
|
a624963384 | ||
|
|
66e34f9388 | ||
|
|
0c2cdc1599 | ||
|
|
010704929f | ||
|
|
d4a938771b |
101
CHANGELOG.md
101
CHANGELOG.md
@@ -1,5 +1,106 @@
|
||||
# Changelog
|
||||
|
||||
## [10.2.0](https://github.com/LuanRT/YouTube.js/compare/v10.1.0...v10.2.0) (2024-07-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Format:** Add `is_secondary` for detecting secondary audio tracks ([#697](https://github.com/LuanRT/YouTube.js/issues/697)) ([a352dde](https://github.com/LuanRT/YouTube.js/commit/a352ddeb9db001e99f49025048ad0942d84f1b3e))
|
||||
* **parser:** add classdata to unhandled parse errors ([#691](https://github.com/LuanRT/YouTube.js/issues/691)) ([090539b](https://github.com/LuanRT/YouTube.js/commit/090539b28f9bc3387d01e37325ba5741b33b1765))
|
||||
* **proto:** Add `comment_id` to commentSectionParams ([#693](https://github.com/LuanRT/YouTube.js/issues/693)) ([a5f6209](https://github.com/LuanRT/YouTube.js/commit/a5f62093a18705fc822abd86beaa81788b6535ce))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **parser:** ignore MiniGameCardView node ([#692](https://github.com/LuanRT/YouTube.js/issues/692)) ([6d0bc89](https://github.com/LuanRT/YouTube.js/commit/6d0bc89be18f27a8ce74517f5cab5020d6790328))
|
||||
* **parser:** ThumbnailView background color ([#686](https://github.com/LuanRT/YouTube.js/issues/686)) ([0f8f92a](https://github.com/LuanRT/YouTube.js/commit/0f8f92a28a5b6143e890626b22ce570730a0cf09))
|
||||
* **Player:** Bump cache version ([#702](https://github.com/LuanRT/YouTube.js/issues/702)) ([6765f4e](https://github.com/LuanRT/YouTube.js/commit/6765f4e0d791c657fc7411e9cdd2c0f9284e9982))
|
||||
* **Player:** Fix extracting the n-token decipher algorithm again ([#701](https://github.com/LuanRT/YouTube.js/issues/701)) ([3048f70](https://github.com/LuanRT/YouTube.js/commit/3048f70f60756884bd7b591d770f7b6343cfa259))
|
||||
|
||||
## [10.1.0](https://github.com/LuanRT/YouTube.js/compare/v10.0.0...v10.1.0) (2024-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Session:** Add `configInfo` to InnerTube context ([5a8fd3a](https://github.com/LuanRT/YouTube.js/commit/5a8fd3ad37bce1decad28ec3727453ddd430a561))
|
||||
* **toDash:** Add option to include WebVTT or TTML captions ([#673](https://github.com/LuanRT/YouTube.js/issues/673)) ([bd9f6ac](https://github.com/LuanRT/YouTube.js/commit/bd9f6ac64ca9ba96e856aabe5fcc175fd9c294dc))
|
||||
* **toDash:** Add the "dub" role to translated captions ([#677](https://github.com/LuanRT/YouTube.js/issues/677)) ([858cdd1](https://github.com/LuanRT/YouTube.js/commit/858cdd197cb2bb1e1d7a7285966cb56043ad8961))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **FormatUtils:** Throw an error if download requests fails ([a19511d](https://github.com/LuanRT/YouTube.js/commit/a19511de24bb82007aab072844efe64bbb8698da))
|
||||
* **InfoPanelContent:** Update InfoPanelContent node to also support `paragraphs` ([4cbaa79](https://github.com/LuanRT/YouTube.js/commit/4cbaa7983f35a82b9907197769672ac3b300bfbe))
|
||||
* **Player:** Fix extracting the n-token decipher algorithm ([#682](https://github.com/LuanRT/YouTube.js/issues/682)) ([142a7d0](https://github.com/LuanRT/YouTube.js/commit/142a7d042885188605bdc0655d3733502d1e20fa))
|
||||
* **proto:** Update `Context` message ([62ac2f6](https://github.com/LuanRT/YouTube.js/commit/62ac2f6f32d35fec3c31b5f5d556bd4569fa49f9)), closes [#681](https://github.com/LuanRT/YouTube.js/issues/681)
|
||||
* **Session:** Round UTC offset minutes ([84f90aa](https://github.com/LuanRT/YouTube.js/commit/84f90aaf2908ecacb9dfb6ce5497c4c4d14a72c3))
|
||||
* **toDash:** Fix image representations not being spec compliant ([#672](https://github.com/LuanRT/YouTube.js/issues/672)) ([e5aab9a](https://github.com/LuanRT/YouTube.js/commit/e5aab9a9b35f0752cd5ca50bfa25936dce4718c6))
|
||||
* **YTMusic:** Add support for new header layouts ([14c3a06](https://github.com/LuanRT/YouTube.js/commit/14c3a06d402989e98a9d32c79b2dc26f74fb0219))
|
||||
|
||||
## [10.0.0](https://github.com/LuanRT/YouTube.js/compare/v9.4.0...v10.0.0) (2024-06-09)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **Innertube#getPlaylists:** Return a `Feed` instance instead of items
|
||||
* **OAuth2:** Rewrite auth module ([#661](https://github.com/LuanRT/YouTube.js/issues/661))
|
||||
|
||||
### Features
|
||||
|
||||
* **Format:** Add `is_drc` ([#656](https://github.com/LuanRT/YouTube.js/issues/656)) ([6bb2086](https://github.com/LuanRT/YouTube.js/commit/6bb2086875d089f47c5f86ce94db9e32cb051319))
|
||||
* **Platform:** Add support for `react-native` platform ([#593](https://github.com/LuanRT/YouTube.js/issues/593)) ([2980a60](https://github.com/LuanRT/YouTube.js/commit/2980a608b67f18416d7f73f1bdbcf4b897307b26))
|
||||
* **Session:** Add `enable_session_cache` option ([#664](https://github.com/LuanRT/YouTube.js/issues/664)) ([7953296](https://github.com/LuanRT/YouTube.js/commit/795329658033652625d2d61b275ccf703573a437))
|
||||
* **toDash:** Add support for stable volume/DRC ([#662](https://github.com/LuanRT/YouTube.js/issues/662)) ([031ffb6](https://github.com/LuanRT/YouTube.js/commit/031ffb696e3b7e160779e8b55a49b0cfa9f95620))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ButtonView:** Rename `type` property to `button_type` ([15f3b5f](https://github.com/LuanRT/YouTube.js/commit/15f3b5fdba17f11cddada168de268546875e48f9))
|
||||
* **Cache:** Use `TextEncoder` to encode compressed data ([384b80e](https://github.com/LuanRT/YouTube.js/commit/384b80ee41d7547a00d8dc17c50c8542629264b5))
|
||||
* **FlexibleActionsView:** Update actions array type to include `ToggleButtonView` ([040a091](https://github.com/LuanRT/YouTube.js/commit/040a09163903b914f546d5083dbfdeab7175b24c))
|
||||
* **InfoPanelContainer:** Use new attributed text prop ([5cdb9e1](https://github.com/LuanRT/YouTube.js/commit/5cdb9e1e2fa4ad5abdb3659bb35d0b3edc60123c))
|
||||
* **ItemSection:** Fix `target_id` not being set because of a typo. ([#655](https://github.com/LuanRT/YouTube.js/issues/655)) ([8106654](https://github.com/LuanRT/YouTube.js/commit/810665407e91b2890a8e555fd759d67ccd800379))
|
||||
* **MusicResponsiveHeader:** Add `Text` import ([583fd9f](https://github.com/LuanRT/YouTube.js/commit/583fd9f8d70735d071b34bd1d68faa62eeac593a))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **general:** Add session cache and LZW compression ([#663](https://github.com/LuanRT/YouTube.js/issues/663)) ([cf29664](https://github.com/LuanRT/YouTube.js/commit/cf29664d376ff792602400ef9a4ac301c676735c))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **Innertube#getPlaylists:** Return a `Feed` instance instead of items ([7660450](https://github.com/LuanRT/YouTube.js/commit/766045049d7154866e6fe32f6d965025d736d77d))
|
||||
* **OAuth2:** Rewrite auth module ([#661](https://github.com/LuanRT/YouTube.js/issues/661)) ([b6ce5f9](https://github.com/LuanRT/YouTube.js/commit/b6ce5f903fa2285cb381d73aedf02cc5e2712478))
|
||||
|
||||
## [9.4.0](https://github.com/LuanRT/YouTube.js/compare/v9.3.0...v9.4.0) (2024-04-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Format:** Add `projection_type` and `stereo_layout` ([#643](https://github.com/LuanRT/YouTube.js/issues/643)) ([064436c](https://github.com/LuanRT/YouTube.js/commit/064436cef30e892d8f569d4f7b146557fd72b09f))
|
||||
* **Format:** Add `spatial_audio_type` ([#647](https://github.com/LuanRT/YouTube.js/issues/647)) ([0ba8c54](https://github.com/LuanRT/YouTube.js/commit/0ba8c54257b068d7e4518c982396acb42f1dd41d))
|
||||
* **Parser:** Add `MusicResponsiveHeader` node ([ea82bea](https://github.com/LuanRT/YouTube.js/commit/ea82beaa10f6c877d6dd3102e10f6ae382560e0f))
|
||||
|
||||
## [9.3.0](https://github.com/LuanRT/YouTube.js/compare/v9.2.1...v9.3.0) (2024-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **CommentView:** Implement comment interaction methods ([1c08bfe](https://github.com/LuanRT/YouTube.js/commit/1c08bfe113804c69fbc4e49b42442d9a63d73be6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **CommentThread:** Replies not being parsed correctly ([66e34f9](https://github.com/LuanRT/YouTube.js/commit/66e34f9388429a2088d5c5835d19eebdc881c957))
|
||||
|
||||
## [9.2.1](https://github.com/LuanRT/YouTube.js/compare/v9.2.0...v9.2.1) (2024-04-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **toDash:** Add missing transfer characteristics for h264 streams ([#631](https://github.com/LuanRT/YouTube.js/issues/631)) ([0107049](https://github.com/LuanRT/YouTube.js/commit/010704929fa4b737f68a5a1f10bf0b166cfbf905))
|
||||
|
||||
## [9.2.0](https://github.com/LuanRT/YouTube.js/compare/v9.1.0...v9.2.0) (2024-03-31)
|
||||
|
||||
|
||||
|
||||
86
README.md
86
README.md
@@ -10,82 +10,55 @@
|
||||
[twitter]: https://twitter.com/thesciencephile
|
||||
[discord]: https://discord.gg/syDu7Yks54
|
||||
|
||||
<h1 align=center>YouTube.js</h1>
|
||||
|
||||
<p align=center>A full-featured wrapper around the InnerTube API</p>
|
||||
|
||||
<div align="center">
|
||||
<br/>
|
||||
<p>
|
||||
<a href="https://github.com/LuanRT/YouTube.js"><img src="https://luanrt.github.io/assets/img/ytjs.svg" title="youtube.js" alt="YouTube.js' Github Page" width="200" /></a>
|
||||
</p>
|
||||
<p align="center">A full-featured wrapper around the InnerTube API</p>
|
||||
|
||||
[][discord]
|
||||
[][actions]
|
||||
[][versions]
|
||||
[][codefactor]
|
||||
[][npm]
|
||||
[][discord]
|
||||
[][codefactor]
|
||||
|
||||
<h5>
|
||||
Sponsored by <a href="https://serpapi.com"><img src="https://luanrt.github.io/assets/img/serpapi.svg" alt="SerpApi - API to get search engine results with ease." height=35 valign="middle"></a>
|
||||
</h5>
|
||||
<br>
|
||||
[][collaborators]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://serpapi.com" target="_blank">
|
||||
<img width="80" alt="SerpApi" src="https://luanrt.github.io/assets/img/serpapi.svg" />
|
||||
<br>
|
||||
<sub>
|
||||
API to get search engine results with ease.
|
||||
</sub>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<br>
|
||||
<hr>
|
||||
<br>
|
||||
InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform [^1]. This library manages all low-level communication with InnerTube, providing a simple and efficient way to interact with YouTube programmatically. Its design aims to closely emulate an actual client, including the parsing of API responses.
|
||||
|
||||
## Table of Contents
|
||||
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).
|
||||
|
||||
### Table of Contents
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#description">Description</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#getting-started">Getting Started</a>
|
||||
<ul>
|
||||
<li><a href="#prerequisites">Prerequisites</a></li>
|
||||
<li><a href="#installation">Installation</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#prerequisites">Prerequisites</a></li>
|
||||
<li><a href="#installation">Installation</a></li>
|
||||
<li>
|
||||
<a href="#usage">Usage</a>
|
||||
<ul>
|
||||
<li><a href="#browser-usage">Browser Usage</a></li>
|
||||
<li><a href="#caching">Caching</a></li>
|
||||
<li><a href="#api">API</a></li>
|
||||
<li><a href="#extending-the-library">Extending the library</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#extending-the-library">Extending the library</a></li>
|
||||
<li><a href="#contributing">Contributing</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
<li><a href="#disclaimer">Disclaimer</a></li>
|
||||
<li><a href="#license">License</a></li>
|
||||
</ol>
|
||||
|
||||
## Description
|
||||
|
||||
InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform [^1]. This library manages all low-level communication with InnerTube, providing a simple and efficient way to interact with YouTube programmatically. Its design aims to closely emulate an actual client, including the parsing of API responses.
|
||||
|
||||
If you have any questions or need help, feel free to reach out to us on our [Discord server][discord] or open an issue [here](https://github.com/LuanRT/YouTube.js/issues).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
YouTube.js runs on Node.js, Deno, and modern browsers.
|
||||
|
||||
It requires a runtime with the following features:
|
||||
- [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
|
||||
- On Node, we use [undici](https://github.com/nodejs/undici)'s fetch implementation, which requires Node.js 16.8+. If you need to use an older version, you may provide your own fetch implementation. See [providing your own fetch implementation](#custom-fetch) for more information.
|
||||
- The `Response` object returned by fetch must thus be spec compliant and return a `ReadableStream` object if you want to use the `VideoInfo#download` method. (Implementations like `node-fetch` returns a non-standard `Readable` object.)
|
||||
- The `Response` object returned by fetch must thus be spec compliant and return a `ReadableStream` object if you want to use the `VideoInfo#download` method. (Implementations like `node-fetch` return a non-standard `Readable` object.)
|
||||
- [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) and [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) are required.
|
||||
|
||||
### Installation
|
||||
@@ -114,7 +87,7 @@ import { Innertube } from 'youtubei.js';
|
||||
const youtube = await Innertube.create(/* options */);
|
||||
```
|
||||
|
||||
### Initialization Options
|
||||
### Options
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
@@ -126,17 +99,18 @@ const youtube = await Innertube.create(/* options */);
|
||||
| `visitor_data` | `string` | Setting this to a valid and persistent visitor data string will allow YouTube to give this session tailored content even when not logged in. A good way to get a valid one is by either grabbing it from a browser or calling InnerTube's `/visitor_id` endpoint. | `undefined` |
|
||||
| `retrieve_player` | `boolean` | Specifies whether to retrieve the JS player. Disabling this will make session creation faster. **NOTE:** Deciphering formats is not possible without the JS player. | `true` |
|
||||
| `enable_safety_mode` | `boolean` | Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content. | `false` |
|
||||
| `generate_session_locally` | `boolean` | Specifies whether to generate the session data locally or retrieve it from YouTube. This can be useful if you need more performance. | `false` |
|
||||
| `generate_session_locally` | `boolean` | Specifies whether to generate the session data locally or retrieve it from YouTube. This can be useful if you need more performance. **NOTE:** If you are using the cache option and a session has already been generated, this will be ignored. If you want to force a new session to be generated, you must clear the cache or disable session caching. | `false` |
|
||||
| `enable_session_cache` | `boolean` | Specifies whether to cache the session data. | `true` |
|
||||
| `device_category` | `DeviceCategory` | Platform to use for the session. | `DESKTOP` |
|
||||
| `client_type` | `ClientType` | InnerTube client type. | `WEB` |
|
||||
| `timezone` | `string` | The time zone. | `*` |
|
||||
| `cache` | `ICache` | Used to cache the deciphering functions from the JS player. | `undefined` |
|
||||
| `cache` | `ICache` | Used to cache algorithms, session data, and OAuth2 tokens. | `undefined` |
|
||||
| `cookie` | `string` | YouTube cookies. | `undefined` |
|
||||
| `fetch` | `FetchFunction` | Fetch function to use. | `fetch` |
|
||||
|
||||
</details>
|
||||
|
||||
## Browser Usage
|
||||
### 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 at [`examples/browser/proxy/deno.ts`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/proxy/deno.ts).
|
||||
|
||||
You may provide your own fetch implementation to be used by YouTube.js, which we will use to modify and send the requests through a proxy. See [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/web) for a simple example using [Vite](https://vitejs.dev/).
|
||||
@@ -195,7 +169,7 @@ A fully working example can be found in [`examples/browser/web`](https://github.
|
||||
|
||||
<a name="custom-fetch"></a>
|
||||
|
||||
## Providing your own fetch implementation
|
||||
### Providing your own fetch implementation
|
||||
You may provide your own fetch implementation to be used by YouTube.js. This can be useful in some cases to modify the requests before they are sent and transform the responses before they are returned (eg. for proxies).
|
||||
```ts
|
||||
// provide a fetch implementation
|
||||
@@ -212,7 +186,7 @@ const yt = await Innertube.create({
|
||||
|
||||
<a name="caching"></a>
|
||||
|
||||
## Caching
|
||||
### Caching
|
||||
Caching the transformed player instance can greatly improve the performance. Our `UniversalCache` implementation uses different caching methods depending on the environment.
|
||||
|
||||
In Node.js, we use the `node:fs` module, `Deno.writeFile()` in Deno, and `indexedDB` in browsers.
|
||||
@@ -237,7 +211,7 @@ const yt = await Innertube.create({
|
||||
});
|
||||
```
|
||||
|
||||
## API
|
||||
### API
|
||||
|
||||
* `Innertube`
|
||||
|
||||
@@ -697,7 +671,7 @@ Utility to call navigation endpoints.
|
||||
| endpoint | `NavigationEndpoint` | The target endpoint |
|
||||
| args? | `object` | Additional payload arguments |
|
||||
|
||||
## Extending the library
|
||||
### Extending the library
|
||||
|
||||
YouTube.js is modular and easy to extend. Most of the 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.
|
||||
|
||||
@@ -805,6 +779,6 @@ As such, any usage of trademarks to refer to such services is considered nominat
|
||||
## License
|
||||
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
|
||||
|
||||
<p align=" right">
|
||||
<p align="right">
|
||||
(<a href="#top">back to top</a>)
|
||||
</p>
|
||||
</p>
|
||||
@@ -8,22 +8,21 @@ Just like the official Data API, YouTube.js supports using your own OAuth2 crede
|
||||
The library supports signing in using YouTube TV's client id. This is the recommended way to sign in as it doesn't require you to create your own OAuth2 credentials.
|
||||
|
||||
```js
|
||||
// 'auth-pending' is fired with the info needed to sign in via OAuth.
|
||||
|
||||
// Fired when waiting for the user to authorize the sign in attempt.
|
||||
yt.session.on('auth-pending', (data) => {
|
||||
// data.verification_url contains the URL to visit to authenticate.
|
||||
// data.user_code contains the code to enter on the website.
|
||||
// data.verification_url contains the authorization URL.
|
||||
// data.user_code contains the code to enter on the website.
|
||||
});
|
||||
|
||||
// 'auth' is fired once the authentication is complete
|
||||
// Fired when authentication is successful.
|
||||
yt.session.on('auth', ({ credentials }) => {
|
||||
// do something with the credentials, eg; save them in a database.
|
||||
// Do something with the credentials, eg; save them in a database.
|
||||
console.log('Sign in successful');
|
||||
});
|
||||
|
||||
// 'update-credentials' is fired when the access token expires, if you do not save the updated credentials any subsequent request will fail
|
||||
yt.session.on('update-credentials', ({ credentials }) => {
|
||||
// do something with the updated credentials
|
||||
});
|
||||
// Fired when the access token expires.
|
||||
yt.session.on('update-credentials', ({ credentials }) => { /** do something with the updated credentials. */ });
|
||||
|
||||
await yt.session.signIn(/* credentials */);
|
||||
```
|
||||
@@ -56,7 +55,7 @@ await yt.session.oauth.removeCache();
|
||||
# Cookies
|
||||
|
||||
> **Note**
|
||||
> This is not as reliable as OAuth2 as cookies expire and can be completely revoked at any time.
|
||||
> This is not as reliable as OAuth2. Cookies can expire and are not very secure.
|
||||
|
||||
```js
|
||||
const yt = await Innertube.create({
|
||||
|
||||
@@ -111,9 +111,11 @@ app.get('/login', async (req, res) => {
|
||||
await innertube.session.signIn({
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires: new Date(tokens.expiry_date),
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
expiry_date: new Date(tokens.expiry_date).toISOString(),
|
||||
client: {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret
|
||||
}
|
||||
});
|
||||
|
||||
await innertube.session.oauth.cacheCredentials();
|
||||
|
||||
@@ -6,17 +6,17 @@ import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
cache: new UniversalCache(false)
|
||||
});
|
||||
|
||||
// 'auth-pending' is fired with the info needed to sign in via OAuth.
|
||||
// Fired when waiting for the user to authorize the sign in attempt.
|
||||
yt.session.on('auth-pending', (data) => {
|
||||
console.log(`Go to ${data.verification_url} in your browser and enter code ${data.user_code} to authenticate.`);
|
||||
});
|
||||
|
||||
// 'auth' is fired once the authentication is complete
|
||||
// Fired when authentication is successful.
|
||||
yt.session.on('auth', ({ credentials }) => {
|
||||
console.log('Sign in successful:', credentials);
|
||||
});
|
||||
|
||||
// 'update-credentials' is fired when the access token expires, if you do not save the updated credentials any subsequent request will fail
|
||||
// Fired when the access token expires.
|
||||
yt.session.on('update-credentials', async ({ credentials }) => {
|
||||
console.log('Credentials updated:', credentials);
|
||||
await yt.session.oauth.cacheCredentials();
|
||||
|
||||
@@ -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, Referer',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-origin, x-youtube-client-version, x-youtube-client-name, x-goog-api-format-version, x-user-agent, Accept-Language, Range, Referer',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
}),
|
||||
@@ -45,6 +45,7 @@ const handler = async (request: Request): Promise<Response> => {
|
||||
JSON.parse(url.searchParams.get('__headers') || '{}'),
|
||||
);
|
||||
copyHeader('range', request_headers, request.headers);
|
||||
|
||||
!request_headers.has('user-agent') && copyHeader('user-agent', request_headers, request.headers);
|
||||
url.searchParams.delete('__headers');
|
||||
|
||||
|
||||
@@ -141,7 +141,8 @@ async function main() {
|
||||
if (shaka.Player.isBrowserSupported()) {
|
||||
videoEl.poster = info.basic_info.thumbnail![0].url;
|
||||
|
||||
player = new shaka.Player(videoEl);
|
||||
player = new shaka.Player();
|
||||
await player.attach(videoEl);
|
||||
ui = new shaka.ui.Overlay(player, shakaContainer, videoEl);
|
||||
|
||||
const config = {
|
||||
@@ -194,6 +195,7 @@ async function main() {
|
||||
request.headers = {};
|
||||
url.searchParams.set("range", headers.Range.split("=")[1]);
|
||||
url.searchParams.set("alr", "yes");
|
||||
delete headers.Range;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,14 +209,12 @@ async function main() {
|
||||
|
||||
player.getNetworkingEngine()?.registerResponseFilter(async (type: any, response: any) => {
|
||||
const dataView = new DataView(response.data);
|
||||
|
||||
|
||||
if (response.data.byteLength < 4 ||
|
||||
dataView.getUint32(0) != HTTP_IN_HEX) {
|
||||
// This doesn't start with "http", so it is not an ALR.
|
||||
return;
|
||||
}
|
||||
|
||||
// Interpret the response data as a URL string.
|
||||
const response_as_string = shaka.util.StringUtils.fromUTF8(response.data);
|
||||
|
||||
let retry_parameters;
|
||||
|
||||
@@ -5,8 +5,8 @@ A `CommentThread` represents a top-level comment and its replies.
|
||||
## API
|
||||
|
||||
* CommentThread
|
||||
* [.comment](#comment) ⇒ `Comment`
|
||||
* [.replies](#replies) ⇒ `Comment[]`
|
||||
* [.comment](#comment) ⇒ `Comment | CommentView`
|
||||
* [.replies](#replies) ⇒ `(Comment | CommentView)[]`
|
||||
* [.getReplies](#getreplies) ⇒ `function`
|
||||
* [.getContinuation](#getcontinuation) ⇒ `function`
|
||||
* [.has_continuation](#hascontinuation) ⇒ `boolean`
|
||||
@@ -14,7 +14,7 @@ A `CommentThread` represents a top-level comment and its replies.
|
||||
|
||||
<a name="comment"></a>
|
||||
### comment
|
||||
The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
|
||||
The top-level comment. **Note:** More about the `Comment` node [here](./Comment.md) (OUTDATED! `Comment` has been replaced by [`CommentView`](./CommentView.md) nodes).
|
||||
|
||||
**Type:** [`Comment`](../../src/parser/classes/comments/Comment.ts)
|
||||
|
||||
@@ -22,7 +22,7 @@ The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
|
||||
### replies
|
||||
An array of replies to the top-level comment. (not populated until [`getReplies()`](#getreplies) is called).
|
||||
|
||||
**Type:** [`Comment[]`](../../src/parser/classes/comments/Comment.ts)
|
||||
**Type:** [`(Comment | CommentView)[]`](../../src/parser/classes/comments/Comment.ts)
|
||||
|
||||
<a name="getreplies"></a>
|
||||
### getReplies()
|
||||
|
||||
48
examples/comments/CommentView.md
Normal file
48
examples/comments/CommentView.md
Normal file
@@ -0,0 +1,48 @@
|
||||
## CommentView
|
||||
Contains information about a single comment. A [`CommentView`](../../src/parser/classes/comments/CommentView.ts) can be a top-level comment or a reply to a top-level comment.
|
||||
|
||||
## API
|
||||
|
||||
* Comment
|
||||
* [.like](#like) ⇒ `function`
|
||||
* [.unlike](#like) ⇒ `function`
|
||||
* [.dislike](#dislike) ⇒ `function`
|
||||
* [.undislike](#dislike) ⇒ `function`
|
||||
* [.reply](#reply) ⇒ `function`
|
||||
* [.translate](#translate) ⇒ `function`
|
||||
|
||||
<a name="like"></a>
|
||||
### like()
|
||||
Likes the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="unlike"></a>
|
||||
### unlike()
|
||||
Unlikes the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="dislike"></a>
|
||||
### dislike()
|
||||
Dislikes the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="undislike"></a>
|
||||
### undislike()
|
||||
Undislikes the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="reply"></a>
|
||||
### reply(comment_text: string)
|
||||
Replies to the comment.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
<a name="translate"></a>
|
||||
### translate(target_language: string)
|
||||
Translates the comment to the given language.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse & { content?: string }>`
|
||||
@@ -59,7 +59,4 @@ Returns whether there are more comments to be fetched.
|
||||
### page
|
||||
Returns original InnerTube response (sanitized).
|
||||
|
||||
**Returns:** `ParsedResponse`
|
||||
|
||||
## Example
|
||||
See [`index.ts`](./index.ts).
|
||||
**Returns:** `ParsedResponse`
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
|
||||
|
||||
const comment_section = await yt.getComments('a-rqu-hjobc');
|
||||
|
||||
console.info(`This video has ${comment_section.header?.comments_count.toString() || 'N/A'} comments.\n`);
|
||||
|
||||
for (const thread of comment_section.contents) {
|
||||
const comment = thread.comment;
|
||||
|
||||
if (comment) {
|
||||
console.info(
|
||||
`${comment.is_pinned ? '[Pinned]' : ''}`,
|
||||
`${comment.is_member ? `${comment.sponsor_comment_badge?.tooltip}` : ''}`,
|
||||
`${comment.author.name} • ${comment.published}\n`,
|
||||
`${comment.content.toString()}`, '\n',
|
||||
`Likes: ${comment.vote_count}`, '\n'
|
||||
);
|
||||
|
||||
if (thread.has_replies) {
|
||||
console.info('Replies:', '\n');
|
||||
|
||||
let comment_thread = await thread.getReplies();
|
||||
|
||||
while (true) {
|
||||
for (const reply of comment_thread?.replies || []) {
|
||||
console.info(
|
||||
`> ${reply.author.name} • ${reply.published}\n`,
|
||||
`${reply.content.toString()}`, '\n',
|
||||
`Likes: ${reply.vote_count}`, '\n'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
comment_thread = await comment_thread.getContinuation();
|
||||
} catch { break; };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
}
|
||||
})();
|
||||
@@ -1,17 +1,10 @@
|
||||
|
||||
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
export default {
|
||||
projects: [
|
||||
{
|
||||
displayName: 'node',
|
||||
roots: [ '<rootDir>/test' ],
|
||||
testTimeout: 10000,
|
||||
transform: {
|
||||
"^.+\\.(ts|tsx)$": "ts-jest",
|
||||
},
|
||||
moduleFileExtensions: ["ts", "tsx", "js"],
|
||||
testMatch: [ '**/*.test.ts' ],
|
||||
setupFiles: []
|
||||
}
|
||||
]
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
transform: { '^.+\\.(ts|tsx)$': 'ts-jest' },
|
||||
testTimeout: 30000,
|
||||
moduleFileExtensions: [ 'ts', 'tsx', 'js' ],
|
||||
testMatch: [ '**/*.test.ts' ],
|
||||
setupFiles: []
|
||||
};
|
||||
8088
package-lock.json
generated
8088
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "9.2.0",
|
||||
"version": "10.2.0",
|
||||
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
|
||||
"type": "module",
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
@@ -12,6 +12,9 @@
|
||||
"web": [
|
||||
"./dist/src/platform/lib.d.ts"
|
||||
],
|
||||
"react-native": [
|
||||
"./dist/src/platform/lib.d.ts"
|
||||
],
|
||||
"web.bundle": [
|
||||
"./dist/src/platform/lib.d.ts"
|
||||
],
|
||||
@@ -32,6 +35,7 @@
|
||||
"deno": "./dist/src/platform/deno.js",
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"browser": "./dist/src/platform/web.js",
|
||||
"react-native": "./dist/src/platform/react-native.js",
|
||||
"default": "./dist/src/platform/web.js"
|
||||
},
|
||||
"./agnostic": {
|
||||
@@ -42,6 +46,10 @@
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"default": "./dist/src/platform/web.js"
|
||||
},
|
||||
"./react-native": {
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"default": "./dist/src/platform/react-native.js"
|
||||
},
|
||||
"./web.bundle": {
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"default": "./bundle/browser.js"
|
||||
@@ -75,13 +83,15 @@
|
||||
"test": "npx jest --verbose",
|
||||
"lint": "npx eslint ./src",
|
||||
"lint:fix": "npx eslint --fix ./src",
|
||||
"build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod && npm run bundle:cf-worker",
|
||||
"build:parser-map": "node ./scripts/gen-parser-map.mjs",
|
||||
"clean": "npx rimraf ./dist/src ./dist/package.json ./bundle/browser.js ./bundle/browser.js.map ./bundle/browser.min.js ./bundle/browser.min.js.map ./bundle/node.cjs ./bundle/node.cjs.map ./bundle/cf-worker.js ./bundle/cf-worker.js.map ./bundle/react-native.js ./bundle/react-native.js.map ./deno",
|
||||
"build": "npm run clean && npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod && npm run bundle:cf-worker && npm run bundle:react-native",
|
||||
"build:parser-map": "node ./dev-scripts/gen-parser-map.mjs",
|
||||
"build:proto": "npx pb-gen-ts --entry-path=\"src/proto\" --out-dir=\"src/proto/generated\" --ext-in-import=\".js\"",
|
||||
"build:esm": "npx tspc",
|
||||
"build:deno": "npx cpy ./src ./deno && npx esbuild ./src/utils/DashManifest.tsx --keep-names --format=esm --platform=neutral --target=es2020 --outfile=./deno/src/utils/DashManifest.js && npx cpy ./package.json ./deno && npx replace \".js';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'./DashManifest.ts';\" \"'./DashManifest.js';\" ./deno -r && npx replace \"'jintr';\" \"'https://esm.sh/jintr';\" ./deno -r",
|
||||
"bundle:node": "npx esbuild ./dist/src/platform/node.js --bundle --target=node10 --keep-names --format=cjs --platform=node --outfile=./bundle/node.cjs --external:jintr --external:undici --external:linkedom --external:tslib --sourcemap --banner:js=\"/* eslint-disable */\"",
|
||||
"bundle:browser": "npx esbuild ./dist/src/platform/web.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/browser.js --platform=browser",
|
||||
"bundle:react-native": "npx esbuild ./dist/src/platform/react-native.js --bundle --target=es2020 --keep-names --format=esm --platform=neutral --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/react-native.js",
|
||||
"bundle:browser:prod": "npm run bundle:browser -- --outfile=./bundle/browser.min.js --minify",
|
||||
"bundle:cf-worker": "npx esbuild ./dist/src/platform/cf-worker.js --banner:js=\"/* eslint-disable */\" --bundle --target=es2020 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/cf-worker.js --platform=node",
|
||||
"prepare": "npm run build",
|
||||
@@ -93,7 +103,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jintr": "^1.1.0",
|
||||
"jintr": "^2.0.0",
|
||||
"tslib": "^2.5.0",
|
||||
"undici": "^5.19.1"
|
||||
},
|
||||
@@ -111,10 +121,10 @@
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.16",
|
||||
"glob": "^8.0.3",
|
||||
"jest": "^28.1.3",
|
||||
"jest": "^29.7.0",
|
||||
"pbkit": "^0.0.59",
|
||||
"replace": "^1.2.2",
|
||||
"ts-jest": "^28.0.8",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-patch": "^3.0.2",
|
||||
"ts-transformer-inline-file": "^0.2.0",
|
||||
"typescript": "^5.0.0"
|
||||
|
||||
@@ -325,6 +325,16 @@ export default class Innertube {
|
||||
return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves playlists.
|
||||
*/
|
||||
async getPlaylists(): Promise<Feed<IBrowseResponse>> {
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEplaylist_aggregation' }), parse: true }
|
||||
);
|
||||
return new Feed(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves playlist contents.
|
||||
* @param id - Playlist id
|
||||
|
||||
@@ -29,14 +29,10 @@ export type ParsedResponse<T> =
|
||||
IParsedResponse;
|
||||
|
||||
export default class Actions {
|
||||
#session: Session;
|
||||
session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
get session(): Session {
|
||||
return this.#session;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +65,7 @@ export default class Actions {
|
||||
s_url.searchParams.set(key, params[key]);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(s_url);
|
||||
const response = await this.session.http.fetch(s_url);
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -88,7 +84,7 @@ export default class Actions {
|
||||
data = { ...args };
|
||||
|
||||
if (Reflect.has(data, 'browseId')) {
|
||||
if (this.#needsLogin(data.browseId) && !this.#session.logged_in)
|
||||
if (this.#needsLogin(data.browseId) && !this.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
}
|
||||
|
||||
@@ -131,7 +127,7 @@ export default class Actions {
|
||||
|
||||
const target_endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : endpoint;
|
||||
|
||||
const response = await this.#session.http.fetch(target_endpoint, {
|
||||
const response = await this.session.http.fetch(target_endpoint, {
|
||||
method: 'POST',
|
||||
body: args?.protobuf ? data : JSON.stringify((data || {})),
|
||||
headers: {
|
||||
@@ -168,6 +164,7 @@ export default class Actions {
|
||||
'FEhistory',
|
||||
'FEsubscriptions',
|
||||
'FEchannels',
|
||||
'FEplaylist_aggregation',
|
||||
'FEmusic_listening_review',
|
||||
'FEmusic_library_landing',
|
||||
'SPaccount_overview',
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import { Log, Constants } from '../utils/index.js';
|
||||
import { OAuthError, Platform } from '../utils/Utils.js';
|
||||
import type Session from './Session.js';
|
||||
|
||||
/**
|
||||
* Represents the credentials used for authentication.
|
||||
*/
|
||||
export interface Credentials {
|
||||
/**
|
||||
* Token used to sign in.
|
||||
*/
|
||||
access_token: string;
|
||||
/**
|
||||
* Token used to get a new access token.
|
||||
*/
|
||||
refresh_token: string;
|
||||
/**
|
||||
* Access token's expiration date, which is usually 24hrs-ish.
|
||||
*/
|
||||
expires: Date;
|
||||
/**
|
||||
* Optional client ID.
|
||||
*/
|
||||
client_id?: string;
|
||||
/**
|
||||
* Optional client secret.
|
||||
*/
|
||||
client_secret?: string;
|
||||
}
|
||||
|
||||
// TODO: actual type info for this.
|
||||
export type OAuthAuthPendingData = any;
|
||||
|
||||
export type OAuthAuthEventHandler = (data: {
|
||||
credentials: Credentials;
|
||||
status: 'SUCCESS';
|
||||
}) => any;
|
||||
|
||||
export type OAuthAuthPendingEventHandler = (data: OAuthAuthPendingData) => any;
|
||||
export type OAuthAuthErrorEventHandler = (err: OAuthError) => any;
|
||||
|
||||
export type OAuthClientIdentity = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
};
|
||||
|
||||
export default class OAuth {
|
||||
static TAG = 'OAuth';
|
||||
|
||||
#identity?: Record<string, string>;
|
||||
#session: Session;
|
||||
#credentials?: Credentials;
|
||||
#polling_interval = 5;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the auth flow in case no valid credentials are available.
|
||||
*/
|
||||
async init(credentials?: Credentials): Promise<void> {
|
||||
this.#credentials = credentials;
|
||||
|
||||
if (this.validateCredentials()) {
|
||||
if (!this.has_access_token_expired)
|
||||
this.#session.emit('auth', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
} else if (!(await this.#loadCachedCredentials())) {
|
||||
await this.#getUserCode();
|
||||
}
|
||||
}
|
||||
|
||||
async cacheCredentials(): Promise<void> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(JSON.stringify(this.#credentials));
|
||||
await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer);
|
||||
}
|
||||
|
||||
async #loadCachedCredentials(): Promise<boolean> {
|
||||
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
|
||||
if (!data) return false;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const credentials = JSON.parse(decoder.decode(data));
|
||||
|
||||
this.#credentials = {
|
||||
access_token: credentials.access_token,
|
||||
refresh_token: credentials.refresh_token,
|
||||
client_id: credentials.client_id,
|
||||
client_secret: credentials.client_secret,
|
||||
expires: new Date(credentials.expires)
|
||||
};
|
||||
|
||||
this.#session.emit('auth', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeCache(): Promise<void> {
|
||||
await this.#session.cache?.remove('youtubei_oauth_credentials');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server for a user code and verification URL.
|
||||
*/
|
||||
async #getUserCode(): Promise<void> {
|
||||
this.#identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: this.#identity.client_id,
|
||||
scope: Constants.OAUTH.SCOPE,
|
||||
device_id: Platform.shim.uuidv4(),
|
||||
device_model: Constants.OAUTH.MODEL_NAME
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const response_data = await response.json();
|
||||
|
||||
this.#session.emit('auth-pending', response_data);
|
||||
this.#polling_interval = response_data.interval;
|
||||
this.#startPolling(response_data.device_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the authorization server until access is granted by the user.
|
||||
*/
|
||||
#startPolling(device_code: string): void {
|
||||
const poller = setInterval(async () => {
|
||||
const data = {
|
||||
...this.#identity,
|
||||
code: device_code,
|
||||
grant_type: Constants.OAUTH.GRANT_TYPE
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const response_data = await response.json();
|
||||
|
||||
if (response_data.error) {
|
||||
switch (response_data.error) {
|
||||
case 'access_denied':
|
||||
this.#session.emit('auth-error', new OAuthError('Access was denied.', { status: 'ACCESS_DENIED' }));
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.#session.emit('auth-error', new OAuthError('The device code has expired, restarting auth flow.', { status: 'DEVICE_CODE_EXPIRED' }));
|
||||
clearInterval(poller);
|
||||
this.#getUserCode();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
|
||||
|
||||
this.#credentials = {
|
||||
access_token: response_data.access_token,
|
||||
refresh_token: response_data.refresh_token,
|
||||
client_id: this.#identity?.client_id,
|
||||
client_secret: this.#identity?.client_secret,
|
||||
expires: expiration_date
|
||||
};
|
||||
|
||||
this.#session.emit('auth', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
|
||||
clearInterval(poller);
|
||||
} catch (err) {
|
||||
clearInterval(poller);
|
||||
return this.#session.emit('auth-error', new OAuthError('Could not obtain user code.', { status: 'FAILED', error: err }));
|
||||
}
|
||||
}, this.#polling_interval * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token if the same has expired.
|
||||
*/
|
||||
async refreshIfRequired(): Promise<void> {
|
||||
if (this.has_access_token_expired) {
|
||||
await this.#refreshAccessToken();
|
||||
}
|
||||
}
|
||||
|
||||
async #refreshAccessToken(): Promise<void> {
|
||||
if (!this.#credentials) return;
|
||||
this.#identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
...this.#identity,
|
||||
refresh_token: this.#credentials.refresh_token,
|
||||
grant_type: 'refresh_token'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const response_data = await response.json();
|
||||
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
|
||||
|
||||
this.#credentials = {
|
||||
access_token: response_data.access_token,
|
||||
refresh_token: response_data.refresh_token || this.#credentials.refresh_token,
|
||||
client_id: this.#identity.client_id,
|
||||
client_secret: this.#identity.client_secret,
|
||||
expires: expiration_date
|
||||
};
|
||||
|
||||
this.#session.emit('update-credentials', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
|
||||
async revokeCredentials(): Promise<Response | undefined> {
|
||||
if (!this.#credentials) return;
|
||||
await this.removeCache();
|
||||
return this.#session.http.fetch_function(new URL(`/o/oauth2/revoke?token=${encodeURIComponent(this.#credentials.access_token)}`, Constants.URLS.YT_BASE), {
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves client identity from YouTube TV.
|
||||
*/
|
||||
async #getClientIdentity(): Promise<OAuthClientIdentity> {
|
||||
if (this.#credentials?.client_id && this.credentials?.client_secret) {
|
||||
Log.info(OAuth.TAG, 'Using custom OAuth2 credentials.\n');
|
||||
return {
|
||||
client_id: this.#credentials.client_id,
|
||||
client_secret: this.credentials.client_secret
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch_function(new URL('/tv', Constants.URLS.YT_BASE), { headers: Constants.OAUTH.HEADERS });
|
||||
|
||||
const response_data = await response.text();
|
||||
const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(response_data)?.[1];
|
||||
|
||||
if (!url_body)
|
||||
throw new OAuthError('Could not obtain script url.', { status: 'FAILED' });
|
||||
|
||||
Log.info(OAuth.TAG, `Got YouTubeTV script URL (${url_body})`);
|
||||
|
||||
const script = await this.#session.http.fetch(url_body, { baseURL: Constants.URLS.YT_BASE });
|
||||
|
||||
const client_identity = (await script.text())
|
||||
.replace(/\n/g, '')
|
||||
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
|
||||
|
||||
const groups = client_identity?.groups as OAuthClientIdentity | null;
|
||||
|
||||
if (!groups)
|
||||
throw new OAuthError('Could not obtain client identity.', { status: 'FAILED' });
|
||||
|
||||
Log.info(OAuth.TAG, 'OAuth2 credentials retrieved.\n', groups);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
get credentials(): Credentials | undefined {
|
||||
return this.#credentials;
|
||||
}
|
||||
|
||||
get has_access_token_expired(): boolean {
|
||||
const timestamp = this.#credentials ? new Date(this.#credentials.expires).getTime() : -Infinity;
|
||||
return new Date().getTime() > timestamp;
|
||||
}
|
||||
|
||||
validateCredentials(): this is this & { credentials: Credentials } {
|
||||
return this.#credentials &&
|
||||
Reflect.has(this.#credentials, 'access_token') &&
|
||||
Reflect.has(this.#credentials, 'refresh_token') &&
|
||||
Reflect.has(this.#credentials, 'expires') || false;
|
||||
}
|
||||
}
|
||||
338
src/core/OAuth2.ts
Normal file
338
src/core/OAuth2.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { OAuth2Error, Platform } from '../utils/Utils.js';
|
||||
import { Log, Constants } from '../utils/index.js';
|
||||
import type Session from './Session.js';
|
||||
|
||||
const TAG = 'OAuth2';
|
||||
|
||||
export type OAuth2ClientID = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
};
|
||||
|
||||
export type OAuth2Tokens = {
|
||||
access_token: string;
|
||||
expiry_date: string;
|
||||
expires_in?: number;
|
||||
refresh_token: string;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
client?: OAuth2ClientID;
|
||||
};
|
||||
|
||||
export type DeviceAndUserCode = {
|
||||
device_code: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
user_code: string;
|
||||
verification_url: string;
|
||||
error_code?: string;
|
||||
};
|
||||
|
||||
export type OAuth2AuthEventHandler = (data: { credentials: OAuth2Tokens; }) => void;
|
||||
export type OAuth2AuthPendingEventHandler = (data: DeviceAndUserCode) => void;
|
||||
export type OAuth2AuthErrorEventHandler = (err: OAuth2Error) => void;
|
||||
|
||||
export default class OAuth2 {
|
||||
#session: Session;
|
||||
|
||||
YTTV_URL: URL;
|
||||
AUTH_SERVER_CODE_URL: URL;
|
||||
AUTH_SERVER_TOKEN_URL: URL;
|
||||
AUTH_SERVER_REVOKE_TOKEN_URL: URL;
|
||||
|
||||
client_id: OAuth2ClientID | undefined;
|
||||
oauth2_tokens: OAuth2Tokens | undefined;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
this.YTTV_URL = new URL('/tv', Constants.URLS.YT_BASE);
|
||||
this.AUTH_SERVER_CODE_URL = new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE);
|
||||
this.AUTH_SERVER_TOKEN_URL = new URL('/o/oauth2/token', Constants.URLS.YT_BASE);
|
||||
this.AUTH_SERVER_REVOKE_TOKEN_URL = new URL('/o/oauth2/revoke', Constants.URLS.YT_BASE);
|
||||
}
|
||||
|
||||
async init(tokens?: OAuth2Tokens): Promise<void> {
|
||||
if (tokens) {
|
||||
this.setTokens(tokens);
|
||||
|
||||
if (this.shouldRefreshToken()) {
|
||||
await this.refreshAccessToken();
|
||||
}
|
||||
|
||||
this.#session.emit('auth', { credentials: this.oauth2_tokens });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded_from_cache = await this.#loadFromCache();
|
||||
|
||||
if (loaded_from_cache) {
|
||||
Log.info(TAG, 'Loaded OAuth2 tokens from cache.', this.oauth2_tokens);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.client_id)
|
||||
this.client_id = await this.getClientID();
|
||||
|
||||
// Initialize OAuth2 flow
|
||||
const device_and_user_code = await this.getDeviceAndUserCode();
|
||||
|
||||
this.#session.emit('auth-pending', device_and_user_code);
|
||||
|
||||
this.pollForAccessToken(device_and_user_code);
|
||||
}
|
||||
|
||||
setTokens(tokens: OAuth2Tokens): void {
|
||||
const tokensMod = tokens;
|
||||
|
||||
// Convert access token remaining lifetime to ISO string
|
||||
if (tokensMod.expires_in) {
|
||||
tokensMod.expiry_date = new Date(Date.now() + tokensMod.expires_in * 1000).toISOString();
|
||||
delete tokensMod.expires_in; // We don't need this anymore
|
||||
}
|
||||
|
||||
if (!this.validateTokens(tokensMod))
|
||||
throw new OAuth2Error('Invalid tokens provided.');
|
||||
|
||||
this.oauth2_tokens = tokensMod;
|
||||
|
||||
if (tokensMod.client) {
|
||||
Log.info(TAG, 'Using provided client id and secret.');
|
||||
this.client_id = tokensMod.client;
|
||||
}
|
||||
}
|
||||
|
||||
async cacheCredentials(): Promise<void> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(JSON.stringify(this.oauth2_tokens));
|
||||
await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer);
|
||||
}
|
||||
|
||||
async #loadFromCache(): Promise<boolean> {
|
||||
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
|
||||
if (!data)
|
||||
return false;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const credentials = JSON.parse(decoder.decode(data));
|
||||
|
||||
this.setTokens(credentials);
|
||||
|
||||
this.#session.emit('auth', { credentials });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeCache(): Promise<void> {
|
||||
await this.#session.cache?.remove('youtubei_oauth_credentials');
|
||||
}
|
||||
|
||||
async pollForAccessToken(device_and_user_code: DeviceAndUserCode): Promise<void> {
|
||||
if (!this.client_id)
|
||||
throw new OAuth2Error('Client ID is missing.');
|
||||
|
||||
const { device_code, interval } = device_and_user_code;
|
||||
const { client_id, client_secret } = this.client_id;
|
||||
|
||||
const payload = {
|
||||
client_id,
|
||||
client_secret,
|
||||
code: device_code,
|
||||
grant_type: 'http://oauth.net/grant_type/device/1.0'
|
||||
};
|
||||
|
||||
const connInterval = setInterval(async () => {
|
||||
const response = await this.#http.fetch_function(this.AUTH_SERVER_TOKEN_URL, {
|
||||
body: JSON.stringify(payload),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const response_data = await response.json();
|
||||
|
||||
if (response_data.error) {
|
||||
switch (response_data.error) {
|
||||
case 'access_denied':
|
||||
this.#session.emit('auth-error', new OAuth2Error('Access was denied.', response_data));
|
||||
clearInterval(connInterval);
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.#session.emit('auth-error', new OAuth2Error('The device code has expired.', response_data));
|
||||
clearInterval(connInterval);
|
||||
break;
|
||||
case 'authorization_pending':
|
||||
case 'slow_down':
|
||||
Log.info(TAG, 'Polling for access token...');
|
||||
break;
|
||||
default:
|
||||
this.#session.emit('auth-error', new OAuth2Error('Server returned an unexpected error.', response_data));
|
||||
clearInterval(connInterval);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.setTokens(response_data);
|
||||
|
||||
this.#session.emit('auth', { credentials: this.oauth2_tokens });
|
||||
|
||||
clearInterval(connInterval);
|
||||
}, interval * 1000);
|
||||
}
|
||||
|
||||
async revokeCredentials(): Promise<Response | undefined> {
|
||||
if (!this.oauth2_tokens)
|
||||
throw new OAuth2Error('Access token not found');
|
||||
|
||||
await this.removeCache();
|
||||
|
||||
const url = this.AUTH_SERVER_REVOKE_TOKEN_URL;
|
||||
url.searchParams.set('token', this.oauth2_tokens.access_token);
|
||||
|
||||
return this.#session.http.fetch_function(url, { method: 'POST' });
|
||||
}
|
||||
|
||||
async refreshAccessToken(): Promise<void> {
|
||||
if (!this.client_id)
|
||||
this.client_id = await this.getClientID();
|
||||
|
||||
if (!this.oauth2_tokens)
|
||||
throw new OAuth2Error('No tokens available to refresh.');
|
||||
|
||||
const { client_id, client_secret } = this.client_id;
|
||||
const { refresh_token } = this.oauth2_tokens;
|
||||
|
||||
const payload = {
|
||||
client_id,
|
||||
client_secret,
|
||||
refresh_token,
|
||||
grant_type: 'refresh_token'
|
||||
};
|
||||
|
||||
const response = await this.#http.fetch_function(this.AUTH_SERVER_TOKEN_URL, {
|
||||
body: JSON.stringify(payload),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw new OAuth2Error(`Failed to refresh access token: ${response.status}`);
|
||||
|
||||
const response_data = await response.json();
|
||||
|
||||
if (response_data.error_code)
|
||||
throw new OAuth2Error('Authorization server returned an error', response_data);
|
||||
|
||||
this.oauth2_tokens.access_token = response_data.access_token;
|
||||
this.oauth2_tokens.expiry_date = new Date(Date.now() + response_data.expires_in * 1000).toISOString();
|
||||
|
||||
this.#session.emit('update-credentials', { credentials: this.oauth2_tokens });
|
||||
}
|
||||
|
||||
async getDeviceAndUserCode(): Promise<DeviceAndUserCode> {
|
||||
if (!this.client_id)
|
||||
throw new OAuth2Error('Client ID is missing.');
|
||||
|
||||
const { client_id } = this.client_id;
|
||||
|
||||
const payload = {
|
||||
client_id,
|
||||
scope: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
|
||||
device_id: Platform.shim.uuidv4(),
|
||||
device_model: 'ytlr::'
|
||||
};
|
||||
|
||||
const response = await this.#http.fetch_function(this.AUTH_SERVER_CODE_URL, {
|
||||
body: JSON.stringify(payload),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw new OAuth2Error(`Failed to get device/user code: ${response.status}`);
|
||||
|
||||
const response_data = await response.json();
|
||||
|
||||
if (response_data.error_code)
|
||||
throw new OAuth2Error('Authorization server returned an error', response_data);
|
||||
|
||||
return response_data;
|
||||
}
|
||||
|
||||
async getClientID(): Promise<OAuth2ClientID> {
|
||||
const yttv_response = await this.#http.fetch_function(this.YTTV_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
|
||||
'Referer': 'https://www.youtube.com/tv',
|
||||
'Accept-Language': 'en-US'
|
||||
}
|
||||
});
|
||||
|
||||
if (!yttv_response.ok)
|
||||
throw new OAuth2Error(`Failed to get client ID: ${yttv_response.status}`);
|
||||
|
||||
const yttv_response_data = await yttv_response.text();
|
||||
|
||||
let script_url_body: RegExpExecArray | null;
|
||||
|
||||
if ((script_url_body = Constants.OAUTH.REGEX.TV_SCRIPT.exec(yttv_response_data)) !== null) {
|
||||
Log.info(TAG, `Got YouTubeTV script URL (${script_url_body[1]})`);
|
||||
|
||||
const script_response = await this.#http.fetch(script_url_body[1], { baseURL: Constants.URLS.YT_BASE });
|
||||
|
||||
if (!script_response.ok)
|
||||
throw new OAuth2Error(`TV script request failed with status code ${script_response.status}`);
|
||||
|
||||
const script_response_data = await script_response.text();
|
||||
|
||||
const client_identity = script_response_data
|
||||
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
|
||||
|
||||
if (!client_identity || !client_identity.groups)
|
||||
throw new OAuth2Error('Could not obtain client ID.');
|
||||
|
||||
const { client_id, client_secret } = client_identity.groups;
|
||||
|
||||
Log.info(TAG, `Client identity retrieved (clientId=${client_id}, clientSecret=${client_secret}).`);
|
||||
|
||||
return {
|
||||
client_id,
|
||||
client_secret
|
||||
};
|
||||
}
|
||||
|
||||
throw new OAuth2Error('Could not obtain script URL.');
|
||||
}
|
||||
|
||||
shouldRefreshToken(): boolean {
|
||||
if (!this.oauth2_tokens)
|
||||
return false;
|
||||
return Date.now() > new Date(this.oauth2_tokens.expiry_date).getTime();
|
||||
}
|
||||
|
||||
validateTokens(tokens: OAuth2Tokens): boolean {
|
||||
const propertiesAreValid = (
|
||||
Boolean(tokens.access_token) &&
|
||||
Boolean(tokens.expiry_date) &&
|
||||
Boolean(tokens.refresh_token)
|
||||
);
|
||||
|
||||
const typesAreValid = (
|
||||
typeof tokens.access_token === 'string' &&
|
||||
typeof tokens.expiry_date === 'string' &&
|
||||
typeof tokens.refresh_token === 'string'
|
||||
);
|
||||
|
||||
return typesAreValid && propertiesAreValid;
|
||||
}
|
||||
|
||||
get #http() {
|
||||
return this.#session.http;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import { Log, Constants } from '../utils/index.js';
|
||||
import { Log, LZW, Constants } from '../utils/index.js';
|
||||
import { Platform, getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils.js';
|
||||
import type { ICache, FetchFunction } from '../types/index.js';
|
||||
|
||||
const TAG = 'Player';
|
||||
|
||||
/**
|
||||
* Represents YouTube's player script. This is required to decipher signatures.
|
||||
*/
|
||||
export default class Player {
|
||||
static TAG = 'Player';
|
||||
|
||||
#nsig_sc;
|
||||
#sig_sc;
|
||||
#sig_sc_timestamp;
|
||||
#player_id;
|
||||
nsig_sc;
|
||||
sig_sc;
|
||||
sts;
|
||||
player_id;
|
||||
|
||||
constructor(signature_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) {
|
||||
this.#nsig_sc = nsig_sc;
|
||||
this.#sig_sc = sig_sc;
|
||||
this.#sig_sc_timestamp = signature_timestamp;
|
||||
this.#player_id = player_id;
|
||||
this.nsig_sc = nsig_sc;
|
||||
this.sig_sc = sig_sc;
|
||||
this.sts = signature_timestamp;
|
||||
this.player_id = player_id;
|
||||
}
|
||||
|
||||
static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch): Promise<Player> {
|
||||
@@ -31,22 +31,23 @@ export default class Player {
|
||||
|
||||
const player_id = getStringBetweenStrings(js, 'player\\/', '\\/');
|
||||
|
||||
Log.info(Player.TAG, `Got player id (${player_id}). Checking for cached players..`);
|
||||
Log.info(TAG, `Got player id (${player_id}). Checking for cached players..`);
|
||||
|
||||
if (!player_id)
|
||||
throw new PlayerError('Failed to get player id');
|
||||
|
||||
// We have the player id, now we can check if we have a cached player.
|
||||
if (cache) {
|
||||
Log.info(Player.TAG, 'Found a cached player.');
|
||||
const cached_player = await Player.fromCache(cache, player_id);
|
||||
if (cached_player)
|
||||
if (cached_player) {
|
||||
Log.info(TAG, 'Found up-to-date player data in cache.');
|
||||
return cached_player;
|
||||
}
|
||||
}
|
||||
|
||||
const player_url = new URL(`/s/player/${player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE);
|
||||
|
||||
Log.info(Player.TAG, `Could not find any cached player. Will download a new player from ${player_url}.`);
|
||||
Log.info(TAG, `Could not find any cached player. Will download a new player from ${player_url}.`);
|
||||
|
||||
const player_res = await fetch(player_url, {
|
||||
headers: {
|
||||
@@ -64,7 +65,7 @@ export default class Player {
|
||||
const sig_sc = this.extractSigSourceCode(player_js);
|
||||
const nsig_sc = this.extractNSigSourceCode(player_js);
|
||||
|
||||
Log.info(Player.TAG, `Got signature timestamp (${sig_timestamp}) and algorithms needed to decipher signatures.`);
|
||||
Log.info(TAG, `Got signature timestamp (${sig_timestamp}) and algorithms needed to decipher signatures.`);
|
||||
|
||||
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
|
||||
}
|
||||
@@ -79,11 +80,11 @@ export default class Player {
|
||||
const url_components = new URL(args.get('url') || url);
|
||||
|
||||
if (signature_cipher || cipher) {
|
||||
const signature = Platform.shim.eval(this.#sig_sc, {
|
||||
const signature = Platform.shim.eval(this.sig_sc, {
|
||||
sig: args.get('s')
|
||||
});
|
||||
|
||||
Log.info(Player.TAG, `Transformed signature ${args.get('s')} to ${signature}.`);
|
||||
Log.info(TAG, `Transformed signature from ${args.get('s')} to ${signature}.`);
|
||||
|
||||
if (typeof signature !== 'string')
|
||||
throw new PlayerError('Failed to decipher signature');
|
||||
@@ -103,17 +104,17 @@ export default class Player {
|
||||
if (this_response_nsig_cache && this_response_nsig_cache.has(n)) {
|
||||
nsig = this_response_nsig_cache.get(n) as string;
|
||||
} else {
|
||||
nsig = Platform.shim.eval(this.#nsig_sc, {
|
||||
nsig = Platform.shim.eval(this.nsig_sc, {
|
||||
nsig: n
|
||||
});
|
||||
|
||||
Log.info(Player.TAG, `Transformed nsig ${n} to ${nsig}.`);
|
||||
Log.info(TAG, `Transformed n signature from ${n} to ${nsig}.`);
|
||||
|
||||
if (typeof nsig !== 'string')
|
||||
throw new PlayerError('Failed to decipher nsig');
|
||||
|
||||
if (nsig.startsWith('enhanced_except_')) {
|
||||
Log.warn(Player.TAG, 'Could not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!');
|
||||
Log.warn(TAG, 'Could not transform nsig, download may be throttled.');
|
||||
} else if (this_response_nsig_cache) {
|
||||
this_response_nsig_cache.set(n, nsig);
|
||||
}
|
||||
@@ -147,7 +148,7 @@ export default class Player {
|
||||
|
||||
const result = url_components.toString();
|
||||
|
||||
Log.info(Player.TAG, `Full deciphered URL: ${result}`);
|
||||
Log.info(TAG, `Deciphered URL: ${result}`);
|
||||
|
||||
return url_components.toString();
|
||||
}
|
||||
@@ -170,10 +171,8 @@ export default class Player {
|
||||
const sig_buf = buffer.slice(12, 12 + sig_len);
|
||||
const nsig_buf = buffer.slice(12 + sig_len);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const sig_sc = decoder.decode(sig_buf);
|
||||
const nsig_sc = decoder.decode(nsig_buf);
|
||||
const sig_sc = LZW.decompress(new TextDecoder().decode(sig_buf));
|
||||
const nsig_sc = LZW.decompress(new TextDecoder().decode(nsig_buf));
|
||||
|
||||
return new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
|
||||
}
|
||||
@@ -189,20 +188,20 @@ export default class Player {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const sig_buf = encoder.encode(this.#sig_sc);
|
||||
const nsig_buf = encoder.encode(this.#nsig_sc);
|
||||
const sig_buf = encoder.encode(LZW.compress(this.sig_sc));
|
||||
const nsig_buf = encoder.encode(LZW.compress(this.nsig_sc));
|
||||
|
||||
const buffer = new ArrayBuffer(12 + sig_buf.byteLength + nsig_buf.byteLength);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint32(0, Player.LIBRARY_VERSION, true);
|
||||
view.setUint32(4, this.#sig_sc_timestamp, true);
|
||||
view.setUint32(4, this.sts, true);
|
||||
view.setUint32(8, sig_buf.byteLength, true);
|
||||
|
||||
new Uint8Array(buffer).set(sig_buf, 12);
|
||||
new Uint8Array(buffer).set(nsig_buf, 12 + sig_buf.byteLength);
|
||||
|
||||
await cache.set(this.#player_id, new Uint8Array(buffer));
|
||||
await cache.set(this.player_id, new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
static extractSigTimestamp(data: string): number {
|
||||
@@ -215,37 +214,26 @@ export default class Player {
|
||||
const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};');
|
||||
|
||||
if (!functions || !calls)
|
||||
Log.warn(Player.TAG, 'Failed to extract signature decipher algorithm.');
|
||||
Log.warn(TAG, 'Failed to extract signature decipher algorithm.');
|
||||
|
||||
return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`;
|
||||
}
|
||||
|
||||
static extractNSigSourceCode(data: string): string {
|
||||
const sc = `function descramble_nsig(a) { let b=a.split("")${getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join(""); } descramble_nsig(nsig)`;
|
||||
const match = data.match(/b=(?:a\.split\(|String\.prototype\.split\.call\(a,)""\).*?\}return (?:b\.join\(|Array\.prototype\.join\.call\(b,)""\)\}/s);
|
||||
|
||||
if (!sc)
|
||||
Log.warn(Player.TAG, 'Failed to extract n-token decipher algorithm');
|
||||
if (!match) {
|
||||
throw new PlayerError('Failed to extract n-token decipher algorithm');
|
||||
}
|
||||
|
||||
return sc;
|
||||
return `function descramble_nsig(a) { let ${match[0]} descramble_nsig(nsig)`;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return new URL(`/s/player/${this.#player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
|
||||
}
|
||||
|
||||
get sts(): number {
|
||||
return this.#sig_sc_timestamp;
|
||||
}
|
||||
|
||||
get nsig_sc(): string {
|
||||
return this.#nsig_sc;
|
||||
}
|
||||
|
||||
get sig_sc(): string {
|
||||
return this.#sig_sc;
|
||||
return new URL(`/s/player/${this.player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
|
||||
}
|
||||
|
||||
static get LIBRARY_VERSION(): number {
|
||||
return 2;
|
||||
return 11;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import OAuth from './OAuth.js';
|
||||
import { Log, EventEmitter, HTTPClient } from '../utils/index.js';
|
||||
import OAuth2 from './OAuth2.js';
|
||||
import { Log, EventEmitter, HTTPClient, LZW } from '../utils/index.js';
|
||||
import * as Constants from '../utils/Constants.js';
|
||||
import * as Proto from '../proto/index.js';
|
||||
import Actions from './Actions.js';
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
|
||||
import type { DeviceCategory } from '../utils/Utils.js';
|
||||
import type { FetchFunction, ICache } from '../types/index.js';
|
||||
import type {
|
||||
Credentials, OAuthAuthErrorEventHandler,
|
||||
OAuthAuthEventHandler, OAuthAuthPendingEventHandler
|
||||
} from './OAuth.js';
|
||||
import type { OAuth2Tokens, OAuth2AuthErrorEventHandler, OAuth2AuthPendingEventHandler, OAuth2AuthEventHandler } from './OAuth2.js';
|
||||
|
||||
export enum ClientType {
|
||||
WEB = 'WEB',
|
||||
@@ -28,7 +25,7 @@ export enum ClientType {
|
||||
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
export type Context = {
|
||||
client: {
|
||||
hl: string;
|
||||
gl: string;
|
||||
@@ -55,6 +52,16 @@ export interface Context {
|
||||
deviceMake: string;
|
||||
deviceModel: string;
|
||||
utcOffsetMinutes: number;
|
||||
mainAppWebInfo?: {
|
||||
graftUrl: string;
|
||||
pwaInstallabilityStatus: string;
|
||||
webDisplayMode: string;
|
||||
isWebNativeShareAvailable: boolean;
|
||||
};
|
||||
memoryTotalKbytes?: string;
|
||||
configInfo?: {
|
||||
appInstallData: string;
|
||||
},
|
||||
kidsAppInfo?: {
|
||||
categorySettings: {
|
||||
enabledCategories: string[];
|
||||
@@ -79,7 +86,27 @@ export interface Context {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionOptions {
|
||||
type ContextData = {
|
||||
hl: string;
|
||||
gl: string;
|
||||
remote_host?: string;
|
||||
visitor_data: string;
|
||||
client_name: string;
|
||||
client_version: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
device_category: string;
|
||||
time_zone: string;
|
||||
enable_safety_mode: boolean;
|
||||
browser_name?: string;
|
||||
browser_version?: string;
|
||||
app_install_data?: string;
|
||||
device_make: string;
|
||||
device_model: string;
|
||||
on_behalf_of_user?: string;
|
||||
}
|
||||
|
||||
export type SessionOptions = {
|
||||
/**
|
||||
* Language.
|
||||
*/
|
||||
@@ -90,8 +117,8 @@ export interface SessionOptions {
|
||||
location?: string;
|
||||
/**
|
||||
* The account index to use. This is useful if you have multiple accounts logged in.
|
||||
* **NOTE:**
|
||||
* Only works if you are signed in with cookies.
|
||||
*
|
||||
* **NOTE:** Only works if you are signed in with cookies.
|
||||
*/
|
||||
account_index?: number;
|
||||
/**
|
||||
@@ -100,6 +127,7 @@ export interface SessionOptions {
|
||||
on_behalf_of_user?: string;
|
||||
/**
|
||||
* Specifies whether to retrieve the JS player. Disabling this will make session creation faster.
|
||||
*
|
||||
* **NOTE:** Deciphering formats is not possible without the JS player.
|
||||
*/
|
||||
retrieve_player?: boolean;
|
||||
@@ -110,8 +138,15 @@ export interface SessionOptions {
|
||||
/**
|
||||
* Specifies whether to generate the session data locally or retrieve it from YouTube.
|
||||
* This can be useful if you need more performance.
|
||||
*
|
||||
* **NOTE:** If you are using the cache option and a session has already been generated, this will be ignored.
|
||||
* If you want to force a new session to be generated, you must clear the cache or disable session caching.
|
||||
*/
|
||||
generate_session_locally?: boolean;
|
||||
/**
|
||||
* Specifies whether the session data should be cached.
|
||||
*/
|
||||
enable_session_cache?: boolean;
|
||||
/**
|
||||
* Platform to use for the session.
|
||||
*/
|
||||
@@ -125,7 +160,7 @@ export interface SessionOptions {
|
||||
*/
|
||||
timezone?: string;
|
||||
/**
|
||||
* Used to cache the deciphering functions from the JS player.
|
||||
* Used to cache algorithms, session data, and OAuth2 tokens.
|
||||
*/
|
||||
cache?: ICache;
|
||||
/**
|
||||
@@ -143,12 +178,18 @@ export interface SessionOptions {
|
||||
fetch?: FetchFunction;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
export type SessionData = {
|
||||
context: Context;
|
||||
api_key: string;
|
||||
api_version: string;
|
||||
}
|
||||
|
||||
export type SWSessionData = {
|
||||
context_data: ContextData;
|
||||
api_key: string;
|
||||
api_version: string;
|
||||
}
|
||||
|
||||
export type SessionArgs = {
|
||||
lang: string;
|
||||
location: string;
|
||||
@@ -160,50 +201,49 @@ export type SessionArgs = {
|
||||
on_behalf_of_user: string | undefined;
|
||||
}
|
||||
|
||||
const TAG = 'Session';
|
||||
|
||||
/**
|
||||
* Represents an InnerTube session. This holds all the data needed to make requests to YouTube.
|
||||
*/
|
||||
export default class Session extends EventEmitter {
|
||||
static TAG = 'Session';
|
||||
|
||||
#api_version: string;
|
||||
#key: string;
|
||||
#context: Context;
|
||||
#account_index: number;
|
||||
#player?: Player;
|
||||
|
||||
oauth: OAuth;
|
||||
context: Context;
|
||||
player?: Player;
|
||||
oauth: OAuth2;
|
||||
http: HTTPClient;
|
||||
logged_in: boolean;
|
||||
actions: Actions;
|
||||
cache?: ICache;
|
||||
key: string;
|
||||
api_version: string;
|
||||
account_index: number;
|
||||
|
||||
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: ICache) {
|
||||
super();
|
||||
this.#context = context;
|
||||
this.#account_index = account_index;
|
||||
this.#key = api_key;
|
||||
this.#api_version = api_version;
|
||||
this.#player = player;
|
||||
this.http = new HTTPClient(this, cookie, fetch);
|
||||
this.actions = new Actions(this);
|
||||
this.oauth = new OAuth(this);
|
||||
this.oauth = new OAuth2(this);
|
||||
this.logged_in = !!cookie;
|
||||
this.cache = cache;
|
||||
this.account_index = account_index;
|
||||
this.key = api_key;
|
||||
this.api_version = api_version;
|
||||
this.context = context;
|
||||
this.player = player;
|
||||
}
|
||||
|
||||
on(type: 'auth', listener: OAuthAuthEventHandler): void;
|
||||
on(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
|
||||
on(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
|
||||
on(type: 'update-credentials', listener: OAuthAuthEventHandler): void;
|
||||
on(type: 'auth', listener: OAuth2AuthEventHandler): void;
|
||||
on(type: 'auth-pending', listener: OAuth2AuthPendingEventHandler): void;
|
||||
on(type: 'auth-error', listener: OAuth2AuthErrorEventHandler): void;
|
||||
on(type: 'update-credentials', listener: OAuth2AuthEventHandler): void;
|
||||
|
||||
on(type: string, listener: (...args: any[]) => void): void {
|
||||
super.on(type, listener);
|
||||
}
|
||||
|
||||
once(type: 'auth', listener: OAuthAuthEventHandler): void;
|
||||
once(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
|
||||
once(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
|
||||
once(type: 'auth', listener: OAuth2AuthEventHandler): void;
|
||||
once(type: 'auth-pending', listener: OAuth2AuthPendingEventHandler): void;
|
||||
once(type: 'auth-error', listener: OAuth2AuthErrorEventHandler): void;
|
||||
|
||||
once(type: string, listener: (...args: any[]) => void): void {
|
||||
super.once(type, listener);
|
||||
@@ -221,7 +261,9 @@ export default class Session extends EventEmitter {
|
||||
options.client_type,
|
||||
options.timezone,
|
||||
options.fetch,
|
||||
options.on_behalf_of_user
|
||||
options.on_behalf_of_user,
|
||||
options.cache,
|
||||
options.enable_session_cache
|
||||
);
|
||||
|
||||
return new Session(
|
||||
@@ -231,6 +273,47 @@ export default class Session extends EventEmitter {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves session data from cache.
|
||||
* @param cache - A valid cache implementation.
|
||||
* @param session_args - User provided session arguments.
|
||||
*/
|
||||
static async fromCache(cache: ICache, session_args: SessionArgs): Promise<SessionData | null> {
|
||||
const buffer = await cache.get('innertube_session_data');
|
||||
|
||||
if (!buffer)
|
||||
return null;
|
||||
|
||||
const data = new TextDecoder().decode(buffer.slice(4));
|
||||
|
||||
try {
|
||||
const result = JSON.parse(LZW.decompress(data)) as SessionData;
|
||||
|
||||
if (session_args.visitor_data) {
|
||||
result.context.client.visitorData = session_args.visitor_data;
|
||||
}
|
||||
|
||||
if (session_args.lang)
|
||||
result.context.client.hl = session_args.lang;
|
||||
|
||||
if (session_args.location)
|
||||
result.context.client.gl = session_args.location;
|
||||
|
||||
if (session_args.on_behalf_of_user)
|
||||
result.context.user.onBehalfOfUser = session_args.on_behalf_of_user;
|
||||
|
||||
result.context.client.timeZone = session_args.time_zone;
|
||||
result.context.client.platform = session_args.device_category.toUpperCase();
|
||||
result.context.client.clientName = session_args.client_name;
|
||||
result.context.user.enableSafetyMode = session_args.enable_safety_mode;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
Log.error(TAG, 'Failed to parse session data from cache.', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async getSessionData(
|
||||
lang = '',
|
||||
location = '',
|
||||
@@ -242,53 +325,101 @@ export default class Session extends EventEmitter {
|
||||
client_name: ClientType = ClientType.WEB,
|
||||
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
fetch: FetchFunction = Platform.shim.fetch,
|
||||
on_behalf_of_user?: string
|
||||
on_behalf_of_user?: string,
|
||||
cache?: ICache,
|
||||
enable_session_cache = true
|
||||
) {
|
||||
let session_data: SessionData;
|
||||
|
||||
const session_args = { lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data, on_behalf_of_user };
|
||||
|
||||
Log.info(Session.TAG, 'Retrieving InnerTube session.');
|
||||
let session_data: SessionData | undefined;
|
||||
|
||||
if (generate_session_locally) {
|
||||
session_data = this.#generateSessionData(session_args);
|
||||
} else {
|
||||
try {
|
||||
// This can fail if the data changes or the request is blocked for some reason.
|
||||
session_data = await this.#retrieveSessionData(session_args, fetch);
|
||||
} catch (err) {
|
||||
Log.error(Session.TAG, 'Failed to retrieve session data from server. Will try to generate it locally.');
|
||||
session_data = this.#generateSessionData(session_args);
|
||||
if (cache && enable_session_cache) {
|
||||
const cached_session_data = await this.fromCache(cache, session_args);
|
||||
if (cached_session_data) {
|
||||
Log.info(TAG, 'Found session data in cache.');
|
||||
session_data = cached_session_data;
|
||||
}
|
||||
}
|
||||
|
||||
Log.info(Session.TAG, 'Got session data.\n', session_data);
|
||||
if (!session_data) {
|
||||
Log.info(TAG, 'Generating session data.');
|
||||
|
||||
let api_key = Constants.CLIENTS.WEB.API_KEY;
|
||||
let api_version = Constants.CLIENTS.WEB.API_VERSION;
|
||||
|
||||
let context_data: ContextData = {
|
||||
hl: lang || 'en',
|
||||
gl: location || 'US',
|
||||
remote_host: '',
|
||||
visitor_data: visitor_data || Proto.encodeVisitorData(generateRandomString(11), Math.floor(Date.now() / 1000)),
|
||||
client_name: client_name,
|
||||
client_version: Constants.CLIENTS.WEB.VERSION,
|
||||
device_category: device_category.toUpperCase(),
|
||||
os_name: 'Windows',
|
||||
os_version: '10.0',
|
||||
time_zone: tz,
|
||||
browser_name: 'Chrome',
|
||||
browser_version: '125.0.0.0',
|
||||
device_make: '',
|
||||
device_model: '',
|
||||
enable_safety_mode: enable_safety_mode
|
||||
};
|
||||
|
||||
if (!generate_session_locally) {
|
||||
try {
|
||||
const sw_session_data = await this.#getSessionData(session_args, fetch);
|
||||
api_key = sw_session_data.api_key;
|
||||
api_version = sw_session_data.api_version;
|
||||
context_data = sw_session_data.context_data;
|
||||
} catch (error) {
|
||||
Log.error(TAG, 'Failed to retrieve session data from server. Session data generated locally will be used instead.', error);
|
||||
}
|
||||
}
|
||||
|
||||
session_data = {
|
||||
api_key,
|
||||
api_version,
|
||||
context: this.#buildContext(context_data)
|
||||
};
|
||||
|
||||
if (enable_session_cache)
|
||||
await this.#storeSession(session_data, cache);
|
||||
}
|
||||
|
||||
Log.debug(TAG, 'Session data:', session_data);
|
||||
|
||||
return { ...session_data, account_index };
|
||||
}
|
||||
|
||||
static #getVisitorID(visitor_data: string) {
|
||||
const decoded_visitor_data = Proto.decodeVisitorData(visitor_data);
|
||||
Log.info(Session.TAG, 'Custom visitor data decoded successfully.\n', decoded_visitor_data);
|
||||
return decoded_visitor_data.id;
|
||||
static async #storeSession(session_data: SessionData, cache?: ICache) {
|
||||
if (!cache) return;
|
||||
|
||||
Log.info(TAG, 'Compressing and caching session data.');
|
||||
|
||||
const compressed_session_data = new TextEncoder().encode(LZW.compress(JSON.stringify(session_data)));
|
||||
|
||||
const buffer = new ArrayBuffer(4 + compressed_session_data.byteLength);
|
||||
new DataView(buffer).setUint32(0, compressed_session_data.byteLength, true); // (Luan) XX: Leave this here for debugging purposes
|
||||
new Uint8Array(buffer).set(compressed_session_data, 4);
|
||||
|
||||
await cache.set('innertube_session_data', new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
static async #retrieveSessionData(options: SessionArgs, fetch: FetchFunction = Platform.shim.fetch): Promise<SessionData> {
|
||||
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
|
||||
|
||||
static async #getSessionData(options: SessionArgs, fetch: FetchFunction = Platform.shim.fetch): Promise<SWSessionData> {
|
||||
let visitor_id = generateRandomString(11);
|
||||
|
||||
if (options.visitor_data) {
|
||||
if (options.visitor_data)
|
||||
visitor_id = this.#getVisitorID(options.visitor_data);
|
||||
}
|
||||
|
||||
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'accept-language': options.lang || 'en-US',
|
||||
'user-agent': getRandomUserAgent('desktop'),
|
||||
'accept': '*/*',
|
||||
'referer': 'https://www.youtube.com/sw.js',
|
||||
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')};VISITOR_INFO1_LIVE=${visitor_id};`
|
||||
'Accept-Language': options.lang || 'en-US',
|
||||
'User-Agent': getRandomUserAgent('desktop'),
|
||||
'Accept': '*/*',
|
||||
'Referer': `${Constants.URLS.YT_BASE}/sw.js`,
|
||||
'Cookie': `PREF=tz=${options.time_zone.replace('/', '.')};VISITOR_INFO1_LIVE=${visitor_id};`
|
||||
}
|
||||
});
|
||||
|
||||
@@ -296,6 +427,10 @@ export default class Session extends EventEmitter {
|
||||
throw new SessionError(`Failed to retrieve session data: ${res.status}`);
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!text.startsWith(')]}\''))
|
||||
throw new SessionError('Invalid JSPB response');
|
||||
|
||||
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
|
||||
|
||||
const ytcfg = data[0][2];
|
||||
@@ -304,117 +439,102 @@ export default class Session extends EventEmitter {
|
||||
|
||||
const [ [ device_info ], api_key ] = ytcfg;
|
||||
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: device_info[0],
|
||||
gl: options.location || device_info[2],
|
||||
remoteHost: device_info[3],
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 1080,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1920,
|
||||
visitorData: device_info[13],
|
||||
clientName: options.client_name,
|
||||
clientVersion: device_info[16],
|
||||
osName: device_info[17],
|
||||
osVersion: device_info[18],
|
||||
platform: options.device_category.toUpperCase(),
|
||||
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
|
||||
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
|
||||
timeZone: device_info[79] || options.time_zone,
|
||||
browserName: device_info[86],
|
||||
browserVersion: device_info[87],
|
||||
originalUrl: Constants.URLS.YT_BASE,
|
||||
deviceMake: device_info[11],
|
||||
deviceModel: device_info[12],
|
||||
utcOffsetMinutes: -new Date().getTimezoneOffset()
|
||||
},
|
||||
user: {
|
||||
enableSafetyMode: options.enable_safety_mode,
|
||||
lockedSafetyMode: false
|
||||
},
|
||||
request: {
|
||||
useSsl: true,
|
||||
internalExperimentFlags: []
|
||||
}
|
||||
const config_info = device_info[61];
|
||||
const app_install_data = config_info[config_info.length - 1];
|
||||
|
||||
const context_info = {
|
||||
hl: options.lang || device_info[0],
|
||||
gl: options.location || device_info[2],
|
||||
remote_host: device_info[3],
|
||||
visitor_data: device_info[13],
|
||||
client_name: options.client_name,
|
||||
client_version: device_info[16],
|
||||
os_name: device_info[17],
|
||||
os_version: device_info[18],
|
||||
time_zone: device_info[79] || options.time_zone,
|
||||
device_category: options.device_category,
|
||||
browser_name: device_info[86],
|
||||
browser_version: device_info[87],
|
||||
device_make: device_info[11],
|
||||
device_model: device_info[12],
|
||||
app_install_data: app_install_data,
|
||||
enable_safety_mode: options.enable_safety_mode
|
||||
};
|
||||
|
||||
if (options.on_behalf_of_user)
|
||||
context.user.onBehalfOfUser = options.on_behalf_of_user;
|
||||
|
||||
return { context, api_key, api_version };
|
||||
return { context_data: context_info, api_key, api_version };
|
||||
}
|
||||
|
||||
static #generateSessionData(options: SessionArgs): SessionData {
|
||||
let visitor_id = generateRandomString(11);
|
||||
|
||||
if (options.visitor_data) {
|
||||
visitor_id = this.#getVisitorID(options.visitor_data);
|
||||
}
|
||||
|
||||
static #buildContext(args: ContextData) {
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: options.lang || 'en',
|
||||
gl: options.location || 'US',
|
||||
hl: args.hl,
|
||||
gl: args.gl,
|
||||
remoteHost: args.remote_host,
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 1080,
|
||||
screenHeightPoints: 1440,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1920,
|
||||
visitorData: Proto.encodeVisitorData(visitor_id, Math.floor(Date.now() / 1000)),
|
||||
clientName: options.client_name,
|
||||
clientVersion: Constants.CLIENTS.WEB.VERSION,
|
||||
osName: 'Windows',
|
||||
osVersion: '10.0',
|
||||
platform: options.device_category.toUpperCase(),
|
||||
screenWidthPoints: 2560,
|
||||
visitorData: args.visitor_data,
|
||||
clientName: args.client_name,
|
||||
clientVersion: args.client_version,
|
||||
osName: args.os_name,
|
||||
osVersion: args.os_version,
|
||||
platform: args.device_category.toUpperCase(),
|
||||
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
|
||||
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
|
||||
timeZone: options.time_zone,
|
||||
timeZone: args.time_zone,
|
||||
originalUrl: Constants.URLS.YT_BASE,
|
||||
deviceMake: '',
|
||||
deviceModel: '',
|
||||
utcOffsetMinutes: -new Date().getTimezoneOffset()
|
||||
},
|
||||
user: {
|
||||
enableSafetyMode: options.enable_safety_mode,
|
||||
lockedSafetyMode: false
|
||||
},
|
||||
request: {
|
||||
useSsl: true,
|
||||
internalExperimentFlags: []
|
||||
}
|
||||
};
|
||||
|
||||
if (options.on_behalf_of_user)
|
||||
context.user.onBehalfOfUser = options.on_behalf_of_user;
|
||||
|
||||
return { context, api_key: Constants.CLIENTS.WEB.API_KEY, api_version: Constants.CLIENTS.WEB.API_VERSION };
|
||||
}
|
||||
|
||||
async signIn(credentials?: Credentials): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const error_handler: OAuthAuthErrorEventHandler = (err) => reject(err);
|
||||
|
||||
this.once('auth', (data) => {
|
||||
this.off('auth-error', error_handler);
|
||||
|
||||
if (data.status === 'SUCCESS') {
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
deviceMake: args.device_make,
|
||||
deviceModel: args.device_model,
|
||||
browserName: args.browser_name,
|
||||
browserVersion: args.browser_version,
|
||||
utcOffsetMinutes: -Math.floor((new Date()).getTimezoneOffset()),
|
||||
memoryTotalKbytes: '8000000',
|
||||
mainAppWebInfo: {
|
||||
graftUrl: Constants.URLS.YT_BASE,
|
||||
pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN',
|
||||
webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER',
|
||||
isWebNativeShareAvailable: true
|
||||
}
|
||||
},
|
||||
user: {
|
||||
enableSafetyMode: args.enable_safety_mode,
|
||||
lockedSafetyMode: false
|
||||
},
|
||||
request: {
|
||||
useSsl: true,
|
||||
internalExperimentFlags: []
|
||||
}
|
||||
};
|
||||
|
||||
reject(data);
|
||||
});
|
||||
if (args.app_install_data)
|
||||
context.client.configInfo = { appInstallData: args.app_install_data };
|
||||
|
||||
if (args.on_behalf_of_user)
|
||||
context.user.onBehalfOfUser = args.on_behalf_of_user;
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
static #getVisitorID(visitor_data: string) {
|
||||
const decoded_visitor_data = Proto.decodeVisitorData(visitor_data);
|
||||
return decoded_visitor_data.id;
|
||||
}
|
||||
|
||||
async signIn(credentials?: OAuth2Tokens): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const error_handler: OAuth2AuthErrorEventHandler = (err) => reject(err);
|
||||
|
||||
this.once('auth-error', error_handler);
|
||||
|
||||
this.once('auth', () => {
|
||||
this.off('auth-error', error_handler);
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
try {
|
||||
await this.oauth.init(credentials);
|
||||
|
||||
if (this.oauth.validateCredentials()) {
|
||||
await this.oauth.refreshIfRequired();
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
@@ -434,41 +554,15 @@ export default class Session extends EventEmitter {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* InnerTube API key.
|
||||
*/
|
||||
get key(): string {
|
||||
return this.#key;
|
||||
}
|
||||
|
||||
/**
|
||||
* InnerTube API version.
|
||||
*/
|
||||
get api_version(): string {
|
||||
return this.#api_version;
|
||||
}
|
||||
|
||||
get client_version(): string {
|
||||
return this.#context.client.clientVersion;
|
||||
return this.context.client.clientVersion;
|
||||
}
|
||||
|
||||
get client_name(): string {
|
||||
return this.#context.client.clientName;
|
||||
}
|
||||
|
||||
get account_index(): number {
|
||||
return this.#account_index;
|
||||
}
|
||||
|
||||
get context(): Context {
|
||||
return this.#context;
|
||||
}
|
||||
|
||||
get player(): Player | undefined {
|
||||
return this.#player;
|
||||
return this.context.client.clientName;
|
||||
}
|
||||
|
||||
get lang(): string {
|
||||
return this.#context.client.hl;
|
||||
return this.context.client.hl;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
|
||||
import AutomixPreviewVideo from '../../parser/classes/AutomixPreviewVideo.js';
|
||||
import Message from '../../parser/classes/Message.js';
|
||||
import MusicCarouselShelf from '../../parser/classes/MusicCarouselShelf.js';
|
||||
import MusicDescriptionShelf from '../../parser/classes/MusicDescriptionShelf.js';
|
||||
import MusicQueue from '../../parser/classes/MusicQueue.js';
|
||||
import MusicTwoRowItem from '../../parser/classes/MusicTwoRowItem.js';
|
||||
@@ -278,7 +277,7 @@ export default class Music {
|
||||
* Retrieves related content.
|
||||
* @param video_id - The video id.
|
||||
*/
|
||||
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
|
||||
async getRelated(video_id: string): Promise<SectionList | Message> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.execute(
|
||||
@@ -297,9 +296,9 @@ export default class Music {
|
||||
if (!page.contents)
|
||||
throw new InnertubeError('Unexpected response', page);
|
||||
|
||||
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
|
||||
const contents = page.contents.item().as(SectionList, Message);
|
||||
|
||||
return shelves;
|
||||
return contents;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { encodeShortsParam } from '../../proto/index.js';
|
||||
import type { IPlayerRequest, PlayerEndpointOptions } from '../../types/index.js';
|
||||
|
||||
export const PATH = '/player';
|
||||
@@ -9,11 +8,6 @@ export const PATH = '/player';
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: PlayerEndpointOptions): IPlayerRequest {
|
||||
const is_android =
|
||||
opts.client === 'ANDROID' ||
|
||||
opts.client === 'YTMUSIC_ANDROID' ||
|
||||
opts.client === 'YTSTUDIO_ANDROID';
|
||||
|
||||
return {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
@@ -43,8 +37,7 @@ export function build(opts: PlayerEndpointOptions): IPlayerRequest {
|
||||
...{
|
||||
client: opts.client,
|
||||
playlistId: opts.playlist_id,
|
||||
// Workaround streaming URLs returning 403 or getting throttled when using Android based clients.
|
||||
params: is_android ? encodeShortsParam() : opts.params
|
||||
params: opts.params
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -7,8 +7,8 @@ export * from './Actions.js';
|
||||
export { default as Player } from './Player.js';
|
||||
export * from './Player.js';
|
||||
|
||||
export { default as OAuth } from './OAuth.js';
|
||||
export * from './OAuth.js';
|
||||
export { default as OAuth2 } from './OAuth2.js';
|
||||
export * from './OAuth2.js';
|
||||
|
||||
export * as Clients from './clients/index.js';
|
||||
export * as Endpoints from './endpoints/index.js';
|
||||
|
||||
@@ -54,12 +54,28 @@ export default class MediaInfo {
|
||||
}
|
||||
|
||||
let storyboards;
|
||||
let captions;
|
||||
|
||||
if (options.include_thumbnails && player_response.storyboards) {
|
||||
storyboards = player_response.storyboards;
|
||||
}
|
||||
|
||||
return FormatUtils.toDash(this.streaming_data, this.page[0].video_details?.is_post_live_dvr, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions, storyboards);
|
||||
if (typeof options.captions_format === 'string' && player_response.captions?.caption_tracks) {
|
||||
captions = player_response.captions.caption_tracks;
|
||||
}
|
||||
|
||||
return FormatUtils.toDash(
|
||||
this.streaming_data,
|
||||
this.page[0].video_details?.is_post_live_dvr,
|
||||
url_transformer,
|
||||
format_filter,
|
||||
this.#cpn,
|
||||
this.#actions.session.player,
|
||||
this.#actions,
|
||||
storyboards,
|
||||
captions,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ export default class ButtonView extends YTNode {
|
||||
accessibility_text: string;
|
||||
style: string;
|
||||
is_full_width: boolean;
|
||||
type: string;
|
||||
button_type: string;
|
||||
button_size: string;
|
||||
on_tap: NavigationEndpoint;
|
||||
|
||||
@@ -21,7 +21,7 @@ export default class ButtonView extends YTNode {
|
||||
this.accessibility_text = data.accessibilityText;
|
||||
this.style = data.style;
|
||||
this.is_full_width = data.isFullWidth;
|
||||
this.type = data.type;
|
||||
this.button_type = data.type;
|
||||
this.button_size = data.buttonSize;
|
||||
this.on_tap = new NavigationEndpoint(data.onTap);
|
||||
}
|
||||
|
||||
14
src/parser/classes/ChipBarView.ts
Normal file
14
src/parser/classes/ChipBarView.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { YTNode, type ObservedArray } from '../helpers.js';
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import ChipView from './ChipView.js';
|
||||
|
||||
export default class ChipBarView extends YTNode {
|
||||
static type = 'ChipBarView';
|
||||
|
||||
chips: ObservedArray<ChipView> | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.chips = Parser.parseArray(data.chips, ChipView);
|
||||
}
|
||||
}
|
||||
20
src/parser/classes/ChipView.ts
Normal file
20
src/parser/classes/ChipView.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { type RawNode } from '../index.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
|
||||
export default class ChipView extends YTNode {
|
||||
static type = 'ChipView';
|
||||
|
||||
text: string;
|
||||
display_type: string;
|
||||
endpoint: NavigationEndpoint;
|
||||
chip_entity_key: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.text = data.text;
|
||||
this.display_type = data.displayType;
|
||||
this.endpoint = new NavigationEndpoint(data.tapCommand);
|
||||
this.chip_entity_key = data.chipEntityKey;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export default class CollectionThumbnailView extends YTNode {
|
||||
static type = 'CollectionThumbnailView';
|
||||
|
||||
primary_thumbnail: ThumbnailView | null;
|
||||
stack_color: {
|
||||
stack_color?: {
|
||||
light_theme: number;
|
||||
dark_theme: number;
|
||||
};
|
||||
@@ -15,9 +15,11 @@ export default class CollectionThumbnailView extends YTNode {
|
||||
super();
|
||||
|
||||
this.primary_thumbnail = Parser.parseItem(data.primaryThumbnail, ThumbnailView);
|
||||
this.stack_color = {
|
||||
light_theme: data.stackColor.lightTheme,
|
||||
dark_theme: data.stackColor.darkTheme
|
||||
};
|
||||
if (data.stackColor) {
|
||||
this.stack_color = {
|
||||
light_theme: data.stackColor.lightTheme,
|
||||
dark_theme: data.stackColor.darkTheme
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { type ObservedArray, YTNode } from '../helpers.js';
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import ButtonView from './ButtonView.js';
|
||||
import ToggleButtonView from './ToggleButtonView.js';
|
||||
|
||||
export type ActionRow = {
|
||||
actions: ObservedArray<ButtonView>;
|
||||
actions: ObservedArray<ButtonView | ToggleButtonView>;
|
||||
};
|
||||
|
||||
export default class FlexibleActionsView extends YTNode {
|
||||
@@ -15,7 +16,7 @@ export default class FlexibleActionsView extends YTNode {
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.actions_rows = data.actionsRows.map((row: RawNode) => ({
|
||||
actions: Parser.parseArray(row.actions, ButtonView)
|
||||
actions: Parser.parseArray(row.actions, [ ButtonView, ToggleButtonView ])
|
||||
}));
|
||||
this.style = data.style;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Parser, type RawNode } from '../index.js';
|
||||
import InfoPanelContent from './InfoPanelContent.js';
|
||||
import Menu from './menus/Menu.js';
|
||||
import Text from './misc/Text.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
|
||||
export default class InfoPanelContainer extends YTNode {
|
||||
static type = 'InfoPanelContainer';
|
||||
@@ -10,7 +11,9 @@ export default class InfoPanelContainer extends YTNode {
|
||||
title: Text;
|
||||
menu: Menu | null;
|
||||
content: InfoPanelContent | null;
|
||||
header_endpoint?: NavigationEndpoint;
|
||||
background: string;
|
||||
title_style?: string;
|
||||
icon_type?: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
@@ -18,7 +21,12 @@ export default class InfoPanelContainer extends YTNode {
|
||||
this.title = new Text(data.title);
|
||||
this.menu = Parser.parseItem(data.menu, Menu);
|
||||
this.content = Parser.parseItem(data.content, InfoPanelContent);
|
||||
|
||||
if (data.headerEndpoint)
|
||||
this.header_endpoint = new NavigationEndpoint(data.headerEndpoint);
|
||||
|
||||
this.background = data.background;
|
||||
this.title_style = data.titleStyle;
|
||||
|
||||
if (Reflect.has(data, 'icon')) {
|
||||
this.icon_type = data.icon?.iconType;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import type { RawNode } from '../index.js';
|
||||
import type { AttributedText } from './misc/Text.js';
|
||||
import Text from './misc/Text.js';
|
||||
import Thumbnail from './misc/Thumbnail.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
@@ -9,7 +10,8 @@ export default class InfoPanelContent extends YTNode {
|
||||
|
||||
title: Text;
|
||||
source: Text;
|
||||
paragraphs: Text[];
|
||||
paragraphs?: Text[];
|
||||
attributed_paragraphs?: Text[];
|
||||
thumbnail: Thumbnail[];
|
||||
source_endpoint: NavigationEndpoint;
|
||||
truncate_paragraphs: boolean;
|
||||
@@ -20,7 +22,13 @@ export default class InfoPanelContent extends YTNode {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.source = new Text(data.source);
|
||||
this.paragraphs = data.paragraphs.map((p: RawNode) => new Text(p));
|
||||
|
||||
if (Reflect.has(data, 'paragraphs'))
|
||||
this.paragraphs = data.paragraphs.map((p: RawNode) => new Text(p));
|
||||
|
||||
if (Reflect.has(data, 'attributedParagraphs'))
|
||||
this.attributed_paragraphs = data.attributedParagraphs.map((p: AttributedText) => Text.fromAttributed(p));
|
||||
|
||||
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
|
||||
this.source_endpoint = new NavigationEndpoint(data.sourceEndpoint);
|
||||
this.truncate_paragraphs = !!data.truncateParagraphs;
|
||||
|
||||
@@ -19,7 +19,7 @@ export default class ItemSection extends YTNode {
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
|
||||
if (data.targetId || data.sectionIdentifier) {
|
||||
this.target_id = data.target_id || data.sectionIdentifier;
|
||||
this.target_id = data.targetId || data.sectionIdentifier;
|
||||
}
|
||||
|
||||
if (data.continuations) {
|
||||
|
||||
@@ -5,11 +5,13 @@ export default class MusicEditablePlaylistDetailHeader extends YTNode {
|
||||
static type = 'MusicEditablePlaylistDetailHeader';
|
||||
|
||||
header: YTNode;
|
||||
edit_header: YTNode;
|
||||
playlist_id: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.header = Parser.parseItem(data.header);
|
||||
|
||||
// TODO: Parse data.editHeader.musicPlaylistEditHeaderRenderer.
|
||||
this.edit_header = Parser.parseItem(data.editHeader);
|
||||
this.playlist_id = data.playlistId;
|
||||
}
|
||||
}
|
||||
28
src/parser/classes/MusicPlaylistEditHeader.ts
Normal file
28
src/parser/classes/MusicPlaylistEditHeader.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import { YTNode } from '../helpers.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
import Dropdown from './Dropdown.js';
|
||||
import Text from './misc/Text.js';
|
||||
|
||||
export default class MusicPlaylistEditHeader extends YTNode {
|
||||
static type = 'MusicPlaylistEditHeader';
|
||||
|
||||
title: Text;
|
||||
edit_title: Text;
|
||||
edit_description: Text;
|
||||
privacy: string;
|
||||
playlist_id: string;
|
||||
endpoint: NavigationEndpoint;
|
||||
privacy_dropdown: Dropdown | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.edit_title = new Text(data.editTitle);
|
||||
this.edit_description = new Text(data.editDescription);
|
||||
this.privacy = data.privacy;
|
||||
this.playlist_id = data.playlistId;
|
||||
this.endpoint = new NavigationEndpoint(data.collaborationSettingsCommand);
|
||||
this.privacy_dropdown = Parser.parseItem(data.privacyDropdown, Dropdown);
|
||||
}
|
||||
}
|
||||
46
src/parser/classes/MusicResponsiveHeader.ts
Normal file
46
src/parser/classes/MusicResponsiveHeader.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import { YTNode } from '../helpers.js';
|
||||
import MusicThumbnail from './MusicThumbnail.js';
|
||||
import MusicDescriptionShelf from './MusicDescriptionShelf.js';
|
||||
import MusicInlineBadge from './MusicInlineBadge.js';
|
||||
import MusicPlayButton from './MusicPlayButton.js';
|
||||
import ToggleButton from './ToggleButton.js';
|
||||
import Menu from './menus/Menu.js';
|
||||
import Text from './misc/Text.js';
|
||||
import Button from './Button.js';
|
||||
import DownloadButton from './DownloadButton.js';
|
||||
|
||||
import type { ObservedArray } from '../helpers.js';
|
||||
|
||||
export default class MusicResponsiveHeader extends YTNode {
|
||||
static type = 'MusicResponsiveHeader';
|
||||
|
||||
thumbnail: MusicThumbnail | null;
|
||||
buttons: ObservedArray<DownloadButton | ToggleButton | MusicPlayButton | Button | Menu>;
|
||||
title: Text;
|
||||
subtitle: Text;
|
||||
strapline_text_one: Text;
|
||||
strapline_thumbnail: MusicThumbnail | null;
|
||||
second_subtitle: Text;
|
||||
subtitle_badge?: ObservedArray<MusicInlineBadge> | null;
|
||||
description?: MusicDescriptionShelf | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
|
||||
this.buttons = Parser.parseArray(data.buttons, [ DownloadButton, ToggleButton, MusicPlayButton, Button, Menu ]);
|
||||
this.title = new Text(data.title);
|
||||
this.subtitle = new Text(data.subtitle);
|
||||
this.strapline_text_one = new Text(data.straplineTextOne);
|
||||
this.strapline_thumbnail = Parser.parseItem(data.straplineThumbnail, MusicThumbnail);
|
||||
this.second_subtitle = new Text(data.secondSubtitle);
|
||||
|
||||
if (Reflect.has(data, 'subtitleBadge')) {
|
||||
this.subtitle_badge = Parser.parseArray(data.subtitleBadge, MusicInlineBadge);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'description')) {
|
||||
this.description = Parser.parseItem(data.description, MusicDescriptionShelf);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,19 @@ import Text from './misc/Text.js';
|
||||
import { YTNode } from '../helpers.js';
|
||||
import type { RawNode } from '../index.js';
|
||||
|
||||
export interface CaptionTrackData {
|
||||
base_url: string;
|
||||
name: Text;
|
||||
vss_id: string;
|
||||
language_code: string;
|
||||
kind?: 'asr' | 'frc';
|
||||
is_translatable: boolean;
|
||||
}
|
||||
|
||||
export default class PlayerCaptionsTracklist extends YTNode {
|
||||
static type = 'PlayerCaptionsTracklist';
|
||||
|
||||
caption_tracks?: {
|
||||
base_url: string;
|
||||
name: Text;
|
||||
vss_id: string;
|
||||
language_code: string;
|
||||
kind?: 'asr' | 'frc';
|
||||
is_translatable: boolean;
|
||||
}[];
|
||||
caption_tracks?: CaptionTrackData[];
|
||||
|
||||
audio_tracks?: {
|
||||
audio_track_id: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ export default class ThumbnailBadgeView extends YTNode {
|
||||
icon_name: string;
|
||||
text: string;
|
||||
badge_style: string;
|
||||
background_color: {
|
||||
background_color?: {
|
||||
light_theme: number;
|
||||
dark_theme: number;
|
||||
};
|
||||
@@ -18,9 +18,11 @@ export default class ThumbnailBadgeView extends YTNode {
|
||||
this.icon_name = data.icon.sources[0].clientResource.imageName;
|
||||
this.text = data.text;
|
||||
this.badge_style = data.badgeStyle;
|
||||
this.background_color = {
|
||||
light_theme: data.backgroundColor.lightTheme,
|
||||
dark_theme: data.backgroundColor.darkTheme
|
||||
};
|
||||
if (data.backgroundColor) {
|
||||
this.background_color = {
|
||||
light_theme: data.backgroundColor.lightTheme,
|
||||
dark_theme: data.backgroundColor.darkTheme
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export default class ThumbnailView extends YTNode {
|
||||
|
||||
image: Thumbnail[];
|
||||
overlays: (ThumbnailOverlayBadgeView | ThumbnailHoverOverlayView)[];
|
||||
background_color: {
|
||||
background_color?: {
|
||||
light_theme: number;
|
||||
dark_theme: number;
|
||||
};
|
||||
@@ -19,9 +19,11 @@ export default class ThumbnailView extends YTNode {
|
||||
|
||||
this.image = Thumbnail.fromResponse(data.image);
|
||||
this.overlays = Parser.parseArray(data.overlays, [ ThumbnailOverlayBadgeView, ThumbnailHoverOverlayView ]);
|
||||
this.background_color = {
|
||||
light_theme: data.backgroundColor.lightTheme,
|
||||
dark_theme: data.backgroundColor.darkTheme
|
||||
};
|
||||
if (data.backgroundColor) {
|
||||
this.background_color = {
|
||||
light_theme: data.backgroundColor.lightTheme,
|
||||
dark_theme: data.backgroundColor.darkTheme
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export default class CommentThread extends YTNode {
|
||||
if (!response.on_response_received_endpoints_memo)
|
||||
throw new InnertubeError('Unexpected response.', response);
|
||||
|
||||
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
|
||||
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment, CommentView).map((comment) => {
|
||||
comment.setActions(this.#actions);
|
||||
return comment;
|
||||
}));
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { YTNode } from '../../helpers.js';
|
||||
import type { RawNode } from '../../index.js';
|
||||
|
||||
import type Actions from '../../../core/Actions.js';
|
||||
import NavigationEndpoint from '../NavigationEndpoint.js';
|
||||
import Author from '../misc/Author.js';
|
||||
import Text from '../misc/Text.js';
|
||||
import CommentReplyDialog from './CommentReplyDialog.js';
|
||||
import { InnertubeError } from '../../../utils/Utils.js';
|
||||
import * as Proto from '../../../proto/index.js';
|
||||
|
||||
import type Actions from '../../../core/Actions.js';
|
||||
import type { ApiResponse } from '../../../core/Actions.js';
|
||||
import type { RawNode } from '../../index.js';
|
||||
|
||||
export default class CommentView extends YTNode {
|
||||
static type = 'CommentView';
|
||||
|
||||
#actions?: Actions;
|
||||
|
||||
like_command?: NavigationEndpoint;
|
||||
dislike_command?: NavigationEndpoint;
|
||||
unlike_command?: NavigationEndpoint;
|
||||
undislike_command?: NavigationEndpoint;
|
||||
reply_command?: NavigationEndpoint;
|
||||
|
||||
comment_id: string;
|
||||
is_pinned: boolean;
|
||||
keys: {
|
||||
@@ -51,7 +62,7 @@ export default class CommentView extends YTNode {
|
||||
};
|
||||
}
|
||||
|
||||
applyMutations(comment?: RawNode, toolbar_state?: RawNode) {
|
||||
applyMutations(comment?: RawNode, toolbar_state?: RawNode, toolbar_surface?: RawNode) {
|
||||
if (comment) {
|
||||
this.content = Text.fromAttributed(comment.properties.content);
|
||||
this.published_time = comment.properties.publishedTime;
|
||||
@@ -78,8 +89,148 @@ export default class CommentView extends YTNode {
|
||||
if (toolbar_state) {
|
||||
this.is_hearted = toolbar_state.heartState === 'TOOLBAR_HEART_STATE_HEARTED';
|
||||
this.is_liked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_LIKED';
|
||||
this.is_disliked = toolbar_state.likeState === 'TOOLBAR_HEART_STATE_DISLIKED';
|
||||
this.is_disliked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_DISLIKED';
|
||||
}
|
||||
|
||||
if (toolbar_surface && !Reflect.has(toolbar_surface, 'prepareAccountCommand')) {
|
||||
this.like_command = new NavigationEndpoint(toolbar_surface.likeCommand);
|
||||
this.dislike_command = new NavigationEndpoint(toolbar_surface.dislikeCommand);
|
||||
this.unlike_command = new NavigationEndpoint(toolbar_surface.unlikeCommand);
|
||||
this.undislike_command = new NavigationEndpoint(toolbar_surface.undislikeCommand);
|
||||
this.reply_command = new NavigationEndpoint(toolbar_surface.replyCommand);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Likes the comment.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the like command is not found.
|
||||
*/
|
||||
async like(): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.like_command)
|
||||
throw new InnertubeError('Like command not found.');
|
||||
|
||||
if (this.is_liked)
|
||||
throw new InnertubeError('This comment is already liked.', { comment_id: this.comment_id });
|
||||
|
||||
return this.like_command.call(this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dislikes the comment.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the dislike command is not found.
|
||||
*/
|
||||
async dislike(): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.dislike_command)
|
||||
throw new InnertubeError('Dislike command not found.');
|
||||
|
||||
if (this.is_disliked)
|
||||
throw new InnertubeError('This comment is already disliked.', { comment_id: this.comment_id });
|
||||
|
||||
return this.dislike_command.call(this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlikes the comment.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the unlike command is not found.
|
||||
*/
|
||||
async unlike(): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.unlike_command)
|
||||
throw new InnertubeError('Unlike command not found.');
|
||||
|
||||
if (!this.is_liked)
|
||||
throw new InnertubeError('This comment is not liked.', { comment_id: this.comment_id });
|
||||
|
||||
return this.unlike_command.call(this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undislikes the comment.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the undislike command is not found.
|
||||
*/
|
||||
async undislike(): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.undislike_command)
|
||||
throw new InnertubeError('Undislike command not found.');
|
||||
|
||||
if (!this.is_disliked)
|
||||
throw new InnertubeError('This comment is not disliked.', { comment_id: this.comment_id });
|
||||
|
||||
return this.undislike_command.call(this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replies to the comment.
|
||||
* @param comment_text - The text of the reply.
|
||||
* @returns A promise that resolves to the API response.
|
||||
* @throws If the Actions instance is not set for this comment or if the reply command is not found.
|
||||
*/
|
||||
async reply(comment_text: string): Promise<ApiResponse> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.reply_command)
|
||||
throw new InnertubeError('Reply command not found.');
|
||||
|
||||
const dialog = this.reply_command.dialog?.as(CommentReplyDialog);
|
||||
|
||||
if (!dialog)
|
||||
throw new InnertubeError('Reply dialog not found.');
|
||||
|
||||
const reply_button = dialog.reply_button;
|
||||
|
||||
if (!reply_button)
|
||||
throw new InnertubeError('Reply button not found in the dialog.');
|
||||
|
||||
if (!reply_button.endpoint)
|
||||
throw new InnertubeError('Reply button endpoint not found.');
|
||||
|
||||
return reply_button.endpoint.call(this.#actions, { commentText: comment_text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the comment to the specified target language.
|
||||
* @param target_language - The target language to translate the comment to, e.g. 'en', 'ja'.
|
||||
* @returns A Promise that resolves to an ApiResponse object with the translated content, if available.
|
||||
* @throws if the Actions instance is not set for this comment or if the comment content is not found.
|
||||
*/
|
||||
async translate(target_language: string): Promise<ApiResponse & { content?: string }> {
|
||||
if (!this.#actions)
|
||||
throw new InnertubeError('Actions instance not set for this comment.');
|
||||
|
||||
if (!this.content)
|
||||
throw new InnertubeError('Comment content not found.', { comment_id: this.comment_id });
|
||||
|
||||
// Emojis must be removed otherwise InnerTube throws a 400 status code at us.
|
||||
const text = this.content.toString().replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, '');
|
||||
|
||||
const payload = {
|
||||
text,
|
||||
target_language
|
||||
};
|
||||
|
||||
const action = Proto.encodeCommentActionParams(22, payload);
|
||||
const response = await this.#actions.execute('comment/perform_comment_action', { action, client: 'ANDROID' });
|
||||
|
||||
// XXX: Should move this to Parser#parseResponse
|
||||
const mutations = response.data.frameworkUpdates?.entityBatchUpdate?.mutations;
|
||||
const content = mutations?.[0]?.payload?.commentEntityPayload?.translatedContent?.content;
|
||||
|
||||
return { ...response, content };
|
||||
}
|
||||
|
||||
setActions(actions: Actions | undefined) {
|
||||
|
||||
@@ -6,57 +6,64 @@ export default class Format {
|
||||
#this_response_nsig_cache?: Map<string, string>;
|
||||
|
||||
itag: number;
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
last_modified: Date;
|
||||
content_length?: number;
|
||||
quality?: string;
|
||||
xtags?: string;
|
||||
drm_families?: string[];
|
||||
fps?: number;
|
||||
quality_label?: string;
|
||||
projection_type?: 'RECTANGULAR' | 'EQUIRECTANGULAR' | 'EQUIRECTANGULAR_THREED_TOP_BOTTOM' | 'MESH';
|
||||
average_bitrate?: number;
|
||||
bitrate: number;
|
||||
spatial_audio_type?: 'AMBISONICS_5_1' | 'AMBISONICS_QUAD' | 'FOA_WITH_NON_DIEGETIC';
|
||||
target_duration_dec?: number;
|
||||
fair_play_key_uri?: string;
|
||||
stereo_layout?: 'LEFT_RIGHT' | 'TOP_BOTTOM';
|
||||
max_dvr_duration_sec?: number;
|
||||
high_replication?: boolean;
|
||||
audio_quality?: string;
|
||||
approx_duration_ms: number;
|
||||
audio_sample_rate?: number;
|
||||
audio_channels?: number;
|
||||
loudness_db?: number;
|
||||
signature_cipher?: string;
|
||||
is_drc?: boolean;
|
||||
drm_track_type?: string;
|
||||
distinct_params?: string;
|
||||
track_absolute_loudness_lkfs?: number;
|
||||
mime_type: string;
|
||||
is_type_otf: boolean;
|
||||
bitrate: number;
|
||||
average_bitrate?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
init_range?: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
index_range?: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
last_modified: Date;
|
||||
content_length?: number;
|
||||
quality?: string;
|
||||
quality_label?: string;
|
||||
fps?: number;
|
||||
url?: string;
|
||||
cipher?: string;
|
||||
signature_cipher?: string;
|
||||
audio_quality?: string;
|
||||
audio_track?: {
|
||||
audio_is_default: boolean;
|
||||
display_name: string;
|
||||
id: string;
|
||||
};
|
||||
approx_duration_ms: number;
|
||||
audio_sample_rate?: number;
|
||||
audio_channels?: number;
|
||||
loudness_db?: number;
|
||||
max_dvr_duration_sec?: number;
|
||||
target_duration_dec?: number;
|
||||
has_audio: boolean;
|
||||
has_video: boolean;
|
||||
has_text: boolean;
|
||||
language?: string | null;
|
||||
is_dubbed?: boolean;
|
||||
is_descriptive?: boolean;
|
||||
is_secondary?: boolean;
|
||||
is_original?: boolean;
|
||||
|
||||
color_info?: {
|
||||
primaries?: string;
|
||||
transfer_characteristics?: string;
|
||||
matrix_coefficients?: string;
|
||||
};
|
||||
|
||||
caption_track?: {
|
||||
display_name: string;
|
||||
vss_id: string;
|
||||
@@ -75,53 +82,116 @@ export default class Format {
|
||||
this.is_type_otf = data.type === 'FORMAT_STREAM_TYPE_OTF';
|
||||
this.bitrate = data.bitrate;
|
||||
this.average_bitrate = data.averageBitrate;
|
||||
this.width = data.width;
|
||||
this.height = data.height;
|
||||
|
||||
this.init_range = data.initRange ? {
|
||||
start: parseInt(data.initRange.start),
|
||||
end: parseInt(data.initRange.end)
|
||||
} : undefined;
|
||||
if (Reflect.has(data, 'width') && Reflect.has(data, 'height')) {
|
||||
this.width = parseInt(data.width);
|
||||
this.height = parseInt(data.height);
|
||||
}
|
||||
|
||||
this.index_range = data.indexRange ? {
|
||||
start: parseInt(data.indexRange.start),
|
||||
end: parseInt(data.indexRange.end)
|
||||
} : undefined;
|
||||
if (Reflect.has(data, 'projectionType'))
|
||||
this.projection_type = data.projectionType;
|
||||
|
||||
if (Reflect.has(data, 'stereoLayout'))
|
||||
this.stereo_layout = data.stereoLayout?.replace('STEREO_LAYOUT_', '');
|
||||
|
||||
if (Reflect.has(data, 'initRange'))
|
||||
this.init_range = {
|
||||
start: parseInt(data.initRange.start),
|
||||
end: parseInt(data.initRange.end)
|
||||
};
|
||||
|
||||
if (Reflect.has(data, 'indexRange'))
|
||||
this.index_range = {
|
||||
start: parseInt(data.indexRange.start),
|
||||
end: parseInt(data.indexRange.end)
|
||||
};
|
||||
|
||||
this.last_modified = new Date(Math.floor(parseInt(data.lastModified) / 1000));
|
||||
this.content_length = parseInt(data.contentLength);
|
||||
this.quality = data.quality;
|
||||
this.quality_label = data.qualityLabel;
|
||||
this.fps = data.fps;
|
||||
this.url = data.url;
|
||||
this.cipher = data.cipher;
|
||||
this.signature_cipher = data.signatureCipher;
|
||||
this.audio_quality = data.audioQuality;
|
||||
|
||||
if (Reflect.has(data, 'contentLength'))
|
||||
this.content_length = parseInt(data.contentLength);
|
||||
|
||||
if (Reflect.has(data, 'quality'))
|
||||
this.quality = data.quality;
|
||||
|
||||
if (Reflect.has(data, 'qualityLabel'))
|
||||
this.quality_label = data.qualityLabel;
|
||||
|
||||
if (Reflect.has(data, 'fps'))
|
||||
this.fps = data.fps;
|
||||
|
||||
if (Reflect.has(data, 'url'))
|
||||
this.url = data.url;
|
||||
|
||||
if (Reflect.has(data, 'cipher'))
|
||||
this.cipher = data.cipher;
|
||||
|
||||
if (Reflect.has(data, 'signatureCipher'))
|
||||
this.signature_cipher = data.signatureCipher;
|
||||
|
||||
if (Reflect.has(data, 'audioQuality'))
|
||||
this.audio_quality = data.audioQuality;
|
||||
|
||||
this.approx_duration_ms = parseInt(data.approxDurationMs);
|
||||
this.audio_sample_rate = parseInt(data.audioSampleRate);
|
||||
this.audio_channels = data.audioChannels;
|
||||
this.loudness_db = data.loudnessDb;
|
||||
this.max_dvr_duration_sec = data.maxDvrDurationSec;
|
||||
this.target_duration_dec = data.targetDurationSec;
|
||||
|
||||
if (Reflect.has(data, 'audioSampleRate'))
|
||||
this.audio_sample_rate = parseInt(data.audioSampleRate);
|
||||
|
||||
if (Reflect.has(data, 'audioChannels'))
|
||||
this.audio_channels = data.audioChannels;
|
||||
|
||||
if (Reflect.has(data, 'loudnessDb'))
|
||||
this.loudness_db = data.loudnessDb;
|
||||
|
||||
if (Reflect.has(data, 'spatialAudioType'))
|
||||
this.spatial_audio_type = data.spatialAudioType?.replace('SPATIAL_AUDIO_TYPE_', '');
|
||||
|
||||
if (Reflect.has(data, 'maxDvrDurationSec'))
|
||||
this.max_dvr_duration_sec = data.maxDvrDurationSec;
|
||||
|
||||
if (Reflect.has(data, 'targetDurationSec'))
|
||||
this.target_duration_dec = data.targetDurationSec;
|
||||
|
||||
this.has_audio = !!data.audioBitrate || !!data.audioQuality;
|
||||
this.has_video = !!data.qualityLabel;
|
||||
this.has_text = !!data.captionTrack;
|
||||
|
||||
this.color_info = data.colorInfo ? {
|
||||
primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''),
|
||||
transfer_characteristics: data.colorInfo.transferCharacteristics?.replace('COLOR_TRANSFER_CHARACTERISTICS_', ''),
|
||||
matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '')
|
||||
} : undefined;
|
||||
if (Reflect.has(data, 'xtags'))
|
||||
this.xtags = data.xtags;
|
||||
|
||||
if (Reflect.has(data, 'audioTrack')) {
|
||||
if (Reflect.has(data, 'fairPlayKeyUri'))
|
||||
this.fair_play_key_uri = data.fairPlayKeyUri;
|
||||
|
||||
if (Reflect.has(data, 'drmFamilies'))
|
||||
this.drm_families = data.drmFamilies;
|
||||
|
||||
if (Reflect.has(data, 'drmTrackType'))
|
||||
this.drm_track_type = data.drmTrackType;
|
||||
|
||||
if (Reflect.has(data, 'distinctParams'))
|
||||
this.distinct_params = data.distinctParams;
|
||||
|
||||
if (Reflect.has(data, 'trackAbsoluteLoudnessLkfs'))
|
||||
this.track_absolute_loudness_lkfs = data.trackAbsoluteLoudnessLkfs;
|
||||
|
||||
if (Reflect.has(data, 'highReplication'))
|
||||
this.high_replication = data.highReplication;
|
||||
|
||||
if (Reflect.has(data, 'colorInfo'))
|
||||
this.color_info = {
|
||||
primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''),
|
||||
transfer_characteristics: data.colorInfo.transferCharacteristics?.replace('COLOR_TRANSFER_CHARACTERISTICS_', ''),
|
||||
matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '')
|
||||
};
|
||||
|
||||
if (Reflect.has(data, 'audioTrack'))
|
||||
this.audio_track = {
|
||||
audio_is_default: data.audioTrack.audioIsDefault,
|
||||
display_name: data.audioTrack.displayName,
|
||||
id: data.audioTrack.id
|
||||
};
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'captionTrack')) {
|
||||
if (Reflect.has(data, 'captionTrack'))
|
||||
this.caption_track = {
|
||||
display_name: data.captionTrack.displayName,
|
||||
vss_id: data.captionTrack.vssId,
|
||||
@@ -129,7 +199,6 @@ export default class Format {
|
||||
kind: data.captionTrack.kind,
|
||||
id: data.captionTrack.id
|
||||
};
|
||||
}
|
||||
|
||||
if (this.has_audio || this.has_text) {
|
||||
const args = new URLSearchParams(this.cipher || this.signature_cipher);
|
||||
@@ -140,10 +209,13 @@ export default class Format {
|
||||
this.language = xtags?.find((x: string) => x.startsWith('lang='))?.split('=')[1] || null;
|
||||
|
||||
if (this.has_audio) {
|
||||
this.is_drc = !!data.isDrc || !!xtags?.includes('drc=1');
|
||||
|
||||
const audio_content = xtags?.find((x) => x.startsWith('acont='))?.split('=')[1];
|
||||
this.is_dubbed = audio_content === 'dubbed';
|
||||
this.is_descriptive = audio_content === 'descriptive';
|
||||
this.is_original = audio_content === 'original' || (!this.is_dubbed && !this.is_descriptive);
|
||||
this.is_secondary = audio_content === 'secondary';
|
||||
this.is_original = audio_content === 'original' || (!this.is_dubbed && !this.is_descriptive && !this.is_secondary && !this.is_drc);
|
||||
}
|
||||
|
||||
// Some text tracks don't have xtags while others do
|
||||
|
||||
@@ -258,7 +258,7 @@ interface RawRun {
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
interface AttributedText {
|
||||
export interface AttributedText {
|
||||
content: string;
|
||||
styleRuns?: StyleRun[];
|
||||
commandRuns?: CommandRun[];
|
||||
|
||||
@@ -55,8 +55,10 @@ export { default as ChannelThumbnailWithLink } from './classes/ChannelThumbnailW
|
||||
export { default as ChannelVideoPlayer } from './classes/ChannelVideoPlayer.js';
|
||||
export { default as Chapter } from './classes/Chapter.js';
|
||||
export { default as ChildVideo } from './classes/ChildVideo.js';
|
||||
export { default as ChipBarView } from './classes/ChipBarView.js';
|
||||
export { default as ChipCloud } from './classes/ChipCloud.js';
|
||||
export { default as ChipCloudChip } from './classes/ChipCloudChip.js';
|
||||
export { default as ChipView } from './classes/ChipView.js';
|
||||
export { default as ClipAdState } from './classes/ClipAdState.js';
|
||||
export { default as ClipCreation } from './classes/ClipCreation.js';
|
||||
export { default as ClipCreationScrubber } from './classes/ClipCreationScrubber.js';
|
||||
@@ -259,8 +261,10 @@ export { default as MusicLargeCardItemCarousel } from './classes/MusicLargeCardI
|
||||
export { default as MusicMultiRowListItem } from './classes/MusicMultiRowListItem.js';
|
||||
export { default as MusicNavigationButton } from './classes/MusicNavigationButton.js';
|
||||
export { default as MusicPlayButton } from './classes/MusicPlayButton.js';
|
||||
export { default as MusicPlaylistEditHeader } from './classes/MusicPlaylistEditHeader.js';
|
||||
export { default as MusicPlaylistShelf } from './classes/MusicPlaylistShelf.js';
|
||||
export { default as MusicQueue } from './classes/MusicQueue.js';
|
||||
export { default as MusicResponsiveHeader } from './classes/MusicResponsiveHeader.js';
|
||||
export { default as MusicResponsiveListItem } from './classes/MusicResponsiveListItem.js';
|
||||
export { default as MusicResponsiveListItemFixedColumn } from './classes/MusicResponsiveListItemFixedColumn.js';
|
||||
export { default as MusicResponsiveListItemFlexColumn } from './classes/MusicResponsiveListItemFlexColumn.js';
|
||||
|
||||
@@ -26,6 +26,7 @@ import Format from './classes/misc/Format.js';
|
||||
import VideoDetails from './classes/misc/VideoDetails.js';
|
||||
import NavigationEndpoint from './classes/NavigationEndpoint.js';
|
||||
import CommentView from './classes/comments/CommentView.js';
|
||||
import MusicThumbnail from './classes/MusicThumbnail.js';
|
||||
|
||||
import type { KeyInfo } from './generator.js';
|
||||
import type { ObservedArray, YTNodeConstructor, YTNode } from './helpers.js';
|
||||
@@ -76,7 +77,8 @@ const IGNORED_LIST = new Set([
|
||||
'BrandVideoSingleton',
|
||||
'StatementBanner',
|
||||
'GuideSigninPromo',
|
||||
'AdsEngagementPanelContent'
|
||||
'AdsEngagementPanelContent',
|
||||
'MiniGameCardView'
|
||||
]);
|
||||
|
||||
const RUNTIME_NODES = new Map<string, YTNodeConstructor>(Object.entries(YTNodes));
|
||||
@@ -93,7 +95,8 @@ let ERROR_HANDLER: ParserErrorHandler = ({ classname, ...context }: ParserError)
|
||||
new InnertubeError(
|
||||
`Something went wrong at ${classname}!\n` +
|
||||
`This is a bug, please report it at ${Platform.shim.info.bugs_url}`, {
|
||||
stack: context.error.stack
|
||||
stack: context.error.stack,
|
||||
classdata: JSON.stringify(context.classdata, null, 2)
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -367,6 +370,11 @@ export function parseResponse<T extends IParsedResponse = IParsedResponse>(data:
|
||||
parsed_data.player_overlays = player_overlays;
|
||||
}
|
||||
|
||||
const background = parseItem(data.background, MusicThumbnail);
|
||||
if (background) {
|
||||
parsed_data.background = background;
|
||||
}
|
||||
|
||||
const playback_tracking = data.playbackTracking ? {
|
||||
videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl,
|
||||
videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl
|
||||
@@ -710,7 +718,10 @@ export function applyCommentsMutations(memo: Memo, mutations: RawNode[]) {
|
||||
.find((mutation) => mutation.payload?.engagementToolbarStateEntityPayload?.key === comment_view.keys.toolbar_state)
|
||||
?.payload?.engagementToolbarStateEntityPayload;
|
||||
|
||||
comment_view.applyMutations(comment_mutation, toolbar_state_mutation);
|
||||
const engagement_toolbar = mutations.find((mutation) => mutation.entityKey === comment_view.keys.toolbar_surface)
|
||||
?.payload?.engagementToolbarSurfaceEntityPayload;
|
||||
|
||||
comment_view.applyMutations(comment_mutation, toolbar_state_mutation, engagement_toolbar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ import type AlertWithButton from '../classes/AlertWithButton.js';
|
||||
import type NavigationEndpoint from '../classes/NavigationEndpoint.js';
|
||||
import type PlayerAnnotationsExpanded from '../classes/PlayerAnnotationsExpanded.js';
|
||||
import type EngagementPanelSectionList from '../classes/EngagementPanelSectionList.js';
|
||||
import type { AppendContinuationItemsAction } from '../nodes.js';
|
||||
import type { AppendContinuationItemsAction, MusicThumbnail } from '../nodes.js';
|
||||
|
||||
export interface IParsedResponse {
|
||||
background?: MusicThumbnail;
|
||||
actions?: SuperParsedResult<YTNode>;
|
||||
actions_memo?: Memo;
|
||||
contents?: SuperParsedResult<YTNode>;
|
||||
@@ -134,6 +135,7 @@ export interface INextResponse {
|
||||
}
|
||||
|
||||
export interface IBrowseResponse {
|
||||
background?: MusicThumbnail;
|
||||
continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation |
|
||||
MusicShelfContinuation | GridContinuation | PlaylistPanelContinuation;
|
||||
continuation_contents_memo?: Memo;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface IRawPlayerConfig {
|
||||
}
|
||||
|
||||
export interface IRawResponse {
|
||||
background?: RawNode;
|
||||
contents?: RawData;
|
||||
onResponseReceivedActions?: RawNode[];
|
||||
onResponseReceivedEndpoints?: RawNode[];
|
||||
|
||||
@@ -3,33 +3,35 @@ import { Parser } from '../index.js';
|
||||
import MicroformatData from '../classes/MicroformatData.js';
|
||||
import MusicCarouselShelf from '../classes/MusicCarouselShelf.js';
|
||||
import MusicDetailHeader from '../classes/MusicDetailHeader.js';
|
||||
import MusicResponsiveHeader from '../classes/MusicResponsiveHeader.js';
|
||||
import MusicShelf from '../classes/MusicShelf.js';
|
||||
import type MusicThumbnail from '../classes/MusicThumbnail.js';
|
||||
|
||||
import type { ApiResponse } from '../../core/index.js';
|
||||
import type { ObservedArray } from '../helpers.js';
|
||||
import { observe, type ObservedArray } from '../helpers.js';
|
||||
import type { IBrowseResponse } from '../types/index.js';
|
||||
import type MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js';
|
||||
|
||||
class Album {
|
||||
#page: IBrowseResponse;
|
||||
|
||||
header?: MusicDetailHeader;
|
||||
header?: MusicDetailHeader | MusicResponsiveHeader;
|
||||
contents: ObservedArray<MusicResponsiveListItem>;
|
||||
sections: ObservedArray<MusicCarouselShelf>;
|
||||
|
||||
url: string | null;
|
||||
background?: MusicThumbnail;
|
||||
url?: string;
|
||||
|
||||
constructor(response: ApiResponse) {
|
||||
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
|
||||
|
||||
this.header = this.#page.header?.item().as(MusicDetailHeader);
|
||||
this.url = this.#page.microformat?.as(MicroformatData).url_canonical || null;
|
||||
|
||||
if (!this.#page.contents_memo)
|
||||
throw new Error('No contents found in the response');
|
||||
|
||||
this.contents = this.#page.contents_memo.getType(MusicShelf)?.first().contents;
|
||||
this.sections = this.#page.contents_memo.getType(MusicCarouselShelf) || [];
|
||||
this.header = this.#page.contents_memo.getType(MusicDetailHeader, MusicResponsiveHeader)?.first();
|
||||
this.contents = this.#page.contents_memo.getType(MusicShelf)?.first().contents || observe([]);
|
||||
this.sections = this.#page.contents_memo.getType(MusicCarouselShelf) || observe([]);
|
||||
this.background = this.#page.background;
|
||||
this.url = this.#page.microformat?.as(MicroformatData).url_canonical;
|
||||
}
|
||||
|
||||
get page(): IBrowseResponse {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Parser } from '../index.js';
|
||||
import { observe } from '../helpers.js';
|
||||
import { InnertubeError } from '../../utils/Utils.js';
|
||||
|
||||
import MusicShelf from '../classes/MusicShelf.js';
|
||||
@@ -10,13 +11,14 @@ import MusicHeader from '../classes/MusicHeader.js';
|
||||
|
||||
import type { ApiResponse, Actions } from '../../core/index.js';
|
||||
import type { IBrowseResponse } from '../types/ParsedResponse.js';
|
||||
import type { ObservedArray } from '../helpers.js';
|
||||
|
||||
class Artist {
|
||||
#page: IBrowseResponse;
|
||||
#actions: Actions;
|
||||
|
||||
header?: MusicImmersiveHeader | MusicVisualHeader | MusicHeader;
|
||||
sections: (MusicCarouselShelf | MusicShelf)[];
|
||||
sections: ObservedArray<MusicCarouselShelf | MusicShelf>;
|
||||
|
||||
constructor(response: ApiResponse, actions: Actions) {
|
||||
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
|
||||
@@ -27,7 +29,7 @@ class Artist {
|
||||
const music_shelf = this.#page.contents_memo?.getType(MusicShelf) || [];
|
||||
const music_carousel_shelf = this.#page.contents_memo?.getType(MusicCarouselShelf) || [];
|
||||
|
||||
this.sections = [ ...music_shelf, ...music_carousel_shelf ];
|
||||
this.sections = observe([ ...music_shelf, ...music_carousel_shelf ]);
|
||||
}
|
||||
|
||||
async getAllSongs(): Promise<MusicPlaylistShelf | undefined> {
|
||||
|
||||
@@ -6,22 +6,25 @@ import MusicEditablePlaylistDetailHeader from '../classes/MusicEditablePlaylistD
|
||||
import MusicPlaylistShelf from '../classes/MusicPlaylistShelf.js';
|
||||
import MusicShelf from '../classes/MusicShelf.js';
|
||||
import SectionList from '../classes/SectionList.js';
|
||||
import MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js';
|
||||
import MusicResponsiveHeader from '../classes/MusicResponsiveHeader.js';
|
||||
|
||||
import { InnertubeError } from '../../utils/Utils.js';
|
||||
import type { ObservedArray, YTNode } from '../helpers.js';
|
||||
import { observe, type ObservedArray } from '../helpers.js';
|
||||
import type { ApiResponse, Actions } from '../../core/index.js';
|
||||
import type { IBrowseResponse } from '../types/index.js';
|
||||
import type MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js';
|
||||
import type MusicThumbnail from '../classes/MusicThumbnail.js';
|
||||
|
||||
class Playlist {
|
||||
#page: IBrowseResponse;
|
||||
#actions: Actions;
|
||||
#continuation: string | null;
|
||||
#last_fetched_suggestions: any;
|
||||
#suggestions_continuation: any;
|
||||
#last_fetched_suggestions: ObservedArray<MusicResponsiveListItem> | null;
|
||||
#suggestions_continuation: string | null;
|
||||
|
||||
header?: MusicDetailHeader;
|
||||
items?: ObservedArray<YTNode> | null;
|
||||
header?: MusicResponsiveHeader | MusicDetailHeader | MusicEditablePlaylistDetailHeader;
|
||||
contents?: ObservedArray<MusicResponsiveListItem>;
|
||||
background?: MusicThumbnail;
|
||||
|
||||
constructor(response: ApiResponse, actions: Actions) {
|
||||
this.#actions = actions;
|
||||
@@ -32,16 +35,17 @@ class Playlist {
|
||||
|
||||
if (this.#page.continuation_contents) {
|
||||
const data = this.#page.continuation_contents?.as(MusicPlaylistShelfContinuation);
|
||||
this.items = data.contents;
|
||||
if (!data.contents)
|
||||
throw new InnertubeError('No contents found in the response');
|
||||
this.contents = data.contents.as(MusicResponsiveListItem);
|
||||
this.#continuation = data.continuation;
|
||||
} else {
|
||||
if (this.#page.header?.item().type === 'MusicEditablePlaylistDetailHeader') {
|
||||
this.header = this.#page.header?.item().as(MusicEditablePlaylistDetailHeader).header?.as(MusicDetailHeader);
|
||||
} else {
|
||||
this.header = this.#page.header?.item().as(MusicDetailHeader);
|
||||
}
|
||||
this.items = this.#page.contents_memo?.getType(MusicPlaylistShelf).first().contents || null;
|
||||
this.#continuation = this.#page.contents_memo?.getType(MusicPlaylistShelf).first().continuation || null;
|
||||
if (!this.#page.contents_memo)
|
||||
throw new InnertubeError('No contents found in the response');
|
||||
this.header = this.#page.contents_memo.getType(MusicResponsiveHeader, MusicEditablePlaylistDetailHeader, MusicDetailHeader)?.first();
|
||||
this.contents = this.#page.contents_memo.getType(MusicPlaylistShelf)?.first()?.contents || observe([]);
|
||||
this.background = this.#page.background;
|
||||
this.#continuation = this.#page.contents_memo.getType(MusicPlaylistShelf)?.first()?.continuation || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +68,12 @@ class Playlist {
|
||||
* Retrieves related playlists
|
||||
*/
|
||||
async getRelated(): Promise<MusicCarouselShelf> {
|
||||
let section_continuation = this.#page.contents_memo?.getType(SectionList)?.[0].continuation;
|
||||
const target_section_list = this.#page.contents_memo?.getType(SectionList).find((section_list) => section_list.continuation);
|
||||
|
||||
if (!target_section_list)
|
||||
throw new InnertubeError('Could not find "Related" section.');
|
||||
|
||||
let section_continuation = target_section_list.continuation;
|
||||
|
||||
while (section_continuation) {
|
||||
const data = await this.#actions.execute('/browse', {
|
||||
@@ -76,7 +85,7 @@ class Playlist {
|
||||
const section_list = data.continuation_contents?.as(SectionListContinuation);
|
||||
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
|
||||
|
||||
const related = sections?.matchCondition((section) => section.is(MusicCarouselShelf))?.as(MusicCarouselShelf);
|
||||
const related = sections?.find((section) => section.is(MusicCarouselShelf))?.as(MusicCarouselShelf);
|
||||
|
||||
if (related)
|
||||
return related;
|
||||
@@ -84,10 +93,10 @@ class Playlist {
|
||||
section_continuation = section_list?.continuation;
|
||||
}
|
||||
|
||||
throw new InnertubeError('Target section not found.');
|
||||
throw new InnertubeError('Could not fetch related playlists.');
|
||||
}
|
||||
|
||||
async getSuggestions(refresh = true) {
|
||||
async getSuggestions(refresh = true): Promise<ObservedArray<MusicResponsiveListItem>> {
|
||||
const require_fetch = refresh || !this.#last_fetched_suggestions;
|
||||
const fetch_promise = require_fetch ? this.#fetchSuggestions() : Promise.resolve(null);
|
||||
const fetch_result = await fetch_promise;
|
||||
@@ -97,11 +106,12 @@ class Playlist {
|
||||
this.#suggestions_continuation = fetch_result.continuation;
|
||||
}
|
||||
|
||||
return fetch_result?.items || this.#last_fetched_suggestions;
|
||||
return fetch_result?.items || this.#last_fetched_suggestions || observe([]);
|
||||
}
|
||||
|
||||
async #fetchSuggestions(): Promise<{ items: never[] | ObservedArray<MusicResponsiveListItem>, continuation: string | null }> {
|
||||
const continuation = this.#suggestions_continuation || this.#page.contents_memo?.get('SectionList')?.[0].as(SectionList).continuation;
|
||||
async #fetchSuggestions(): Promise<{ items: ObservedArray<MusicResponsiveListItem>, continuation: string | null }> {
|
||||
const target_section_list = this.#page.contents_memo?.getType(SectionList).find((section_list) => section_list.continuation);
|
||||
const continuation = this.#suggestions_continuation || target_section_list?.continuation;
|
||||
|
||||
if (continuation) {
|
||||
const page = await this.#actions.execute('/browse', {
|
||||
@@ -113,16 +123,16 @@ class Playlist {
|
||||
const section_list = page.continuation_contents?.as(SectionListContinuation);
|
||||
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
|
||||
|
||||
const suggestions = sections?.matchCondition((section) => section.is(MusicShelf))?.as(MusicShelf);
|
||||
const suggestions = sections?.find((section) => section.is(MusicShelf))?.as(MusicShelf);
|
||||
|
||||
return {
|
||||
items: suggestions?.contents || [],
|
||||
items: suggestions?.contents || observe([]),
|
||||
continuation: suggestions?.continuation || null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
items: [],
|
||||
items: observe([]),
|
||||
continuation: null
|
||||
};
|
||||
}
|
||||
@@ -131,6 +141,10 @@ class Playlist {
|
||||
return this.#page;
|
||||
}
|
||||
|
||||
get items(): ObservedArray<MusicResponsiveListItem> {
|
||||
return this.contents || observe([]);
|
||||
}
|
||||
|
||||
get has_continuation(): boolean {
|
||||
return !!this.#continuation;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ We provide shims for the following platforms:
|
||||
- Modern Browsers
|
||||
- Node.js
|
||||
- Deno
|
||||
- [React-Native](./react-native.md)
|
||||
|
||||
## Contributing Support for a New Platform
|
||||
|
||||
|
||||
@@ -5,17 +5,17 @@ import { Log } from '../lib.js';
|
||||
const TAG = 'JsRuntime';
|
||||
|
||||
export default function evaluate(code: string, env: Record<string, VMPrimative>) {
|
||||
Log.info(TAG, 'Evaluating JavaScript.\n', code);
|
||||
Log.debug(TAG, 'Evaluating JavaScript:\n', code);
|
||||
|
||||
const runtime = new Jinter(code);
|
||||
const runtime = new Jinter();
|
||||
|
||||
for (const [ key, value ] of Object.entries(env)) {
|
||||
runtime.scope.set(key, value);
|
||||
}
|
||||
|
||||
const result = runtime.interpret();
|
||||
const result = runtime.evaluate(code);
|
||||
|
||||
Log.info(TAG, 'Done. Result:', result);
|
||||
Log.debug(TAG, 'Done. Result:', result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
Response,
|
||||
Headers,
|
||||
FormData,
|
||||
File
|
||||
File,
|
||||
setGlobalDispatcher,
|
||||
Agent
|
||||
} from 'undici';
|
||||
import type { ICache } from '../types/Cache.js';
|
||||
import { Platform } from '../utils/Utils.js';
|
||||
@@ -27,6 +29,12 @@ const __dirname__ = is_cjs ? __dirname : path.dirname(fileURLToPath(meta_url));
|
||||
const { homepage, version, bugs } = $INLINE_JSON('../../package.json');
|
||||
const repo_url = homepage?.split('#')[0];
|
||||
|
||||
setGlobalDispatcher(new Agent({
|
||||
connect: {
|
||||
timeout: 60_000
|
||||
}
|
||||
}));
|
||||
|
||||
class Cache implements ICache {
|
||||
#persistent_directory: string;
|
||||
#persistent: boolean;
|
||||
|
||||
98
src/platform/react-native.md
Normal file
98
src/platform/react-native.md
Normal file
@@ -0,0 +1,98 @@
|
||||
Making the `Youtube.js` work in React-Native involves polyfilling number of APIs and using MMKV as an underlying storage for cache.
|
||||
Below configuration is tested with `"react-native": "0.73.2`
|
||||
|
||||
Following polyfills are required to be installed:
|
||||
|
||||
```
|
||||
"base-64": "^1.0.0",
|
||||
"event-target-polyfill": "^0.0.4",
|
||||
"react-native-mmkv": "^2.11.0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"text-encoding-polyfill": "^0.6.7",
|
||||
"web-streams-polyfill": "^3.3.2"
|
||||
```
|
||||
|
||||
And following `devDependencies`:
|
||||
```
|
||||
"@types/base-64": "^1.0.2",
|
||||
"@babel/plugin-syntax-import-attributes": "^7.23.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.23.4",
|
||||
```
|
||||
|
||||
Adding `unstable_enablePackageExports: true` flag to Metro is required as well, because `Youtube.js` uses package exports feature in `package.json`.
|
||||
|
||||
`metro.config.js`:
|
||||
```js
|
||||
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
|
||||
|
||||
const config = {
|
||||
resolver: {
|
||||
sourceExts: ['jsx', 'js', 'ts', 'tsx', 'cjs', 'json', 'd.ts'],
|
||||
unstable_enablePackageExports: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
|
||||
```
|
||||
|
||||
`babel.config.js`:
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
['@babel/plugin-syntax-import-attributes', {deprecatedAssertSyntax: true}],
|
||||
'@babel/plugin-transform-export-namespace-from',
|
||||
],
|
||||
presets: ['module:@react-native/babel-preset'],
|
||||
};
|
||||
```
|
||||
|
||||
Below is the sample file that loads all of the polyfills, makes `MMKV` storage globally available (to be used by `Cache` in `Youtube.js`) and inits the `Innertube` instance.
|
||||
|
||||
```typescript
|
||||
// === START === Making Youtube.js work
|
||||
import 'event-target-polyfill';
|
||||
import 'web-streams-polyfill';
|
||||
import 'text-encoding-polyfill';
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import {decode, encode} from 'base-64';
|
||||
|
||||
if (!global.btoa) {
|
||||
global.btoa = encode;
|
||||
}
|
||||
|
||||
if (!global.atob) {
|
||||
global.atob = decode;
|
||||
}
|
||||
|
||||
import {MMKV} from 'react-native-mmkv';
|
||||
// @ts-expect-error to avoid typings' fuss
|
||||
global.mmkvStorage = MMKV as any;
|
||||
|
||||
// See https://github.com/nodejs/node/issues/40678#issuecomment-1126944677
|
||||
class CustomEvent extends Event {
|
||||
#detail;
|
||||
|
||||
constructor(type: string, options?: CustomEventInit<any[]>) {
|
||||
super(type, options);
|
||||
this.#detail = options?.detail ?? null;
|
||||
}
|
||||
|
||||
get detail() {
|
||||
return this.#detail;
|
||||
}
|
||||
}
|
||||
|
||||
global.CustomEvent = CustomEvent as any;
|
||||
|
||||
// === END === Making Youtube.js work
|
||||
|
||||
import Innertube, {UniversalCache} from 'youtubei.js';
|
||||
|
||||
let innertube: Promise<Innertube> = Innertube.create({
|
||||
cache: new UniversalCache(false),
|
||||
generate_session_locally: true,
|
||||
});
|
||||
|
||||
export default innertube;
|
||||
|
||||
```
|
||||
79
src/platform/react-native.ts
Normal file
79
src/platform/react-native.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// React Native Platform Support
|
||||
import type { ICache } from '../types/Cache.js';
|
||||
import { Platform } from '../utils/Utils.js';
|
||||
import sha1Hash from './polyfills/web-crypto.js';
|
||||
import package_json from '../../package.json' assert { type: 'json' };
|
||||
import evaluate from './jsruntime/jinter.js';
|
||||
|
||||
class Cache implements ICache {
|
||||
#persistent_directory: string;
|
||||
#persistent: boolean;
|
||||
|
||||
constructor(persistent = false, persistent_directory?: string) {
|
||||
this.#persistent_directory = persistent_directory || '';
|
||||
this.#persistent = persistent;
|
||||
}
|
||||
|
||||
get cache_dir() {
|
||||
return this.#persistent ? this.#persistent_directory : '';
|
||||
}
|
||||
|
||||
#getStorage() {
|
||||
const storage = new ((globalThis as any).mmkvStorage as any)({ id: 'InnertubeCache' });
|
||||
return storage;
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
const storage = this.#getStorage();
|
||||
return storage.getBuffer(key)?.buffer;
|
||||
}
|
||||
|
||||
async set(key: string, value: ArrayBuffer) {
|
||||
const storage = this.#getStorage();
|
||||
storage.set(key, new Uint8Array(value));
|
||||
}
|
||||
|
||||
async remove(key: string) {
|
||||
const storage = this.#getStorage();
|
||||
storage.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
Platform.load({
|
||||
runtime: 'react-native',
|
||||
server: false,
|
||||
info: {
|
||||
version: package_json.version,
|
||||
bugs_url: package_json.bugs.url,
|
||||
repo_url: package_json.homepage.split('#')[0]
|
||||
},
|
||||
Cache: Cache,
|
||||
sha1Hash,
|
||||
uuidv4() {
|
||||
if (globalThis.crypto?.randomUUID()) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
// See https://stackoverflow.com/a/2117523
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (cc) => {
|
||||
const c = parseInt(cc);
|
||||
return (
|
||||
c ^
|
||||
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
|
||||
).toString(16);
|
||||
});
|
||||
},
|
||||
eval: evaluate,
|
||||
fetch: globalThis.fetch,
|
||||
Request: globalThis.Request,
|
||||
Response: globalThis.Response,
|
||||
Headers: globalThis.Headers,
|
||||
FormData: globalThis.FormData,
|
||||
File: globalThis.File,
|
||||
ReadableStream: globalThis.ReadableStream,
|
||||
CustomEvent: globalThis.CustomEvent
|
||||
});
|
||||
|
||||
export * from './lib.js';
|
||||
import Innertube from './lib.js';
|
||||
export default Innertube;
|
||||
@@ -21,6 +21,7 @@ export declare namespace $.youtube.GetCommentsSectionParams.Params {
|
||||
videoId: string;
|
||||
sortBy: number;
|
||||
type: number;
|
||||
commentId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ export function getDefaultValue(): $.youtube.GetCommentsSectionParams.Params.Opt
|
||||
videoId: "",
|
||||
sortBy: 0,
|
||||
type: 0,
|
||||
commentId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ export function encodeJson(value: $.youtube.GetCommentsSectionParams.Params.Opti
|
||||
if (value.videoId !== undefined) result.videoId = tsValueToJsonValueFns.string(value.videoId);
|
||||
if (value.sortBy !== undefined) result.sortBy = tsValueToJsonValueFns.int32(value.sortBy);
|
||||
if (value.type !== undefined) result.type = tsValueToJsonValueFns.int32(value.type);
|
||||
if (value.commentId !== undefined) result.commentId = tsValueToJsonValueFns.string(value.commentId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -54,6 +57,7 @@ export function decodeJson(value: any): $.youtube.GetCommentsSectionParams.Param
|
||||
if (value.videoId !== undefined) result.videoId = jsonValueToTsValueFns.string(value.videoId);
|
||||
if (value.sortBy !== undefined) result.sortBy = jsonValueToTsValueFns.int32(value.sortBy);
|
||||
if (value.type !== undefined) result.type = jsonValueToTsValueFns.int32(value.type);
|
||||
if (value.commentId !== undefined) result.commentId = jsonValueToTsValueFns.string(value.commentId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -77,6 +81,12 @@ export function encodeBinary(value: $.youtube.GetCommentsSectionParams.Params.Op
|
||||
[15, tsValueToWireValueFns.int32(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.commentId !== undefined) {
|
||||
const tsValue = value.commentId;
|
||||
result.push(
|
||||
[16, tsValueToWireValueFns.string(tsValue)],
|
||||
);
|
||||
}
|
||||
return serialize(result);
|
||||
}
|
||||
|
||||
@@ -105,5 +115,12 @@ export function decodeBinary(binary: Uint8Array): $.youtube.GetCommentsSectionPa
|
||||
if (value === undefined) break field;
|
||||
result.type = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(16);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.string(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.commentId = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,17 @@ import {
|
||||
|
||||
export declare namespace $.youtube.InnertubePayload.Context {
|
||||
export type Client = {
|
||||
unkparam: number;
|
||||
deviceMake: string;
|
||||
deviceModel: string;
|
||||
nameId: number;
|
||||
clientVersion: string;
|
||||
clientName: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
acceptLanguage: string;
|
||||
acceptRegion: string;
|
||||
androidSdkVersion: number;
|
||||
windowWidthPoints: number;
|
||||
windowHeightPoints: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +36,17 @@ export type Type = $.youtube.InnertubePayload.Context.Client;
|
||||
|
||||
export function getDefaultValue(): $.youtube.InnertubePayload.Context.Client {
|
||||
return {
|
||||
unkparam: 0,
|
||||
deviceMake: "",
|
||||
deviceModel: "",
|
||||
nameId: 0,
|
||||
clientVersion: "",
|
||||
clientName: "",
|
||||
osName: "",
|
||||
osVersion: "",
|
||||
acceptLanguage: "",
|
||||
acceptRegion: "",
|
||||
androidSdkVersion: 0,
|
||||
windowWidthPoints: 0,
|
||||
windowHeightPoints: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,24 +59,52 @@ export function createValue(partialValue: Partial<$.youtube.InnertubePayload.Con
|
||||
|
||||
export function encodeJson(value: $.youtube.InnertubePayload.Context.Client): unknown {
|
||||
const result: any = {};
|
||||
if (value.unkparam !== undefined) result.unkparam = tsValueToJsonValueFns.int32(value.unkparam);
|
||||
if (value.deviceMake !== undefined) result.deviceMake = tsValueToJsonValueFns.string(value.deviceMake);
|
||||
if (value.deviceModel !== undefined) result.deviceModel = tsValueToJsonValueFns.string(value.deviceModel);
|
||||
if (value.nameId !== undefined) result.nameId = tsValueToJsonValueFns.int32(value.nameId);
|
||||
if (value.clientVersion !== undefined) result.clientVersion = tsValueToJsonValueFns.string(value.clientVersion);
|
||||
if (value.clientName !== undefined) result.clientName = tsValueToJsonValueFns.string(value.clientName);
|
||||
if (value.osName !== undefined) result.osName = tsValueToJsonValueFns.string(value.osName);
|
||||
if (value.osVersion !== undefined) result.osVersion = tsValueToJsonValueFns.string(value.osVersion);
|
||||
if (value.acceptLanguage !== undefined) result.acceptLanguage = tsValueToJsonValueFns.string(value.acceptLanguage);
|
||||
if (value.acceptRegion !== undefined) result.acceptRegion = tsValueToJsonValueFns.string(value.acceptRegion);
|
||||
if (value.androidSdkVersion !== undefined) result.androidSdkVersion = tsValueToJsonValueFns.int32(value.androidSdkVersion);
|
||||
if (value.windowWidthPoints !== undefined) result.windowWidthPoints = tsValueToJsonValueFns.int32(value.windowWidthPoints);
|
||||
if (value.windowHeightPoints !== undefined) result.windowHeightPoints = tsValueToJsonValueFns.int32(value.windowHeightPoints);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function decodeJson(value: any): $.youtube.InnertubePayload.Context.Client {
|
||||
const result = getDefaultValue();
|
||||
if (value.unkparam !== undefined) result.unkparam = jsonValueToTsValueFns.int32(value.unkparam);
|
||||
if (value.deviceMake !== undefined) result.deviceMake = jsonValueToTsValueFns.string(value.deviceMake);
|
||||
if (value.deviceModel !== undefined) result.deviceModel = jsonValueToTsValueFns.string(value.deviceModel);
|
||||
if (value.nameId !== undefined) result.nameId = jsonValueToTsValueFns.int32(value.nameId);
|
||||
if (value.clientVersion !== undefined) result.clientVersion = jsonValueToTsValueFns.string(value.clientVersion);
|
||||
if (value.clientName !== undefined) result.clientName = jsonValueToTsValueFns.string(value.clientName);
|
||||
if (value.osName !== undefined) result.osName = jsonValueToTsValueFns.string(value.osName);
|
||||
if (value.osVersion !== undefined) result.osVersion = jsonValueToTsValueFns.string(value.osVersion);
|
||||
if (value.acceptLanguage !== undefined) result.acceptLanguage = jsonValueToTsValueFns.string(value.acceptLanguage);
|
||||
if (value.acceptRegion !== undefined) result.acceptRegion = jsonValueToTsValueFns.string(value.acceptRegion);
|
||||
if (value.androidSdkVersion !== undefined) result.androidSdkVersion = jsonValueToTsValueFns.int32(value.androidSdkVersion);
|
||||
if (value.windowWidthPoints !== undefined) result.windowWidthPoints = jsonValueToTsValueFns.int32(value.windowWidthPoints);
|
||||
if (value.windowHeightPoints !== undefined) result.windowHeightPoints = jsonValueToTsValueFns.int32(value.windowHeightPoints);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function encodeBinary(value: $.youtube.InnertubePayload.Context.Client): Uint8Array {
|
||||
const result: WireMessage = [];
|
||||
if (value.unkparam !== undefined) {
|
||||
const tsValue = value.unkparam;
|
||||
if (value.deviceMake !== undefined) {
|
||||
const tsValue = value.deviceMake;
|
||||
result.push(
|
||||
[12, tsValueToWireValueFns.string(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.deviceModel !== undefined) {
|
||||
const tsValue = value.deviceModel;
|
||||
result.push(
|
||||
[13, tsValueToWireValueFns.string(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.nameId !== undefined) {
|
||||
const tsValue = value.nameId;
|
||||
result.push(
|
||||
[16, tsValueToWireValueFns.int32(tsValue)],
|
||||
);
|
||||
@@ -71,12 +115,48 @@ export function encodeBinary(value: $.youtube.InnertubePayload.Context.Client):
|
||||
[17, tsValueToWireValueFns.string(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.clientName !== undefined) {
|
||||
const tsValue = value.clientName;
|
||||
if (value.osName !== undefined) {
|
||||
const tsValue = value.osName;
|
||||
result.push(
|
||||
[18, tsValueToWireValueFns.string(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.osVersion !== undefined) {
|
||||
const tsValue = value.osVersion;
|
||||
result.push(
|
||||
[19, tsValueToWireValueFns.string(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.acceptLanguage !== undefined) {
|
||||
const tsValue = value.acceptLanguage;
|
||||
result.push(
|
||||
[21, tsValueToWireValueFns.string(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.acceptRegion !== undefined) {
|
||||
const tsValue = value.acceptRegion;
|
||||
result.push(
|
||||
[22, tsValueToWireValueFns.string(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.androidSdkVersion !== undefined) {
|
||||
const tsValue = value.androidSdkVersion;
|
||||
result.push(
|
||||
[34, tsValueToWireValueFns.int32(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.windowWidthPoints !== undefined) {
|
||||
const tsValue = value.windowWidthPoints;
|
||||
result.push(
|
||||
[37, tsValueToWireValueFns.int32(tsValue)],
|
||||
);
|
||||
}
|
||||
if (value.windowHeightPoints !== undefined) {
|
||||
const tsValue = value.windowHeightPoints;
|
||||
result.push(
|
||||
[38, tsValueToWireValueFns.int32(tsValue)],
|
||||
);
|
||||
}
|
||||
return serialize(result);
|
||||
}
|
||||
|
||||
@@ -84,12 +164,26 @@ export function decodeBinary(binary: Uint8Array): $.youtube.InnertubePayload.Con
|
||||
const result = getDefaultValue();
|
||||
const wireMessage = deserialize(binary);
|
||||
const wireFields = new Map(wireMessage);
|
||||
field: {
|
||||
const wireValue = wireFields.get(12);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.string(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.deviceMake = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(13);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.string(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.deviceModel = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(16);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.int32(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.unkparam = value;
|
||||
result.nameId = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(17);
|
||||
@@ -103,7 +197,49 @@ export function decodeBinary(binary: Uint8Array): $.youtube.InnertubePayload.Con
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.string(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.clientName = value;
|
||||
result.osName = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(19);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.string(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.osVersion = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(21);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.string(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.acceptLanguage = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(22);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.string(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.acceptRegion = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(34);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.int32(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.androidSdkVersion = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(37);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.int32(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.windowWidthPoints = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(38);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.int32(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.windowHeightPoints = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ import {
|
||||
export declare namespace $.youtube {
|
||||
export type InnertubePayload = {
|
||||
context?: Context;
|
||||
target?: string;
|
||||
videoId?: string;
|
||||
title?: Title;
|
||||
description?: Description;
|
||||
tags?: Tags;
|
||||
@@ -100,6 +100,7 @@ export declare namespace $.youtube {
|
||||
privacy?: Privacy;
|
||||
madeForKids?: MadeForKids;
|
||||
ageRestricted?: AgeRestricted;
|
||||
field83?: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ export type Type = $.youtube.InnertubePayload;
|
||||
export function getDefaultValue(): $.youtube.InnertubePayload {
|
||||
return {
|
||||
context: undefined,
|
||||
target: undefined,
|
||||
videoId: undefined,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
tags: undefined,
|
||||
@@ -118,6 +119,7 @@ export function getDefaultValue(): $.youtube.InnertubePayload {
|
||||
privacy: undefined,
|
||||
madeForKids: undefined,
|
||||
ageRestricted: undefined,
|
||||
field83: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +133,7 @@ export function createValue(partialValue: Partial<$.youtube.InnertubePayload>):
|
||||
export function encodeJson(value: $.youtube.InnertubePayload): unknown {
|
||||
const result: any = {};
|
||||
if (value.context !== undefined) result.context = encodeJson_1(value.context);
|
||||
if (value.target !== undefined) result.target = tsValueToJsonValueFns.string(value.target);
|
||||
if (value.videoId !== undefined) result.videoId = tsValueToJsonValueFns.string(value.videoId);
|
||||
if (value.title !== undefined) result.title = encodeJson_2(value.title);
|
||||
if (value.description !== undefined) result.description = encodeJson_3(value.description);
|
||||
if (value.tags !== undefined) result.tags = encodeJson_4(value.tags);
|
||||
@@ -141,13 +143,14 @@ export function encodeJson(value: $.youtube.InnertubePayload): unknown {
|
||||
if (value.privacy !== undefined) result.privacy = encodeJson_8(value.privacy);
|
||||
if (value.madeForKids !== undefined) result.madeForKids = encodeJson_9(value.madeForKids);
|
||||
if (value.ageRestricted !== undefined) result.ageRestricted = encodeJson_10(value.ageRestricted);
|
||||
if (value.field83 !== undefined) result.field83 = tsValueToJsonValueFns.int32(value.field83);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function decodeJson(value: any): $.youtube.InnertubePayload {
|
||||
const result = getDefaultValue();
|
||||
if (value.context !== undefined) result.context = decodeJson_1(value.context);
|
||||
if (value.target !== undefined) result.target = jsonValueToTsValueFns.string(value.target);
|
||||
if (value.videoId !== undefined) result.videoId = jsonValueToTsValueFns.string(value.videoId);
|
||||
if (value.title !== undefined) result.title = decodeJson_2(value.title);
|
||||
if (value.description !== undefined) result.description = decodeJson_3(value.description);
|
||||
if (value.tags !== undefined) result.tags = decodeJson_4(value.tags);
|
||||
@@ -157,6 +160,7 @@ export function decodeJson(value: any): $.youtube.InnertubePayload {
|
||||
if (value.privacy !== undefined) result.privacy = decodeJson_8(value.privacy);
|
||||
if (value.madeForKids !== undefined) result.madeForKids = decodeJson_9(value.madeForKids);
|
||||
if (value.ageRestricted !== undefined) result.ageRestricted = decodeJson_10(value.ageRestricted);
|
||||
if (value.field83 !== undefined) result.field83 = jsonValueToTsValueFns.int32(value.field83);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -168,8 +172,8 @@ export function encodeBinary(value: $.youtube.InnertubePayload): Uint8Array {
|
||||
[1, { type: WireType.LengthDelimited as const, value: encodeBinary_1(tsValue) }],
|
||||
);
|
||||
}
|
||||
if (value.target !== undefined) {
|
||||
const tsValue = value.target;
|
||||
if (value.videoId !== undefined) {
|
||||
const tsValue = value.videoId;
|
||||
result.push(
|
||||
[2, tsValueToWireValueFns.string(tsValue)],
|
||||
);
|
||||
@@ -228,6 +232,12 @@ export function encodeBinary(value: $.youtube.InnertubePayload): Uint8Array {
|
||||
[69, { type: WireType.LengthDelimited as const, value: encodeBinary_10(tsValue) }],
|
||||
);
|
||||
}
|
||||
if (value.field83 !== undefined) {
|
||||
const tsValue = value.field83;
|
||||
result.push(
|
||||
[83, tsValueToWireValueFns.int32(tsValue)],
|
||||
);
|
||||
}
|
||||
return serialize(result);
|
||||
}
|
||||
|
||||
@@ -247,7 +257,7 @@ export function decodeBinary(binary: Uint8Array): $.youtube.InnertubePayload {
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.string(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.target = value;
|
||||
result.videoId = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(3);
|
||||
@@ -312,5 +322,12 @@ export function decodeBinary(binary: Uint8Array): $.youtube.InnertubePayload {
|
||||
if (value === undefined) break field;
|
||||
result.ageRestricted = value;
|
||||
}
|
||||
field: {
|
||||
const wireValue = wireFields.get(83);
|
||||
if (wireValue === undefined) break field;
|
||||
const value = wireValueToTsValueFns.int32(wireValue);
|
||||
if (value === undefined) break field;
|
||||
result.field83 = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,8 @@ export function encodeMessageParams(channel_id: string, video_id: string): strin
|
||||
|
||||
export function encodeCommentsSectionParams(video_id: string, options: {
|
||||
type?: number,
|
||||
sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'
|
||||
sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST',
|
||||
comment_id?: string
|
||||
} = {}): string {
|
||||
const sort_options = {
|
||||
TOP_COMMENTS: 0,
|
||||
@@ -171,7 +172,8 @@ export function encodeCommentsSectionParams(video_id: string, options: {
|
||||
opts: {
|
||||
videoId: video_id,
|
||||
sortBy: sort_options[options.sort_by || 'TOP_COMMENTS'],
|
||||
type: options.type || 2
|
||||
type: options.type || 2,
|
||||
commentId: options.comment_id || ''
|
||||
},
|
||||
target: 'comments-section'
|
||||
}
|
||||
@@ -240,12 +242,20 @@ export function encodeVideoMetadataPayload(video_id: string, metadata: UpdateVid
|
||||
const data: InnertubePayload.Type = {
|
||||
context: {
|
||||
client: {
|
||||
unkparam: 14,
|
||||
clientName: CLIENTS.ANDROID.NAME,
|
||||
clientVersion: CLIENTS.YTSTUDIO_ANDROID.VERSION
|
||||
nameId: 3,
|
||||
osName: 'Android',
|
||||
androidSdkVersion: CLIENTS.ANDROID.SDK_VERSION,
|
||||
osVersion: '13',
|
||||
acceptLanguage: 'en-US',
|
||||
acceptRegion: 'US',
|
||||
deviceMake: 'Google',
|
||||
deviceModel: 'sdk_gphone64_x86_64',
|
||||
windowHeightPoints: 840,
|
||||
windowWidthPoints: 432,
|
||||
clientVersion: CLIENTS.ANDROID.VERSION
|
||||
}
|
||||
},
|
||||
target: video_id
|
||||
videoId: video_id
|
||||
};
|
||||
|
||||
if (Reflect.has(metadata, 'title'))
|
||||
@@ -302,12 +312,20 @@ export function encodeCustomThumbnailPayload(video_id: string, bytes: Uint8Array
|
||||
const data: InnertubePayload.Type = {
|
||||
context: {
|
||||
client: {
|
||||
unkparam: 14,
|
||||
clientName: CLIENTS.ANDROID.NAME,
|
||||
clientVersion: CLIENTS.YTSTUDIO_ANDROID.VERSION
|
||||
nameId: 3,
|
||||
osName: 'Android',
|
||||
androidSdkVersion: CLIENTS.ANDROID.SDK_VERSION,
|
||||
osVersion: '13',
|
||||
acceptLanguage: 'en-US',
|
||||
acceptRegion: 'US',
|
||||
deviceMake: 'Google',
|
||||
deviceModel: 'sdk_gphone64_x86_64',
|
||||
windowHeightPoints: 840,
|
||||
windowWidthPoints: 432,
|
||||
clientVersion: CLIENTS.ANDROID.VERSION
|
||||
}
|
||||
},
|
||||
target: video_id,
|
||||
videoId: video_id,
|
||||
videoThumbnail: {
|
||||
type: 3,
|
||||
thumbnail: {
|
||||
|
||||
@@ -11,17 +11,24 @@ message VisitorData {
|
||||
message InnertubePayload {
|
||||
message Context {
|
||||
message Client {
|
||||
required int32 unkparam = 16;
|
||||
required string client_version = 17;
|
||||
required string client_name = 18;
|
||||
string deviceMake = 12;
|
||||
string deviceModel = 13;
|
||||
int32 nameId = 16;
|
||||
string clientVersion = 17;
|
||||
string osName = 18;
|
||||
string osVersion = 19;
|
||||
string acceptLanguage = 21;
|
||||
string acceptRegion = 22;
|
||||
int32 windowWidthPoints = 37;
|
||||
int32 windowHeightPoints = 38;
|
||||
int32 androidSdkVersion = 34;
|
||||
}
|
||||
required Client client = 1;
|
||||
}
|
||||
|
||||
required Context context = 1;
|
||||
|
||||
// This can be either a target id or a video id.
|
||||
optional string target = 2;
|
||||
optional string videoId = 2;
|
||||
|
||||
/**** YT Sudio stuff ****/
|
||||
|
||||
@@ -155,6 +162,7 @@ message GetCommentsSectionParams {
|
||||
required string video_id = 4;
|
||||
required int32 sort_by = 6;
|
||||
required int32 type = 15;
|
||||
optional string comment_id = 16;
|
||||
}
|
||||
|
||||
message RepliesOptions {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface DashOptions {
|
||||
import type { StreamingInfoOptions } from './StreamingInfoOptions.js';
|
||||
|
||||
export interface DashOptions extends StreamingInfoOptions {
|
||||
/**
|
||||
* Include the storyboards in the DASH manifest when YouTube provides them.
|
||||
* Not all players support parsing and displaying storyboards.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ICacheConstructor } from './Cache.js';
|
||||
|
||||
export type Runtime = 'deno' | 'node' | 'browser' | 'cf-worker' | 'unknown';
|
||||
export type Runtime = 'deno' | 'node' | 'browser' | 'cf-worker' | 'unknown' | 'react-native';
|
||||
|
||||
export type FetchFunction = typeof fetch;
|
||||
|
||||
|
||||
30
src/types/StreamingInfoOptions.ts
Normal file
30
src/types/StreamingInfoOptions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface StreamingInfoOptions {
|
||||
/**
|
||||
* The format to use for the captions, when the video has captions.
|
||||
* If this option is not set, the DASH manifest will not include the captions.
|
||||
*
|
||||
* Possible values:
|
||||
* * `vtt`: Tells YouTube to return the captions in the WebVTT format
|
||||
* * `ttml`: Tells YouTube to return the captions in the TTML format
|
||||
*/
|
||||
captions_format?: 'vtt' | 'ttml';
|
||||
/**
|
||||
* The label to use for the non-DRC streams when a video has DRC and streams.
|
||||
*
|
||||
* Defaults to `"Original"`
|
||||
*/
|
||||
label_original?: string;
|
||||
/**
|
||||
* The label to use for the DRC streams when a video has DRC streams.
|
||||
*
|
||||
* Defaults to `"Stable Volume"`
|
||||
*/
|
||||
label_drc?: string;
|
||||
/**
|
||||
* A function that generates the label to use for the DRC streams when a video has multiple audio tracks and DRC streams.
|
||||
* The non-DRC streams use the unmodified audio track label provided by YouTube.
|
||||
*
|
||||
* Defaults to `(audio_track_display_name) => audio_track_display_name + " (Stable Volume)"`
|
||||
*/
|
||||
label_drc_mutiple?: (audio_track_display_name: string) => string;
|
||||
}
|
||||
@@ -16,30 +16,21 @@ export const URLS = Object.freeze({
|
||||
})
|
||||
});
|
||||
export const OAUTH = Object.freeze({
|
||||
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
|
||||
GRANT_TYPE: 'http://oauth.net/grant_type/device/1.0',
|
||||
MODEL_NAME: 'ytlr::',
|
||||
HEADERS: Object.freeze({
|
||||
'accept': '*/*',
|
||||
'origin': 'https://www.youtube.com',
|
||||
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
|
||||
'content-type': 'application/json',
|
||||
'referer': 'https://www.youtube.com/tv',
|
||||
'accept-language': 'en-US'
|
||||
}),
|
||||
REGEX: Object.freeze({
|
||||
AUTH_SCRIPT: /<script id="base-js" src="(.*?)" nonce=".*?"><\/script>/,
|
||||
CLIENT_IDENTITY: /var .+?={clientId:"(?<client_id>.+?)",.+?:"(?<client_secret>.+?)".+?}/
|
||||
TV_SCRIPT: new RegExp('<script\\s+id="base-js"\\s+src="([^"]+)"[^>]*><\\/script>'),
|
||||
CLIENT_IDENTITY: new RegExp('clientId:"(?<client_id>[^"]+)",[^"]*?:"(?<client_secret>[^"]+)"')
|
||||
})
|
||||
});
|
||||
export const CLIENTS = Object.freeze({
|
||||
iOS: {
|
||||
NAME_ID: '5',
|
||||
NAME: 'iOS',
|
||||
VERSION: '18.06.35',
|
||||
USER_AGENT: 'com.google.ios.youtube/18.06.35 (iPhone; CPU iPhone OS 14_4 like Mac OS X; en_US)',
|
||||
DEVICE_MODEL: 'iPhone10,6'
|
||||
},
|
||||
WEB: {
|
||||
NAME_ID: '1',
|
||||
NAME: 'WEB',
|
||||
VERSION: '2.20240111.09.00',
|
||||
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||
@@ -47,28 +38,34 @@ export const CLIENTS = Object.freeze({
|
||||
STATIC_VISITOR_ID: '6zpwvWUNAco'
|
||||
},
|
||||
WEB_KIDS: {
|
||||
NAME_ID: '76',
|
||||
NAME: 'WEB_KIDS',
|
||||
VERSION: '2.20230111.00.00'
|
||||
},
|
||||
YTMUSIC: {
|
||||
NAME_ID: '67',
|
||||
NAME: 'WEB_REMIX',
|
||||
VERSION: '1.20211213.00.00'
|
||||
},
|
||||
ANDROID: {
|
||||
NAME_ID: '3',
|
||||
NAME: 'ANDROID',
|
||||
VERSION: '18.48.37',
|
||||
SDK_VERSION: 33,
|
||||
USER_AGENT: 'com.google.android.youtube/18.48.37(Linux; U; Android 13; en_US; sdk_gphone64_x86_64 Build/UPB4.230623.005) gzip'
|
||||
},
|
||||
YTSTUDIO_ANDROID: {
|
||||
NAME_ID: '14',
|
||||
NAME: 'ANDROID_CREATOR',
|
||||
VERSION: '22.43.101'
|
||||
},
|
||||
YTMUSIC_ANDROID: {
|
||||
NAME_ID: '21',
|
||||
NAME: 'ANDROID_MUSIC',
|
||||
VERSION: '5.34.51'
|
||||
},
|
||||
TV_EMBEDDED: {
|
||||
NAME_ID: '85',
|
||||
NAME: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',
|
||||
VERSION: '2.0'
|
||||
}
|
||||
|
||||
@@ -12,16 +12,20 @@ import type { PlayerStoryboardSpec } from '../parser/nodes.js';
|
||||
import type { SegmentInfo as FSegmentInfo } from './StreamingInfo.js';
|
||||
import type { FormatFilter, URLTransformer } from '../types/FormatUtils.js';
|
||||
import type PlayerLiveStoryboardSpec from '../parser/classes/PlayerLiveStoryboardSpec.js';
|
||||
import type { StreamingInfoOptions } from '../types/StreamingInfoOptions.js';
|
||||
import type { CaptionTrackData } from '../parser/classes/PlayerCaptionsTracklist.js';
|
||||
|
||||
interface DashManifestProps {
|
||||
streamingData: IStreamingData;
|
||||
isPostLiveDvr: boolean;
|
||||
transformURL?: URLTransformer;
|
||||
rejectFormat?: FormatFilter;
|
||||
options?: StreamingInfoOptions,
|
||||
cpn?: string;
|
||||
player?: Player;
|
||||
actions?: Actions;
|
||||
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
|
||||
captionTracks?: CaptionTrackData[];
|
||||
}
|
||||
|
||||
async function OTFPostLiveDvrSegmentInfo({ info }: { info: FSegmentInfo }) {
|
||||
@@ -70,14 +74,17 @@ async function DashManifest({
|
||||
cpn,
|
||||
player,
|
||||
actions,
|
||||
storyboards
|
||||
storyboards,
|
||||
captionTracks,
|
||||
options
|
||||
}: DashManifestProps) {
|
||||
const {
|
||||
getDuration,
|
||||
audio_sets,
|
||||
video_sets,
|
||||
image_sets
|
||||
} = getStreamingInfo(streamingData, isPostLiveDvr, transformURL, rejectFormat, cpn, player, actions, storyboards);
|
||||
image_sets,
|
||||
text_sets
|
||||
} = getStreamingInfo(streamingData, isPostLiveDvr, transformURL, rejectFormat, cpn, player, actions, storyboards, captionTracks, options);
|
||||
|
||||
// XXX: DASH spec: https://standards.iso.org/ittf/PubliclyAvailableStandards/c083314_ISO_IEC%2023009-1_2022(en).zip
|
||||
|
||||
@@ -104,11 +111,12 @@ async function DashManifest({
|
||||
contentType="audio"
|
||||
>
|
||||
{
|
||||
set.track_role &&
|
||||
<role
|
||||
schemeIdUri="urn:mpeg:dash:role:2011"
|
||||
value={set.track_role}
|
||||
/>
|
||||
set.track_roles && set.track_roles.map((role) => (
|
||||
<role
|
||||
schemeIdUri="urn:mpeg:dash:role:2011"
|
||||
value={role}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{
|
||||
set.track_name &&
|
||||
@@ -225,6 +233,36 @@ async function DashManifest({
|
||||
</adaptation-set>;
|
||||
})
|
||||
}
|
||||
{
|
||||
text_sets.map((set, index) => {
|
||||
return <adaptation-set
|
||||
id={index + audio_sets.length + video_sets.length + image_sets.length}
|
||||
mimeType={set.mime_type}
|
||||
lang={set.language}
|
||||
contentType="text"
|
||||
>
|
||||
{
|
||||
set.track_roles.map((role) => (
|
||||
<role
|
||||
schemeIdUri="urn:mpeg:dash:role:2011"
|
||||
value={role}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<label id={index + audio_sets.length}>
|
||||
{set.track_name}
|
||||
</label>
|
||||
<representation
|
||||
id={set.representation.uid}
|
||||
bandwidth="0"
|
||||
>
|
||||
<base-url>
|
||||
{set.representation.base_url}
|
||||
</base-url>
|
||||
</representation>
|
||||
</adaptation-set>;
|
||||
})
|
||||
}
|
||||
</period>
|
||||
</mpd>;
|
||||
}
|
||||
@@ -237,7 +275,9 @@ export function toDash(
|
||||
cpn?: string,
|
||||
player?: Player,
|
||||
actions?: Actions,
|
||||
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec
|
||||
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec,
|
||||
caption_tracks?: CaptionTrackData[],
|
||||
options?: StreamingInfoOptions
|
||||
) {
|
||||
if (!streaming_data)
|
||||
throw new InnertubeError('Streaming data not available');
|
||||
@@ -247,11 +287,13 @@ export function toDash(
|
||||
streamingData={streaming_data}
|
||||
isPostLiveDvr={is_post_live_dvr}
|
||||
transformURL={url_transformer}
|
||||
options={options}
|
||||
rejectFormat={format_filter}
|
||||
cpn={cpn}
|
||||
player={player}
|
||||
actions={actions}
|
||||
storyboards={storyboards}
|
||||
captionTracks={caption_tracks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ export async function download(
|
||||
signal: cancel.signal
|
||||
});
|
||||
|
||||
// Throw if the response is not 2xx
|
||||
if (!response.ok)
|
||||
throw new InnertubeError('The server responded with a non 2xx status code', { error_type: 'FETCH_FAILED', response });
|
||||
|
||||
const body = response.body;
|
||||
|
||||
if (!body)
|
||||
@@ -155,7 +159,7 @@ export function chooseFormat(options: FormatOptions, streaming_data?: IStreaming
|
||||
return false;
|
||||
if (!is_best && format.quality_label !== quality)
|
||||
return false;
|
||||
if (best_width < format.width)
|
||||
if (format.width && (best_width < format.width))
|
||||
best_width = format.width;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
Platform,
|
||||
generateSidAuth,
|
||||
getRandomUserAgent,
|
||||
getStringBetweenStrings,
|
||||
InnertubeError
|
||||
InnertubeError,
|
||||
getCookie
|
||||
} from './Utils.js';
|
||||
|
||||
import type { Context, Session } from '../core/index.js';
|
||||
@@ -57,12 +57,19 @@ export default class HTTPClient {
|
||||
request_headers.set('Accept', '*/*');
|
||||
request_headers.set('Accept-Language', '*');
|
||||
request_headers.set('X-Goog-Visitor-Id', this.#session.context.client.visitorData || '');
|
||||
request_headers.set('X-Origin', request_url.origin);
|
||||
request_headers.set('X-Youtube-Client-Version', this.#session.context.client.clientVersion || '');
|
||||
|
||||
const client_constant = Object.values(Constants.CLIENTS).find((client) => {
|
||||
return client.NAME === this.#session.context.client.clientName;
|
||||
});
|
||||
|
||||
if (client_constant) {
|
||||
request_headers.set('X-Youtube-Client-Name', client_constant.NAME_ID);
|
||||
}
|
||||
|
||||
if (Platform.shim.server) {
|
||||
request_headers.set('User-Agent', getRandomUserAgent('desktop'));
|
||||
request_headers.set('origin', request_url.origin);
|
||||
request_headers.set('Origin', request_url.origin);
|
||||
}
|
||||
|
||||
request_url.searchParams.set('prettyPrint', 'false');
|
||||
@@ -88,17 +95,23 @@ export default class HTTPClient {
|
||||
};
|
||||
|
||||
this.#adjustContext(n_body.context, n_body.client);
|
||||
request_headers.set('x-youtube-client-version', n_body.context.client.clientVersion);
|
||||
request_headers.set('X-Youtube-Client-Version', n_body.context.client.clientVersion);
|
||||
|
||||
const client_constant = Object.values(Constants.CLIENTS).find((client) => {
|
||||
return client.NAME === n_body.context.client.clientName;
|
||||
});
|
||||
|
||||
if (client_constant) {
|
||||
request_headers.set('X-Youtube-Client-Name', client_constant.NAME_ID);
|
||||
}
|
||||
|
||||
delete n_body.client;
|
||||
|
||||
if (Platform.shim.server) {
|
||||
if (n_body.context.client.clientName === 'ANDROID' || n_body.context.client.clientName === 'ANDROID_MUSIC') {
|
||||
request_headers.set('User-Agent', Constants.CLIENTS.ANDROID.USER_AGENT);
|
||||
request_headers.set('X-GOOG-API-FORMAT-VERSION', '2');
|
||||
} else if (n_body.context.client.clientName === 'iOS') {
|
||||
request_headers.set('User-Agent', Constants.CLIENTS.iOS.USER_AGENT);
|
||||
}
|
||||
if (n_body.context.client.clientName === 'ANDROID' || n_body.context.client.clientName === 'ANDROID_MUSIC') {
|
||||
request_headers.set('User-Agent', Constants.CLIENTS.ANDROID.USER_AGENT);
|
||||
request_headers.set('X-GOOG-API-FORMAT-VERSION', '2');
|
||||
} else if (n_body.context.client.clientName === 'iOS') {
|
||||
request_headers.set('User-Agent', Constants.CLIENTS.iOS.USER_AGENT);
|
||||
}
|
||||
|
||||
is_web_kids = n_body.context.client.clientName === 'WEB_KIDS';
|
||||
@@ -109,7 +122,6 @@ export default class HTTPClient {
|
||||
request_headers.set('User-Agent', Constants.CLIENTS.ANDROID.USER_AGENT);
|
||||
request_headers.set('X-GOOG-API-FORMAT-VERSION', '2');
|
||||
request_headers.delete('X-Youtube-Client-Version');
|
||||
request_headers.delete('X-Origin');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,21 +129,23 @@ export default class HTTPClient {
|
||||
if (this.#session.logged_in && is_innertube_req && !is_web_kids) {
|
||||
const oauth = this.#session.oauth;
|
||||
|
||||
if (oauth.validateCredentials()) {
|
||||
await oauth.refreshIfRequired();
|
||||
if (oauth.oauth2_tokens) {
|
||||
if (oauth.shouldRefreshToken()) {
|
||||
await oauth.refreshAccessToken();
|
||||
}
|
||||
|
||||
request_headers.set('authorization', `Bearer ${oauth.credentials.access_token}`);
|
||||
request_headers.set('Authorization', `Bearer ${oauth.oauth2_tokens.access_token}`);
|
||||
}
|
||||
|
||||
if (this.#cookie) {
|
||||
const papisid = getStringBetweenStrings(this.#cookie, 'PAPISID=', ';');
|
||||
const sapisid = getCookie(this.#cookie, 'SAPISID');
|
||||
|
||||
if (papisid) {
|
||||
request_headers.set('authorization', await generateSidAuth(papisid));
|
||||
request_headers.set('x-goog-authuser', this.#session.account_index.toString());
|
||||
if (sapisid) {
|
||||
request_headers.set('Authorization', await generateSidAuth(sapisid));
|
||||
request_headers.set('X-Goog-Authuser', this.#session.account_index.toString());
|
||||
}
|
||||
|
||||
request_headers.set('cookie', this.#cookie);
|
||||
request_headers.set('Cookie', this.#cookie);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
src/utils/LZW.ts
Normal file
64
src/utils/LZW.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Compresses a string using the LZW compression algorithm.
|
||||
* @param input - The data to compress.
|
||||
*/
|
||||
export function compress(input: string): string {
|
||||
const output: number[] = [];
|
||||
const dictionary: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
dictionary[String.fromCharCode(i)] = i;
|
||||
}
|
||||
|
||||
let current_string = '';
|
||||
let dictionary_size = 256;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const current_char = input[i];
|
||||
const combined_string = current_string + current_char;
|
||||
|
||||
if (dictionary.hasOwnProperty(combined_string)) {
|
||||
current_string = combined_string;
|
||||
} else {
|
||||
output.push(dictionary[current_string]);
|
||||
dictionary[combined_string] = dictionary_size++;
|
||||
current_string = current_char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current_string !== '') {
|
||||
output.push(dictionary[current_string]);
|
||||
}
|
||||
|
||||
return output.map((code) => String.fromCharCode(code)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompresses data that was compressed using the LZW compression algorithm.
|
||||
* @param input - The data to be decompressed.
|
||||
*/
|
||||
export function decompress(input: string): string {
|
||||
const dictionary: Record<number, string> = {};
|
||||
const input_data = input.split('');
|
||||
const output: string[] = [ input_data.shift() as string ];
|
||||
const input_length = input_data.length >>> 0; // Convert to unsigned 32-bit integer
|
||||
|
||||
let dictionary_code = 256;
|
||||
let current_char = output[0];
|
||||
let current_string = current_char;
|
||||
|
||||
for (let i = 0; i < input_length; ++i) {
|
||||
const current_code = input_data[i].charCodeAt(0);
|
||||
const entry =
|
||||
current_code < 256 ? input_data[i] : (dictionary[current_code] ?
|
||||
dictionary[current_code] : (current_string + current_char));
|
||||
|
||||
output.push(entry);
|
||||
|
||||
current_char = entry.charAt(0);
|
||||
dictionary[dictionary_code++] = current_string + current_char;
|
||||
current_string = entry;
|
||||
}
|
||||
|
||||
return output.join('');
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import type { IStreamingData } from '../parser/index.js';
|
||||
import type { Format } from '../parser/misc.js';
|
||||
import type { PlayerLiveStoryboardSpec } from '../parser/nodes.js';
|
||||
import type { FormatFilter, URLTransformer } from '../types/FormatUtils.js';
|
||||
import type { StreamingInfoOptions } from '../types/StreamingInfoOptions.js';
|
||||
import type { CaptionTrackData } from '../parser/classes/PlayerCaptionsTracklist.js';
|
||||
|
||||
const TAG_ = 'StreamingInfo';
|
||||
|
||||
@@ -19,6 +21,7 @@ export interface StreamingInfo {
|
||||
audio_sets: AudioSet[];
|
||||
video_sets: VideoSet[];
|
||||
image_sets: ImageSet[];
|
||||
text_sets: TextSet[];
|
||||
}
|
||||
|
||||
export interface AudioSet {
|
||||
@@ -27,7 +30,7 @@ export interface AudioSet {
|
||||
codecs?: string;
|
||||
audio_sample_rate?: number;
|
||||
track_name?: string;
|
||||
track_role?: 'main' | 'dub' | 'description' | 'alternate';
|
||||
track_roles?: ('main' | 'dub' | 'description' | 'enhanced-audio-intelligibility' | 'alternate')[];
|
||||
channels?: number;
|
||||
representations: AudioRepresentation[];
|
||||
}
|
||||
@@ -84,8 +87,8 @@ export interface VideoSet {
|
||||
export interface VideoRepresentation {
|
||||
uid: string;
|
||||
bitrate: number;
|
||||
width: number;
|
||||
height: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
codecs?: string;
|
||||
segment_info: SegmentInfo;
|
||||
@@ -121,6 +124,19 @@ export interface ImageRepresentation {
|
||||
getURL(n: number): string;
|
||||
}
|
||||
|
||||
export interface TextSet {
|
||||
mime_type: string;
|
||||
language: string;
|
||||
track_name: string;
|
||||
track_roles: ('caption' | 'dub')[];
|
||||
representation: TextRepresentation;
|
||||
}
|
||||
|
||||
export interface TextRepresentation {
|
||||
uid: string;
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
interface PostLiveDvrInfo {
|
||||
duration: number,
|
||||
segment_count: number
|
||||
@@ -130,6 +146,12 @@ interface SharedPostLiveDvrInfo {
|
||||
item?: PostLiveDvrInfo
|
||||
}
|
||||
|
||||
interface DrcLabels {
|
||||
label_original: string;
|
||||
label_drc: string;
|
||||
label_drc_mutiple: (audio_track_display_name: string) => string;
|
||||
}
|
||||
|
||||
function getFormatGroupings(formats: Format[], is_post_live_dvr: boolean) {
|
||||
const group_info = new Map<string, Format[]>();
|
||||
|
||||
@@ -149,7 +171,9 @@ function getFormatGroupings(formats: Format[], is_post_live_dvr: boolean) {
|
||||
|
||||
const audio_track_id = format.audio_track?.id || '';
|
||||
|
||||
const group_id = `${mime_type}-${just_codec}-${color_info}-${audio_track_id}`;
|
||||
const drc = format.is_drc ? 'drc' : '';
|
||||
|
||||
const group_id = `${mime_type}-${just_codec}-${color_info}-${audio_track_id}-${drc}`;
|
||||
|
||||
if (!group_info.has(group_id)) {
|
||||
group_info.set(group_id, []);
|
||||
@@ -373,8 +397,18 @@ function getAudioRepresentation(
|
||||
const url = new URL(format.decipher(player));
|
||||
url.searchParams.set('cpn', cpn || '');
|
||||
|
||||
const uid_parts = [ format.itag.toString() ];
|
||||
|
||||
if (format.audio_track) {
|
||||
uid_parts.push(format.audio_track.id);
|
||||
}
|
||||
|
||||
if (format.is_drc) {
|
||||
uid_parts.push('drc');
|
||||
}
|
||||
|
||||
const rep: AudioRepresentation = {
|
||||
uid: format.audio_track ? `${format.itag}-${format.audio_track.id}` : format.itag.toString(),
|
||||
uid: uid_parts.join('-'),
|
||||
bitrate: format.bitrate,
|
||||
codecs: !hoisted.includes('codecs') ? getStringBetweenStrings(format.mime_type, 'codecs="', '"') : undefined,
|
||||
audio_sample_rate: !hoisted.includes('audio_sample_rate') ? format.audio_sample_rate : undefined,
|
||||
@@ -385,22 +419,25 @@ function getAudioRepresentation(
|
||||
return rep;
|
||||
}
|
||||
|
||||
function getTrackRole(format: Format) {
|
||||
const { audio_track } = format;
|
||||
|
||||
if (!audio_track)
|
||||
function getTrackRoles(format: Format, has_drc_streams: boolean) {
|
||||
if (!format.audio_track && !has_drc_streams) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (audio_track.audio_is_default)
|
||||
return 'main';
|
||||
const roles: ('main' | 'dub' | 'description' | 'enhanced-audio-intelligibility' | 'alternate')[] = [
|
||||
format.is_original ? 'main' : 'alternate'
|
||||
];
|
||||
|
||||
if (format.is_dubbed)
|
||||
return 'dub';
|
||||
roles.push('dub');
|
||||
|
||||
if (format.is_descriptive)
|
||||
return 'description';
|
||||
roles.push('description');
|
||||
|
||||
return 'alternate';
|
||||
if (format.is_drc)
|
||||
roles.push('enhanced-audio-intelligibility');
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
function getAudioSet(
|
||||
@@ -409,19 +446,34 @@ function getAudioSet(
|
||||
actions?: Actions,
|
||||
player?: Player,
|
||||
cpn?: string,
|
||||
shared_post_live_dvr_info?: SharedPostLiveDvrInfo
|
||||
shared_post_live_dvr_info?: SharedPostLiveDvrInfo,
|
||||
drc_labels?: DrcLabels
|
||||
) {
|
||||
const first_format = formats[0];
|
||||
const { audio_track } = first_format;
|
||||
const hoisted: string[] = [];
|
||||
|
||||
const has_drc_streams = !!drc_labels;
|
||||
|
||||
let track_name;
|
||||
|
||||
if (audio_track) {
|
||||
if (has_drc_streams && first_format.is_drc) {
|
||||
track_name = drc_labels.label_drc_mutiple(audio_track.display_name);
|
||||
} else {
|
||||
track_name = audio_track.display_name;
|
||||
}
|
||||
} else if (has_drc_streams) {
|
||||
track_name = first_format.is_drc ? drc_labels.label_drc : drc_labels.label_original;
|
||||
}
|
||||
|
||||
const set: AudioSet = {
|
||||
mime_type: first_format.mime_type.split(';')[0],
|
||||
language: first_format.language ?? undefined,
|
||||
codecs: hoistCodecsIfPossible(formats, hoisted),
|
||||
audio_sample_rate: hoistNumberAttributeIfPossible(formats, 'audio_sample_rate', hoisted),
|
||||
track_name: audio_track?.display_name,
|
||||
track_role: getTrackRole(first_format),
|
||||
track_name,
|
||||
track_roles: getTrackRoles(first_format, has_drc_streams),
|
||||
channels: hoistAudioChannelsIfPossible(formats, hoisted),
|
||||
representations: formats.map((format) => getAudioRepresentation(format, hoisted, url_transformer, actions, player, cpn, shared_post_live_dvr_info))
|
||||
};
|
||||
@@ -465,9 +517,6 @@ function getColorInfo(format: Format) {
|
||||
|
||||
if (color_info.transfer_characteristics) {
|
||||
transfer_characteristics = COLOR_TRANSFER_CHARACTERISTICS[color_info.transfer_characteristics];
|
||||
} else if (getStringBetweenStrings(format.mime_type, 'codecs="', '"')?.startsWith('avc1')) {
|
||||
// YouTube's h264 streams always seem to be SDR, so this is a pretty safe bet.
|
||||
transfer_characteristics = COLOR_TRANSFER_CHARACTERISTICS.BT709;
|
||||
}
|
||||
|
||||
if (color_info.matrix_coefficients) {
|
||||
@@ -486,6 +535,9 @@ function getColorInfo(format: Format) {
|
||||
+ `InnerTube client: ${url.searchParams.get('c')}\nformat:`, anonymisedFormat);
|
||||
}
|
||||
}
|
||||
} else if (getStringBetweenStrings(format.mime_type, 'codecs="', '"')?.startsWith('avc1')) {
|
||||
// YouTube's h264 streams always seem to be SDR, so this is a pretty safe bet.
|
||||
transfer_characteristics = COLOR_TRANSFER_CHARACTERISTICS.BT709;
|
||||
}
|
||||
|
||||
const info: ColorInfo = {
|
||||
@@ -669,7 +721,7 @@ function getImageRepresentation(
|
||||
thumbnail_width: board.thumbnail_width,
|
||||
rows: board.rows,
|
||||
columns: board.columns,
|
||||
template_duration: template_duration,
|
||||
template_duration: Math.round(template_duration),
|
||||
template_url: transform_url(template_url).toString(),
|
||||
getURL(n) {
|
||||
return template_url.toString().replace('$Number$', n.toString());
|
||||
@@ -698,6 +750,36 @@ function getImageSets(
|
||||
}));
|
||||
}
|
||||
|
||||
function getTextSets(
|
||||
caption_tracks: CaptionTrackData[],
|
||||
format: 'vtt' | 'ttml',
|
||||
transform_url: URLTransformer
|
||||
): TextSet[] {
|
||||
const mime_type = format === 'vtt' ? 'text/vtt' : 'application/ttml+xml';
|
||||
|
||||
return caption_tracks.map((caption_track) => {
|
||||
const url = new URL(caption_track.base_url);
|
||||
url.searchParams.set('fmt', format);
|
||||
|
||||
const track_roles: ('caption' | 'dub')[] = [ 'caption' ];
|
||||
|
||||
if (url.searchParams.has('tlang')) {
|
||||
track_roles.push('dub');
|
||||
}
|
||||
|
||||
return {
|
||||
mime_type,
|
||||
language: caption_track.language_code,
|
||||
track_name: caption_track.name.toString(),
|
||||
track_roles,
|
||||
representation: {
|
||||
uid: `text-${caption_track.vss_id}`,
|
||||
base_url: transform_url(url).toString()
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getStreamingInfo(
|
||||
streaming_data?: IStreamingData,
|
||||
is_post_live_dvr = false,
|
||||
@@ -706,7 +788,9 @@ export function getStreamingInfo(
|
||||
cpn?: string,
|
||||
player?: Player,
|
||||
actions?: Actions,
|
||||
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec
|
||||
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec,
|
||||
caption_tracks?: CaptionTrackData[],
|
||||
options?: StreamingInfoOptions
|
||||
) {
|
||||
if (!streaming_data)
|
||||
throw new InnertubeError('Streaming data not available');
|
||||
@@ -768,7 +852,17 @@ export function getStreamingInfo(
|
||||
audio_groups: [] as Format[][]
|
||||
});
|
||||
|
||||
const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn, shared_post_live_dvr_info));
|
||||
let drc_labels: DrcLabels | undefined;
|
||||
|
||||
if (audio_groups.flat().some((format) => format.is_drc)) {
|
||||
drc_labels = {
|
||||
label_original: options?.label_original || 'Original',
|
||||
label_drc: options?.label_drc || 'Stable Volume',
|
||||
label_drc_mutiple: options?.label_drc_mutiple || ((display_name) => `${display_name} (Stable Volume)`)
|
||||
};
|
||||
}
|
||||
|
||||
const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn, shared_post_live_dvr_info, drc_labels));
|
||||
|
||||
const video_sets = video_groups.map((formats) => getVideoSet(formats, url_transformer, player, actions, cpn, shared_post_live_dvr_info));
|
||||
|
||||
@@ -791,11 +885,21 @@ export function getStreamingInfo(
|
||||
image_sets = getImageSets(duration, actions, storyboards, url_transformer);
|
||||
}
|
||||
|
||||
let text_sets: TextSet[] = [];
|
||||
|
||||
if (caption_tracks && options?.captions_format) {
|
||||
if ((options.captions_format as string) !== 'vtt' && (options.captions_format as string) !== 'ttml') {
|
||||
throw new InnertubeError('Invalid captions format', options.captions_format);
|
||||
}
|
||||
text_sets = getTextSets(caption_tracks, options.captions_format, url_transformer);
|
||||
}
|
||||
|
||||
const info : StreamingInfo = {
|
||||
getDuration,
|
||||
audio_sets,
|
||||
video_sets,
|
||||
image_sets
|
||||
image_sets,
|
||||
text_sets
|
||||
};
|
||||
|
||||
return info;
|
||||
|
||||
@@ -40,7 +40,7 @@ export class InnertubeError extends Error {
|
||||
|
||||
export class ParsingError extends InnertubeError { }
|
||||
export class MissingParamError extends InnertubeError { }
|
||||
export class OAuthError extends InnertubeError { }
|
||||
export class OAuth2Error extends InnertubeError { }
|
||||
export class PlayerError extends Error { }
|
||||
export class SessionError extends Error { }
|
||||
export class ChannelError extends Error { }
|
||||
@@ -239,4 +239,10 @@ export function base64ToU8(base64: string): Uint8Array {
|
||||
|
||||
export function isTextRun(run: TextRun | EmojiRun): run is TextRun {
|
||||
return !('emoji' in run);
|
||||
}
|
||||
|
||||
export function getCookie(cookies: string, name: string, matchWholeName = false): string | undefined {
|
||||
const regex = matchWholeName ? `(^|\\s?)\\b${name}\\b=([^;]+)` : `(^|s?)${name}=([^;]+)`;
|
||||
const match = cookies.match(new RegExp(regex));
|
||||
return match ? match[2] : undefined;
|
||||
}
|
||||
@@ -12,4 +12,5 @@ export * from './HTTPClient.js';
|
||||
export { Platform } from './Utils.js';
|
||||
export * as Utils from './Utils.js';
|
||||
|
||||
export { default as Log } from './Log.js';
|
||||
export { default as Log } from './Log.js';
|
||||
export * as LZW from './LZW.js';
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createWriteStream, existsSync } from 'node:fs';
|
||||
import { Innertube, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
|
||||
|
||||
jest.useRealTimers();
|
||||
|
||||
describe('YouTube.js Tests', () => {
|
||||
let innertube: Innertube;
|
||||
|
||||
@@ -99,7 +101,7 @@ describe('YouTube.js Tests', () => {
|
||||
let comments: YT.Comments;
|
||||
|
||||
beforeAll(async () => {
|
||||
comments = await innertube.getComments('gmX-ceF-N1k');
|
||||
comments = await innertube.getComments('bUHZ2k9DYHY');
|
||||
expect(comments).toBeDefined();
|
||||
expect(comments.header).toBeDefined();
|
||||
expect(comments.contents).toBeDefined();
|
||||
@@ -112,10 +114,20 @@ describe('YouTube.js Tests', () => {
|
||||
expect(incremental_continuation.contents.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('CommentThread#getReplies', async () => {
|
||||
let comment_thread = comments.contents.first();
|
||||
let loaded_comment_thread = await comment_thread.getReplies();
|
||||
expect(loaded_comment_thread.replies).toBeDefined();
|
||||
describe('CommentThread#getReplies', () => {
|
||||
let loaded_comment_thread: YTNodes.CommentThread;
|
||||
|
||||
beforeAll(async () => {
|
||||
let comment_thread = comments.contents.first();
|
||||
loaded_comment_thread = await comment_thread.getReplies();
|
||||
expect(loaded_comment_thread.replies).toBeDefined();
|
||||
});
|
||||
|
||||
test('CommentThread#getContinuation', async () => {
|
||||
const incremental_continuation = await loaded_comment_thread.getContinuation();
|
||||
expect(incremental_continuation.replies).toBeDefined();
|
||||
expect(incremental_continuation.replies?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,8 +381,8 @@ describe('YouTube.js Tests', () => {
|
||||
const playlist = await innertube.music.getPlaylist('PLQxo8OvVvJ1WI_Bp67F2wdIl_R2Rc_1-u');
|
||||
expect(playlist).toBeDefined();
|
||||
expect(playlist.header).toBeDefined();
|
||||
expect(playlist.items).toBeDefined();
|
||||
expect(playlist.items?.length).toBeGreaterThan(0);
|
||||
expect(playlist.contents).toBeDefined();
|
||||
expect(playlist.contents?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Innertube#music.getLyrics', async () => {
|
||||
@@ -390,7 +402,6 @@ describe('YouTube.js Tests', () => {
|
||||
test('Innertube#music.getRelated', async () => {
|
||||
const related = await innertube.music.getRelated('eaJHysi5tYg');
|
||||
expect(related).toBeDefined();
|
||||
expect(related?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Innertube#music.getSearchSuggestions', async () => {
|
||||
|
||||
@@ -107,10 +107,11 @@
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.js",
|
||||
"scripts/**/*.mjs",
|
||||
"dev-scripts/**/*.mjs",
|
||||
"jest.config.js",
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.d.ts"
|
||||
"**/*.d.ts",
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user