mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-19 12:31:17 +00:00
Compare commits
40 Commits
v4.3.0-den
...
v10.4.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5c3bb223 | ||
|
|
742e7151fa | ||
|
|
843ecd28e3 | ||
|
|
8293af93fa | ||
|
|
f3d43bd395 | ||
|
|
09e0f91b66 | ||
|
|
28e766779e | ||
|
|
a482556187 | ||
|
|
b063b2f4a8 | ||
|
|
299548f255 | ||
|
|
016a7aacae | ||
|
|
bd5b3470ec | ||
|
|
648488641d | ||
|
|
944f821d9a | ||
|
|
bd9ae29de3 | ||
|
|
1b159e5162 | ||
|
|
2b58b3887c | ||
|
|
2294bf7065 | ||
|
|
f2d673ac95 | ||
|
|
1b557f25e3 | ||
|
|
20f055b7ea | ||
|
|
09321408e5 | ||
|
|
848c7e68e2 | ||
|
|
bc437015e2 | ||
|
|
96e337a1d0 | ||
|
|
8d47c81318 | ||
|
|
5b9febc7bf | ||
|
|
55d47e1fe6 | ||
|
|
a40abda80c | ||
|
|
e147a01ba5 | ||
|
|
499937fe3a | ||
|
|
0582bcb677 | ||
|
|
50fdef7c07 | ||
|
|
5f69f10e85 | ||
|
|
dc69b7437e | ||
|
|
7d71f3d2cd | ||
|
|
37e9987afd | ||
|
|
6b1d5f0655 | ||
|
|
2d1972bee0 | ||
|
|
48dc99e28b |
105
README.md
105
README.md
@@ -10,82 +10,55 @@
|
||||
[twitter]: https://twitter.com/thesciencephile
|
||||
[discord]: https://discord.gg/syDu7Yks54
|
||||
|
||||
<h1 align=center>YouTube.js</h1>
|
||||
|
||||
<p align=center>A full-featured wrapper around the InnerTube API</p>
|
||||
|
||||
<div align="center">
|
||||
<br/>
|
||||
<p>
|
||||
<a href="https://github.com/LuanRT/YouTube.js"><img src="https://luanrt.github.io/assets/img/ytjs.svg" title="youtube.js" alt="YouTube.js' Github Page" width="200" /></a>
|
||||
</p>
|
||||
<p align="center">A full-featured wrapper around the InnerTube API</p>
|
||||
|
||||
[][discord]
|
||||
[][actions]
|
||||
[][versions]
|
||||
[][codefactor]
|
||||
[][npm]
|
||||
[][discord]
|
||||
[][codefactor]
|
||||
|
||||
<h5>
|
||||
Sponsored by <a href="https://serpapi.com"><img src="https://luanrt.github.io/assets/img/serpapi.svg" alt="SerpApi - API to get search engine results with ease." height=35 valign="middle"></a>
|
||||
</h5>
|
||||
<br>
|
||||
[][collaborators]
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://serpapi.com" target="_blank">
|
||||
<img width="80" alt="SerpApi" src="https://luanrt.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>
|
||||
|
||||
2
deno.ts
2
deno.ts
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
338
deno/src/core/OAuth2.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
118
deno/src/core/clients/Kids.ts
Normal file
118
deno/src/core/clients/Kids.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
3
deno/src/core/clients/index.ts
Normal file
3
deno/src/core/clients/index.ts
Normal 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';
|
||||
19
deno/src/core/endpoints/BrowseEndpoint.ts
Normal file
19
deno/src/core/endpoints/BrowseEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
16
deno/src/core/endpoints/GetNotificationMenuEndpoint.ts
Normal file
16
deno/src/core/endpoints/GetNotificationMenuEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
1
deno/src/core/endpoints/GuideEndpoint.ts
Normal file
1
deno/src/core/endpoints/GuideEndpoint.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const PATH = '/guide';
|
||||
21
deno/src/core/endpoints/NextEndpoint.ts
Normal file
21
deno/src/core/endpoints/NextEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
54
deno/src/core/endpoints/PlayerEndpoint.ts
Normal file
54
deno/src/core/endpoints/PlayerEndpoint.ts
Normal 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;
|
||||
}
|
||||
16
deno/src/core/endpoints/ResolveURLEndpoint.ts
Normal file
16
deno/src/core/endpoints/ResolveURLEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
19
deno/src/core/endpoints/SearchEndpoint.ts
Normal file
19
deno/src/core/endpoints/SearchEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
13
deno/src/core/endpoints/account/AccountListEndpoint.ts
Normal file
13
deno/src/core/endpoints/account/AccountListEndpoint.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
1
deno/src/core/endpoints/account/index.ts
Normal file
1
deno/src/core/endpoints/account/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as AccountListEndpoint from './AccountListEndpoint.ts';
|
||||
24
deno/src/core/endpoints/browse/EditPlaylistEndpoint.ts
Normal file
24
deno/src/core/endpoints/browse/EditPlaylistEndpoint.ts
Normal 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
|
||||
}
|
||||
}))
|
||||
};
|
||||
}
|
||||
1
deno/src/core/endpoints/browse/index.ts
Normal file
1
deno/src/core/endpoints/browse/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as EditPlaylistEndpoint from './EditPlaylistEndpoint.ts';
|
||||
15
deno/src/core/endpoints/channel/EditDescriptionEndpoint.ts
Normal file
15
deno/src/core/endpoints/channel/EditDescriptionEndpoint.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
15
deno/src/core/endpoints/channel/EditNameEndpoint.ts
Normal file
15
deno/src/core/endpoints/channel/EditNameEndpoint.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
2
deno/src/core/endpoints/channel/index.ts
Normal file
2
deno/src/core/endpoints/channel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as EditNameEndpoint from './EditNameEndpoint.ts';
|
||||
export * as EditDescriptionEndpoint from './EditDescriptionEndpoint.ts';
|
||||
18
deno/src/core/endpoints/comment/CreateCommentEndpoint.ts
Normal file
18
deno/src/core/endpoints/comment/CreateCommentEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
2
deno/src/core/endpoints/comment/index.ts
Normal file
2
deno/src/core/endpoints/comment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as PerformCommentActionEndpoint from './PerformCommentActionEndpoint.ts';
|
||||
export * as CreateCommentEndpoint from './CreateCommentEndpoint.ts';
|
||||
20
deno/src/core/endpoints/index.ts
Normal file
20
deno/src/core/endpoints/index.ts
Normal 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';
|
||||
12
deno/src/core/endpoints/kids/BlocklistPickerEndpoint.ts
Normal file
12
deno/src/core/endpoints/kids/BlocklistPickerEndpoint.ts
Normal 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 } };
|
||||
}
|
||||
1
deno/src/core/endpoints/kids/index.ts
Normal file
1
deno/src/core/endpoints/kids/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as BlocklistPickerEndpoint from './BlocklistPickerEndpoint.ts';
|
||||
19
deno/src/core/endpoints/like/DislikeEndpoint.ts
Normal file
19
deno/src/core/endpoints/like/DislikeEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
19
deno/src/core/endpoints/like/LikeEndpoint.ts
Normal file
19
deno/src/core/endpoints/like/LikeEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
19
deno/src/core/endpoints/like/RemoveLikeEndpoint.ts
Normal file
19
deno/src/core/endpoints/like/RemoveLikeEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
3
deno/src/core/endpoints/like/index.ts
Normal file
3
deno/src/core/endpoints/like/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * as LikeEndpoint from './LikeEndpoint.ts';
|
||||
export * as DislikeEndpoint from './DislikeEndpoint.ts';
|
||||
export * as RemoveLikeEndpoint from './RemoveLikeEndpoint.ts';
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
1
deno/src/core/endpoints/music/index.ts
Normal file
1
deno/src/core/endpoints/music/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as GetSearchSuggestionsEndpoint from './GetSearchSuggestionsEndpoint.ts';
|
||||
@@ -0,0 +1 @@
|
||||
export const PATH = '/notification/get_unseen_count';
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
2
deno/src/core/endpoints/notification/index.ts
Normal file
2
deno/src/core/endpoints/notification/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as GetUnseenCountEndpoint from './GetUnseenCountEndpoint.ts';
|
||||
export * as ModifyChannelPreferenceEndpoint from './ModifyChannelPreferenceEndpoint.ts';
|
||||
15
deno/src/core/endpoints/playlist/CreateEndpoint.ts
Normal file
15
deno/src/core/endpoints/playlist/CreateEndpoint.ts
Normal 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
|
||||
};
|
||||
}
|
||||
14
deno/src/core/endpoints/playlist/DeleteEndpoint.ts
Normal file
14
deno/src/core/endpoints/playlist/DeleteEndpoint.ts
Normal 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
|
||||
};
|
||||
}
|
||||
2
deno/src/core/endpoints/playlist/index.ts
Normal file
2
deno/src/core/endpoints/playlist/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as CreateEndpoint from './CreateEndpoint.ts';
|
||||
export * as DeleteEndpoint from './DeleteEndpoint.ts';
|
||||
20
deno/src/core/endpoints/reel/ReelItemWatchEndpoint.ts
Normal file
20
deno/src/core/endpoints/reel/ReelItemWatchEndpoint.ts
Normal 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
|
||||
};
|
||||
}
|
||||
14
deno/src/core/endpoints/reel/ReelWatchSequenceEndpoint.ts
Normal file
14
deno/src/core/endpoints/reel/ReelWatchSequenceEndpoint.ts
Normal 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
|
||||
};
|
||||
}
|
||||
2
deno/src/core/endpoints/reel/index.ts
Normal file
2
deno/src/core/endpoints/reel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as ReelItemWatchEndpoint from './ReelItemWatchEndpoint.ts';
|
||||
export * as ReelWatchSequenceEndpoint from './ReelWatchSequenceEndpoint.ts';
|
||||
18
deno/src/core/endpoints/subscription/SubscribeEndpoint.ts
Normal file
18
deno/src/core/endpoints/subscription/SubscribeEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
18
deno/src/core/endpoints/subscription/UnsubscribeEndpoint.ts
Normal file
18
deno/src/core/endpoints/subscription/UnsubscribeEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
2
deno/src/core/endpoints/subscription/index.ts
Normal file
2
deno/src/core/endpoints/subscription/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as SubscribeEndpoint from './SubscribeEndpoint.ts';
|
||||
export * as UnsubscribeEndpoint from './UnsubscribeEndpoint.ts';
|
||||
37
deno/src/core/endpoints/upload/CreateVideoEndpoint.ts
Normal file
37
deno/src/core/endpoints/upload/CreateVideoEndpoint.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
1
deno/src/core/endpoints/upload/index.ts
Normal file
1
deno/src/core/endpoints/upload/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as CreateVideoEndpoint from './CreateVideoEndpoint.ts';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
3
deno/src/core/managers/index.ts
Normal file
3
deno/src/core/managers/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
241
deno/src/core/mixins/MediaInfo.ts
Normal file
241
deno/src/core/mixins/MediaInfo.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
4
deno/src/core/mixins/index.ts
Normal file
4
deno/src/core/mixins/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
|
||||
18
deno/src/parser/classes/AboutChannel.ts
Normal file
18
deno/src/parser/classes/AboutChannel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
87
deno/src/parser/classes/AboutChannelView.ts
Normal file
87
deno/src/parser/classes/AboutChannelView.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
19
deno/src/parser/classes/AlertWithButton.ts
Normal file
19
deno/src/parser/classes/AlertWithButton.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
deno/src/parser/classes/AttributionView.ts
Normal file
17
deno/src/parser/classes/AttributionView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
26
deno/src/parser/classes/AvatarView.ts
Normal file
26
deno/src/parser/classes/AvatarView.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
28
deno/src/parser/classes/ButtonView.ts
Normal file
28
deno/src/parser/classes/ButtonView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
17
deno/src/parser/classes/CarouselLockup.ts
Normal file
17
deno/src/parser/classes/CarouselLockup.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
20
deno/src/parser/classes/ChannelExternalLinkView.ts
Normal file
20
deno/src/parser/classes/ChannelExternalLinkView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
22
deno/src/parser/classes/ChannelHeaderLinksView.ts
Normal file
22
deno/src/parser/classes/ChannelHeaderLinksView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user