Compare commits

...

40 Commits

Author SHA1 Message Date
LuanRT
9b5c3bb223 chore: v10.4.0 release 2024-08-27 02:42:27 +00:00
LuanRT
742e7151fa chore: v10.3.0 release 2024-08-01 09:22:26 +00:00
LuanRT
843ecd28e3 chore: v10.2.0 release 2024-07-25 13:53:43 +00:00
LuanRT
8293af93fa chore: v10.1.0 release 2024-07-10 06:45:35 +00:00
LuanRT
f3d43bd395 chore: v10.0.0 release 2024-06-09 23:44:15 +00:00
LuanRT
09e0f91b66 chore: v9.4.0 release 2024-04-29 12:15:47 +00:00
LuanRT
28e766779e chore: v9.3.0 release 2024-04-11 21:20:11 +00:00
LuanRT
a482556187 chore: v9.2.1 release 2024-04-09 20:43:19 +00:00
LuanRT
b063b2f4a8 chore: v9.2.0 release 2024-03-31 14:40:18 +00:00
LuanRT
299548f255 chore: v9.1.0 release 2024-02-23 01:30:26 +00:00
LuanRT
016a7aacae chore: v9.0.2 release 2024-01-31 22:51:52 +00:00
LuanRT
bd5b3470ec chore: v9.0.1 release 2024-01-26 00:33:50 +00:00
LuanRT
648488641d chore: v9.0.0 release 2024-01-25 22:05:21 +00:00
LuanRT
944f821d9a chore: v8.2.0 release 2024-01-08 23:47:23 +00:00
LuanRT
bd9ae29de3 chore: v8.1.0 release 2023-12-27 02:25:21 +00:00
LuanRT
1b159e5162 chore: v8.0.0 release 2023-12-01 03:55:35 +00:00
LuanRT
2b58b3887c chore: v7.0.0 release 2023-10-28 18:23:22 +00:00
LuanRT
2294bf7065 chore: v6.4.1 release 2023-10-02 03:05:14 +00:00
LuanRT
f2d673ac95 chore: v6.4.0 release 2023-09-10 05:09:20 +00:00
LuanRT
1b557f25e3 chore: v6.3.0 release 2023-08-31 23:42:13 +00:00
LuanRT
20f055b7ea chore: v6.2.0 release 2023-08-29 17:26:54 +00:00
LuanRT
09321408e5 chore: v6.1.0 release 2023-08-27 19:44:15 +00:00
LuanRT
848c7e68e2 chore: v6.0.2 release 2023-08-24 20:07:16 +00:00
LuanRT
bc437015e2 chore: v6.0.1 release 2023-08-22 12:09:04 +00:00
LuanRT
96e337a1d0 chore: v6.0.0 release 2023-08-18 11:40:24 +00:00
LuanRT
8d47c81318 chore: v5.8.0 release 2023-07-30 19:03:20 +00:00
LuanRT
5b9febc7bf chore: v5.7.1 release 2023-07-25 05:47:21 +00:00
LuanRT
55d47e1fe6 chore: v5.7.0 release 2023-07-24 23:49:17 +00:00
LuanRT
a40abda80c chore: v5.6.0 release 2023-07-18 18:35:51 +00:00
LuanRT
e147a01ba5 chore: v5.5.0 release 2023-07-16 20:58:15 +00:00
LuanRT
499937fe3a chore: v5.4.0 release 2023-07-14 03:01:47 +00:00
LuanRT
0582bcb677 chore: v5.3.0 release 2023-07-11 18:49:53 +00:00
LuanRT
50fdef7c07 chore: v5.2.1 release 2023-07-04 01:31:31 +00:00
LuanRT
5f69f10e85 chore: v5.2.0 release 2023-06-28 20:14:40 +00:00
LuanRT
dc69b7437e chore: v5.1.0 release 2023-05-14 04:13:24 +00:00
LuanRT
7d71f3d2cd chore: v5.0.4 release 2023-05-10 18:45:39 +00:00
LuanRT
37e9987afd chore: v5.0.3 release 2023-05-03 22:48:00 +00:00
LuanRT
6b1d5f0655 chore: v5.0.2 release 2023-04-30 20:25:31 +00:00
LuanRT
2d1972bee0 chore: v5.0.1 release 2023-04-30 03:52:53 +00:00
LuanRT
48dc99e28b chore: v5.0.0 release 2023-04-29 05:15:47 +00:00
594 changed files with 14733 additions and 7502 deletions

