Compare commits

..

53 Commits

Author SHA1 Message Date
github-actions[bot]
7d03469e64 chore(main): release 10.1.0 (#669)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-07-10 03:44:43 -03:00
Luan
62ac2f6f32 fix(proto): Update Context message
Closes #681
2024-07-10 03:41:16 -03:00
absidue
142a7d0428 fix(Player): Fix extracting the n-token decipher algorithm (#682)
* fix(Player): Fix extracting the n-token decipher algorithm

* fix: bump Jinter to v2

---------

Co-authored-by: Luan <luan.lrt4@gmail.com>
2024-07-10 02:21:39 -03:00
Luan
efa7205723 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-07-01 08:53:45 -03:00
Luan
84f90aaf29 fix(Session): Round UTC offset minutes 2024-07-01 08:53:08 -03:00
absidue
858cdd197c feat(toDash): Add the "dub" role to translated captions (#677) 2024-06-30 23:05:08 -03:00
Luan
5a8fd3ad37 feat(Session): Add configInfo to InnerTube context
Minor addition. It's needed for certain UMP requests.
2024-06-30 22:51:02 -03:00
슈리튬
a19511de24 fix(FormatUtils): Throw an error if download requests fails 2024-06-28 16:45:39 -03:00
absidue
bd9f6ac64c feat(toDash): Add option to include WebVTT or TTML captions (#673) 2024-06-25 01:22:11 -03:00
absidue
e5aab9a9b3 fix(toDash): Fix image representations not being spec compliant (#672) 2024-06-24 15:48:38 -03:00
Luan
d6fa134c3d chore(Playlist): Add MusicResponsiveHeader to header types
Oops! I forgot this one also existed : ).
2024-06-21 21:12:54 -03:00
Luan
fe953072a2 chore: fix tests 2024-06-21 20:57:38 -03:00
Luan
055fa33403 chore: lint 2024-06-21 19:32:50 -03:00
Luan
14c3a06d40 fix(YTMusic): Add support for new header layouts
This is due to a minor page redesign by YouTube Music. See https://9to5google.com/2024/06/20/youtube-music-web-album-playlist-redesign/.
2024-06-21 19:31:40 -03:00
Luan
67376afae6 chore(Format): Clean up and add some extra fields 2024-06-16 16:22:33 -03:00
Luan
4cbaa7983f fix(InfoPanelContent): Update InfoPanelContent node to also support paragraphs
This would fail when `attributedParagraphs` was missing, so we still need `paragraphs` there.
2024-06-16 15:39:47 -03:00
github-actions[bot]
9802483233 chore(main): release 10.0.0 (#658)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-06-09 20:43:14 -03:00
Aidar Nugmanoff
2980a608b6 feat(Platform): Add support for react-native platform (#593) 2024-06-09 18:15:57 -03:00
Luan
b6cecb10f5 chore(docs): update readme 2024-06-09 18:02:46 -03:00
Luan
040a091639 fix(FlexibleActionsView): Update actions array type to include ToggleButtonView 2024-06-08 16:33:34 -03:00
Luan
3939405cc6 chore(Player): Rephrase nsig failure message 2024-06-07 14:26:27 -03:00
Luan
978ab1ed29 chore(docs): fix typo [skip ci] 2024-06-07 14:18:23 -03:00
Luan
5cdb9e1e2f fix(InfoPanelContainer): Use new attributed text prop
+ And update other related nodes.
2024-06-07 14:15:44 -03:00
Luan
15f3b5fdba fix(ButtonView): Rename type property to button_type
It was overriding the static property "type".
2024-06-05 16:00:16 -03:00
Luan
384b80ee41 fix(Cache): Use TextEncoder to encode compressed data 2024-06-05 12:30:12 -03:00
Luan
b588554ce1 chore: update docs [skip ci] 2024-06-03 19:16:41 -03:00
Luan
583fd9f8d7 fix(MusicResponsiveHeader): Add Text import
Looks like I forgot to add it.
2024-06-03 19:08:33 -03:00
Luan
7953296580 feat(Session): Add enable_session_cache option (#664)
See https://github.com/LuanRT/YouTube.js/pull/663#issuecomment-2146161637
2024-06-03 19:04:30 -03:00
Luan
cf29664d37 perf(general): Add session cache and LZW compression (#663)
* feat(utils): Implement LZW compression module

* feat(Session): Implement cache for sessions
This should improve performance quite a bit for those who are not using the `generate_session_locally` option (like me :P).

* refactor(Player): Add LZW compression
This considerably reduces the size of the cache.
2024-06-03 18:21:48 -03:00
Luan
4015a5e560 chore(JsRuntime): Change log levels in evaluate function 2024-06-03 17:42:18 -03:00
Luan
184df79b3a refactor(HTTPClient): Use getCookie fn to get SAPISID token 2024-06-03 17:41:09 -03:00
Luan
000f3f0915 refactor(Artist.ts): Change sections type to ObservedArray<MusicCarouselShelf | MusicShelf> 2024-06-03 17:39:42 -03:00
Luan
8372b3d22f Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-06-03 17:33:04 -03:00
Luan
b9d50daa57 chore: clean up
Updated deps, fixed some ts issues, renamed "scripts" to "dev-scripts", and added a script to delete build output.
2024-06-03 17:29:11 -03:00
absidue
031ffb696e feat(toDash): Add support for stable volume/DRC (#662) 2024-05-28 02:43:10 -03:00
Luan
8e942ada3b chore(docs): fix some markdown issues 2024-05-24 04:33:03 -03:00
LuanRT
aa3f34c428 chore: Fix browser example 2024-05-23 21:00:47 -03:00
LuanRT
c82bb70180 chore(HTTPClient): Remove env check when setting Android headers
These requests are supposed to be proxied, so there's no need to worry about browsers not liking it.
2024-05-23 20:58:37 -03:00
LuanRT
766045049d refactor(Innertube#getPlaylists)!: Return a Feed instance instead of items 2024-05-21 20:50:41 -03:00
Luan
b6ce5f903f refactor(OAuth2)!: Rewrite auth module (#661)
This is a rewrite of the OAuth2 module to address some bugs and inconsistencies. And since it changes the structure of the credentials, I'm marking this as a breaking change.

Note that you will have to update your existing credentials, that is if you wish to continue using them. Otherwise, simply delete them and sign in again.
2024-05-21 18:47:31 -03:00
absidue
6bb2086875 feat(Format): Add is_drc (#656) 2024-05-06 11:55:58 -03:00
Brahim Hadriche
810665407e Item section target_id fix (#655) 2024-04-29 14:22:28 -03:00
github-actions[bot]
1b00e2c6ce chore(main): release 9.4.0 (#644)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-29 09:14:43 -03:00
LuanRT
ea82beaa10 feat(Parser): Add MusicResponsiveHeader node 2024-04-29 08:24:13 -03:00
absidue
0ba8c54257 feat(Format): Add spatial_audio_type (#647) 2024-04-29 08:10:08 -03:00
Brahim Hadriche
7315fca1b4 Add getPlaylists function (#650) 2024-04-29 08:09:35 -03:00
Brahim Hadriche
0602dd2c3d Lint fix (#651) 2024-04-29 08:07:24 -03:00
LuanRT
13321888e8 chore(PlayerEndpoint): Remove outdated code 2024-04-29 08:05:59 -03:00
absidue
d48b9d0946 chore(HTTPClient): Add X-Youtube-Client-Name and remove X-Origin headers (#645) 2024-04-25 18:04:10 -03:00
LuanRT
592ddac30f chore: Fix tests
Oops :)
2024-04-19 16:37:38 -03:00
LuanRT
1ec2ea85e2 refactor(Music#getRelated): Return page contents directy 2024-04-19 16:22:21 -03:00
absidue
064436cef3 feat(Format): Add projection_type and stereo_layout (#643)
5930ebda46
2024-04-19 16:08:12 -03:00
ChunkyProgrammer
4022d7aa89 Remove test code (#636) 2024-04-11 23:29:46 -03:00
65 changed files with 5292 additions and 5595 deletions

View File

@@ -1,5 +1,70 @@
# Changelog
## [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)

View File

@@ -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](https://img.shields.io/badge/discord-online-brightgreen.svg)][discord]
[![CI](https://github.com/LuanRT/YouTube.js/actions/workflows/test.yml/badge.svg)][actions]
[![NPM Version](https://img.shields.io/npm/v/youtubei.js?color=%2335C757)][versions]
[![Codefactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)][codefactor]
[![Downloads](https://img.shields.io/npm/dt/youtubei.js)][npm]
[![Discord](https://img.shields.io/badge/discord-online-brightgreen.svg)][discord]
[![Codefactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)][codefactor]
<h5>
Sponsored by&nbsp;&nbsp;&nbsp;&nbsp;<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>
[![Donate](https://img.shields.io/badge/donate-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white)][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>

View File

@@ -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({

View File

@@ -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();

View File

@@ -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();

View File

@@ -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');

View File

@@ -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;

View File

@@ -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: []
};

8082
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "9.3.0",
"version": "10.1.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"

View File

@@ -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

View File

@@ -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',

View File

@@ -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
View 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;
}
}

View File

@@ -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,33 @@ 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)`;
let sc = getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}');
if (!sc)
Log.warn(Player.TAG, 'Failed to extract n-token decipher algorithm');
if (sc)
return `function descramble_nsig(a) { let b=a.split("")${sc}} return b.join(""); } descramble_nsig(nsig)`;
return sc;
sc = getStringBetweenStrings(data, 'b=String.prototype.split.call(a,"")', '}return Array.prototype.join.call(b,"")}');
if (sc)
return `function descramble_nsig(a) { let b=String.prototype.split.call(a, "")${sc}} return Array.prototype.join.call(b, ""); } descramble_nsig(nsig)`;
// We really should throw an error here to avoid errors later, returning a pass-through function for backwards-compatibility
Log.warn(TAG, 'Failed to extract n-token decipher algorithm');
return 'function descramble_nsig(a) { return a; } 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 10;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
/**

View File

@@ -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
}
};
}

View File

@@ -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';

View File

@@ -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
);
}
/**

View File

@@ -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);
}

View 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);
}
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;
}
}

View 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);
}
}

View 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);
}
}
}

View File

@@ -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;

View File

@@ -43,8 +43,6 @@ export default class CommentView extends YTNode {
};
author?: Author;
test: any;
is_liked?: boolean;
is_disliked?: boolean;
is_hearted?: boolean;

View File

@@ -6,43 +6,51 @@ 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;
@@ -50,13 +58,11 @@ export default class Format {
is_dubbed?: boolean;
is_descriptive?: boolean;
is_original?: boolean;
color_info?: {
primaries?: string;
transfer_characteristics?: string;
matrix_coefficients?: string;
};
caption_track?: {
display_name: string;
vss_id: string;
@@ -75,53 +81,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 +198,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 +208,12 @@ 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_original = audio_content === 'original' || (!this.is_dubbed && !this.is_descriptive && !this.is_drc);
}
// Some text tracks don't have xtags while others do

View File

@@ -258,7 +258,7 @@ interface RawRun {
startIndex: number;
}
interface AttributedText {
export interface AttributedText {
content: string;
styleRuns?: StyleRun[];
commandRuns?: CommandRun[];

View File

@@ -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';

View File

@@ -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';
@@ -367,6 +368,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

View File

@@ -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;

View File

@@ -20,6 +20,7 @@ export interface IRawPlayerConfig {
}
export interface IRawResponse {
background?: RawNode;
contents?: RawData;
onResponseReceivedActions?: RawNode[];
onResponseReceivedEndpoints?: RawNode[];

View File

@@ -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 {

View File

@@ -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> {

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View 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;
```

View 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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -240,12 +240,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 +310,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: {

View File

@@ -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 ****/

View File

@@ -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.

View File

@@ -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;

View 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;
}

View File

@@ -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'
}

View File

@@ -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}
/>
);
}

View File

@@ -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;
});

View File

@@ -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
View 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('');
}

View File

@@ -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))
};
@@ -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;

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -381,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 () => {
@@ -402,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 () => {

View File

@@ -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",
]
}