105
README.md
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.is-a.dev/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>
@@ -124,19 +97,22 @@ const youtube = await Innertube.create(/* options */);
| `location` | `string` | Geolocation. | `US` |
| `account_index` | `number` | 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. | `0` |
| `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` |
| `po_token` | `string` | Proof of Origin Token. This is an attestation token generated by BotGuard/DroidGuard. It is used to confirm that the request is coming from a genuine client. Valid tokens can be generated using [BgUtils](https://github.com/LuanRT/BgUtils) or [Invidious' tool](https://github.com/iv-org/youtube-trusted-session-generator). | `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` |
| `client_type` | `ClientType` | InnerTube client type. It is not recommended to change this unless you know what you are doing. | `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/).
@@ -171,7 +147,7 @@ import dashjs from 'dashjs';
const youtube = await Innertube.create({ /* setup - see above */ });
// get the video info
// Get the video info
const videoInfo = await youtube.getInfo('videoId');
// now convert to a dash manifest
@@ -191,11 +167,11 @@ const player = dashjs.MediaPlayer().create();
player.initialize(videoElement, uri, true);
```
A fully working example can be found in [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/blob/main/examples/browser/web). Alternatively, you can view it live at [ytjsexample.pages.dev](https://ytjsexample.pages.dev/).
A fully working example can be found in [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/blob/main/examples/browser/web).
<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 +188,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 +213,7 @@ const yt = await Innertube.create({
});
```
## API
### API
* `Innertube`
@@ -295,7 +271,7 @@ Retrieves video info.
| Param | Type | Description |
| --- | --- | --- |
| target | `string` \| `NavigationEndpoint` | If `string`, the id of the video. If `NavigationEndpoint`, the endpoint of watchable elements such as `Video`, `Mix` and `Playlist`. To clarify, valid endpoints have payloads containing at least `videoId` and optionally `playlistId`, `params` and `index`. |
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC`, `YTMUSIC_ANDROID` or `TV_EMBEDDED` |
| client? | `InnerTubeClient` | InnerTube client to use. |
<details>
<summary>Methods & Getters</summary>
@@ -325,6 +301,9 @@ Retrieves video info.
- `<info>#download(options)`
- Downloads the video. See [download](#download).
- `<info>#getTranscript()`
- Retrieves the video's transcript.
- `<info>#filters`
- Returns filters that can be applied to the watch next feed.
@@ -359,7 +338,7 @@ Suitable for cases where you only need basic video metadata. Also, it is faster
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The id of the video |
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC_ANDROID`, `YTMUSIC`, `TV_EMBEDDED` |
| client? | `InnerTubeClient` | InnerTube client to use. |
<a name="search"></a>
### `search(query, filters?)`
@@ -542,6 +521,8 @@ Retrieves contents for a given channel.
- `<channel>#getVideos()`
- `<channel>#getShorts()`
- `<channel>#getLiveStreams()`
- `<channel>#getReleases()`
- `<channel>#getPodcasts()`
- `<channel>#getPlaylists()`
- `<channel>#getHome()`
- `<channel>#getCommunity()`
@@ -692,7 +673,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.
@@ -707,7 +688,7 @@ import { Innertube } from 'youtubei.js';
const videoInfo = await yt.actions.execute('/player', {
// You can add any additional payloads here, and they'll merge with the default payload sent to InnerTube.
videoId,
client: 'YTMUSIC', // InnerTube client options: ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB, or TV_EMBEDDED.
client: 'YTMUSIC', // InnerTube client to use.
parse: true // tells YouTube.js to parse the response (not sent to InnerTube).
});
@@ -786,7 +767,7 @@ We are immensely grateful to all the wonderful people who have contributed to th
## Contact
LuanRT - [@thesciencephile][twitter] - luan.lrt4@gmail.com
LuanRT - [@thesciencephile][twitter] - luanrt@thatsciencephile.com
Project Link: [https://github.com/LuanRT/YouTube.js][project]
@@ -800,6 +781,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>

View File

@@ -1,3 +1,3 @@
export * from './deno/src/platform/deno.ts';
import Innertube from './deno/src/platform/deno.ts';
export default Innertube;
export default Innertube;

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "4.3.0",
"version": "10.4.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,11 +12,17 @@
"web": [
"./dist/src/platform/lib.d.ts"
],
"react-native": [
"./dist/src/platform/lib.d.ts"
],
"web.bundle": [
"./dist/src/platform/lib.d.ts"
],
"web.bundle.min": [
"./dist/src/platform/lib.d.ts"
],
"cf-worker": [
"./dist/src/platform/lib.d.ts"
]
}
},
@@ -29,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": {
@@ -39,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"
@@ -46,6 +57,10 @@
"./web.bundle.min": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./bundle/browser.min.js"
},
"./cf-worker": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./dist/src/platform/cf-worker.js"
}
},
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
@@ -56,25 +71,24 @@
"Wykerd (https://github.com/wykerd/)",
"MasterOfBob777 (https://github.com/MasterOfBob777)",
"patrickkfkan (https://github.com/patrickkfkan)",
"akkadaska (https://github.com/akkadaska)"
"akkadaska (https://github.com/akkadaska)",
"Absidue (https://github.com/absidue)"
],
"directories": {
"test": "./test",
"examples": "./examples",
"dist": "./dist"
},
"scripts": {
"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",
"build:parser-map": "node ./scripts/build-parser-map.cjs",
"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 tsc",
"build:deno": "npx cpy ./src ./deno && npx cpy ./package.json ./deno && npx replace \".ts';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'https://esm.sh/linkedom';\" \"'https://esm.sh/linkedom';\" ./deno -r && npx replace \"'https://esm.sh/jintr';\" \"'https://esm.sh/jintr';\" ./deno -r && npx replace \"new Jinter\" \"new Jinter\" ./deno -r",
"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 \".ts';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'./DashManifest.js';\" \"'./DashManifest.js';\" ./deno -r && npx replace \"'https://esm.sh/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 --outfile=./bundle/browser.js --platform=browser",
"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",
"watch": "npx tsc --watch"
},
@@ -84,26 +98,31 @@
},
"license": "MIT",
"dependencies": {
"jintr": "^1.0.0",
"linkedom": "^0.14.12",
"jintr": "^2.1.1",
"tslib": "^2.5.0",
"undici": "^5.19.1"
},
"overrides": {
"typescript": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/glob": "^8.1.0",
"@types/jest": "^28.1.7",
"@types/node": "^17.0.45",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"cpy-cli": "^4.2.0",
"esbuild": "^0.14.49",
"eslint": "^8.19.0",
"eslint-plugin-tsdoc": "^0.2.16",
"eslint": "^9.9.0",
"glob": "^8.0.3",
"jest": "^28.1.3",
"globals": "^15.9.0",
"jest": "^29.7.0",
"pbkit": "^0.0.59",
"replace": "^1.2.2",
"ts-jest": "^28.0.8",
"typescript": "^4.9.5"
"ts-jest": "^29.1.4",
"ts-patch": "^3.0.2",
"ts-transformer-inline-file": "^0.2.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.2.0"
},
"bugs": {
"url": "https://github.com/LuanRT/YouTube.js/issues"

View File

@@ -1,49 +1,47 @@
import Session from './core/Session.ts';
import { Kids, Music, Studio } from './core/clients/index.ts';
import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.ts';
import { Feed, TabbedFeed } from './core/mixins/index.ts';
import Session, { SessionOptions } from './core/Session.ts';
import {
BrowseEndpoint,
GetNotificationMenuEndpoint,
GuideEndpoint,
NextEndpoint,
PlayerEndpoint,
ResolveURLEndpoint,
SearchEndpoint,
Reel,
Notification
} from './core/endpoints/index.ts';
import {
Channel,
Comments,
Guide,
HashtagFeed,
History,
HomeFeed,
Library,
NotificationsMenu,
Playlist,
Search,
VideoInfo
} from './parser/youtube/index.ts';
import { ShortFormVideoInfo } from './parser/ytshorts/index.ts';
import NavigationEndpoint from './parser/classes/NavigationEndpoint.ts';
import Channel from './parser/youtube/Channel.ts';
import Comments from './parser/youtube/Comments.ts';
import History from './parser/youtube/History.ts';
import Library from './parser/youtube/Library.ts';
import NotificationsMenu from './parser/youtube/NotificationsMenu.ts';
import Playlist from './parser/youtube/Playlist.ts';
import Search from './parser/youtube/Search.ts';
import VideoInfo from './parser/youtube/VideoInfo.ts';
import HashtagFeed from './parser/youtube/HashtagFeed.ts';
import AccountManager from './core/AccountManager.ts';
import Feed from './core/Feed.ts';
import InteractionManager from './core/InteractionManager.ts';
import YTKids from './core/Kids.ts';
import YTMusic from './core/Music.ts';
import PlaylistManager from './core/PlaylistManager.ts';
import YTStudio from './core/Studio.ts';
import TabbedFeed from './core/TabbedFeed.ts';
import HomeFeed from './parser/youtube/HomeFeed.ts';
import Guide from './parser/youtube/Guide.ts';
import Proto from './proto/index.ts';
import Constants from './utils/Constants.ts';
import type Actions from './core/Actions.ts';
import type Format from './parser/classes/misc/Format.ts';
import * as Proto from './proto/index.ts';
import * as Constants from './utils/Constants.ts';
import { InnertubeError, generateRandomString, throwIfMissing } from './utils/Utils.ts';
import type { ApiResponse } from './core/Actions.ts';
import type { InnerTubeConfig, InnerTubeClient, SearchFilters, INextRequest } from './types/index.ts';
import type { IBrowseResponse, IParsedResponse } from './parser/types/index.ts';
import type { DownloadOptions, FormatOptions } from './utils/FormatUtils.ts';
import { generateRandomString, InnertubeError, throwIfMissing } from './utils/Utils.ts';
export type InnertubeConfig = SessionOptions;
export interface SearchFilters {
upload_date?: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
type?: 'all' | 'video' | 'channel' | 'playlist' | 'movie';
duration?: 'all' | 'short' | 'medium' | 'long';
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
features?: ('hd' | 'subtitles' | 'creative_commons' | '3d' | 'live' | 'purchased' | '4k' | '360' | 'location' | 'hdr' | 'vr180')[];
}
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED' | 'YTKIDS'
import type { DownloadOptions, FormatOptions } from './types/FormatUtils.ts';
import type Format from './parser/classes/misc/Format.ts';
/**
* Provides access to various services and modules in the YouTube API.
@@ -55,101 +53,99 @@ export default class Innertube {
this.#session = session;
}
static async create(config: InnertubeConfig = {}): Promise<Innertube> {
static async create(config: InnerTubeConfig = {}): Promise<Innertube> {
return new Innertube(await Session.create(config));
}
/**
* Retrieves video info.
* @param target - The video id or `NavigationEndpoint`.
* @param client - The client to use.
*/
async getInfo(target: string | NavigationEndpoint, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ target });
throwIfMissing({ target: target });
let payload: {
videoId: string,
playlistId?: string,
params?: string,
playlistIndex?: number
};
let next_payload: INextRequest;
if (target instanceof NavigationEndpoint) {
const video_id = target.payload?.videoId;
if (!video_id)
throw new InnertubeError('Missing video id in endpoint payload.', target);
payload = {
videoId: video_id
};
if (target.payload.playlistId) {
payload.playlistId = target.payload.playlistId;
}
if (target.payload.params) {
payload.params = target.payload.params;
}
if (target.payload.index) {
payload.playlistIndex = target.payload.index;
}
next_payload = NextEndpoint.build({
video_id: target.payload?.videoId,
playlist_id: target.payload?.playlistId,
params: target.payload?.params,
playlist_index: target.payload?.index
});
} else if (typeof target === 'string') {
payload = {
videoId: target
};
next_payload = NextEndpoint.build({
video_id: target
});
} else {
throw new InnertubeError('Invalid target, expected either a video id or a valid NavigationEndpoint', target);
throw new InnertubeError('Invalid target. Expected a video id or NavigationEndpoint.', target);
}
if (!next_payload.videoId)
throw new InnertubeError('Video id cannot be empty', next_payload);
const player_payload = PlayerEndpoint.build({
video_id: next_payload.videoId,
playlist_id: next_payload?.playlistId,
client: client,
sts: this.#session.player?.sts,
po_token: this.#session.po_token
});
const player_response = this.actions.execute(PlayerEndpoint.PATH, player_payload);
const next_response = this.actions.execute(NextEndpoint.PATH, next_payload);
const response = await Promise.all([ player_response, next_response ]);
const cpn = generateRandomString(16);
const initial_info = this.actions.getVideoInfo(payload.videoId, cpn, client);
const continuation = this.actions.execute('/next', payload);
const response = await Promise.all([ initial_info, continuation ]);
return new VideoInfo(response, this.actions, cpn);
}
/**
* Retrieves basic video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ video_id });
const response = await this.actions.execute(
PlayerEndpoint.PATH, PlayerEndpoint.build({
video_id: video_id,
client: client,
sts: this.#session.player?.sts,
po_token: this.#session.po_token
})
);
const cpn = generateRandomString(16);
const response = await this.actions.getVideoInfo(video_id, cpn, client);
return new VideoInfo([ response ], this.actions, cpn);
}
/**
* Searches a given query.
* @param query - The search query.
* @param filters - Search filters.
*/
async getShortsVideoInfo(video_id: string, client?: InnerTubeClient): Promise<ShortFormVideoInfo> {
throwIfMissing({ video_id });
const watch_response = this.actions.execute(
Reel.ReelItemWatchEndpoint.PATH, Reel.ReelItemWatchEndpoint.build({ video_id, client })
);
const sequence_response = this.actions.execute(
Reel.ReelWatchSequenceEndpoint.PATH, Reel.ReelWatchSequenceEndpoint.build({
sequence_params: Proto.encodeReelSequence(video_id)
})
);
const response = await Promise.all([ watch_response, sequence_response ]);
const cpn = generateRandomString(16);
return new ShortFormVideoInfo([ response[0] ], this.actions, cpn, response[1]);
}
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
throwIfMissing({ query });
const args = {
query,
...{
params: filters ? Proto.encodeSearchFilters(filters) : undefined
}
};
const response = await this.actions.execute('/search', args);
const response = await this.actions.execute(
SearchEndpoint.PATH, SearchEndpoint.build({
query, params: filters ? Proto.encodeSearchFilters(filters) : undefined
})
);
return new Search(this.actions, response);
}
/**
* Retrieves search suggestions for a given query.
* @param query - The search query.
*/
async getSearchSuggestions(query: string): Promise<string[]> {
throwIfMissing({ query });
@@ -171,28 +167,24 @@ export default class Innertube {
return suggestions;
}
/**
* Retrieves comments for a video.
* @param video_id - The video id.
* @param sort_by - Sorting options.
*/
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
throwIfMissing({ video_id });
const payload = Proto.encodeCommentsSectionParams(video_id, {
sort_by: sort_by || 'TOP_COMMENTS'
});
const response = await this.actions.execute('/next', { continuation: payload });
const response = await this.actions.execute(
NextEndpoint.PATH, NextEndpoint.build({
continuation: Proto.encodeCommentsSectionParams(video_id, {
sort_by: sort_by || 'TOP_COMMENTS'
})
})
);
return new Comments(this.actions, response.data);
}
/**
* Retrieves YouTube's home feed (aka recommendations).
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
const response = await this.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEwhat_to_watch' })
);
return new HomeFeed(this.actions, response);
}
@@ -200,74 +192,78 @@ export default class Innertube {
* Retrieves YouTube's content guide.
*/
async getGuide(): Promise<Guide> {
const response = await this.actions.execute('/guide');
const response = await this.actions.execute(GuideEndpoint.PATH);
return new Guide(response.data);
}
/**
* Returns the account's library.
*/
async getLibrary(): Promise<Library> {
const response = await this.actions.execute('/browse', { browseId: 'FElibrary' });
const response = await this.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FElibrary' })
);
return new Library(this.actions, response);
}
/**
* Retrieves watch history.
* Which can also be achieved with {@link getLibrary}.
*/
async getHistory(): Promise<History> {
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
const response = await this.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEhistory' })
);
return new History(this.actions, response);
}
/**
* Retrieves trending content.
*/
async getTrending(): Promise<TabbedFeed<IBrowseResponse>> {
const response = await this.actions.execute('/browse', { browseId: 'FEtrending', parse: true });
const response = await this.actions.execute(
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEtrending' }), parse: true }
);
return new TabbedFeed(this.actions, response);
}
/**
* Retrieves subscriptions feed.
*/
async getSubscriptionsFeed(): Promise<Feed<IBrowseResponse>> {
const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions', parse: true });
const response = await this.actions.execute(
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEsubscriptions' }), parse: true }
);
return new Feed(this.actions, response);
}
async getChannelsFeed(): Promise<Feed<IBrowseResponse>> {
const response = await this.actions.execute(
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEchannels' }), parse: true }
);
return new Feed(this.actions, response);
}
/**
* Retrieves contents for a given channel.
* @param id - Channel id
*/
async getChannel(id: string): Promise<Channel> {
throwIfMissing({ id });
const response = await this.actions.execute('/browse', { browseId: id });
const response = await this.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: id })
);
return new Channel(this.actions, response);
}
/**
* Retrieves notifications.
*/
async getNotifications(): Promise<NotificationsMenu> {
const response = await this.actions.execute('/notification/get_notification_menu', { notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' });
const response = await this.actions.execute(
GetNotificationMenuEndpoint.PATH, GetNotificationMenuEndpoint.build({
notifications_menu_request_type: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
})
);
return new NotificationsMenu(this.actions, response);
}
/**
* Retrieves unseen notifications count.
*/
async getUnseenNotificationsCount(): Promise<number> {
const response = await this.actions.execute('/notification/get_unseen_count');
// TODO: properly parse this
const response = await this.actions.execute(Notification.GetUnseenCountEndpoint.PATH);
// FIXME: properly parse this.
return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;
}
/**
* Retrieves playlist contents.
* @param id - Playlist id
* Retrieves the user's 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);
}
async getPlaylist(id: string): Promise<Playlist> {
throwIfMissing({ id });
@@ -275,20 +271,22 @@ export default class Innertube {
id = `VL${id}`;
}
const response = await this.actions.execute('/browse', { browseId: id });
const response = await this.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: id })
);
return new Playlist(this.actions, response);
}
/**
* Retrieves a given hashtag's page.
* @param hashtag - The hashtag to fetch.
*/
async getHashtag(hashtag: string): Promise<HashtagFeed> {
throwIfMissing({ hashtag });
const params = Proto.encodeHashtag(hashtag);
const response = await this.actions.execute('/browse', { browseId: 'FEhashtag', params });
const response = await this.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
browse_id: 'FEhashtag',
params: Proto.encodeHashtag(hashtag)
})
);
return new HashtagFeed(this.actions, response);
}
@@ -303,11 +301,15 @@ export default class Innertube {
*/
async getStreamingData(video_id: string, options: FormatOptions = {}): Promise<Format> {
const info = await this.getBasicInfo(video_id);
return info.chooseFormat(options);
const format = info.chooseFormat(options);
format.url = format.decipher(this.#session.player);
return format;
}
/**
* Downloads a given video. If you only need the direct download link see {@link getStreamingData}.
* Downloads a given video. If all you need the direct download link, see {@link getStreamingData}.
* If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}.
* @param video_id - The video id.
* @param options - Download options.
@@ -322,7 +324,13 @@ export default class Innertube {
* @param url - The URL.
*/
async resolveURL(url: string): Promise<NavigationEndpoint> {
const response = await this.actions.execute('/navigation/resolve_url', { url, parse: true });
const response = await this.actions.execute(
ResolveURLEndpoint.PATH, { ...ResolveURLEndpoint.build({ url }), parse: true }
);
if (!response.endpoint)
throw new InnertubeError('Failed to resolve URL. Expected a NavigationEndpoint but got undefined', response);
return response.endpoint;
}
@@ -338,58 +346,58 @@ export default class Innertube {
}
/**
* An instance of YTMusic for interacting with the YouTube Music service.
* An interface for interacting with YouTube Music.
*/
get music(): YTMusic {
return new YTMusic(this.#session);
get music() {
return new Music(this.#session);
}
/**
* An instance of YTStudio for interacting with the YouTube Studio service.
* An interface for interacting with YouTube Studio.
*/
get studio(): YTStudio {
return new YTStudio(this.#session);
get studio() {
return new Studio(this.#session);
}
/**
* An instance of YTKids for interacting with the YouTube Kids service.
* An interface for interacting with YouTube Kids.
*/
get kids(): YTKids {
return new YTKids(this.#session);
get kids() {
return new Kids(this.#session);
}
/**
* An instance of AccountManager for managing a user's account.
* An interface for managing and retrieving account information.
*/
get account(): AccountManager {
get account() {
return new AccountManager(this.#session.actions);
}
/**
* An instance of PlaylistManager for managing playlists.
* An interface for managing playlists.
*/
get playlist(): PlaylistManager {
get playlist() {
return new PlaylistManager(this.#session.actions);
}
/**
* An instance of InteractionManager for interacting with contents in YouTube.
* An interface for directly interacting with certain YouTube features.
*/
get interact(): InteractionManager {
get interact() {
return new InteractionManager(this.#session.actions);
}
/**
* An instance of Actions.
* An internal class used to dispatch requests.
*/
get actions(): Actions {
get actions() {
return this.#session.actions;
}
/**
* Returns the InnerTube session instance.
* The session used by this instance.
*/
get session(): Session {
get session() {
return this.#session;
}
}

View File

@@ -1,7 +1,7 @@
import Parser, { NavigateAction } from '../parser/index.ts';
import { Parser, NavigateAction } from '../parser/index.ts';
import { InnertubeError } from '../utils/Utils.ts';
import type Session from './Session.ts';
import type { Session } from './index.ts';
import type {
IBrowseResponse, IGetNotificationsMenuResponse,
@@ -16,7 +16,7 @@ export interface ApiResponse {
data: IRawResponse;
}
export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/updated_metadata' | '/notification/get_notification_menu' | string;
export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/reel' | '/updated_metadata' | '/notification/get_notification_menu' | string;
export type ParsedResponse<T> =
T extends '/player' ? IPlayerResponse :
@@ -28,15 +28,11 @@ export type ParsedResponse<T> =
T extends '/notification/get_notification_menu' ? IGetNotificationsMenuResponse :
IParsedResponse;
class Actions {
#session: Session;
export default class Actions {
session: Session;
constructor(session: Session) {
this.#session = session;
}
get session(): Session {
return this.#session;
this.session = session;
}
/**
@@ -51,57 +47,6 @@ class Actions {
};
}
/**
* Used to retrieve video info.
* @param id - The video ID.
* @param cpn - Content Playback Nonce.
* @param client - The client to use.
* @param playlist_id - The playlist ID.
*/
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise<ApiResponse> {
const data: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
vis: 0,
splay: false,
referer: 'https://www.youtube.com',
currentUrl: `/watch?v=${id}`,
autonavState: 'STATE_NONE',
signatureTimestamp: this.#session.player?.sts || 0,
autoCaptionsDefaultOn: false,
html5Preference: 'HTML5_PREF_WANTS',
lactMilliseconds: '-1'
}
},
attestationRequest: {
omitBotguardData: true
},
videoId: id
};
if (client) {
data.client = client;
}
if (cpn) {
data.cpn = cpn;
}
if (playlist_id) {
data.playlistId = playlist_id;
}
const response = await this.#session.http.fetch('/player', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Makes calls to the playback tracking API.
* @param url - The URL to call.
@@ -120,7 +65,7 @@ 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;
}
@@ -139,7 +84,7 @@ 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.');
}
@@ -182,7 +127,7 @@ 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: {
@@ -218,6 +163,8 @@ class Actions {
'FElibrary',
'FEhistory',
'FEsubscriptions',
'FEchannels',
'FEplaylist_aggregation',
'FEmusic_listening_review',
'FEmusic_library_landing',
'SPaccount_overview',
@@ -226,6 +173,4 @@ class Actions {
'SPtime_watched'
].includes(id);
}
}
export default Actions;
}

View File

@@ -1,68 +0,0 @@
import Search from '../parser/ytkids/Search.ts';
import HomeFeed from '../parser/ytkids/HomeFeed.ts';
import VideoInfo from '../parser/ytkids/VideoInfo.ts';
import Channel from '../parser/ytkids/Channel.ts';
import type Session from './Session.ts';
import { generateRandomString } from '../utils/Utils.ts';
class Kids {
#session: Session;
constructor(session: Session) {
this.#session = session;
}
/**
* Searches the given query.
* @param query - The query.
*/
async search(query: string): Promise<Search> {
const response = await this.#session.actions.execute('/search', { query, client: 'YTKIDS' });
return new Search(this.#session.actions, response);
}
/**
* Retrieves video info.
* @param video_id - The video id.
*/
async getInfo(video_id: string): Promise<VideoInfo> {
const cpn = generateRandomString(16);
const initial_info = this.#session.actions.execute('/player', {
cpn,
client: 'YTKIDS',
videoId: video_id,
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
const continuation = this.#session.actions.execute('/next', { videoId: video_id, client: 'YTKIDS' });
const response = await Promise.all([ initial_info, continuation ]);
return new VideoInfo(response, this.#session.actions, cpn);
}
/**
* Retrieves the contents of the given channel.
* @param channel_id - The channel id.
*/
async getChannel(channel_id: string): Promise<Channel> {
const response = await this.#session.actions.execute('/browse', { browseId: channel_id, client: 'YTKIDS' });
return new Channel(this.#session.actions, response);
}
/**
* Retrieves the home feed.
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#session.actions.execute('/browse', { browseId: 'FEkids_home', client: 'YTKIDS' });
return new HomeFeed(this.#session.actions, response);
}
}
export default Kids;

View File

@@ -1,103 +0,0 @@
import Actions, { ApiResponse } from './Actions.ts';
import Constants from '../utils/Constants.ts';
import FormatUtils, { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../utils/FormatUtils.ts';
import { InnertubeError } from '../utils/Utils.ts';
import Format from '../parser/classes/misc/Format.ts';
import Parser, { INextResponse, IPlayerResponse } from '../parser/index.ts';
export class MediaInfo {
#page: [IPlayerResponse, INextResponse?];
#actions: Actions;
#cpn: string;
#playback_tracking;
streaming_data;
playability_status;
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) {
this.#actions = actions;
const info = Parser.parseResponse<IPlayerResponse>(data[0].data);
const next = data?.[1]?.data ? Parser.parseResponse<INextResponse>(data[1].data) : undefined;
this.#page = [ info, next ];
this.#cpn = cpn;
if (info.playability_status?.status === 'ERROR')
throw new InnertubeError('This video is unavailable', info.playability_status);
this.streaming_data = info.streaming_data;
this.playability_status = info.playability_status;
this.#playback_tracking = info.playback_tracking;
}
/**
* Generates a DASH manifest from the streaming data.
* @param url_transformer - Function to transform the URLs.
* @param format_filter - Function to filter the formats.
* @returns DASH manifest
*/
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise<string> {
return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions);
}
/**
* Selects the format that best matches the given options.
* @param options - Options
*/
chooseFormat(options: FormatOptions): Format {
return FormatUtils.chooseFormat(options, this.streaming_data);
}
/**
* Downloads the video.
* @param options - Download options.
*/
async download(options: DownloadOptions = {}): Promise<ReadableStream<Uint8Array>> {
return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player, this.cpn);
}
/**
* Adds video to the watch history.
*/
async addToWatchHistory(client_name = Constants.CLIENTS.WEB.NAME, client_version = Constants.CLIENTS.WEB.VERSION, replacement = 'https://www.'): Promise<Response> {
if (!this.#playback_tracking)
throw new InnertubeError('Playback tracking not available');
const url_params = {
cpn: this.#cpn,
fmt: 251,
rtn: 0,
rt: 0
};
const url = this.#playback_tracking.videostats_playback_url.replace('https://s.', replacement);
const response = await this.#actions.stats(url, {
client_name,
client_version
}, url_params);
return response;
}
/**
* Actions instance.
*/
get actions(): Actions {
return this.#actions;
}
/**
* Content Playback Nonce.
*/
get cpn(): string {
return this.#cpn;
}
/**
* Original parsed InnerTube response.
*/
get page(): [IPlayerResponse, INextResponse?] {
return this.#page;
}
}

View File

@@ -1,269 +0,0 @@
import Constants from '../utils/Constants.ts';
import { OAuthError, Platform } from '../utils/Utils.ts';
import type Session from './Session.ts';
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;
}
// 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;
class 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,
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(),
model_name: 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,
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,
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<{ [key: string]: string; }> {
const response = await this.#session.http.fetch_function(new URL('/tv', Constants.URLS.YT_BASE), { headers: Constants.OAUTH.HEADERS });
const response_data = await response.text();
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' });
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;
if (!groups)
throw new OAuthError('Could not obtain client identity.', { status: 'FAILED' });
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;
}
}
export default OAuth;

338
deno/src/core/OAuth2.ts Normal file
View File

@@ -0,0 +1,338 @@
import { OAuth2Error, Platform } from '../utils/Utils.ts';
import { Log, Constants } from '../utils/index.ts';
import type Session from './Session.ts';
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,26 +1,27 @@
import { Platform, getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils.ts';
import { Log, LZW, Constants } from '../utils/index.ts';
import { Platform, getRandomUserAgent, getStringBetweenStrings, findFunction, PlayerError } from '../utils/Utils.ts';
import type { ICache, FetchFunction } from '../types/index.ts';
import Constants from '../utils/Constants.ts';
import { ICache } from '../types/Cache.ts';
import { FetchFunction } from '../types/PlatformShim.ts';
const TAG = 'Player';
/**
* Represents YouTube's player script. This is required to decipher signatures.
*/
export default class Player {
#nsig_sc;
#sig_sc;
#sig_sc_timestamp;
#player_id;
player_id: string;
sts: number;
nsig_sc?: string;
sig_sc?: string;
po_token?: string;
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;
constructor(player_id: string, signature_timestamp: number, sig_sc?: string, nsig_sc?: string) {
this.player_id = player_id;
this.sts = signature_timestamp;
this.nsig_sc = nsig_sc;
this.sig_sc = sig_sc;
}
static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch): Promise<Player> {
static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch, po_token?: string): Promise<Player> {
const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
const res = await fetch(url);
@@ -31,18 +32,25 @@ export default class Player {
const player_id = getStringBetweenStrings(js, 'player\\/', '\\/');
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 playerID now we can check if we have a cached player
// We have the player id, now we can check if we have a cached player.
if (cache) {
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.');
cached_player.po_token = po_token;
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(TAG, `Could not find any cached player. Will download a new player from ${player_url}.`);
const player_res = await fetch(player_url, {
headers: {
'user-agent': getRandomUserAgent('desktop')
@@ -56,14 +64,18 @@ export default class Player {
const player_js = await player_res.text();
const sig_timestamp = this.extractSigTimestamp(player_js);
const sig_sc = this.extractSigSourceCode(player_js);
const nsig_sc = this.extractNSigSourceCode(player_js);
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
Log.info(TAG, `Got signature timestamp (${sig_timestamp}) and algorithms needed to decipher signatures.`);
const player = await Player.fromSource(player_id, sig_timestamp, cache, sig_sc, nsig_sc);
player.po_token = po_token;
return player;
}
decipher(url?: string, signature_cipher?: string, cipher?: string): string {
decipher(url?: string, signature_cipher?: string, cipher?: string, this_response_nsig_cache?: Map<string, string>): string {
url = url || signature_cipher || cipher;
if (!url)
@@ -72,38 +84,83 @@ export default class Player {
const args = new URLSearchParams(url);
const url_components = new URL(args.get('url') || url);
if (signature_cipher || cipher) {
const signature = Platform.shim.eval(this.#sig_sc, {
if (this.sig_sc && (signature_cipher || cipher)) {
const signature = Platform.shim.eval(this.sig_sc, {
sig: args.get('s')
});
Log.info(TAG, `Transformed signature from ${args.get('s')} to ${signature}.`);
if (typeof signature !== 'string')
throw new PlayerError('Failed to decipher signature');
const sp = args.get('sp');
sp ?
url_components.searchParams.set(sp, signature) :
if (sp) {
url_components.searchParams.set(sp, signature);
} else {
url_components.searchParams.set('signature', signature);
}
}
const n = url_components.searchParams.get('n');
if (n) {
const nsig = Platform.shim.eval(this.#nsig_sc, {
nsig: n
});
if (this.nsig_sc && n) {
let nsig;
if (typeof nsig !== 'string')
throw new PlayerError('Failed to decipher nsig');
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: n
});
if (nsig.startsWith('enhanced_except_')) {
console.warn('Warning:\nCould not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!');
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(TAG, 'Could not transform nsig, download may be throttled.');
} else if (this_response_nsig_cache) {
this_response_nsig_cache.set(n, nsig);
}
}
url_components.searchParams.set('n', nsig);
}
// @NOTE: SABR requests should include the PoToken (not base64d, but as bytes!) in the payload.
if (url_components.searchParams.get('sabr') !== '1' && this.po_token)
url_components.searchParams.set('pot', this.po_token);
const client = url_components.searchParams.get('c');
switch (client) {
case 'WEB':
url_components.searchParams.set('cver', Constants.CLIENTS.WEB.VERSION);
break;
case 'WEB_REMIX':
url_components.searchParams.set('cver', Constants.CLIENTS.YTMUSIC.VERSION);
break;
case 'WEB_KIDS':
url_components.searchParams.set('cver', Constants.CLIENTS.WEB_KIDS.VERSION);
break;
case 'ANDROID':
url_components.searchParams.set('cver', Constants.CLIENTS.ANDROID.VERSION);
break;
case 'ANDROID_MUSIC':
url_components.searchParams.set('cver', Constants.CLIENTS.YTMUSIC_ANDROID.VERSION);
break;
case 'TVHTML5_SIMPLY_EMBEDDED_PLAYER':
url_components.searchParams.set('cver', Constants.CLIENTS.TV_EMBEDDED.VERSION);
break;
}
const result = url_components.toString();
Log.info(TAG, `Deciphered URL: ${result}`);
return url_components.toString();
}
@@ -125,39 +182,38 @@ 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 = LZW.decompress(new TextDecoder().decode(sig_buf));
const nsig_sc = LZW.decompress(new TextDecoder().decode(nsig_buf));
const sig_sc = decoder.decode(sig_buf);
const nsig_sc = decoder.decode(nsig_buf);
return new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
return new Player(player_id, sig_timestamp, sig_sc, nsig_sc);
}
static async fromSource(cache: ICache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string): Promise<Player> {
const player = new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
static async fromSource(player_id: string, sig_timestamp: number, cache?: ICache, sig_sc?: string, nsig_sc?: string): Promise<Player> {
const player = new Player(player_id, sig_timestamp, sig_sc, nsig_sc);
await player.cache(cache);
return player;
}
async cache(cache?: ICache): Promise<void> {
if (!cache) return;
if (!cache || !this.sig_sc || !this.nsig_sc)
return;
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 {
@@ -170,37 +226,23 @@ export default class Player {
const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};');
if (!functions || !calls)
console.warn(new PlayerError('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)`;
if (!sc)
console.warn(new PlayerError('Failed to extract n-token decipher algorithm'));
return sc;
static extractNSigSourceCode(data: string): string | undefined {
const nsig_function = findFunction(data, { includes: 'enhanced_except' });
if (nsig_function) {
return `${nsig_function.result} ${nsig_function.name}(nsig);`;
}
}
get url(): string {
return new URL(`/s/player/${this.#player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
}
get sts(): number {
return this.#sig_sc_timestamp;
}
get nsig_sc(): string {
return this.#nsig_sc;
}
get sig_sc(): string {
return this.#sig_sc;
return new URL(`/s/player/${this.player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
}
static get LIBRARY_VERSION(): number {
return 2;
return 11;
}
}
}

View File

@@ -1,52 +1,67 @@
import Constants, { CLIENTS } from '../utils/Constants.ts';
import EventEmitterLike from '../utils/EventEmitterLike.ts';
import OAuth2 from './OAuth2.ts';
import { Log, EventEmitter, HTTPClient, LZW } from '../utils/index.ts';
import * as Constants from '../utils/Constants.ts';
import * as Proto from '../proto/index.ts';
import Actions from './Actions.ts';
import Player from './Player.ts';
import Proto from '../proto/index.ts';
import { ICache } from '../types/Cache.ts';
import { FetchFunction } from '../types/PlatformShim.ts';
import HTTPClient from '../utils/HTTPClient.ts';
import { DeviceCategory, getRandomUserAgent, InnertubeError, Platform, SessionError } from '../utils/Utils.ts';
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth.ts';
import {
generateRandomString, getRandomUserAgent,
InnertubeError, Platform, SessionError
} from '../utils/Utils.ts';
import type { DeviceCategory } from '../utils/Utils.ts';
import type { FetchFunction, ICache } from '../types/index.ts';
import type { OAuth2Tokens, OAuth2AuthErrorEventHandler, OAuth2AuthPendingEventHandler, OAuth2AuthEventHandler } from './OAuth2.ts';
export enum ClientType {
WEB = 'WEB',
KIDS = 'WEB_KIDS',
MUSIC = 'WEB_REMIX',
IOS = 'iOS',
ANDROID = 'ANDROID',
ANDROID_MUSIC = 'ANDROID_MUSIC',
ANDROID_CREATOR = 'ANDROID_CREATOR',
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
}
export interface Context {
export type Context = {
client: {
hl: string;
gl: string;
remoteHost?: string;
screenDensityFloat: number;
screenHeightPoints: number;
screenPixelDensity: number;
screenWidthPoints: number;
visitorData: string;
userAgent: string;
screenDensityFloat?: number;
screenHeightPoints?: number;
screenPixelDensity?: number;
screenWidthPoints?: number;
visitorData?: string;
clientName: string;
clientVersion: string;
clientScreen?: string,
androidSdkVersion?: string;
androidSdkVersion?: number;
osName: string;
osVersion: string;
platform: string;
clientFormFactor: string;
userInterfaceTheme: string;
userInterfaceTheme?: string;
timeZone: string;
userAgent?: string;
browserName?: string;
browserVersion?: string;
originalUrl: string;
originalUrl?: string;
deviceMake: string;
deviceModel: string;
utcOffsetMinutes: number;
mainAppWebInfo?: {
graftUrl: string;
pwaInstallabilityStatus: string;
webDisplayMode: string;
isWebNativeShareAvailable: boolean;
};
memoryTotalKbytes?: string;
configInfo?: {
appInstallData: string;
},
kidsAppInfo?: {
categorySettings: {
enabledCategories: string[];
@@ -60,16 +75,38 @@ export interface Context {
user: {
enableSafetyMode: boolean;
lockedSafetyMode: boolean;
onBehalfOfUser?: string;
};
thirdParty?: {
embedUrl: string;
};
request: {
useSsl: true;
request?: {
useSsl: boolean;
internalExperimentFlags: any[];
};
}
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.
*/
@@ -80,12 +117,17 @@ 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;
/**
* Specify the Page ID of the YouTube profile/channel to use, if the logged-in account has multiple profiles.
*/
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;
@@ -96,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.
*/
@@ -111,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;
/**
@@ -127,53 +176,80 @@ export interface SessionOptions {
* Fetch function to use.
*/
fetch?: FetchFunction;
/**
* Proof of Origin Token. This is an attestation token generated by BotGuard/DroidGuard. It is used to confirm that the request is coming from a genuine client.
*/
po_token?: string;
}
export interface SessionData {
export type SessionData = {
context: Context;
api_key: string;
api_version: string;
}
export default class Session extends EventEmitterLike {
#api_version: string;
#key: string;
#context: Context;
#account_index: number;
#player?: Player;
export type SWSessionData = {
context_data: ContextData;
api_key: string;
api_version: string;
}
oauth: OAuth;
export type SessionArgs = {
lang: string;
location: string;
time_zone: string;
device_category: DeviceCategory;
client_name: ClientType;
enable_safety_mode: boolean;
visitor_data: string;
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 {
context: Context;
player?: Player;
oauth: OAuth2;
http: HTTPClient;
logged_in: boolean;
actions: Actions;
cache?: ICache;
key: string;
api_version: string;
account_index: number;
po_token?: string;
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: ICache) {
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: ICache, po_token?: string) {
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;
this.po_token = po_token;
}
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);
@@ -190,16 +266,61 @@ export default class Session extends EventEmitterLike {
options.device_category,
options.client_type,
options.timezone,
options.fetch
options.fetch,
options.on_behalf_of_user,
options.cache,
options.enable_session_cache,
options.po_token
);
return new Session(
context, api_key, api_version, account_index,
options.retrieve_player === false ? undefined : await Player.create(options.cache, options.fetch),
options.cookie, options.fetch, options.cache
options.retrieve_player === false ? undefined : await Player.create(options.cache, options.fetch, options.po_token),
options.cookie, options.fetch, options.cache, options.po_token
);
}
/**
* 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 = '',
@@ -210,44 +331,103 @@ export default class Session extends EventEmitterLike {
device_category: DeviceCategory = 'desktop',
client_name: ClientType = ClientType.WEB,
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
fetch: FetchFunction = Platform.shim.fetch
fetch: FetchFunction = Platform.shim.fetch,
on_behalf_of_user?: string,
cache?: ICache,
enable_session_cache = true,
po_token?: string
) {
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, po_token };
if (generate_session_locally) {
session_data = this.#generateSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data });
} else {
session_data = await this.#retrieveSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data }, fetch);
let session_data: SessionData | undefined;
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;
}
}
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 async #retrieveSessionData(options: {
lang: string;
location: string;
time_zone: string;
device_category: string;
client_name: string;
enable_safety_mode: boolean;
visitor_data: string;
}, fetch: FetchFunction = Platform.shim.fetch): Promise<SessionData> {
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 #getSessionData(options: SessionArgs, fetch: FetchFunction = Platform.shim.fetch): Promise<SWSessionData> {
let visitor_id = generateRandomString(11);
if (options.visitor_data)
visitor_id = this.#getVisitorID(options.visitor_data);
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
let visitor_id = Constants.CLIENTS.WEB.STATIC_VISITOR_ID;
if (options.visitor_data) {
const decoded_visitor_data = Proto.decodeVisitorData(options.visitor_data);
visitor_id = decoded_visitor_data.id;
}
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};`
}
});
@@ -255,128 +435,114 @@ export default class Session extends EventEmitterLike {
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];
const api_version = `v${ytcfg[0][0][6]}`;
const api_version = Constants.CLIENTS.WEB.API_VERSION;
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],
userAgent: device_info[14],
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
}
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: options.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
};
return { context, api_key, api_version };
return { context_data: context_info, api_key, api_version };
}
static #generateSessionData(options: {
lang: string;
location: string;
time_zone: string;
device_category: DeviceCategory;
client_name: string;
enable_safety_mode: boolean;
visitor_data: string;
}): SessionData {
let visitor_id = Constants.CLIENTS.WEB.STATIC_VISITOR_ID;
if (options.visitor_data) {
const decoded_visitor_data = Proto.decodeVisitorData(options.visitor_data);
visitor_id = decoded_visitor_data.id;
}
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)),
userAgent: getRandomUserAgent('desktop'),
clientName: options.client_name,
clientVersion: 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
}
};
return { context, api_key: CLIENTS.WEB.API_KEY, api_version: CLIENTS.WEB.API_VERSION };
}
async signIn(credentials?: Credentials): Promise<void> {
return new Promise(async (resolve, reject) => {
const error_handler: OAuthAuthErrorEventHandler = (err) => reject(err);
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);
}
@@ -396,41 +562,15 @@ export default class Session extends EventEmitterLike {
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

@@ -0,0 +1,118 @@
import { Parser } from '../../parser/index.ts';
import { Channel, HomeFeed, Search, VideoInfo } from '../../parser/ytkids/index.ts';
import { InnertubeError, generateRandomString } from '../../utils/Utils.ts';
import KidsBlocklistPickerItem from '../../parser/classes/ytkids/KidsBlocklistPickerItem.ts';
import {
BrowseEndpoint, NextEndpoint,
PlayerEndpoint, SearchEndpoint
} from '../endpoints/index.ts';
import { BlocklistPickerEndpoint } from '../endpoints/kids/index.ts';
import type { Session, ApiResponse } from '../index.ts';
export default class Kids {
#session: Session;
constructor(session: Session) {
this.#session = session;
}
/**
* Searches the given query.
* @param query - The query.
*/
async search(query: string): Promise<Search> {
const response = await this.#session.actions.execute(
SearchEndpoint.PATH, SearchEndpoint.build({ client: 'YTKIDS', query })
);
return new Search(this.#session.actions, response);
}
/**
* Retrieves video info.
* @param video_id - The video id.
*/
async getInfo(video_id: string): Promise<VideoInfo> {
const player_payload = PlayerEndpoint.build({
sts: this.#session.player?.sts,
client: 'YTKIDS',
video_id
});
const next_payload = NextEndpoint.build({
video_id,
client: 'YTKIDS'
});
const player_response = this.#session.actions.execute(PlayerEndpoint.PATH, player_payload);
const next_response = this.#session.actions.execute(NextEndpoint.PATH, next_payload);
const response = await Promise.all([ player_response, next_response ]);
const cpn = generateRandomString(16);
return new VideoInfo(response, this.#session.actions, cpn);
}
/**
* Retrieves the contents of the given channel.
* @param channel_id - The channel id.
*/
async getChannel(channel_id: string): Promise<Channel> {
const response = await this.#session.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
browse_id: channel_id,
client: 'YTKIDS'
})
);
return new Channel(this.#session.actions, response);
}
/**
* Retrieves the home feed.
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#session.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
browse_id: 'FEkids_home',
client: 'YTKIDS'
})
);
return new HomeFeed(this.#session.actions, response);
}
/**
* Retrieves the list of supervised accounts that the signed-in user has
* access to, and blocks the given channel for each of them.
* @param channel_id - The channel id to block.
* @returns A list of API responses.
*/
async blockChannel(channel_id: string): Promise<ApiResponse[]> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const blocklist_payload = BlocklistPickerEndpoint.build({ channel_id: channel_id });
const response = await this.#session.actions.execute(BlocklistPickerEndpoint.PATH, blocklist_payload );
const popup = response.data.command.confirmDialogEndpoint;
const popup_fragment = { contents: popup.content, engagementPanels: [] };
const kid_picker = Parser.parseResponse(popup_fragment);
const kids = kid_picker.contents_memo?.getType(KidsBlocklistPickerItem);
if (!kids)
throw new InnertubeError('Could not find any kids profiles or supervised accounts.');
// Iterate through the kids and block the channel if not already blocked.
const responses: ApiResponse[] = [];
for (const kid of kids) {
if (!kid.block_button?.is_toggled) {
kid.setActions(this.#session.actions);
// Block channel and add to the response list.
responses.push(await kid.blockChannel());
}
}
return responses;
}
}

View File

@@ -1,38 +1,36 @@
import * as Proto from '../../proto/index.ts';
import { InnertubeError, generateRandomString, throwIfMissing } from '../../utils/Utils.ts';
import Album from '../parser/ytmusic/Album.ts';
import Artist from '../parser/ytmusic/Artist.ts';
import Explore from '../parser/ytmusic/Explore.ts';
import HomeFeed from '../parser/ytmusic/HomeFeed.ts';
import Library from '../parser/ytmusic/Library.ts';
import Playlist from '../parser/ytmusic/Playlist.ts';
import Recap from '../parser/ytmusic/Recap.ts';
import Search from '../parser/ytmusic/Search.ts';
import TrackInfo from '../parser/ytmusic/TrackInfo.ts';
import {
Album, Artist, Explore,
HomeFeed, Library, Playlist,
Recap, Search, TrackInfo
} from '../../parser/ytmusic/index.ts';
import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo.ts';
import Message from '../parser/classes/Message.ts';
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf.ts';
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf.ts';
import MusicQueue from '../parser/classes/MusicQueue.ts';
import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem.ts';
import PlaylistPanel from '../parser/classes/PlaylistPanel.ts';
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection.ts';
import SectionList from '../parser/classes/SectionList.ts';
import Tab from '../parser/classes/Tab.ts';
import AutomixPreviewVideo from '../../parser/classes/AutomixPreviewVideo.ts';
import Message from '../../parser/classes/Message.ts';
import MusicDescriptionShelf from '../../parser/classes/MusicDescriptionShelf.ts';
import MusicQueue from '../../parser/classes/MusicQueue.ts';
import MusicTwoRowItem from '../../parser/classes/MusicTwoRowItem.ts';
import PlaylistPanel from '../../parser/classes/PlaylistPanel.ts';
import SearchSuggestionsSection from '../../parser/classes/SearchSuggestionsSection.ts';
import SectionList from '../../parser/classes/SectionList.ts';
import Tab from '../../parser/classes/Tab.ts';
import { observe } from '../parser/helpers.ts';
import Proto from '../proto/index.ts';
import { generateRandomString, InnertubeError, throwIfMissing } from '../utils/Utils.ts';
import {
BrowseEndpoint,
NextEndpoint,
PlayerEndpoint,
SearchEndpoint
} from '../endpoints/index.ts';
import type { ObservedArray, YTNode } from '../parser/helpers.ts';
import type Actions from './Actions.ts';
import type Session from './Session.ts';
import { GetSearchSuggestionsEndpoint } from '../endpoints/music/index.ts';
export interface MusicSearchFilters {
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
}
import type { ObservedArray } from '../../parser/helpers.ts';
import type { MusicSearchFilters } from '../../types/index.ts';
import type { Actions, Session } from '../index.ts';
class Music {
export default class Music {
#session: Session;
#actions: Actions;
@@ -56,25 +54,23 @@ class Music {
}
async #fetchInfoFromVideoId(video_id: string): Promise<TrackInfo> {
const player_payload = PlayerEndpoint.build({
video_id,
sts: this.#session.player?.sts,
client: 'YTMUSIC'
});
const next_payload = NextEndpoint.build({
video_id,
client: 'YTMUSIC'
});
const player_response = this.#actions.execute(PlayerEndpoint.PATH, player_payload);
const next_response = this.#actions.execute(NextEndpoint.PATH, next_payload);
const response = await Promise.all([ player_response, next_response ]);
const cpn = generateRandomString(16);
const initial_info = this.#actions.execute('/player', {
cpn,
client: 'YTMUSIC',
videoId: video_id,
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
const continuation = this.#actions.execute('/next', {
client: 'YTMUSIC',
videoId: video_id
});
const response = await Promise.all([ initial_info, continuation ]);
return new TrackInfo(response, this.#actions, cpn);
}
@@ -85,25 +81,26 @@ class Music {
if (!list_item.endpoint)
throw new Error('This item does not have an endpoint.');
const cpn = generateRandomString(16);
const initial_info = list_item.endpoint.call(this.#actions, {
cpn,
const player_response = list_item.endpoint.call(this.#actions, {
client: 'YTMUSIC',
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player?.sts || 0
...{
signatureTimestamp: this.#session.player?.sts
}
}
}
});
const continuation = list_item.endpoint.call(this.#actions, {
const next_response = list_item.endpoint.call(this.#actions, {
client: 'YTMUSIC',
enablePersistentPlaylistPanel: true,
override_endpoint: '/next'
});
const response = await Promise.all([ initial_info, continuation ]);
const cpn = generateRandomString(16);
const response = await Promise.all([ player_response, next_response ]);
return new TrackInfo(response, this.#actions, cpn);
}
@@ -115,17 +112,12 @@ class Music {
async search(query: string, filters: MusicSearchFilters = {}): Promise<Search> {
throwIfMissing({ query });
const payload: {
query: string;
client: string;
params?: string;
} = { query, client: 'YTMUSIC' };
if (filters.type && filters.type !== 'all') {
payload.params = Proto.encodeMusicSearchFilters(filters);
}
const response = await this.#actions.execute('/search', payload);
const response = await this.#actions.execute(
SearchEndpoint.PATH, SearchEndpoint.build({
query, client: 'YTMUSIC',
params: filters.type && filters.type !== 'all' ? Proto.encodeMusicSearchFilters(filters) : undefined
})
);
return new Search(response, this.#actions, Reflect.has(filters, 'type') && filters.type !== 'all');
}
@@ -134,10 +126,12 @@ class Music {
* Retrieves the home feed.
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_home'
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
browse_id: 'FEmusic_home',
client: 'YTMUSIC'
})
);
return new HomeFeed(response, this.#actions);
}
@@ -146,10 +140,12 @@ class Music {
* Retrieves the Explore feed.
*/
async getExplore(): Promise<Explore> {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_explore'
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: 'FEmusic_explore'
})
);
return new Explore(response);
// TODO: return new Explore(response, this.#actions);
@@ -159,10 +155,12 @@ class Music {
* Retrieves the library.
*/
async getLibrary(): Promise<Library> {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_library_landing'
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: 'FEmusic_library_landing'
})
);
return new Library(response, this.#actions);
}
@@ -177,10 +175,12 @@ class Music {
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
throw new InnertubeError('Invalid artist id', artist_id);
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: artist_id
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: artist_id
})
);
return new Artist(response, this.#actions);
}
@@ -195,10 +195,12 @@ class Music {
if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
throw new InnertubeError('Invalid album id', album_id);
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: album_id
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: album_id
})
);
return new Album(response);
}
@@ -214,10 +216,12 @@ class Music {
playlist_id = `VL${playlist_id}`;
}
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: playlist_id
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: playlist_id
})
);
return new Playlist(response, this.#actions);
}
@@ -230,13 +234,11 @@ class Music {
async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
throwIfMissing({ video_id });
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const response = await this.#actions.execute(
NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
);
const tabs = data.contents_memo?.getType(Tab);
const tabs = response.contents_memo?.getType(Tab);
const tab = tabs?.first();
@@ -275,16 +277,14 @@ 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 data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const response = await this.#actions.execute(
NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
);
const tabs = data.contents_memo?.getType(Tab);
const tabs = response.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
@@ -296,9 +296,9 @@ 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;
}
/**
@@ -308,13 +308,11 @@ class Music {
async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
throwIfMissing({ video_id });
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const response = await this.#actions.execute(
NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
);
const tabs = data.contents_memo?.getType(Tab);
const tabs = response.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
@@ -326,8 +324,8 @@ class Music {
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
if (page.contents.item().key('type').string() === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text, video_id);
if (page.contents.item().type === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text.toString(), video_id);
const section_list = page.contents.item().as(SectionList).contents;
@@ -338,10 +336,12 @@ class Music {
* Retrieves recap.
*/
async getRecap(): Promise<Recap> {
const response = await this.#actions.execute('/browse', {
browseId: 'FEmusic_listening_review',
client: 'YTMUSIC_ANDROID'
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC_ANDROID',
browse_id: 'FEmusic_listening_review'
})
);
return new Recap(response, this.#actions);
}
@@ -350,20 +350,17 @@ class Music {
* Retrieves search suggestions for the given query.
* @param query - The query.
*/
async getSearchSuggestions(query: string): Promise<ObservedArray<YTNode>> {
const response = await this.#actions.execute('/music/get_search_suggestions', {
parse: true,
input: query,
client: 'YTMUSIC'
});
async getSearchSuggestions(query: string): Promise<ObservedArray<SearchSuggestionsSection>> {
const response = await this.#actions.execute(
GetSearchSuggestionsEndpoint.PATH,
{ ...GetSearchSuggestionsEndpoint.build({ input: query }), parse: true }
);
const search_suggestions_section = response.contents_memo?.getType(SearchSuggestionsSection)?.[0];
if (!response.contents_memo)
return [] as unknown as ObservedArray<SearchSuggestionsSection>;
if (!search_suggestions_section?.contents.is_array)
return observe([] as YTNode[]);
const search_suggestions_sections = response.contents_memo.getType(SearchSuggestionsSection);
return search_suggestions_section?.contents.array();
return search_suggestions_sections;
}
}
export default Music;
}

View File

@@ -1,9 +1,10 @@
import Proto from '../proto/index.ts';
import { Constants } from '../utils/index.ts';
import { InnertubeError, MissingParamError, Platform } from '../utils/Utils.ts';
import * as Proto from '../../proto/index.ts';
import { Constants } from '../../utils/index.ts';
import { InnertubeError, MissingParamError, Platform } from '../../utils/Utils.ts';
import { CreateVideoEndpoint } from '../endpoints/upload/index.ts';
import type { ApiResponse } from './Actions.ts';
import type Session from './Session.ts';
import type { UpdateVideoMetadataOptions, UploadedVideoMetadataOptions } from '../../types/Misc.ts';
import type { ApiResponse, Session } from '../index.ts';
interface UploadResult {
status: string;
@@ -18,25 +19,7 @@ interface InitialUploadData {
chunk_granularity: string;
}
export interface VideoMetadata {
title?: string;
description?: string;
tags?: string[];
category?: number;
license?: string;
age_restricted?: boolean;
made_for_kids?: boolean;
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
}
export interface UploadedVideoMetadata {
title?: string;
description?: string;
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
is_draft?: boolean;
}
class Studio {
export default class Studio {
#session: Session;
constructor(session: Session) {
@@ -69,7 +52,7 @@ class Studio {
}
/**
* Updates given video's metadata.
* Updates a given video's metadata.
* @example
* ```ts
* const response = await yt.studio.updateVideoMetadata('videoid', {
@@ -82,7 +65,7 @@ class Studio {
* });
* ```
*/
async updateVideoMetadata(video_id: string, metadata: VideoMetadata): Promise<ApiResponse> {
async updateVideoMetadata(video_id: string, metadata: UpdateVideoMetadataOptions): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
@@ -104,7 +87,7 @@ class Studio {
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
* ```
*/
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<ApiResponse> {
async upload(file: BodyInit, metadata: UploadedVideoMetadataOptions = {}): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
@@ -174,38 +157,34 @@ class Studio {
return data;
}
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadata) {
const metadata_payload = {
resourceId: {
scottyResourceId: {
id: upload_result.scottyResourceId
}
},
frontendUploadId: initial_data.frontend_upload_id,
initialMetadata: {
title: {
newTitle: metadata.title || new Date().toDateString()
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadataOptions) {
const response = await this.#session.actions.execute(
CreateVideoEndpoint.PATH, CreateVideoEndpoint.build({
resource_id: {
scotty_resource_id: {
id: upload_result.scottyResourceId
}
},
description: {
newDescription: metadata.description || '',
shouldSegment: true
frontend_upload_id: initial_data.frontend_upload_id,
initial_metadata: {
title: {
new_title: metadata.title || new Date().toDateString()
},
description: {
new_description: metadata.description || '',
should_segment: true
},
privacy: {
new_privacy: metadata.privacy || 'PRIVATE'
},
draft_state: {
is_draft: metadata.is_draft
}
},
privacy: {
newPrivacy: metadata.privacy || 'PRIVATE'
},
draftState: {
isDraft: metadata.is_draft || false
}
}
};
const response = await this.#session.actions.execute('/upload/createvideo', {
client: 'ANDROID',
...metadata_payload
});
client: 'ANDROID'
})
);
return response;
}
}
export default Studio;
}

View File

@@ -0,0 +1,3 @@
export { default as Kids } from './Kids.ts';
export { default as Music } from './Music.ts';
export { default as Studio } from './Studio.ts';

View File

@@ -0,0 +1,19 @@
import type { IBrowseRequest, BrowseEndpointOptions } from '../../types/index.ts';
export const PATH = '/browse';
/**
* Builds a `/browse` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: BrowseEndpointOptions): IBrowseRequest {
return {
...{
browseId: opts.browse_id,
params: opts.params,
continuation: opts.continuation,
client: opts.client
}
};
}

View File

@@ -0,0 +1,16 @@
import type { IGetNotificationMenuRequest, GetNotificationMenuEndpointOptions } from '../../types/index.ts';
export const PATH = '/notification/get_notification_menu';
/**
* Builds a `/get_notification_menu` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: GetNotificationMenuEndpointOptions): IGetNotificationMenuRequest {
return {
...{
notificationsMenuRequestType: opts.notifications_menu_request_type
}
};
}

View File

@@ -0,0 +1 @@
export const PATH = '/guide';

View File

@@ -0,0 +1,21 @@
import type { INextRequest, NextEndpointOptions } from '../../types/index.ts';
export const PATH = '/next';
/**
* Builds a `/next` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: NextEndpointOptions): INextRequest {
return {
...{
videoId: opts.video_id,
playlistId: opts.playlist_id,
params: opts.params,
playlistIndex: opts.playlist_index,
client: opts.client,
continuation: opts.continuation
}
};
}

View File

@@ -0,0 +1,54 @@
import type { IPlayerRequest, PlayerEndpointOptions } from '../../types/index.ts';
export const PATH = '/player';
/**
* Builds a `/player` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: PlayerEndpointOptions): IPlayerRequest {
const payload: IPlayerRequest = {
playbackContext: {
contentPlaybackContext: {
vis: 0,
splay: false,
referer: opts.playlist_id ?
`https://www.youtube.com/watch?v=${opts.video_id}&list=${opts.playlist_id}` :
`https://www.youtube.com/watch?v=${opts.video_id}`,
currentUrl: opts.playlist_id ?
`/watch?v=${opts.video_id}&list=${opts.playlist_id}` :
`/watch?v=${opts.video_id}`,
autonavState: 'STATE_ON',
autoCaptionsDefaultOn: false,
html5Preference: 'HTML5_PREF_WANTS',
lactMilliseconds: '-1',
...{
signatureTimestamp: opts.sts
}
}
},
attestationRequest: {
omitBotguardData: true
},
racyCheckOk: true,
contentCheckOk: true,
videoId: opts.video_id
};
if (opts.client)
payload.client = opts.client;
if (opts.playlist_id)
payload.playlistId = opts.playlist_id;
if (opts.params)
payload.params = opts.params;
if (opts.po_token)
payload.serviceIntegrityDimensions = {
poToken: opts.po_token
};
return payload;
}

View File

@@ -0,0 +1,16 @@
import type { IResolveURLRequest, ResolveURLEndpointOptions } from '../../types/index.ts';
export const PATH = '/navigation/resolve_url';
/**
* Builds a `/resolve_url` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: ResolveURLEndpointOptions): IResolveURLRequest {
return {
...{
url: opts.url
}
};
}

View File

@@ -0,0 +1,19 @@
import type { ISearchRequest, SearchEndpointOptions } from '../../types/index.ts';
export const PATH = '/search';
/**
* Builds a `/search` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: SearchEndpointOptions): ISearchRequest {
return {
...{
query: opts.query,
params: opts.params,
continuation: opts.continuation,
client: opts.client
}
};
}

View File

@@ -0,0 +1,13 @@
import type { IAccountListRequest } from '../../../types/index.ts';
export const PATH = '/account/accounts_list';
/**
* Builds a `/account/accounts_list` request payload.
* @returns The payload.
*/
export function build(): IAccountListRequest {
return {
client: 'ANDROID'
};
}

View File

@@ -0,0 +1 @@
export * as AccountListEndpoint from './AccountListEndpoint.ts';

View File

@@ -0,0 +1,24 @@
import type { IEditPlaylistRequest, EditPlaylistEndpointOptions } from '../../../types/index.ts';
export const PATH = '/browse/edit_playlist';
/**
* Builds a `/browse/edit_playlist` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: EditPlaylistEndpointOptions): IEditPlaylistRequest {
return {
playlistId: opts.playlist_id,
actions: opts.actions.map((action) => ({
action: action.action,
...{
addedVideoId: action.added_video_id,
setVideoId: action.set_video_id,
movedSetVideoIdPredecessor: action.moved_set_video_id_predecessor,
playlistDescription: action.playlist_description,
playlistName: action.playlist_name
}
}))
};
}

View File

@@ -0,0 +1 @@
export * as EditPlaylistEndpoint from './EditPlaylistEndpoint.ts';

View File

@@ -0,0 +1,15 @@
import type { IChannelEditDescriptionRequest, ChannelEditDescriptionEndpointOptions } from '../../../types/Endpoints.ts';
export const PATH = '/channel/edit_description';
/**
* Builds a `/channel/edit_description` request payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: ChannelEditDescriptionEndpointOptions): IChannelEditDescriptionRequest {
return {
givenDescription: options.given_description,
client: 'ANDROID'
};
}

View File

@@ -0,0 +1,15 @@
import type { IChannelEditNameRequest, ChannelEditNameEndpointOptions } from '../../../types/index.ts';
export const PATH = '/channel/edit_name';
/**
* Builds a `/channel/edit_name` request payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: ChannelEditNameEndpointOptions): IChannelEditNameRequest {
return {
givenName: options.given_name,
client: 'ANDROID'
};
}

View File

@@ -0,0 +1,2 @@
export * as EditNameEndpoint from './EditNameEndpoint.ts';
export * as EditDescriptionEndpoint from './EditDescriptionEndpoint.ts';

View File

@@ -0,0 +1,18 @@
import type { ICreateCommentRequest, CreateCommentEndpointOptions } from '../../../types/index.ts';
export const PATH = '/comment/create_comment';
/**
* Builds a `/comment/create_comment` request payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: CreateCommentEndpointOptions): ICreateCommentRequest {
return {
commentText: options.comment_text,
createCommentParams: options.create_comment_params,
...{
client: options.client
}
};
}

View File

@@ -0,0 +1,17 @@
import type { IPerformCommentActionRequest, PerformCommentActionEndpointOptions } from '../../../types/index.ts';
export const PATH = '/comment/perform_comment_action';
/**
* Builds a `/comment/perform_comment_action` request payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: PerformCommentActionEndpointOptions): IPerformCommentActionRequest {
return {
actions: options.actions,
...{
client: options.client
}
};
}

View File

@@ -0,0 +1,2 @@
export * as PerformCommentActionEndpoint from './PerformCommentActionEndpoint.ts';
export * as CreateCommentEndpoint from './CreateCommentEndpoint.ts';

View File

@@ -0,0 +1,20 @@
export * as BrowseEndpoint from './BrowseEndpoint.ts';
export * as GetNotificationMenuEndpoint from './GetNotificationMenuEndpoint.ts';
export * as GuideEndpoint from './GuideEndpoint.ts';
export * as NextEndpoint from './NextEndpoint.ts';
export * as PlayerEndpoint from './PlayerEndpoint.ts';
export * as ResolveURLEndpoint from './ResolveURLEndpoint.ts';
export * as SearchEndpoint from './SearchEndpoint.ts';
export * as Account from './account/index.ts';
export * as Browse from './browse/index.ts';
export * as Channel from './channel/index.ts';
export * as Comment from './comment/index.ts';
export * as Like from './like/index.ts';
export * as Music from './music/index.ts';
export * as Notification from './notification/index.ts';
export * as Playlist from './playlist/index.ts';
export * as Subscription from './subscription/index.ts';
export * as Reel from './reel/index.ts';
export * as Upload from './upload/index.ts';
export * as Kids from './kids/index.ts';

View File

@@ -0,0 +1,12 @@
import type { IBlocklistPickerRequest, BlocklistPickerRequestEndpointOptions } from '../../../types/index.ts';
export const PATH = '/kids/get_kids_blocklist_picker';
/**
* Builds a `/kids/get_kids_blocklist_picker` request payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: BlocklistPickerRequestEndpointOptions): IBlocklistPickerRequest {
return { blockedForKidsContent: { external_channel_id: options.channel_id } };
}

View File

@@ -0,0 +1 @@
export * as BlocklistPickerEndpoint from './BlocklistPickerEndpoint.ts';

View File

@@ -0,0 +1,19 @@
import type { IDislikeRequest, DislikeEndpointOptions } from '../../../types/index.ts';
export const PATH = '/like/dislike';
/**
* Builds a `/like/dislike` endpoint payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: DislikeEndpointOptions): IDislikeRequest {
return {
target: {
videoId: options.target.video_id
},
...{
client: options.client
}
};
}

View File

@@ -0,0 +1,19 @@
import type { ILikeRequest, LikeEndpointOptions } from '../../../types/index.ts';
export const PATH = '/like/like';
/**
* Builds a `/like/like` endpoint payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: LikeEndpointOptions): ILikeRequest {
return {
target: {
videoId: options.target.video_id
},
...{
client: options.client
}
};
}

View File

@@ -0,0 +1,19 @@
import type { IRemoveLikeRequest, RemoveLikeEndpointOptions } from '../../../types/index.ts';
export const PATH = '/like/removelike';
/**
* Builds a `/like/removelike` endpoint payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: RemoveLikeEndpointOptions): IRemoveLikeRequest {
return {
target: {
videoId: options.target.video_id
},
...{
client: options.client
}
};
}

View File

@@ -0,0 +1,3 @@
export * as LikeEndpoint from './LikeEndpoint.ts';
export * as DislikeEndpoint from './DislikeEndpoint.ts';
export * as RemoveLikeEndpoint from './RemoveLikeEndpoint.ts';

View File

@@ -0,0 +1,15 @@
import type { IMusicGetSearchSuggestionsRequest, MusicGetSearchSuggestionsEndpointOptions } from '../../../types/index.ts';
export const PATH = '/music/get_search_suggestions';
/**
* Builds a `/music/get_search_suggestions` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: MusicGetSearchSuggestionsEndpointOptions): IMusicGetSearchSuggestionsRequest {
return {
input: opts.input,
client: 'YTMUSIC'
};
}

View File

@@ -0,0 +1 @@
export * as GetSearchSuggestionsEndpoint from './GetSearchSuggestionsEndpoint.ts';

View File

@@ -0,0 +1 @@
export const PATH = '/notification/get_unseen_count';

View File

@@ -0,0 +1,17 @@
import type { IModifyChannelPreferenceRequest, ModifyChannelPreferenceEndpointOptions } from '../../../types/index.ts';
export const PATH = '/notification/modify_channel_preference';
/**
* Builds a `/notification/modify_channel_preference` request payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: ModifyChannelPreferenceEndpointOptions): IModifyChannelPreferenceRequest {
return {
params: options.params,
...{
client: options.client
}
};
}

View File

@@ -0,0 +1,2 @@
export * as GetUnseenCountEndpoint from './GetUnseenCountEndpoint.ts';
export * as ModifyChannelPreferenceEndpoint from './ModifyChannelPreferenceEndpoint.ts';

View File

@@ -0,0 +1,15 @@
import type { ICreatePlaylistRequest, CreatePlaylistEndpointOptions } from '../../../types/index.ts';
export const PATH = '/playlist/create';
/**
* Builds a `/playlist/create` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: CreatePlaylistEndpointOptions): ICreatePlaylistRequest {
return {
title: opts.title,
ids: opts.ids
};
}

View File

@@ -0,0 +1,14 @@
import type { IDeletePlaylistRequest, DeletePlaylistEndpointOptions } from '../../../types/index.ts';
export const PATH = '/playlist/delete';
/**
* Builds a `/playlist/delete` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: DeletePlaylistEndpointOptions): IDeletePlaylistRequest {
return {
playlistId: opts.playlist_id
};
}

View File

@@ -0,0 +1,2 @@
export * as CreateEndpoint from './CreateEndpoint.ts';
export * as DeleteEndpoint from './DeleteEndpoint.ts';

View File

@@ -0,0 +1,20 @@
import type { IReelItemWatchRequest, ReelItemWatchEndpointOptions } from '../../../types/index.ts';
export const PATH = '/reel/reel_item_watch';
/**
* Builds a `/reel/reel_watch_sequence` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: ReelItemWatchEndpointOptions): IReelItemWatchRequest {
return {
disablePlayerResponse: false,
playerRequest: {
videoId: opts.video_id,
params: opts.params ?? 'CAUwAg%3D%3D'
},
params: opts.params ?? 'CAUwAg%3D%3D',
client: opts.client
};
}

View File

@@ -0,0 +1,14 @@
import type { IReelWatchSequenceRequest, ReelWatchSequenceEndpointOptions } from '../../../types/index.ts';
export const PATH = '/reel/reel_watch_sequence';
/**
* Builds a `/reel/reel_watch_sequence` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: ReelWatchSequenceEndpointOptions): IReelWatchSequenceRequest {
return {
sequenceParams: opts.sequence_params
};
}

View File

@@ -0,0 +1,2 @@
export * as ReelItemWatchEndpoint from './ReelItemWatchEndpoint.ts';
export * as ReelWatchSequenceEndpoint from './ReelWatchSequenceEndpoint.ts';

View File

@@ -0,0 +1,18 @@
import type { ISubscribeRequest, SubscribeEndpointOptions } from '../../../types/index.ts';
export const PATH = '/subscription/subscribe';
/**
* Builds a `/subscription/subscribe` endpoint payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: SubscribeEndpointOptions): ISubscribeRequest {
return {
channelIds: options.channel_ids,
...{
client: options.client,
params: options.params
}
};
}

View File

@@ -0,0 +1,18 @@
import type { IUnsubscribeRequest, UnsubscribeEndpointOptions } from '../../../types/index.ts';
export const PATH = '/subscription/unsubscribe';
/**
* Builds a `/subscription/unsubscribe` endpoint payload.
* @param options - The options to use.
* @returns The payload.
*/
export function build(options: UnsubscribeEndpointOptions): IUnsubscribeRequest {
return {
channelIds: options.channel_ids,
...{
client: options.client,
params: options.params
}
};
}

View File

@@ -0,0 +1,2 @@
export * as SubscribeEndpoint from './SubscribeEndpoint.ts';
export * as UnsubscribeEndpoint from './UnsubscribeEndpoint.ts';

View File

@@ -0,0 +1,37 @@
import type { ICreateVideoRequest, CreateVideoEndpointOptions } from '../../../types/index.ts';
export const PATH = '/upload/createvideo';
/**
* Builds a `/upload/createvideo` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: CreateVideoEndpointOptions): ICreateVideoRequest {
return {
resourceId: {
scottyResourceId: {
id: opts.resource_id.scotty_resource_id.id
}
},
frontendUploadId: opts.frontend_upload_id,
initialMetadata: {
title: {
newTitle: opts.initial_metadata.title.new_title
},
description: {
newDescription: opts.initial_metadata.description.new_description,
shouldSegment: opts.initial_metadata.description.should_segment
},
privacy: {
newPrivacy: opts.initial_metadata.privacy.new_privacy
},
draftState: {
isDraft: !!opts.initial_metadata.draft_state.is_draft
}
},
...{
client: opts.client
}
};
}

View File

@@ -0,0 +1 @@
export * as CreateVideoEndpoint from './CreateVideoEndpoint.ts';

View File

@@ -1,38 +1,16 @@
export { default as AccountManager } from './AccountManager.ts';
export * from './AccountManager.ts';
export { default as Session } from './Session.ts';
export * from './Session.ts';
export { default as Actions } from './Actions.ts';
export * from './Actions.ts';
export { default as Feed } from './Feed.ts';
export * from './Feed.ts';
export { default as FilterableFeed } from './FilterableFeed.ts';
export * from './FilterableFeed.ts';
export { default as InteractionManager } from './InteractionManager.ts';
export * from './InteractionManager.ts';
export { default as Kids } from './Kids.ts';
export * from './Kids.ts';
export { default as Music } from './Music.ts';
export * from './Music.ts';
export { default as OAuth } from './OAuth.ts';
export * from './OAuth.ts';
export { default as Player } from './Player.ts';
export * from './Player.ts';
export { default as PlaylistManager } from './PlaylistManager.ts';
export * from './PlaylistManager.ts';
export { default as OAuth2 } from './OAuth2.ts';
export * from './OAuth2.ts';
export { default as Session } from './Session.ts';
export * from './Session.ts';
export { default as Studio } from './Studio.ts';
export * from './Studio.ts';
export { default as TabbedFeed } from './TabbedFeed.ts';
export * from './TabbedFeed.ts';
export * as Clients from './clients/index.ts';
export * as Endpoints from './endpoints/index.ts';
export * as Managers from './managers/index.ts';
export * as Mixins from './mixins/index.ts';

View File

@@ -1,15 +1,15 @@
import Proto from '../proto/index.ts';
import type Actions from './Actions.ts';
import type { ApiResponse } from './Actions.ts';
import type { Actions, ApiResponse } from '../index.ts';
import Analytics from '../parser/youtube/Analytics.ts';
import TimeWatched from '../parser/youtube/TimeWatched.ts';
import AccountInfo from '../parser/youtube/AccountInfo.ts';
import Settings from '../parser/youtube/Settings.ts';
import AccountInfo from '../../parser/youtube/AccountInfo.ts';
import Analytics from '../../parser/youtube/Analytics.ts';
import Settings from '../../parser/youtube/Settings.ts';
import TimeWatched from '../../parser/youtube/TimeWatched.ts';
import { InnertubeError } from '../utils/Utils.ts';
import * as Proto from '../../proto/index.ts';
import { InnertubeError } from '../../utils/Utils.ts';
import { Account, BrowseEndpoint, Channel } from '../endpoints/index.ts';
class AccountManager {
export default class AccountManager {
#actions: Actions;
channel: {
@@ -30,10 +30,12 @@ class AccountManager {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
return this.#actions.execute('/channel/edit_name', {
givenName: new_name,
client: 'ANDROID'
});
return this.#actions.execute(
Channel.EditNameEndpoint.PATH,
Channel.EditNameEndpoint.build({
given_name: new_name
})
);
},
/**
* Edits channel description.
@@ -43,10 +45,12 @@ class AccountManager {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
return this.#actions.execute('/channel/edit_description', {
givenDescription: new_description,
client: 'ANDROID'
});
return this.#actions.execute(
Channel.EditDescriptionEndpoint.PATH,
Channel.EditDescriptionEndpoint.build({
given_description: new_description
})
);
},
/**
* Retrieves basic channel analytics.
@@ -62,7 +66,11 @@ class AccountManager {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('/account/accounts_list', { client: 'ANDROID' });
const response = await this.#actions.execute(
Account.AccountListEndpoint.PATH,
Account.AccountListEndpoint.build()
);
return new AccountInfo(response);
}
@@ -70,10 +78,12 @@ class AccountManager {
* Retrieves time watched statistics.
*/
async getTimeWatched(): Promise<TimeWatched> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPtime_watched',
client: 'ANDROID'
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
browse_id: 'SPtime_watched',
client: 'ANDROID'
})
);
return new TimeWatched(response);
}
@@ -82,10 +92,11 @@ class AccountManager {
* Opens YouTube settings.
*/
async getSettings(): Promise<Settings> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPaccount_overview'
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
browse_id: 'SPaccount_overview'
})
);
return new Settings(this.#actions, response);
}
@@ -95,16 +106,14 @@ class AccountManager {
async getAnalytics(): Promise<Analytics> {
const info = await this.getInfo();
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
const response = await this.#actions.execute('/browse', {
browseId: 'FEanalytics_screen',
client: 'ANDROID',
params
});
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
browse_id: 'FEanalytics_screen',
params: Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId),
client: 'ANDROID'
})
);
return new Analytics(response);
}
}
export default AccountManager;
}

View File

@@ -1,9 +1,14 @@
import Proto from '../proto/index.ts';
import type Actions from './Actions.ts';
import type { ApiResponse } from './Actions.ts';
import { throwIfMissing } from '../utils/Utils.ts';
import * as Proto from '../../proto/index.ts';
class InteractionManager {
import { throwIfMissing } from '../../utils/Utils.ts';
import { LikeEndpoint, DislikeEndpoint, RemoveLikeEndpoint } from '../endpoints/like/index.ts';
import { SubscribeEndpoint, UnsubscribeEndpoint } from '../endpoints/subscription/index.ts';
import { CreateCommentEndpoint, PerformCommentActionEndpoint } from '../endpoints/comment/index.ts';
import { ModifyChannelPreferenceEndpoint } from '../endpoints/notification/index.ts';
import type { Actions, ApiResponse } from '../index.ts';
export default class InteractionManager {
#actions: Actions;
constructor(actions: Actions) {
@@ -20,12 +25,12 @@ class InteractionManager {
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/like/like', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
const action = await this.#actions.execute(
LikeEndpoint.PATH, LikeEndpoint.build({
client: 'ANDROID',
target: { video_id }
})
);
return action;
}
@@ -40,12 +45,12 @@ class InteractionManager {
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/like/dislike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
const action = await this.#actions.execute(
DislikeEndpoint.PATH, DislikeEndpoint.build({
client: 'ANDROID',
target: { video_id }
})
);
return action;
}
@@ -60,12 +65,12 @@ class InteractionManager {
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/like/removelike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
const action = await this.#actions.execute(
RemoveLikeEndpoint.PATH, RemoveLikeEndpoint.build({
client: 'ANDROID',
target: { video_id }
})
);
return action;
}
@@ -80,11 +85,13 @@ class InteractionManager {
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/subscription/subscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'EgIIAhgA'
});
const action = await this.#actions.execute(
SubscribeEndpoint.PATH, SubscribeEndpoint.build({
client: 'ANDROID',
channel_ids: [ channel_id ],
params: 'EgIIAhgA'
})
);
return action;
}
@@ -93,17 +100,19 @@ class InteractionManager {
* Unsubscribes from a given channel.
* @param channel_id - The channel ID
*/
async unsubscribe(channel_id: string): Promise<ApiResponse>{
async unsubscribe(channel_id: string): Promise<ApiResponse> {
throwIfMissing({ channel_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/subscription/unsubscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'CgIIAhgA'
});
const action = await this.#actions.execute(
UnsubscribeEndpoint.PATH, UnsubscribeEndpoint.build({
client: 'ANDROID',
channel_ids: [ channel_id ],
params: 'CgIIAhgA'
})
);
return action;
}
@@ -119,11 +128,13 @@ class InteractionManager {
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/comment/create_comment', {
client: 'ANDROID',
commentText: text,
createCommentParams: Proto.encodeCommentParams(video_id)
});
const action = await this.#actions.execute(
CreateCommentEndpoint.PATH, CreateCommentEndpoint.build({
comment_text: text,
create_comment_params: Proto.encodeCommentParams(video_id),
client: 'ANDROID'
})
);
return action;
}
@@ -139,10 +150,12 @@ class InteractionManager {
const target_action = Proto.encodeCommentActionParams(22, { text, target_language, ...args });
const response = await this.#actions.execute('/comment/perform_comment_action', {
client: 'ANDROID',
actions: [ target_action ]
});
const response = await this.#actions.execute(
PerformCommentActionEndpoint.PATH, PerformCommentActionEndpoint.build({
client: 'ANDROID',
actions: [ target_action ]
})
);
const mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload;
@@ -175,13 +188,13 @@ class InteractionManager {
if (!Object.keys(pref_types).includes(type.toUpperCase()))
throw new Error(`Invalid notification preference type: ${type}`);
const action = await this.#actions.execute('/notification/modify_channel_preference', {
client: 'WEB',
params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types])
});
const action = await this.#actions.execute(
ModifyChannelPreferenceEndpoint.PATH, ModifyChannelPreferenceEndpoint.build({
client: 'WEB',
params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types])
})
);
return action;
}
}
export default InteractionManager;
}

View File

@@ -1,10 +1,14 @@
import type Feed from './Feed.ts';
import type Actions from './Actions.ts';
import Playlist from '../parser/youtube/Playlist.ts';
import { InnertubeError, throwIfMissing } from '../../utils/Utils.ts';
import { EditPlaylistEndpoint } from '../endpoints/browse/index.ts';
import { BrowseEndpoint } from '../endpoints/index.ts';
import { CreateEndpoint, DeleteEndpoint } from '../endpoints/playlist/index.ts';
import Playlist from '../../parser/youtube/Playlist.ts';
import { InnertubeError, throwIfMissing } from '../utils/Utils.ts';
import type { Actions } from '../index.ts';
import type { Feed } from '../mixins/index.ts';
import type { EditPlaylistEndpointOptions } from '../../types/index.ts';
class PlaylistManager {
export default class PlaylistManager {
#actions: Actions;
constructor(actions: Actions) {
@@ -22,11 +26,12 @@ class PlaylistManager {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('/playlist/create', {
title,
ids: video_ids,
parse: false
});
const response = await this.#actions.execute(
CreateEndpoint.PATH, CreateEndpoint.build({
ids: video_ids,
title
})
);
return {
success: response.success,
@@ -46,7 +51,11 @@ class PlaylistManager {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id });
const response = await this.#actions.execute(
DeleteEndpoint.PATH, DeleteEndpoint.build({
playlist_id
})
);
return {
playlist_id,
@@ -67,14 +76,15 @@ class PlaylistManager {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('/browse/edit_playlist', {
playlistId: playlist_id,
actions: video_ids.map((id) => ({
action: 'ACTION_ADD_VIDEO',
addedVideoId: id
})),
parse: false
});
const response = await this.#actions.execute(
EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build({
actions: video_ids.map((id) => ({
action: 'ACTION_ADD_VIDEO',
added_video_id: id
})),
playlist_id
})
);
return {
playlist_id,
@@ -86,38 +96,33 @@ class PlaylistManager {
* Removes videos from a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to remove from the playlist.
* @param use_set_video_ids - Option to remove videos using set video IDs.
*/
async removeVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
async removeVideos(playlist_id: string, video_ids: string[], use_set_video_ids = false): Promise<{ playlist_id: string; action_result: any }> {
throwIfMissing({ playlist_id, video_ids });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const info = await this.#actions.execute('/browse', {
browseId: `VL${playlist_id}`,
parse: true
});
const info = await this.#actions.execute(
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: `VL${playlist_id}` }), parse: true }
);
const playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
const payload = {
playlistId: playlist_id,
actions: [] as {
action: string;
setVideoId: string;
}[]
};
const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] };
const getSetVideoIds = async (pl: Feed): Promise<void> => {
const videos = pl.videos.filter((video) => video_ids.includes(video.key('id').string()));
const key_id = use_set_video_ids ? 'set_video_id' : 'id';
const videos = pl.videos.filter((video) => video_ids.includes(video.key(key_id).string()));
videos.forEach((video) =>
payload.actions.push({
action: 'ACTION_REMOVE_VIDEO',
setVideoId: video.key('set_video_id').string()
set_video_id: video.key('set_video_id').string()
})
);
@@ -132,7 +137,9 @@ class PlaylistManager {
if (!payload.actions.length)
throw new InnertubeError('Given video ids were not found in this playlist.', video_ids);
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
const response = await this.#actions.execute(
EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload)
);
return {
playlist_id,
@@ -152,24 +159,16 @@ class PlaylistManager {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const info = await this.#actions.execute('/browse', {
browseId: `VL${playlist_id}`,
parse: true
});
const info = await this.#actions.execute(
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: `VL${playlist_id}` }), parse: true }
);
const playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
const payload = {
playlistId: playlist_id,
actions: [] as {
action: string,
setVideoId?: string,
movedSetVideoIdPredecessor?: string
}[]
};
const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] };
let set_video_id_0: string | undefined, set_video_id_1: string | undefined;
@@ -190,20 +189,73 @@ class PlaylistManager {
payload.actions.push({
action: 'ACTION_MOVE_VIDEO_AFTER',
setVideoId: set_video_id_0,
movedSetVideoIdPredecessor: set_video_id_1
set_video_id: set_video_id_0,
moved_set_video_id_predecessor: set_video_id_1
});
const response = await this.#actions.execute('/browse/edit_playlist', {
...payload,
parse: false
});
const response = await this.#actions.execute(
EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload)
);
return {
playlist_id,
action_result: response.data.actions // TODO: implement actions in the parser
};
}
}
export default PlaylistManager;
/**
* Sets the name (title) for the given playlist.
* @param playlist_id - The playlist ID.
* @param name - The name / title to use for the playlist.
*/
async setName(playlist_id: string, name: string): Promise<{ playlist_id: string; action_result: any; }> {
throwIfMissing({ playlist_id, name });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] };
payload.actions.push({
action: 'ACTION_SET_PLAYLIST_NAME',
playlist_name: name
});
const response = await this.#actions.execute(
EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload)
);
return {
playlist_id,
action_result: response.data.actions
};
}
/**
* Sets the description for the given playlist.
* @param playlist_id - The playlist ID.
* @param description - The description to use for the playlist.
*/
async setDescription(playlist_id: string, description: string): Promise<{ playlist_id: string; action_result: any; }> {
throwIfMissing({ playlist_id, description });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] };
payload.actions.push({
action: 'ACTION_SET_PLAYLIST_DESCRIPTION',
playlist_description: description
});
const response = await this.#actions.execute(
EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload)
);
return {
playlist_id,
action_result: response.data.actions
};
}
}

View File

@@ -0,0 +1,3 @@
export { default as AccountManager } from './AccountManager.ts';
export { default as PlaylistManager } from './PlaylistManager.ts';
export { default as InteractionManager } from './InteractionManager.ts';

View File

@@ -1,40 +1,42 @@
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers.ts';
import Parser, { ReloadContinuationItemsCommand } from '../parser/index.ts';
import { concatMemos, InnertubeError } from '../utils/Utils.ts';
import type Actions from './Actions.ts';
import { Parser, ReloadContinuationItemsCommand } from '../../parser/index.ts';
import { concatMemos, InnertubeError } from '../../utils/Utils.ts';
import BackstagePost from '../parser/classes/BackstagePost.ts';
import SharedPost from '../parser/classes/SharedPost.ts';
import Channel from '../parser/classes/Channel.ts';
import CompactVideo from '../parser/classes/CompactVideo.ts';
import GridChannel from '../parser/classes/GridChannel.ts';
import GridPlaylist from '../parser/classes/GridPlaylist.ts';
import GridVideo from '../parser/classes/GridVideo.ts';
import Playlist from '../parser/classes/Playlist.ts';
import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo.ts';
import PlaylistVideo from '../parser/classes/PlaylistVideo.ts';
import Post from '../parser/classes/Post.ts';
import ReelItem from '../parser/classes/ReelItem.ts';
import ReelShelf from '../parser/classes/ReelShelf.ts';
import RichShelf from '../parser/classes/RichShelf.ts';
import Shelf from '../parser/classes/Shelf.ts';
import Tab from '../parser/classes/Tab.ts';
import Video from '../parser/classes/Video.ts';
import BackstagePost from '../../parser/classes/BackstagePost.ts';
import SharedPost from '../../parser/classes/SharedPost.ts';
import Channel from '../../parser/classes/Channel.ts';
import CompactVideo from '../../parser/classes/CompactVideo.ts';
import GridChannel from '../../parser/classes/GridChannel.ts';
import GridPlaylist from '../../parser/classes/GridPlaylist.ts';
import GridVideo from '../../parser/classes/GridVideo.ts';
import LockupView from '../../parser/classes/LockupView.ts';
import Playlist from '../../parser/classes/Playlist.ts';
import PlaylistPanelVideo from '../../parser/classes/PlaylistPanelVideo.ts';
import PlaylistVideo from '../../parser/classes/PlaylistVideo.ts';
import Post from '../../parser/classes/Post.ts';
import ReelItem from '../../parser/classes/ReelItem.ts';
import ReelShelf from '../../parser/classes/ReelShelf.ts';
import RichShelf from '../../parser/classes/RichShelf.ts';
import Shelf from '../../parser/classes/Shelf.ts';
import Tab from '../../parser/classes/Tab.ts';
import Video from '../../parser/classes/Video.ts';
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction.ts';
import ContinuationItem from '../parser/classes/ContinuationItem.ts';
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults.ts';
import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults.ts';
import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo.ts';
import AppendContinuationItemsAction from '../../parser/classes/actions/AppendContinuationItemsAction.ts';
import ContinuationItem from '../../parser/classes/ContinuationItem.ts';
import TwoColumnBrowseResults from '../../parser/classes/TwoColumnBrowseResults.ts';
import TwoColumnSearchResults from '../../parser/classes/TwoColumnSearchResults.ts';
import WatchCardCompactVideo from '../../parser/classes/WatchCardCompactVideo.ts';
import type MusicQueue from '../parser/classes/MusicQueue.ts';
import type RichGrid from '../parser/classes/RichGrid.ts';
import type SectionList from '../parser/classes/SectionList.ts';
import type { ApiResponse, Actions } from '../index.ts';
import type {
Memo, ObservedArray,
SuperParsedResult, YTNode
} from '../../parser/helpers.ts';
import type MusicQueue from '../../parser/classes/MusicQueue.ts';
import type RichGrid from '../../parser/classes/RichGrid.ts';
import type SectionList from '../../parser/classes/SectionList.ts';
import type { IParsedResponse } from '../../parser/types/index.ts';
import type { IParsedResponse } from '../parser/types/index.ts';
import type { ApiResponse } from './Actions.ts';
class Feed<T extends IParsedResponse = IParsedResponse> {
export default class Feed<T extends IParsedResponse = IParsedResponse> {
#page: T;
#continuation?: ObservedArray<ContinuationItem>;
#actions: Actions;
@@ -87,7 +89,18 @@ class Feed<T extends IParsedResponse = IParsedResponse> {
* Get all playlists on a given page via memo
*/
static getPlaylistsFromMemo(memo: Memo) {
return memo.getType(Playlist, GridPlaylist);
const playlists: ObservedArray<Playlist | GridPlaylist | LockupView> = memo.getType(Playlist, GridPlaylist);
const lockup_views = memo.getType(LockupView)
.filter((lockup) => {
return [ 'PLAYLIST', 'ALBUM', 'PODCAST' ].includes(lockup.content_type);
});
if (lockup_views.length > 0) {
playlists.push(...lockup_views);
}
return playlists;
}
/**
@@ -177,7 +190,7 @@ class Feed<T extends IParsedResponse = IParsedResponse> {
* Checks if the feed has continuation.
*/
get has_continuation(): boolean {
return (this.#memo.get('ContinuationItem') || []).length > 0;
return this.#getBodyContinuations().length > 0;
}
/**
@@ -185,17 +198,15 @@ class Feed<T extends IParsedResponse = IParsedResponse> {
*/
async getContinuationData(): Promise<T | undefined> {
if (this.#continuation) {
if (this.#continuation.length > 1)
throw new InnertubeError('There are too many continuations, you\'ll need to find the correct one yourself in this.page');
if (this.#continuation.length === 0)
throw new InnertubeError('There are no continuations');
throw new InnertubeError('There are no continuations.');
const response = await this.#continuation[0].endpoint.call<T>(this.#actions, { parse: true });
return response;
}
this.#continuation = this.#memo.getType(ContinuationItem);
this.#continuation = this.#getBodyContinuations();
if (this.#continuation)
return this.getContinuationData();
@@ -210,6 +221,14 @@ class Feed<T extends IParsedResponse = IParsedResponse> {
throw new InnertubeError('Could not get continuation data');
return new Feed<T>(this.actions, continuation_data, true);
}
}
export default Feed;
#getBodyContinuations(): ObservedArray<ContinuationItem> {
if (this.#page.header_memo) {
const header_continuations = this.#page.header_memo.getType(ContinuationItem);
return this.#memo.getType(ContinuationItem).filter(
(continuation) => !header_continuations.includes(continuation)
) as ObservedArray<ContinuationItem>;
}
return this.#memo.getType(ContinuationItem);
}
}

View File

@@ -1,14 +1,13 @@
import ChipCloudChip from '../parser/classes/ChipCloudChip.ts';
import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar.ts';
import { InnertubeError } from '../utils/Utils.ts';
import Feed from './Feed.ts';
import ChipCloudChip from '../../parser/classes/ChipCloudChip.ts';
import FeedFilterChipBar from '../../parser/classes/FeedFilterChipBar.ts';
import { InnertubeError } from '../../utils/Utils.ts';
import type { ObservedArray } from '../parser/helpers.ts';
import type { IParsedResponse } from '../parser/types/ParsedResponse.ts';
import type Actions from './Actions.ts';
import type { ApiResponse } from './Actions.ts';
import type { ObservedArray } from '../../parser/helpers.ts';
import type { IParsedResponse } from '../../parser/types/index.ts';
import type { ApiResponse, Actions } from '../index.ts';
class FilterableFeed<T extends IParsedResponse> extends Feed<T> {
export default class FilterableFeed<T extends IParsedResponse> extends Feed<T> {
#chips?: ObservedArray<ChipCloudChip>;
constructor(actions: Actions, data: ApiResponse | T, already_parsed = false) {
@@ -69,6 +68,4 @@ class FilterableFeed<T extends IParsedResponse> extends Feed<T> {
return new Feed(this.actions, response, true);
}
}
export default FilterableFeed;
}

View File

@@ -0,0 +1,241 @@
import { Constants, FormatUtils } from '../../utils/index.ts';
import { InnertubeError } from '../../utils/Utils.ts';
import { getStreamingInfo } from '../../utils/StreamingInfo.ts';
import { Parser } from '../../parser/index.ts';
import { TranscriptInfo } from '../../parser/youtube/index.ts';
import ContinuationItem from '../../parser/classes/ContinuationItem.ts';
import PlayerMicroformat from '../../parser/classes/PlayerMicroformat.ts';
import MicroformatData from '../../parser/classes/MicroformatData.ts';
import type { ApiResponse, Actions } from '../index.ts';
import type { INextResponse, IPlayabilityStatus, IPlaybackTracking, IPlayerConfig, IPlayerResponse, IStreamingData } from '../../parser/index.ts';
import type { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../../types/FormatUtils.ts';
import type Format from '../../parser/classes/misc/Format.ts';
import type { DashOptions } from '../../types/DashOptions.ts';
import type { ObservedArray } from '../../parser/helpers.ts';
import type CardCollection from '../../parser/classes/CardCollection.ts';
import type Endscreen from '../../parser/classes/Endscreen.ts';
import type PlayerAnnotationsExpanded from '../../parser/classes/PlayerAnnotationsExpanded.ts';
import type PlayerCaptionsTracklist from '../../parser/classes/PlayerCaptionsTracklist.ts';
import type PlayerLiveStoryboardSpec from '../../parser/classes/PlayerLiveStoryboardSpec.ts';
import type PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.ts';
export default class MediaInfo {
#page: [IPlayerResponse, INextResponse?];
#actions: Actions;
#cpn: string;
#playback_tracking?: IPlaybackTracking;
basic_info;
annotations?: ObservedArray<PlayerAnnotationsExpanded>;
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
endscreen?: Endscreen;
captions?: PlayerCaptionsTracklist;
cards?: CardCollection;
streaming_data?: IStreamingData;
playability_status?: IPlayabilityStatus;
player_config?: IPlayerConfig;
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) {
this.#actions = actions;
const info = Parser.parseResponse<IPlayerResponse>(data[0].data.playerResponse ? data[0].data.playerResponse : data[0].data);
const next = data[1]?.data ? Parser.parseResponse<INextResponse>(data[1].data) : undefined;
this.#page = [ info, next ];
this.#cpn = cpn;
if (info.playability_status?.status === 'ERROR')
throw new InnertubeError('This video is unavailable', info.playability_status);
if (info.microformat && !info.microformat?.is(PlayerMicroformat, MicroformatData))
throw new InnertubeError('Unsupported microformat', info.microformat);
this.basic_info = { // This type is inferred so no need for an explicit type
...info.video_details,
/**
* Microformat is a bit redundant, so only
* a few things there are interesting to us.
*/
...{
embed: info.microformat?.is(PlayerMicroformat) ? info.microformat?.embed : null,
channel: info.microformat?.is(PlayerMicroformat) ? info.microformat?.channel : null,
is_unlisted: info.microformat?.is_unlisted,
is_family_safe: info.microformat?.is_family_safe,
category: info.microformat?.is(PlayerMicroformat) ? info.microformat?.category : null,
has_ypc_metadata: info.microformat?.is(PlayerMicroformat) ? info.microformat?.has_ypc_metadata : null,
start_timestamp: info.microformat?.is(PlayerMicroformat) ? info.microformat.start_timestamp : null,
end_timestamp: info.microformat?.is(PlayerMicroformat) ? info.microformat.end_timestamp : null,
view_count: info.microformat?.is(PlayerMicroformat) && isNaN(info.video_details?.view_count as number) ? info.microformat.view_count : info.video_details?.view_count,
url_canonical: info.microformat?.is(MicroformatData) ? info.microformat?.url_canonical : null,
tags: info.microformat?.is(MicroformatData) ? info.microformat?.tags : null
},
like_count: undefined as number | undefined,
is_liked: undefined as boolean | undefined,
is_disliked: undefined as boolean | undefined
};
this.annotations = info.annotations;
this.storyboards = info.storyboards;
this.endscreen = info.endscreen;
this.captions = info.captions;
this.cards = info.cards;
this.streaming_data = info.streaming_data;
this.playability_status = info.playability_status;
this.player_config = info.player_config;
this.#playback_tracking = info.playback_tracking;
}
/**
* Generates a DASH manifest from the streaming data.
* @param url_transformer - Function to transform the URLs.
* @param format_filter - Function to filter the formats.
* @param options - Additional options to customise the manifest generation
* @returns DASH manifest
*/
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter, options: DashOptions = { include_thumbnails: false }): Promise<string> {
const player_response = this.#page[0];
if (player_response.video_details && (player_response.video_details.is_live)) {
throw new InnertubeError('Generating DASH manifests for live videos is not supported. Please use the DASH and HLS manifests provided by YouTube in `streaming_data.dash_manifest_url` and `streaming_data.hls_manifest_url` instead.');
}
let storyboards;
let captions;
if (options.include_thumbnails && player_response.storyboards) {
storyboards = player_response.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
);
}
/**
* Get a cleaned up representation of the adaptive_formats
*/
getStreamingInfo(url_transformer?: URLTransformer, format_filter?: FormatFilter) {
return getStreamingInfo(
this.streaming_data,
this.page[0].video_details?.is_post_live_dvr,
url_transformer,
format_filter,
this.cpn,
this.#actions.session.player,
this.#actions,
this.#page[0].storyboards ? this.#page[0].storyboards : undefined
);
}
/**
* Selects the format that best matches the given options.
* @param options - Options
*/
chooseFormat(options: FormatOptions): Format {
return FormatUtils.chooseFormat(options, this.streaming_data);
}
/**
* Downloads the video.
* @param options - Download options.
*/
async download(options: DownloadOptions = {}): Promise<ReadableStream<Uint8Array>> {
const player_response = this.#page[0];
if (player_response.video_details && (player_response.video_details.is_live || player_response.video_details.is_post_live_dvr)) {
throw new InnertubeError('Downloading is not supported for live and Post-Live-DVR videos, as they are split up into 5 second segments that are individual files, which require using a tool such as ffmpeg to stitch them together, so they cannot be returned in a single stream.');
}
return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player, this.cpn);
}
/**
* Retrieves the video's transcript.
* @param video_id - The video id.
*/
async getTranscript(): Promise<TranscriptInfo> {
const next_response = this.page[1];
if (!next_response)
throw new InnertubeError('Cannot get transcript from basic video info.');
if (!next_response.engagement_panels)
throw new InnertubeError('Engagement panels not found. Video likely has no transcript.');
const transcript_panel = next_response.engagement_panels.get({
panel_identifier: 'engagement-panel-searchable-transcript'
});
if (!transcript_panel)
throw new InnertubeError('Transcript panel not found. Video likely has no transcript.');
const transcript_continuation = transcript_panel.content?.as(ContinuationItem);
if (!transcript_continuation)
throw new InnertubeError('Transcript continuation not found.');
const response = await transcript_continuation.endpoint.call(this.actions);
return new TranscriptInfo(this.actions, response);
}
/**
* Adds video to the watch history.
*/
async addToWatchHistory(client_name = Constants.CLIENTS.WEB.NAME, client_version = Constants.CLIENTS.WEB.VERSION, replacement = 'https://www.'): Promise<Response> {
if (!this.#playback_tracking)
throw new InnertubeError('Playback tracking not available');
const url_params = {
cpn: this.#cpn,
fmt: 251,
rtn: 0,
rt: 0
};
const url = this.#playback_tracking.videostats_playback_url.replace('https://s.', replacement);
const response = await this.#actions.stats(url, {
client_name,
client_version
}, url_params);
return response;
}
/**
* Actions instance.
*/
get actions(): Actions {
return this.#actions;
}
/**
* Content Playback Nonce.
*/
get cpn(): string {
return this.#cpn;
}
/**
* Original parsed InnerTube response.
*/
get page(): [IPlayerResponse, INextResponse?] {
return this.#page;
}
}

View File

@@ -1,13 +1,12 @@
import Tab from '../parser/classes/Tab.ts';
import Feed from './Feed.ts';
import { InnertubeError } from '../utils/Utils.ts';
import { Feed } from './index.ts';
import { InnertubeError } from '../../utils/Utils.ts';
import Tab from '../../parser/classes/Tab.ts';
import type Actions from './Actions.ts';
import type { ObservedArray } from '../parser/helpers.ts';
import type { IParsedResponse } from '../parser/types/ParsedResponse.ts';
import type { ApiResponse } from './Actions.ts';
import type { Actions, ApiResponse } from '../index.ts';
import type { ObservedArray } from '../../parser/helpers.ts';
import type { IParsedResponse } from '../../parser/types/ParsedResponse.ts';
class TabbedFeed<T extends IParsedResponse> extends Feed<T> {
export default class TabbedFeed<T extends IParsedResponse> extends Feed<T> {
#tabs?: ObservedArray<Tab>;
#actions: Actions;
@@ -56,6 +55,4 @@ class TabbedFeed<T extends IParsedResponse> extends Feed<T> {
get title(): string | undefined {
return this.page.contents_memo?.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
}
}
export default TabbedFeed;
}

View File

@@ -0,0 +1,4 @@
export { default as Feed } from './Feed.ts';
export { default as FilterableFeed } from './FilterableFeed.ts';
export { default as TabbedFeed } from './TabbedFeed.ts';
export { default as MediaInfo } from './MediaInfo.ts';

View File

@@ -310,7 +310,7 @@ const example_data = {
// The first argument is the name of the class, the second is the data you have for the node.
// It will return a class that extends YTNode.
const Example = Generator.YTNodeGenerator.generateRuntimeClass('Example', example_data);
const Example = Generator.generateRuntimeClass('Example', example_data);
// You may now use this class as you would any other node.
const example = new Example(example_data);

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import AboutChannelView from './AboutChannelView.ts';
import Button from './Button.ts';
export default class AboutChannel extends YTNode {
static type = 'AboutChannel';
metadata: AboutChannelView | null;
share_channel: Button | null;
constructor(data: RawNode) {
super();
this.metadata = Parser.parseItem(data.metadata, AboutChannelView);
this.share_channel = Parser.parseItem(data.shareChannel, Button);
}
}

View File

@@ -0,0 +1,87 @@
import type { ObservedArray } from '../helpers.ts';
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import ChannelExternalLinkView from './ChannelExternalLinkView.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import Text from './misc/Text.ts';
export default class AboutChannelView extends YTNode {
static type = 'AboutChannelView';
description?: string;
description_label?: Text;
country?: string;
custom_links_label?: Text;
subscriber_count?: string;
view_count?: string;
joined_date?: Text;
canonical_channel_url?: string;
channel_id?: string;
additional_info_label?: Text;
custom_url_on_tap?: NavigationEndpoint;
video_count?: string;
sign_in_for_business_email?: Text;
links: ObservedArray<ChannelExternalLinkView>;
constructor(data: RawNode) {
super();
if (Reflect.has(data, 'description')) {
this.description = data.description;
}
if (Reflect.has(data, 'descriptionLabel')) {
this.description_label = Text.fromAttributed(data.descriptionLabel);
}
if (Reflect.has(data, 'country')) {
this.country = data.country;
}
if (Reflect.has(data, 'customLinksLabel')) {
this.custom_links_label = Text.fromAttributed(data.customLinksLabel);
}
if (Reflect.has(data, 'subscriberCountText')) {
this.subscriber_count = data.subscriberCountText;
}
if (Reflect.has(data, 'viewCountText')) {
this.view_count = data.viewCountText;
}
if (Reflect.has(data, 'joinedDateText')) {
this.joined_date = Text.fromAttributed(data.joinedDateText);
}
if (Reflect.has(data, 'canonicalChannelUrl')) {
this.canonical_channel_url = data.canonicalChannelUrl;
}
if (Reflect.has(data, 'channelId')) {
this.channel_id = data.channelId;
}
if (Reflect.has(data, 'additionalInfoLabel')) {
this.additional_info_label = Text.fromAttributed(data.additionalInfoLabel);
}
if (Reflect.has(data, 'customUrlOnTap')) {
this.custom_url_on_tap = new NavigationEndpoint(data.customUrlOnTap);
}
if (Reflect.has(data, 'videoCountText')) {
this.video_count = data.videoCountText;
}
if (Reflect.has(data, 'signInForBusinessEmail')) {
this.sign_in_for_business_email = Text.fromAttributed(data.signInForBusinessEmail);
}
if (Reflect.has(data, 'links')) {
this.links = Parser.parseArray(data.links, ChannelExternalLinkView);
} else {
this.links = [] as unknown as ObservedArray<ChannelExternalLinkView>;
}
}
}

View File

@@ -3,7 +3,7 @@ import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class AccountChannel extends YTNode {
export default class AccountChannel extends YTNode {
static type = 'AccountChannel';
title: Text;
@@ -14,6 +14,4 @@ class AccountChannel extends YTNode {
this.title = new Text(data.title);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
}
export default AccountChannel;
}

View File

@@ -1,14 +1,16 @@
import Parser from '../index.ts';
import { Parser } from '../index.ts';
import AccountItemSectionHeader from './AccountItemSectionHeader.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import AccountItemSectionHeader from './AccountItemSectionHeader.ts';
import { YTNode } from '../helpers.ts';
import { YTNode, observe, type ObservedArray } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class AccountItem {
/**
* Not a real renderer but we treat it as one to keep things organized.
*/
export class AccountItem extends YTNode {
static type = 'AccountItem';
account_name: Text;
@@ -20,27 +22,26 @@ class AccountItem {
account_byline: Text;
constructor(data: RawNode) {
super();
this.account_name = new Text(data.accountName);
this.account_photo = Thumbnail.fromResponse(data.accountPhoto);
this.is_selected = data.isSelected;
this.is_disabled = data.isDisabled;
this.has_channel = data.hasChannel;
this.is_selected = !!data.isSelected;
this.is_disabled = !!data.isDisabled;
this.has_channel = !!data.hasChannel;
this.endpoint = new NavigationEndpoint(data.serviceEndpoint);
this.account_byline = new Text(data.accountByline);
}
}
class AccountItemSection extends YTNode {
export default class AccountItemSection extends YTNode {
static type = 'AccountItemSection';
contents;
header;
contents: ObservedArray<AccountItem>;
header: AccountItemSectionHeader | null;
constructor(data: RawNode) {
super();
this.contents = data.contents.map((ac: any) => new AccountItem(ac.accountItem));
this.contents = observe<AccountItem>(data.contents.map((ac: RawNode) => new AccountItem(ac.accountItem)));
this.header = Parser.parseItem(data.header, AccountItemSectionHeader);
}
}
export default AccountItemSection;
}

View File

@@ -1,7 +1,8 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class AccountItemSectionHeader extends YTNode {
export default class AccountItemSectionHeader extends YTNode {
static type = 'AccountItemSectionHeader';
title: Text;
@@ -10,6 +11,4 @@ class AccountItemSectionHeader extends YTNode {
super();
this.title = new Text(data.title);
}
}
export default AccountItemSectionHeader;
}

View File

@@ -1,10 +1,11 @@
import Parser from '../index.ts';
import { Parser } from '../index.ts';
import AccountChannel from './AccountChannel.ts';
import AccountItemSection from './AccountItemSection.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class AccountSectionList extends YTNode {
export default class AccountSectionList extends YTNode {
static type = 'AccountSectionList';
contents;
@@ -15,6 +16,4 @@ class AccountSectionList extends YTNode {
this.contents = Parser.parseItem(data.contents[0], AccountItemSection);
this.footers = Parser.parseItem(data.footers[0], AccountChannel);
}
}
export default AccountSectionList;
}

View File

@@ -1,7 +1,8 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class Alert extends YTNode {
export default class Alert extends YTNode {
static type = 'Alert';
text: Text;
@@ -12,6 +13,4 @@ class Alert extends YTNode {
this.text = new Text(data.text);
this.alert_type = data.type;
}
}
export default Alert;
}

View File

@@ -0,0 +1,19 @@
import Button from './Button.ts';
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
export default class AlertWithButton extends YTNode {
static type = 'AlertWithButton';
text: Text;
alert_type: string;
dismiss_button: Button | null;
constructor(data: RawNode) {
super();
this.text = new Text(data.text);
this.alert_type = data.type;
this.dismiss_button = Parser.parseItem(data.dismissButton, Button);
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import Text from './misc/Text.ts';
export default class AttributionView extends YTNode {
static type = 'AttributionView';
text: Text;
suffix: Text;
constructor(data: RawNode) {
super();
this.text = Text.fromAttributed(data.text);
this.suffix = Text.fromAttributed(data.suffix);
}
}

View File

@@ -1,6 +1,7 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class AudioOnlyPlayability extends YTNode {
export default class AudioOnlyPlayability extends YTNode {
static type = 'AudioOnlyPlayability';
audio_only_availability: string;
@@ -9,6 +10,4 @@ class AudioOnlyPlayability extends YTNode {
super();
this.audio_only_availability = data.audioOnlyAvailability;
}
}
export default AudioOnlyPlayability;
}

View File

@@ -1,7 +1,8 @@
import { YTNode } from '../helpers.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import type { RawNode } from '../index.ts';
class AutomixPreviewVideo extends YTNode {
export default class AutomixPreviewVideo extends YTNode {
static type = 'AutomixPreviewVideo';
playlist_video?: { endpoint: NavigationEndpoint };
@@ -14,6 +15,4 @@ class AutomixPreviewVideo extends YTNode {
};
}
}
}
export default AutomixPreviewVideo;
}

View File

@@ -0,0 +1,26 @@
import { YTNode } from '../helpers.ts';
import { type RawNode } from '../index.ts';
import { Thumbnail } from '../misc.ts';
export default class AvatarView extends YTNode {
static type = 'AvatarView';
image: Thumbnail[];
image_processor: {
border_image_processor: {
circular: boolean
}
};
avatar_image_size: string;
constructor(data: RawNode) {
super();
this.image = Thumbnail.fromResponse(data.image);
this.image_processor = {
border_image_processor: {
circular: data.image.processor.borderImageProcessor.circular
}
};
this.avatar_image_size = data.avatarImageSize;
}
}

View File

@@ -1,18 +1,17 @@
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class BackstageImage extends YTNode {
export default class BackstageImage extends YTNode {
static type = 'BackstageImage';
image: Thumbnail[];
endpoint: NavigationEndpoint;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.image = Thumbnail.fromResponse(data.image);
this.endpoint = new NavigationEndpoint(data.command);
}
}
export default BackstageImage;
}

View File

@@ -1,13 +1,13 @@
import Parser from '../index.ts';
import Author from './misc/Author.ts';
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import Button from './Button.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import CommentActionButtons from './comments/CommentActionButtons.ts';
import Menu from './menus/Menu.ts';
import Author from './misc/Author.ts';
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class BackstagePost extends YTNode {
export default class BackstagePost extends YTNode {
static type = 'BackstagePost';
id: string;
@@ -18,13 +18,13 @@ class BackstagePost extends YTNode {
vote_status?: string;
vote_count?: Text;
menu?: Menu | null;
action_buttons;
vote_button;
action_buttons?: CommentActionButtons | null;
vote_button?: Button | null;
surface: string;
endpoint?: NavigationEndpoint;
attachment;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.id = data.postId;
@@ -36,40 +36,38 @@ class BackstagePost extends YTNode {
this.content = new Text(data.contentText);
this.published = new Text(data.publishedTimeText);
if (data.pollStatus) {
if (Reflect.has(data, 'pollStatus')) {
this.poll_status = data.pollStatus;
}
if (data.voteStatus) {
if (Reflect.has(data, 'voteStatus')) {
this.vote_status = data.voteStatus;
}
if (data.voteCount) {
if (Reflect.has(data, 'voteCount')) {
this.vote_count = new Text(data.voteCount);
}
if (data.actionMenu) {
if (Reflect.has(data, 'actionMenu')) {
this.menu = Parser.parseItem(data.actionMenu, Menu);
}
if (data.actionButtons) {
if (Reflect.has(data, 'actionButtons')) {
this.action_buttons = Parser.parseItem(data.actionButtons, CommentActionButtons);
}
if (data.voteButton) {
this.vote_button = Parser.parseItem(data.voteButton, CommentActionButtons);
if (Reflect.has(data, 'voteButton')) {
this.vote_button = Parser.parseItem(data.voteButton, Button);
}
if (data.navigationEndpoint) {
if (Reflect.has(data, 'navigationEndpoint')) {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
if (data.backstageAttachment) {
if (Reflect.has(data, 'backstageAttachment')) {
this.attachment = Parser.parseItem(data.backstageAttachment);
}
this.surface = data.surface;
}
}
export default BackstagePost;
}

View File

@@ -1,15 +1,13 @@
import Parser from '../index.ts';
import { Parser, type RawNode } from '../index.ts';
import { YTNode } from '../helpers.ts';
class BackstagePostThread extends YTNode {
export default class BackstagePostThread extends YTNode {
static type = 'BackstagePostThread';
post;
post: YTNode;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.post = Parser.parseItem(data.post);
}
}
export default BackstagePostThread;
}

View File

@@ -1,15 +1,13 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import { type ObservedArray, YTNode } from '../helpers.ts';
class BrowseFeedActions extends YTNode {
export default class BrowseFeedActions extends YTNode {
static type = 'BrowseFeedActions';
contents;
contents: ObservedArray<YTNode>;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents);
}
}
export default BrowseFeedActions;
}

View File

@@ -1,18 +1,17 @@
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class BrowserMediaSession extends YTNode {
export default class BrowserMediaSession extends YTNode {
static type = 'BrowserMediaSession';
album;
thumbnails;
album: Text;
thumbnails: Thumbnail[];
constructor (data: any) {
constructor (data: RawNode) {
super();
this.album = new Text(data.album);
this.thumbnails = Thumbnail.fromResponse(data.thumbnailDetails);
}
}
export default BrowserMediaSession;
}

View File

@@ -1,6 +1,5 @@
import Text from './misc/Text.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
@@ -8,36 +7,28 @@ export default class Button extends YTNode {
static type = 'Button';
text?: string;
label?: string;
tooltip?: string;
icon_type?: string;
is_disabled?: boolean;
endpoint: NavigationEndpoint;
constructor(data: RawNode) {
super();
if (data.text) {
if (Reflect.has(data, 'text'))
this.text = new Text(data.text).toString();
}
if (data.accessibility?.label) {
this.label = data.accessibility?.label;
}
if (Reflect.has(data, 'accessibility') && Reflect.has(data.accessibility, 'label'))
this.label = data.accessibility.label;
if (data.tooltip) {
if (Reflect.has(data, 'tooltip'))
this.tooltip = data.tooltip;
}
if (data.icon?.iconType) {
this.icon_type = data.icon?.iconType;
}
if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType'))
this.icon_type = data.icon.iconType;
if (Reflect.has(data, 'isDisabled')) {
if (Reflect.has(data, 'isDisabled'))
this.is_disabled = data.isDisabled;
}
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command);
}

View File

@@ -0,0 +1,28 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
export default class ButtonView extends YTNode {
static type = 'ButtonView';
icon_name: string;
title: string;
accessibility_text: string;
style: string;
is_full_width: boolean;
button_type: string;
button_size: string;
on_tap: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.icon_name = data.iconName;
this.title = data.title;
this.accessibility_text = data.accessibilityText;
this.style = data.style;
this.is_full_width = data.isFullWidth;
this.button_type = data.type;
this.button_size = data.buttonSize;
this.on_tap = new NavigationEndpoint(data.onTap);
}
}

View File

@@ -1,15 +1,15 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import Button from './Button.ts';
import ChannelHeaderLinks from './ChannelHeaderLinks.ts';
import ChannelHeaderLinksView from './ChannelHeaderLinksView.ts';
import ChannelTagline from './ChannelTagline.ts';
import SubscribeButton from './SubscribeButton.ts';
import Author from './misc/Author.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import Button from './Button.ts';
import ChannelHeaderLinks from './ChannelHeaderLinks.ts';
import SubscribeButton from './SubscribeButton.ts';
import { YTNode } from '../helpers.ts';
class C4TabbedHeader extends YTNode {
export default class C4TabbedHeader extends YTNode {
static type = 'C4TabbedHeader';
author: Author;
@@ -20,57 +20,60 @@ class C4TabbedHeader extends YTNode {
videos_count?: Text;
sponsor_button?: Button | null;
subscribe_button?: SubscribeButton | Button | null;
header_links?: ChannelHeaderLinks | null;
header_links?: ChannelHeaderLinks | ChannelHeaderLinksView | null;
channel_handle?: Text;
channel_id?: string;
tagline?: ChannelTagline | null;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.author = new Author({
simpleText: data.title,
navigationEndpoint: data.navigationEndpoint
}, data.badges, data.avatar);
if (data.banner) {
if (Reflect.has(data, 'banner')) {
this.banner = Thumbnail.fromResponse(data.banner);
}
if (data.tv_banner) {
if (Reflect.has(data, 'tv_banner')) {
this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
}
if (data.mobile_banner) {
if (Reflect.has(data, 'mobile_banner')) {
this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);
}
if (data.subscriberCountText) {
if (Reflect.has(data, 'subscriberCountText')) {
this.subscribers = new Text(data.subscriberCountText);
}
if (data.videosCountText) {
if (Reflect.has(data, 'videosCountText')) {
this.videos_count = new Text(data.videosCountText);
}
if (data.sponsorButton) {
if (Reflect.has(data, 'sponsorButton')) {
this.sponsor_button = Parser.parseItem(data.sponsorButton, Button);
}
if (data.subscribeButton) {
if (Reflect.has(data, 'subscribeButton')) {
this.subscribe_button = Parser.parseItem(data.subscribeButton, [ SubscribeButton, Button ]);
}
if (data.headerLinks) {
this.header_links = Parser.parseItem(data.headerLinks, ChannelHeaderLinks);
if (Reflect.has(data, 'headerLinks')) {
this.header_links = Parser.parseItem(data.headerLinks, [ ChannelHeaderLinks, ChannelHeaderLinksView ]);
}
if (data.channelHandleText) {
if (Reflect.has(data, 'channelHandleText')) {
this.channel_handle = new Text(data.channelHandleText);
}
if (data.channelId) {
if (Reflect.has(data, 'channelId')) {
this.channel_id = data.channelId;
}
}
}
export default C4TabbedHeader;
if (Reflect.has(data, 'tagline')) {
this.tagline = Parser.parseItem(data.tagline, ChannelTagline);
}
}
}

View File

@@ -1,19 +1,18 @@
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class CallToActionButton extends YTNode {
export default class CallToActionButton extends YTNode {
static type = 'CallToActionButton';
label: Text;
icon_type: string;
style: string;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.label = new Text(data.label);
this.icon_type = data.icon.iconType;
this.style = data.style;
}
}
export default CallToActionButton;
}

View File

@@ -1,13 +1,13 @@
import Parser from '../index.ts';
import { Parser, type RawNode } from '../index.ts';
import { YTNode } from '../helpers.ts';
class Card extends YTNode {
export default class Card extends YTNode {
static type = 'Card';
teaser;
content;
card_id: string | null;
feature: string | null;
teaser: YTNode;
content: YTNode;
card_id?: string;
feature?: string;
cue_ranges: {
start_card_active_ms: string;
@@ -16,12 +16,18 @@ class Card extends YTNode {
icon_after_teaser_ms: string;
}[];
constructor(data: any) {
constructor(data: RawNode) {
super();
this.teaser = Parser.parseItem(data.teaser);
this.content = Parser.parseItem(data.content);
this.card_id = data.cardId || null;
this.feature = data.feature || null;
if (Reflect.has(data, 'cardId')) {
this.card_id = data.cardId;
}
if (Reflect.has(data, 'feature')) {
this.feature = data.feature;
}
this.cue_ranges = data.cueRanges.map((cr: any) => ({
start_card_active_ms: cr.startCardActiveMs,
@@ -30,6 +36,4 @@ class Card extends YTNode {
icon_after_teaser_ms: cr.iconAfterTeaserMs
}));
}
}
export default Card;
}

View File

@@ -1,20 +1,18 @@
import Parser from '../index.ts';
import { YTNode, type ObservedArray } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import Text from './misc/Text.ts';
import { YTNode } from '../helpers.ts';
class CardCollection extends YTNode {
export default class CardCollection extends YTNode {
static type = 'CardCollection';
cards;
cards: ObservedArray<YTNode>;
header: Text;
allow_teaser_dismiss: boolean;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.cards = Parser.parseArray(data.cards);
this.header = new Text(data.headerText);
this.allow_teaser_dismiss = data.allowTeaserDismiss;
}
}
export default CardCollection;
}

View File

@@ -1,15 +1,13 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import { type ObservedArray, YTNode } from '../helpers.ts';
class CarouselHeader extends YTNode {
export default class CarouselHeader extends YTNode {
static type = 'CarouselHeader';
contents: YTNode[];
contents: ObservedArray<YTNode>;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents);
}
}
export default CarouselHeader;
}

View File

@@ -1,18 +1,17 @@
import Parser from '../index.ts';
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import { type ObservedArray, YTNode } from '../helpers.ts';
import Thumbnail from './misc/Thumbnail.ts';
class CarouselItem extends YTNode {
export default class CarouselItem extends YTNode {
static type = 'CarouselItem';
items: YTNode[];
items: ObservedArray<YTNode>;
background_color: string;
layout_style: string;
pagination_thumbnails: Thumbnail[];
paginator_alignment: string;
constructor (data: any) {
constructor (data: RawNode) {
super();
this.items = Parser.parseArray(data.carouselItems);
this.background_color = data.backgroundColor;
@@ -20,6 +19,9 @@ class CarouselItem extends YTNode {
this.pagination_thumbnails = Thumbnail.fromResponse(data.paginationThumbnails);
this.paginator_alignment = data.paginatorAlignment;
}
}
export default CarouselItem;
// XXX: For consistency.
get contents() {
return this.items;
}
}

View File

@@ -0,0 +1,17 @@
import { type ObservedArray, YTNode } from '../helpers.ts';
import InfoRow from './InfoRow.ts';
import { Parser, type RawNode } from '../index.ts';
import CompactVideo from './CompactVideo.ts';
export default class CarouselLockup extends YTNode {
static type = 'CarouselLockup';
info_rows: ObservedArray<InfoRow>;
video_lockup?: CompactVideo | null;
constructor(data: RawNode) {
super();
this.info_rows = Parser.parseArray(data.infoRows, InfoRow);
this.video_lockup = Parser.parseItem(data.videoLockup, CompactVideo);
}
}

View File

@@ -1,28 +1,26 @@
import Parser from '../index.ts';
import Text from './misc/Text.ts';
import Author from './misc/Author.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import SubscribeButton from './SubscribeButton.ts';
import Button from './Button.ts';
import { Log } from '../../utils/index.ts';
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import Button from './Button.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import SubscribeButton from './SubscribeButton.ts';
import Author from './misc/Author.ts';
import Text from './misc/Text.ts';
class Channel extends YTNode {
export default class Channel extends YTNode {
static type = 'Channel';
id: string;
author: Author;
subscribers: Text;
videos: Text;
subscriber_count: Text;
video_count: Text;
long_byline: Text;
short_byline: Text;
endpoint: NavigationEndpoint;
subscribe_button: SubscribeButton | Button | null;
description_snippet: Text;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.id = data.channelId;
@@ -31,15 +29,33 @@ class Channel extends YTNode {
navigationEndpoint: data.navigationEndpoint
}, data.ownerBadges, data.thumbnail);
// TODO: subscriberCountText is now the channel's handle and videoCountText is the subscriber count. Why haven't they renamed the properties?
this.subscribers = new Text(data.subscriberCountText);
this.videos = new Text(data.videoCountText);
// XXX: `subscriberCountText` is now the channel's handle and `videoCountText` is the subscriber count.
this.subscriber_count = new Text(data.subscriberCountText);
this.video_count = new Text(data.videoCountText);
this.long_byline = new Text(data.longBylineText);
this.short_byline = new Text(data.shortBylineText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.subscribe_button = Parser.parseItem(data.subscribeButton, [ SubscribeButton, Button ]);
this.description_snippet = new Text(data.descriptionSnippet);
}
}
export default Channel;
/**
* @deprecated
* This will be removed in a future release.
* Please use {@link Channel.subscriber_count} instead.
*/
get subscribers(): Text {
Log.warnOnce(Channel.type, 'Channel#subscribers is deprecated. Please use Channel#subscriber_count instead.');
return this.subscriber_count;
}
/**
* @deprecated
* This will be removed in a future release.
* Please use {@link Channel.video_count} instead.
*/
get videos(): Text {
Log.warnOnce(Channel.type, 'Channel#videos is deprecated. Please use Channel#video_count instead.');
return this.video_count;
}
}

View File

@@ -1,14 +1,12 @@
import Parser from '../index.ts';
import { Log } from '../../utils/index.ts';
import { YTNode, type ObservedArray } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import Button from './Button.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import Button from './Button.ts';
import { YTNode } from '../helpers.ts';
class ChannelAboutFullMetadata extends YTNode {
export default class ChannelAboutFullMetadata extends YTNode {
static type = 'ChannelAboutFullMetadata';
id: string;
@@ -22,15 +20,15 @@ class ChannelAboutFullMetadata extends YTNode {
title: Text;
}[];
views: Text;
joined: Text;
view_count: Text;
joined_date: Text;
description: Text;
email_reveal: NavigationEndpoint;
can_reveal_email: boolean;
country: Text;
buttons: Button[];
buttons: ObservedArray<Button>;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.id = data.channelId;
this.name = new Text(data.title);
@@ -43,14 +41,32 @@ class ChannelAboutFullMetadata extends YTNode {
title: new Text(link.title)
})) ?? [];
this.views = new Text(data.viewCountText);
this.joined = new Text(data.joinedDateText);
this.view_count = new Text(data.viewCountText);
this.joined_date = new Text(data.joinedDateText);
this.description = new Text(data.description);
this.email_reveal = new NavigationEndpoint(data.onBusinessEmailRevealClickCommand);
this.can_reveal_email = !data.signInForBusinessEmail;
this.country = new Text(data.country);
this.buttons = Parser.parseArray(data.actionButtons, Button);
}
}
export default ChannelAboutFullMetadata;
/**
* @deprecated
* This will be removed in a future release.
* Please use {@link Channel.view_count} instead.
*/
get views() {
Log.warnOnce(ChannelAboutFullMetadata.type, 'ChannelAboutFullMetadata#views is deprecated. Please use ChannelAboutFullMetadata#view_count instead.');
return this.view_count;
}
/**
* @deprecated
* This will be removed in a future release.
* Please use {@link Channel.joined_date} instead.
*/
get joined(): Text {
Log.warnOnce(ChannelAboutFullMetadata.type, 'ChannelAboutFullMetadata#joined is deprecated. Please use ChannelAboutFullMetadata#joined_date instead.');
return this.joined_date;
}
}

View File

@@ -1,12 +1,11 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import { Parser } from '../index.ts';
import Button from './Button.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class ChannelAgeGate extends YTNode {
export default class ChannelAgeGate extends YTNode {
static type = 'ChannelAgeGate';
channel_title: string;
@@ -25,6 +24,4 @@ class ChannelAgeGate extends YTNode {
this.sign_in_button = Parser.parseItem(data.signInButton, Button);
this.secondary_text = new Text(data.secondaryText);
}
}
export default ChannelAgeGate;
}

View File

@@ -0,0 +1,20 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
export default class ChannelExternalLinkView extends YTNode {
static type = 'ChannelExternalLinkView';
title: Text;
link: Text;
favicon: Thumbnail[];
constructor(data: RawNode) {
super();
this.title = Text.fromAttributed(data.title);
this.link = Text.fromAttributed(data.link);
this.favicon = Thumbnail.fromResponse(data.favicon);
}
}

View File

@@ -1,18 +1,16 @@
import { YTNode } from '../helpers.ts';
import Parser, { RawNode } from '../index.ts';
import { type ObservedArray, YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import Text from './misc/Text.ts';
class ChannelFeaturedContent extends YTNode {
export default class ChannelFeaturedContent extends YTNode {
static type = 'ChannelFeaturedContent';
title: Text;
items;
items: ObservedArray<YTNode>;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.items = Parser.parseArray(data.items);
}
}
export default ChannelFeaturedContent;
}

View File

@@ -1,31 +1,34 @@
import { YTNode, observe, type ObservedArray } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
class HeaderLink {
// XXX (LuanRT): This is not a real YTNode, but we treat it as one to keep things clean.
export class HeaderLink extends YTNode {
static type = 'HeaderLink';
endpoint: NavigationEndpoint;
icon: Thumbnail[];
title: Text;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.icon = Thumbnail.fromResponse(data.icon);
this.title = new Text(data.title);
}
}
class ChannelHeaderLinks extends YTNode {
export default class ChannelHeaderLinks extends YTNode {
static type = 'ChannelHeaderLinks';
primary: HeaderLink[];
secondary: HeaderLink[];
primary: ObservedArray<HeaderLink>;
secondary: ObservedArray<HeaderLink>;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.primary = data.primaryLinks?.map((link: any) => new HeaderLink(link)) || [];
this.secondary = data.secondaryLinks?.map((link: any) => new HeaderLink(link)) || [];
this.primary = observe(data.primaryLinks?.map((link: RawNode) => new HeaderLink(link)) || []);
this.secondary = observe(data.secondaryLinks?.map((link: RawNode) => new HeaderLink(link)) || []);
}
}
export default ChannelHeaderLinks;
}

View File

@@ -0,0 +1,22 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import Text from './misc/Text.ts';
export default class ChannelHeaderLinksView extends YTNode {
static type = 'ChannelHeaderLinksView';
first_link?: Text;
more?: Text;
constructor(data: RawNode) {
super();
if (Reflect.has(data, 'firstLink')) {
this.first_link = Text.fromAttributed(data.firstLink);
}
if (Reflect.has(data, 'more')) {
this.more = Text.fromAttributed(data.more);
}
}
}

View File

@@ -1,39 +1,41 @@
import Thumbnail from './misc/Thumbnail.ts';
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
class ChannelMetadata extends YTNode {
export default class ChannelMetadata extends YTNode {
static type = 'ChannelMetadata';
title: string;
description: string;
url: string;
rss_urls: any; // Array?
rss_url: string;
vanity_channel_url: string;
external_id: string;
is_family_safe: boolean;
keywords: string[];
avatar: Thumbnail[];
music_artist_name?: string;
available_countries: string[];
android_deep_link: string;
android_appindexing_link: string;
ios_appindexing_link: string;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.title = data.title;
this.description = data.description;
this.url = data.channelUrl;
this.rss_urls = data.rssUrl;
this.rss_url = data.rssUrl;
this.vanity_channel_url = data.vanityChannelUrl;
this.external_id = data.externalId;
this.is_family_safe = data.isFamilySafe;
this.keywords = data.keywords;
this.avatar = Thumbnail.fromResponse(data.avatar);
// Can be an empty string sometimes, so we need the extra length check
this.music_artist_name = typeof data.musicArtistName === 'string' && data.musicArtistName.length > 0 ? data.musicArtistName : undefined;
this.available_countries = data.availableCountryCodes;
this.android_deep_link = data.androidDeepLink;
this.android_appindexing_link = data.androidAppindexingLink;
this.ios_appindexing_link = data.iosAppindexingLink;
}
}
export default ChannelMetadata;
}

Some files were not shown because too many files have changed in this diff Show More