Compare commits

..

91 Commits

Author SHA1 Message Date
LuanRT
56e6e23453 chore(release): v2.8.0 2023-01-06 03:18:17 -03:00
LuanRT
00fa514b03 feat: add support for generating sessions locally (#277)
* feat: add visitor data proto

* feat: add support for generating session data locally

* chore: add test
2023-01-06 03:06:49 -03:00
LuanRT
d36389c865 refactor(VideoInfo): simplify watch next feed extraction 2023-01-05 21:44:56 -03:00
LuanRT
55ca986888 chore: use optional chaining to avoid problems 2023-01-05 21:34:04 -03:00
LuanRT
b04df7e119 chore: lint 2023-01-05 21:22:50 -03:00
LuanRT
d8d92866d1 fix(Format): some types were incorrect 2023-01-05 20:56:55 -03:00
LuanRT
b4b0731589 refactor: remove unneeded check when generating search filter params
YouTube doesn't do this so I don't see why we should.
2023-01-05 20:32:14 -03:00
LuanRT
d69d701869 fix(VideoInfo): watch next feed not being parsed when logged out (#276) 2023-01-05 19:09:16 -03:00
absidue
cd4d28c951 feat: add live stream start_timestamp (#275) 2023-01-05 17:35:39 -03:00
absidue
22b9c174bb feat: add is_live and is_upcoming to VideoDetails (#271)
* feat: add is_live and is_upcoming to VideoDetails

* chore: add tests
2023-01-03 20:52:05 -03:00
LuanRT
b704c8e78c chore(release): v2.7.0 2023-01-02 00:00:13 -03:00
LuanRT
bbfeb99f55 chore: update docs 2023-01-01 23:10:38 -03:00
LuanRT
f2adeeeab4 docs: rephrasing 2023-01-01 23:04:04 -03:00
LuanRT
3756e63996 feat(Search): add support for features filter (#270) 2023-01-01 22:40:35 -03:00
LuanRT
a27807b6c1 feat: allow enabling safety mode (#269)
Unrelated: this also simplifies the creation of sessions without a player instance.
2023-01-01 19:55:08 -03:00
LuanRT
5cfb969e33 feat: implement Innertube#resolveURL(url) (#268) 2022-12-31 18:35:55 -03:00
LuanRT
1163125f5c feat: add LiveChatRestrictedParticipation node (#267) 2022-12-31 17:42:59 -03:00
LuanRT
9ac5043309 chore: clean up & remove unneeded code (#265) 2022-12-31 05:49:41 -03:00
LuanRT
6a4b4f3359 feat: add support for chapters & video heatmap (#263)
* feat: add support for chapters & video heatmap

* chore: add tests
2022-12-27 04:17:05 -03:00
LuanRT
2b3642ba63 feat: add support for searching within a channel (#262)
* feat(Channel): add support for searching

* dev: add channel search test

* chore: update docs
2022-12-26 18:56:37 -03:00
LuanRT
fb2e237284 fix: add YouTube Studio to the list of clients (#261)
As of December 16, YouTube Studio (Android) endpoints fail with a "Precondition check failed." message. If a newer version of the YouTube app is used then it throws a 404, indicating that it is now a requirement to use the correct client for YT Studio requests. I would say that's a bit of a bummer as we'll have to keep track of yet another client's version to make sure it doesn't get too outdated.
2022-12-20 18:34:50 -03:00
LuanRT
6f3deaf16a fix: use WEB client in setNotificationPreferences 2022-12-19 18:51:20 -03:00
LuanRT
d4382e81c3 chore: update proto and format code 2022-12-19 18:48:00 -03:00
LuanRT
89956cab46 chore: default Accept-Language to * 2022-12-19 18:46:47 -03:00
LuanRT
ac9341c769 chore(release): v2.6.0 2022-12-19 04:07:48 -03:00
LuanRT
cac762569a feat(Session): allow overriding geolocation (#260)
* Allow overriding geolocation

* Fix some inconsistencies (unrelated)
2022-12-19 03:55:38 -03:00
LuanRT
9978ebf085 refactor(Parser): reduce reliance on localised strings (#258) 2022-12-17 00:54:08 -03:00
LuanRT
b036e2fcdc feat(Channel): parse subscribe button
This way one can subscribe to a given channel simply by calling the button's endpoint.
2022-12-16 17:13:13 -03:00
LuanRT
e37f42f41b feat: bring back Video#is_live and add ExpandableMetadata (#256)
* bring back `Video#is_live`

* add ExpandableMetadata
2022-12-15 19:01:42 -03:00
absidue
883a023624 feat(TextRun): add support for formatting (#254) 2022-12-14 22:48:35 -03:00
LuanRT
506834b253 docs: fix formatting (oops) 2022-12-12 01:18:42 -03:00
LuanRT
87e7ef77eb chore(release): v2.5.2 2022-12-12 00:21:32 -03:00
LuanRT
27fdd8268a docs: update ToC 2022-12-12 00:14:55 -03:00
LuanRT
d4ea87b8b0 chore(docs): fix typo 2022-12-12 00:13:24 -03:00
LuanRT
ec87eea20d chore: update deps 2022-12-12 00:10:56 -03:00
LuanRT
e43ad202f4 chore: update examples 2022-12-12 00:10:33 -03:00
LuanRT
104c36b450 docs: reword a few things 2022-12-11 23:58:26 -03:00
absidue
f5d61d70f2 fix: author and thumbnails for autogenerated playlists (#251) 2022-12-07 20:34:53 -03:00
LuanRT
c76f5f478d 2.5.1 2022-11-30 19:11:40 -03:00
LuanRT
49d1432b5a chore: fix a few inconsistencies 2022-11-30 19:02:49 -03:00
LuanRT
be157ef016 fix: signature decipher extraction failing (#249) 2022-11-30 18:39:37 -03:00
LuanRT
9f703203b6 chore(docs): update readme 2022-11-29 05:49:15 -03:00
LuanRT
516eeeff45 refactor: improve Search parser (#247)
* refactor: improve Search parser

* chore: lint
2022-11-29 03:50:17 -03:00
LuanRT
6caa679df6 chore(release) v2.5.0 2022-11-25 01:36:50 -03:00
LuanRT
2a87f42b32 fix(Search): check if WatchCardHeroVideo is null before casting
Related #243
2022-11-25 01:25:02 -03:00
LuanRT
f7c1e0f249 fix(Music): search endpoint missing
Related: #242
2022-11-23 20:04:24 -03:00
LuanRT
fe4c5433cf feat: make Player instance optional (#240) 2022-11-16 03:17:59 -03:00
LuanRT
0e5e0c0fab feat(Channel): add support for filters (#237)
* feat: add support for filters

Also add `channel#getShorts()` and `channel#getLiveStreams()`

* docs: update API ref

* chore: add tests
2022-11-14 19:08:16 -03:00
LuanRT
f0fd6146c7 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-11-14 15:32:08 -03:00
LuanRT
43061970c6 fix: export Player & Session classes 2022-11-14 15:30:40 -03:00
LuanRT
746023d9bb chore(docs): fix typo' 2022-11-12 19:36:47 -03:00
LuanRT
3102479dd9 chore(release): v2.4.1
:]
2022-11-12 19:07:06 -03:00
LuanRT
c7a13c948c chore: remove unnecessary code 2022-11-12 19:02:40 -03:00
LuanRT
ec875ba321 chore(release): v2.4.0 2022-11-12 18:49:56 -03:00
LuanRT
db77bba802 fix(NotificationsCount): default to 0 2022-11-12 17:29:07 -03:00
LuanRT
5ea0a0ebf8 feat: add support for switching accounts (cookie based auth only) (#236)
* feat: add support for switching accounts

* style: lint
2022-11-12 16:26:02 -03:00
LuanRT
0130229236 fix(Actions): do not send undefined payloads 2022-11-12 15:38:29 -03:00
LuanRT
da517fe6d1 refactor: improve home feed parsing (#234)
* chore: update tests

* style: format code

* docs: update API ref
2022-11-12 01:31:11 -03:00
LuanRT
95ff1e6c5e refactor(Library): use memo to get target YTNodes 2022-11-11 19:00:12 -03:00
LuanRT
0f8adfd9b8 chore(parser): ignore AdSlot 2022-11-11 17:23:13 -03:00
LuanRT
b514765354 chore(docs): update examples 2022-11-11 17:05:24 -03:00
LuanRT
3cbcd71a3a feat: add support for topic/auto-generated channels and fix minor parsing errors (#233)
* dev: add support for topic channels

* dev(parser): do not try to parse empty nodes

* dev: add support for auto-generated game channels
2022-11-11 00:38:44 -03:00
Burhan Syed
4c00f15f55 fix: WatchCardHeroVideo accessibilityData parse error (#231)
* fix #230: WatchCardHeroVideo AccessibilityData Parser error

* add WatchCardHeroVideo test case
2022-11-10 19:18:08 -03:00
LuanRT
ea1d206b26 2.3.3 2022-11-06 03:38:47 -03:00
LuanRT
aa334aacbd refactor: clean up, fix & remove outdated code (#228)
* dev: refactor and remove redundant code

* docs(music): update `Library` API ref

* docs: update examples

* chore: update lock file
2022-11-06 03:32:16 -03:00
LuanRT
1eda93ee08 fix(session): visitorData and originalUrl 2022-10-21 14:42:34 -03:00
LuanRT
fe0ac0a961 chore(studio): fix a small typo 2022-10-19 17:11:50 -03:00
Daniel Wykerd
8740deb1f2 feat: custom parser error handler (#222)
As suggested in issue #218
2022-10-18 18:44:22 -03:00
mdashlw
d71b762df5 fix: don't remove "VL" from playlist id (#223) 2022-10-18 18:42:55 -03:00
LuanRT
dc14d3785f chore(release): v2.3.2 2022-10-13 16:58:19 -03:00
LuanRT
088f909515 chore: update proto 2022-10-13 16:52:19 -03:00
LuanRT
2a78d77aa3 refactor: get visitor data from the API [skip ci] 2022-10-13 16:39:56 -03:00
LuanRT
1b2862c00f refactor: improve live chat polling (#220)
* dev: add RemoveChatItemByAuthorAction renderer parser

* dev: improve live chat polling
2022-10-12 16:16:07 -03:00
LuanRT
477c030084 feat(studio): add support for updating video metadata (#219)
* dev: update proto

* dev: add `Studio#updateVideoMetadata`

* feat: add `category` option

* chore(studio): update API ref
2022-10-12 16:08:53 -03:00
Émilien Devos
19d579df13 fix: wrong element name (#217) 2022-10-11 05:03:21 -03:00
LuanRT
5313c57783 chore(docs): fix typos [skip ci] 2022-10-06 05:24:09 -03:00
LuanRT
190f7681be chore: update tests 2022-10-06 05:20:24 -03:00
LuanRT
6e027bcc85 docs(livechat): update API ref 2022-10-06 04:44:49 -03:00
LuanRT
6b531dd0ea chore: lint 2022-10-06 04:38:28 -03:00
LuanRT
92f24076db docs(ytmusic): add library class docs 2022-10-06 04:36:17 -03:00
Akazawa Daisuke
a9eba7ca62 feat: add RemoveChatItemAction and LiveChatTickerStickerItem (#214) 2022-10-03 03:09:40 -03:00
Akazawa Daisuke
2f56c15ecc feat(LiveChat): add support for moderation & more (#202)
* Live Chat - Implement moderation

* Live Chat - Implement class ItemMenu

* fix moderation method

Co-authored-by: LuanRT <luan.lrt4@gmail.com>
2022-10-02 02:00:24 -03:00
LuanRT
95e0479745 docs: add parser ytnode instructions & other minor changes (#206)
* docs: add instructions on implementing ytnodes

* docs(parser): fix grammar & other minor improvements

* docs: update guidelines

* chore: update parser warning messages
2022-09-28 03:08:51 -03:00
LuanRT
556c7cd6e8 docs(parser): rephrase validTypes description [skip ci] 2022-09-23 03:38:11 -03:00
LuanRT
a4a88419ef docs(parser): escape | separators [skip ci] 2022-09-23 03:34:53 -03:00
LuanRT
aefecd061e chore(release): v2.2.3 2022-09-23 03:19:54 -03:00
LuanRT
7485726f1e refactor: fix a few parsing inconsistencies 2022-09-23 03:06:21 -03:00
LuanRT
9e703abe3a chore(deps): bump jintr to 0.3.1 2022-09-22 18:44:16 -03:00
LuanRT
affbe84284 fix: include thirdParty prop for requests using TV_EMBEDDED (#198)
* dev: update `Context` interface

* dev: include `thirdParty` prop in requests using `TV_EMBEDDED`
2022-09-18 16:58:51 -03:00
Daniel Wykerd
fcbdae3e34 fix: browser example (#197) 2022-09-18 12:46:19 -03:00
LuanRT
059c858021 chore(docs): add a note about streaming data [skip ci] 2022-09-17 21:29:33 -03:00
143 changed files with 6203 additions and 4163 deletions

View File

@@ -22,7 +22,7 @@ If you find a problem, search if an issue already exists. If a related issue doe
<a id="issue-2"></a>
#### Solve an issue
Scan through the existing issues to find one that interests you. You can narrow down the search using labels as filters. If you find an issue to work on, you are welcome to open a PR with a fix.
Scan through the existing issues to find one that interests you. You can narrow down the search using labels as filters. If you find an issue to work on, you are welcome to open a PR with a fix. Documentation updates and grammar fixes are also appreciated!
<a id="changes"></a>
## Make changes

219
README.md
View File

@@ -1,5 +1,3 @@
<!-- Hi there, fellow coder :) -->
<!-- BADGE LINKS -->
[npm]: https://www.npmjs.com/package/youtubei.js
[versions]: https://www.npmjs.com/package/youtubei.js?activeTab=versions
@@ -10,43 +8,27 @@
<!-- OTHER LINKS -->
[project]: https://github.com/LuanRT/YouTube.js
[twitter]: https://twitter.com/lrt_nooneknows
[twitter]: https://twitter.com/thesciencephile
[discord]: https://discord.gg/syDu7Yks54
[nodejs]: https://nodejs.org
<!-- INTRODUCTION -->
<h1 align=center>
YouTube.js
</h1>
<h1 align=center>YouTube.js</h1>
<p align=center>
<i>
A full-featured wrapper around the InnerTube API, which is what YouTube itself uses.
</i>
</p>
<p align=center>A full-featured wrapper around the InnerTube API, which is what YouTube itself uses</p>
<p align="center">
<a href="https://github.com/LuanRT/YouTube.js/issues">
Report Bug
</a>
·
<a href="https://github.com/LuanRT/YouTube.js/issues">
Request Feature
</a>
</p>
<!-- BADGES -->
<div align="center">
[![Tests](https://github.com/LuanRT/YouTube.js/actions/workflows/node.js.yml/badge.svg)][actions]
[![Latest version](https://img.shields.io/npm/v/youtubei.js?color=%2335C757)][versions]
[![NPM Version](https://img.shields.io/npm/v/youtubei.js?color=%2335C757)][versions]
[![Codefactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)][codefactor]
[![Downloads](https://img.shields.io/npm/dt/youtubei.js)][npm]
[![Discord](https://img.shields.io/badge/discord-online-brightgreen.svg)][discord]
[![Say thanks](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)][say-thanks]
<br>
[![Donate](https://img.shields.io/badge/donate-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white)][github-sponsors]
</div>
<!-- SPONSORS -->
<p align="center">
<a><sub>Special thanks to:<sub></a>
</p>
@@ -55,7 +37,7 @@
<body>
<tr>
<td align="center">
<a href="https://serpapi.com/">
<a href="https://serpapi.com/" target="_blank">
<img width="80" alt="SerpApi" src="https://luanrt.is-a.dev/assets/img/serpapi.svg" />
<br>
<b>
@@ -71,7 +53,6 @@
___
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
<ol>
@@ -93,23 +74,20 @@ ___
<li><a href="#api">API</a></li>
</ul>
</li>
<li><a href="#implementing-custom-functionality">Implementing custom functionality </a></li>
<li><a href="#extending-the-library">Extending the library</a></li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#contributors">Contributors</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#disclaimer">Disclaimer</a></li>
<li><a href="#license">License</a></li>
</ol>
</details>
<!-- ABOUT THE PROJECT -->
## About
## Description
InnerTube is an API used across all YouTube clients, it was created to simplify[^1] the internal structure of the platform in a way that updates, tweaks, and experiments can be easily made. This library handles all the low-level communication with InnerTube, providing a simple, fast, and efficient way to interact with YouTube programmatically.
InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform[^1]. This library handles all the low-level communication with InnerTube, providing a simple, and efficient way to interact with YouTube programmatically. It is designed to emulate an actual client as closely as possible, including how API responses are [parsed](https://github.com/LuanRT/YouTube.js/tree/main/src/parser#how-it-works).
If you have any questions or need help, feel free to contact us on our chat server [here](https://discord.gg/syDu7Yks54).
If you have any questions or need help, feel free to reach out to us on our [Discord server][discord] or open an issue [here](https://github.com/LuanRT/YouTube.js/issues).
<!-- GETTING STARTED -->
## Getting Started
### Prerequisites
@@ -136,7 +114,6 @@ npm install github:LuanRT/YouTube.js
**TODO:** Deno install instructions (esm.sh possibly?)
<!-- USAGE -->
## Usage
Create an InnerTube instance:
```ts
@@ -243,7 +220,7 @@ const yt = await Innertube.create({
* `Innertube`
<details>
<summary>objects</summary>
<summary>Objects</summary>
<p>
* [.session](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/session.md)
@@ -258,7 +235,7 @@ const yt = await Innertube.create({
<details>
<summary>methods</summary>
<summary>Methods</summary>
<p>
* [.getInfo(video_id, client?)](#getinfo)
@@ -277,6 +254,7 @@ const yt = await Innertube.create({
* [.getPlaylist(id)](#getplaylist)
* [.getStreamingData(video_id, options)](#getstreamingdata)
* [.download(video_id, options?)](#download)
* [.resolveURL(url)](#resolveurl)
* [.call(endpoint, args?)](#call)
</p>
@@ -404,7 +382,32 @@ See [`./examples/comments`](https://github.com/LuanRT/YouTube.js/blob/main/examp
### getHomeFeed()
Retrieves YouTube's home feed.
**Returns**: `Promise.<FilterableFeed>`
**Returns**: `Promise.<HomeFeed>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<home_feed>#videos`
- Returns all videos in the home feed.
- `<home_feed>#posts`
- Returns all posts in the home feed.
- `<home_feed>#shelves`
- Returns all shelves in the home feed.
- `<home_feed>#filters`
- Returns available filters.
- `<home_feed>#applyFilter(name | ChipCloudChip)`
- Applies given filter and returns a new HomeFeed instance.
- `<home_feed>#getContinuation()`
- Retrieves feed continuation.
</p>
</details>
<a name="getlibrary"></a>
### getLibrary()
@@ -470,11 +473,17 @@ Retrieves contents for a given channel.
<p>
- `<channel>#getVideos()`
- `<channel>#getShorts()`
- `<channel>#getLiveStreams()`
- `<channel>#getPlaylists()`
- `<channel>#getHome()`
- `<channel>#getCommunity()`
- `<channel>#getChannels()`
- `<channel>#getAbout()`
- `<channel>#search(query)`
- `<channel>#getContinuation()`
- `<channel>#filters`
- `<channel>#page`
</p>
</details>
@@ -527,6 +536,14 @@ Retrieves playlist contents.
### getStreamingData(video_id, options)
Returns deciphered streaming data.
**Note:**
It is recommended to retrieve streaming data from a `VideoInfo`/`TrackInfo` object instead if you want to select formats manually, example:
```ts
const info = await yt.getBasicInfo('somevideoid');
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
console.info('Playback url:', url);
```
**Returns**: `Promise.<object>`
| Param | Type | Description |
@@ -547,6 +564,16 @@ Downloads a given video.
See [`./examples/download`](https://github.com/LuanRT/YouTube.js/blob/main/examples/download) for examples.
<a name="resolveurl"></a>
### resolveURL(url)
Resolves a given url.
**Returns**: `Promise.<NavigationEndpoint>`
| Param | Type | Description |
| --- | --- | --- |
| url | `string` | Url to resolve |
<a name="call"></a>
### call(endpoint, args?)
Utility to call navigation endpoints.
@@ -557,66 +584,66 @@ Utility to call navigation endpoints.
| --- | --- | --- |
| endpoint | `NavigationEndpoint` | The target endpoint |
| args? | `object` | Additional payload arguments |
## Extending the library
## Implementing custom functionality
YouTube.js is completely modular and easy to extend. Almost all methods, classes, and utilities used internally are exposed and can be used to implement your own extensions without having to modify the library's source code.
Something cool about YouTube.js is that it is completely modular and easy to tinker with. Almost all methods, classes, and utilities used internally are exposed and can be used to implement your own extensions without having to modify the library's source code.
For example, you may want to call an endpoint directly, that can be achieved with the `Actions` class:
For example, let's say we want to implement a method to retrieve video info manually. We can do that by using an instance of the `Actions` class:
```ts
// ...
import { Innertube } from 'youtubei.js';
const payload = {
videoId: 'jLTOuvBTLxA',
client: 'YTMUSIC', // InnerTube client, can be ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB or TV_EMBEDDED
parse: true // tells YouTube.js to parse the response, this is not sent to InnerTube.
};
(async () => {
const yt = await Innertube.create();
const response = await yt.actions.execute('/player', payload);
async function getVideoInfo(videoId: string) {
const videoInfo = await yt.actions.execute('/player', {
// anything added here will be merged with the default payload and sent to InnerTube.
videoId,
client: 'YTMUSIC', // InnerTube client, can be ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB or TV_EMBEDDED
parse: true // tells YouTube.js to parse the response, this is not sent to InnerTube.
});
console.info(response);
return videoInfo;
}
const videoInfo = await getVideoInfo('jLTOuvBTLxA');
console.info(videoInfo);
})();
```
Or maybe there's an interesting `NavigationEndpoint` in a parsed response and we want to call it to see what happens:
Or perhaps there's a `NavigationEndpoint` in a parsed response and we want to call it to see what happens:
```ts
// ...
const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
const albums = artist.sections[1].as(MusicCarouselShelf);
import { Innertube, YTNodes } from 'youtubei.js';
// Say we have a button and want to “click” it
const button = albums.as(MusicCarouselShelf).header?.more_content;
if (button) {
// To do that, we can call its navigation endpoint:
const page = await button.endpoint.call(yt.actions, 'YTMUSIC', true);
console.info(page);
}
(async () => {
const yt = await Innertube.create();
const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
const albums = artist.sections[1].as(YTNodes.MusicCarouselShelf);
// Say we want to click the “More” button:
const button = albums.as(YTNodes.MusicCarouselShelf).header?.more_content;
if (button) {
// After making sure it exists, we can call its navigation endpoint:
const page = await button.endpoint.call(yt.actions);
console.info(page);
}
})();
```
### Parser
If you're working on an extension for the library or just want to have nicely typed and sanitized InnerTube responses for a project then have a look at our powerful parser!
<details>
<summary>Example:</summary>
<p>
YouTube.js' parser allows you to parse InnerTube responses and turn their nodes into strongly typed objects that can be easily manipulated. It also provides a set of utility methods that make working with InnerTube much easier.
Example:
```ts
// See ./examples/parser
import { Parser } from 'youtubei.js';
import SectionList from 'youtubei.js/dist/src/parser/classes/SectionList';
import SingleColumnBrowseResults from 'youtubei.js/dist/src/parser/classes/SingleColumnBrowseResults';
import MusicVisualHeader from 'youtubei.js/dist/src/parser/classes/MusicVisualHeader';
import MusicImmersiveHeader from 'youtubei.js/dist/src/parser/classes/MusicImmersiveHeader';
import MusicCarouselShelf from 'youtubei.js/dist/src/parser/classes/MusicCarouselShelf';
import MusicDescriptionShelf from 'youtubei.js/dist/src/parser/classes/MusicDescriptionShelf';
import MusicShelf from 'youtubei.js/dist/src/parser/classes/MusicShelf';
import { Parser, YTNodes } from 'youtubei.js';
import { readFileSync } from 'fs';
// Artist page response from YouTube Music
@@ -624,15 +651,18 @@ const data = readFileSync('./artist.json').toString();
const page = Parser.parseResponse(JSON.parse(data));
const header = page.header.item().as(MusicImmersiveHeader, MusicVisualHeader);
const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);
console.info('Header:', header);
// The parser encapsulates all arrays in a proxy object.
// A proxy intercepts access to the actual data, allowing
// the parser to add type safety and many utility methods
// that make working with InnerTube much easier.
const tab = page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: false });
/**
* The parser encapsulates all arrays in a proxy object.
* A proxy intercepts access to the actual data, allowing
* the parser to add type safety and many utility methods
* that make working with InnerTube much easier.
*/
const tab = page.contents.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
if (!tab)
throw new Error('Target tab not found');
@@ -640,31 +670,26 @@ if (!tab)
if (!tab.content)
throw new Error('Target tab appears to be empty');
const sections = tab.content?.as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf, MusicShelf);
const sections = tab.content?.as(YTNodes.SectionList).contents.array().as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
console.info('Sections:', sections);
```
</p>
</details>
Detailed documentation can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/src/parser).
Documentation for the parser can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/src/parser).
<!-- CONTRIBUTING -->
## Contributing
Contributions, issues, and feature requests are welcome.
Feel free to check the [issues page](https://github.com/LuanRT/YouTube.js/issues) and our [guidelines](https://github.com/LuanRT/YouTube.js/blob/main/CONTRIBUTING.md) if you want to contribute.
<!-- CONTRIBUTORS -->
## Contributors
Thank you to all the wonderful people who have contributed to this project:
<a href="https://github.com/LuanRT/YouTube.js/graphs/contributors">
<img src="https://contrib.rocks/image?repo=LuanRT/YouTube.js" />
</a>
<!-- CONTACT -->
## Contact
LuanRT - [@lrt_nooneknows][twitter] - luan.lrt4@gmail.com
LuanRT - [@thesciencephile][twitter] - luan.lrt4@gmail.com
Project Link: [https://github.com/LuanRT/YouTube.js][project]
@@ -674,13 +699,11 @@ All trademarks, logos, and brand names are the property of their respective owne
Should you have any questions or concerns please contact me directly via email.
<!-- Footnotes -->
[^1]: https://gizmodo.com/how-project-innertube-helped-pull-youtube-out-of-the-gu-1704946491
<!-- LICENSE -->
## License
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
<p align=" right">
(<a href="#top">back to top</a>)
</p>
</p>

View File

@@ -6,4 +6,6 @@ export * from './src/utils';
export { YTNodes } from './src/parser/map';
export { default as Parser } from './src/parser';
export { default as Innertube } from './src/Innertube';
export { default as Session } from './src/core/Session';
export { default as Player } from './src/core/Player';
export default Innertube;

View File

@@ -7,7 +7,7 @@ Handles direct interactions.
* InteractionManager
* [.like(video_id)](#like)
* [.dislike(video_id)](#dislike)
* [.removeLike(video_id)](#removelike)
* [.removeRating(video_id)](#removerating)
* [.subscribe(video_id)](#subscribe)
* [.unsubscribe(video_id)](#unsubscribe)
* [.comment(video_id, text)](#comment)
@@ -36,7 +36,7 @@ Dislikes given video.
| --- | --- | --- |
| video_id | `string` | Video id |
<a name="removelike"></a>
<a name="removerating"></a>
### removeLike(video_id)
Remover like/dislike.

View File

@@ -149,9 +149,35 @@ Retrieves “Explore” feed.
Retrieves library.
**Returns:** `Promise.<Library>`
**Returns:** `Library`
<!-- TODO: document Library's methods and getters. -->
<details>
<summary>Methods & Getters</summary>
<p>
- `<library>#applyFilter(filter)`
- Applies given filter to the library.
- `<library>#applySortFilter(filter)`
- Applies given sort filter to the library items.
- `<library>#getContinuation()`
- Retrieves continuation of the library items.
- `<library>#has_continuation`
- Checks if continuation is available.
- `<library>#filters`
- Returns available filters.
- `<library>#sort_filters`
- Returns available sort filters.
- `<library>#page`
- Returns original InnerTube response (sanitized).
</p>
</details>
<a name="getartist"></a>
### getArtist(artist_id)

View File

@@ -6,6 +6,7 @@ YouTube Studio class (WIP).
* Studio
* [.setThumbnail(video_id, buffer)](#setthumbnail)
* [.updateVideoMetadata(video_id, metadata)](#updatemetadata)
* [.upload(file, metadata)](#upload)
<a name="setthumbnail"></a>
@@ -20,6 +21,18 @@ Uploads a custom thumbnail and sets it for a video.
| video_id | `string` | Video id |
| buffer | `Uint8Array` | Thumbnail buffer |
<a name="updatemetadata"></a>
### updateVideoMetadata(video_id, metadata)
Updates given video's metadata.
**Returns:** `Promise.<ActionsResponse>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| metadata | `VideoMetadata` | Video metadata |
<a name="upload"></a>
### upload(file, metadata)
@@ -30,4 +43,4 @@ Uploads a video to YouTube.
| Param | Type | Description |
| --- | --- | --- |
| file | `BodyInit` | Video file |
| metadata | `VideoMetadata` | Video metadata |
| metadata | `UploadedVideoMetadata` | Video metadata |

View File

@@ -0,0 +1,54 @@
# Updating the parser
YouTube is constantly changing, so it is not rare to see YouTube crawlers/scrapers breaking every now and then.
Our parser, on the other hand, was written so that it behaves similarly to an official client, parsing and mapping renderers (a.k.a YTNodes) dynamically without hard-coding their path in the response. This way, whenever a new renderer pops up (e.g; YouTube adds a new feature / minor UI changes) the library will print a warning similar to this:
```
InnertubeError: SomeRenderer not found!
This is a bug, want to help us fix it? Follow the instructions at https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md or report it at https://github.com/LuanRT/YouTube.js/issues!
at Parser.printError (...)
at Parser.parseItem (...)
at Parser.parseArray (...) {
info: {
// renderer data, can be used as a reference to implement the renderer parser
},
date: 2022-05-22T22:16:06.831Z,
version: '2.2.3'
}
```
This warning **does not** throw an error. The parser itself will continue working normally, even if a parsing error occurs in an existing renderer parser.
## Adding a new renderer parser
Thanks to the modularity of the parser, a renderer can be implemented by simply adding a new file anywhere in the [classes directory](../src/parser/classes)!
For example, say we found a new renderer named `verticalListRenderer`, to let the parser know it exists we would have to create a file with the following structure:
> `../classes/VerticalList.ts`
```ts
import Parser from '..';
import { YTNode } from '../helpers';
class VerticalList extends YTNode {
static type = 'VerticalList';
header;
contents;
constructor(data: any) {
// parse the data here, ex;
this.header = Parser.parseItem(data.header);
this.contents = Parser.parseArray(data.contents);
}
}
export default VerticalList;
```
Then update the parser map:
```bash
npm run build:parser-map
```
And that's it!

View File

@@ -6,17 +6,52 @@ YouTube.js works in the browser!
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in `examples/browser/proxy/deno.ts`.
Once the proxy is set up you need to tell Innertube about it when instantiating it.
We'll use our own fetch implementation to proxy requests through our server. This is a simple example, but you can use any fetch implementation you want.
```ts
import { Innertube } from "youtubei.js/build/browser";
const yt = await Innertube.create({
browser_proxy: {
host: "localhost",
schema: 'http',
}
})
fetch: async (input, init) => {
// url
const url = typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
// transform the url for use with our proxy
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
const headers = init?.headers
? new Headers(init.headers)
: input instanceof Request
? input.headers
: new Headers();
// now serialize the headers
url.searchParams.set('__headers', JSON.stringify([...headers]));
// copy over the request
const request = new Request(
url,
input instanceof Request ? input : undefined,
);
headers.delete('user-agent');
// fetch the url
return fetch(request, init ? {
...init,
headers
} : {
headers
});
},
cache: new UniversalCache(),
});
```
after that you can use the library as normal.

View File

@@ -18,7 +18,7 @@ const handler = async (request: Request): Promise<Response> => {
'Access-Control-Allow-Origin': request.headers.get('origin') || '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers':
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-origin, x-youtube-client-version, Accept-Language, Range',
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-origin, x-youtube-client-version, Accept-Language, Range, Referer',
'Access-Control-Max-Age': '86400',
'Access-Control-Allow-Credentials': 'true',
}),
@@ -45,7 +45,7 @@ const handler = async (request: Request): Promise<Response> => {
JSON.parse(url.searchParams.get('__headers') || '{}'),
);
copyHeader('range', request_headers, request.headers);
copyHeader('user-agent', request_headers, request.headers);
!request_headers.has('user-agent') && copyHeader('user-agent', request_headers, request.headers);
url.searchParams.delete('__headers');
// Make the request to YouTube
@@ -62,6 +62,8 @@ const handler = async (request: Request): Promise<Response> => {
copyHeader('content-length', headers, fetchRes.headers);
copyHeader('content-type', headers, fetchRes.headers);
copyHeader('content-disposition', headers, fetchRes.headers);
copyHeader('accept-ranges', headers, fetchRes.headers);
copyHeader('content-range', headers, fetchRes.headers);
// add cors headers
headers.set(

View File

@@ -1,39 +1,47 @@
import { Innertube, UniversalCache } from 'youtubei.js';
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const channel = await yt.getChannel('UCX6OQ3DkcsbYNE6H8uQQuVA');
console.info('Viewing channel:', channel.header.author.name);
console.info('Family Safe:', channel.metadata.is_family_safe);
if (channel.header?.is(YTNodes.C4TabbedHeader)) {
console.info('Viewing channel:', channel?.header?.author.name);
console.info('Family Safe:', channel.metadata.is_family_safe);
}
const about = await channel.getAbout();
console.info('Country:', about.country.toString());
console.info('\nLists the following videos:');
console.info('\nVideos:');
const videos = await channel.getVideos();
for (const video of videos.videos) {
console.info('Video:', video.title.toString());
}
console.info('\nLists the following playlists:');
console.info('\nPopular videos:');
const popular_videos = await videos.applyFilter('Popular');
for (const video of popular_videos.videos) {
console.info('Video:', video.title.toString());
}
console.info('\nPlaylists:');
const playlists = await channel.getPlaylists();
for (const playlist of playlists.playlists) {
console.info('Playlist:', playlist.title.toString());
}
console.info('\nLists the following channels:');
console.info('\nChannels:');
const channels = await channel.getChannels();
for (const channel of channels.channels) {
console.info('Channel:', channel.author.name);
}
console.info('\nLists the following community posts:');
console.info('\nCommunity posts:');
const posts = await channel.getCommunity();
for (const post of posts.posts) {

View File

@@ -4,42 +4,42 @@ import { streamToIterable } from 'youtubei.js/dist/src/utils/Utils';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const search = await yt.music.search('No Copyright Background Music', { type: 'album' });
if (!search.results)
throw new Error('Filter "type" must be used');
const album = await yt.music.getAlbum(search.results[0].id as string);
if (!album.contents)
throw new Error('Album appears to be empty');
console.info(`Album "${album.header.title.toString()}" by ${album.header.author?.name}`, '\n');
console.info(`Album "${album.header?.title.toString()}" by ${album.header?.author?.name}`, '\n');
for (const song of album.contents) {
const stream = await yt.download(song.id as string, {
type: 'audio', // audio, video or audio+video
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'mp4' // media container format
});
console.info(`Downloading ${song.title} (${song.id})`);
const dir = `./${album.header.title.toString()}`;
const dir = `./${album.header?.title.toString()}`;
if (!existsSync(dir)) {
mkdirSync(dir);
}
const file = createWriteStream(`${dir}/${song.title?.replace(/\//g, '')}.m4a`);
for await (const chunk of streamToIterable(stream)) {
file.write(chunk);
}
console.info(`${song.id} - Done!`, '\n');
}
console.info(`Downloaded ${album.header.song_count}!`);
console.info(`Downloaded ${album.header?.song_count}!`);
})();

View File

@@ -21,6 +21,7 @@ const livechat = await info.getLiveChat();
* [.ev](#ev) ⇒ `EventEmitter`
* [.start](#start) ⇒ `function`
* [.stop](#stop) ⇒ `function`
* [.getItemMenu](#getitemmenu) ⇒ `function`
* [.sendMessage](#sendmessage) ⇒ `function`
<a name="ev"></a>
@@ -58,6 +59,16 @@ Starts the Live Chat.
### stop()
Stops the Live Chat.
<a name="getitemmenu"></a>
### getItemMenu(item)
Retrieves given chat item's menu.
| Param | Type | Description |
| --- | --- | --- |
| item | `object` | Chat item |
**Returns:** `Promise<ItemMenu>`
<a name="sendmessage"></a>
### sendMessage(text)
Sends a message.

View File

@@ -1,31 +1,25 @@
import { Innertube, UniversalCache } from 'youtubei.js';
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
import { LiveChatContinuation } from 'youtubei.js/dist/src/parser';
import LiveChat, { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
import Video from 'youtubei.js/dist/src/parser/classes/Video';
import AddChatItemAction from 'youtubei.js/dist/src/parser/classes/livechat/AddChatItemAction';
import MarkChatItemAsDeletedAction from 'youtubei.js/dist/src/parser/classes/livechat/MarkChatItemAsDeletedAction';
import LiveChatTextMessage from 'youtubei.js/dist/src/parser/classes/livechat/items/LiveChatTextMessage';
import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/items/LiveChatPaidMessage';
import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const search = await yt.search('Lofi girl live');
const info = await yt.getInfo(search.videos[0].as(Video).id);
const info = await yt.getInfo(search.videos[0].as(YTNodes.Video).id);
const livechat = info.getLiveChat();
const livechat = await info.getLiveChat();
livechat.on('start', (initial_data: LiveChatContinuation) => {
/**
* Initial info is what you see when you first open a Live Chat — this is; inital actions (pinned messages, top donations..), account's info and so on.
*/
console.info(`Hey ${initial_data.viewer_name || 'N/A'}, welcome to Live Chat!`);
});
livechat.on('chat-update', (action: ChatAction) => {
/**
* An action represents what is being added to
@@ -35,28 +29,28 @@ import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/it
* Below are a few examples of how this can be used.
*/
if (action.is(AddChatItemAction)) {
const item = action.as(AddChatItemAction).item;
if (action.is(YTNodes.AddChatItemAction)) {
const item = action.as(YTNodes.AddChatItemAction).item;
if (!item)
return console.info('Action did not have an item.', action);
const hours = new Date(item.hasKey('timestamp') ? item.timestamp : Date.now()).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
switch (item.type) {
case 'LiveChatTextMessage':
console.info(
`${hours} - ${item.as(LiveChatTextMessage).author?.name.toString()}:\n` +
`${item.as(LiveChatTextMessage).message.toString()}\n`
`${hours} - ${item.as(YTNodes.LiveChatTextMessage).author?.name.toString()}:\n` +
`${item.as(YTNodes.LiveChatTextMessage).message.toString()}\n`
);
break;
case 'LiveChatPaidMessage':
console.info(
`${hours} - ${item.as(LiveChatPaidMessage).author.name.toString()}:\n` +
`${item.as(LiveChatPaidMessage).purchase_amount}\n`
`${hours} - ${item.as(YTNodes.LiveChatPaidMessage).author.name.toString()}:\n` +
`${item.as(YTNodes.LiveChatPaidMessage).purchase_amount}\n`
);
break;
default:
@@ -64,8 +58,8 @@ import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/it
break;
}
}
if (action.is(MarkChatItemAsDeletedAction)) {
if (action.is(YTNodes.MarkChatItemAsDeletedAction)) {
console.warn(`Message ${action.target_item_id} just got deleted and should be replaced with ${action.deleted_state_message.toString()}!`, '\n');
}
});

View File

@@ -1,22 +1,11 @@
import { Parser } from 'youtubei.js';
import SectionList from 'youtubei.js/dist/src/parser/classes/SectionList';
import SingleColumnBrowseResults from 'youtubei.js/dist/src/parser/classes/SingleColumnBrowseResults';
import MusicVisualHeader from 'youtubei.js/dist/src/parser/classes/MusicVisualHeader';
import MusicImmersiveHeader from 'youtubei.js/dist/src/parser/classes/MusicImmersiveHeader';
import MusicCarouselShelf from 'youtubei.js/dist/src/parser/classes/MusicCarouselShelf';
import MusicDescriptionShelf from 'youtubei.js/dist/src/parser/classes/MusicDescriptionShelf';
import MusicShelf from 'youtubei.js/dist/src/parser/classes/MusicShelf';
import { Parser, YTNodes } from 'youtubei.js';
import { readFileSync } from 'fs';
// Artist page response from YouTube Music
const data = readFileSync('./artist.json').toString();
const page = Parser.parseResponse(JSON.parse(data));
const header = page.header.item().as(MusicImmersiveHeader, MusicVisualHeader);
const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);
console.info('Header:', header);
@@ -24,14 +13,14 @@ console.info('Header:', header);
// A proxy intercepts access to the actual data, allowing
// the parser to add type safety and many utility methods
// that make working with InnerTube much easier.
const tab = page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: false });
const tab = page.contents.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
if (!tab)
throw new Error('Target tab not found');
if (!tab.content)
throw new Error('Target tab appears to be empty');
const sections = tab.content?.as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf, MusicShelf);
const sections = tab.content?.as(YTNodes.SectionList).contents.array().as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
console.info('Sections:', sections);

View File

@@ -23,4 +23,6 @@ export * from './src/utils';
export { YTNodes } from './src/parser/map';
export { default as Parser } from './src/parser';
export { default as Innertube } from './src/Innertube';
export { default as Session } from './src/core/Session';
export { default as Player } from './src/core/Player';
export default Innertube;

2357
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "youtubei.js",
"version": "2.2.1",
"description": "Full-featured wrapper around YouTube's private API.",
"version": "2.8.0",
"description": "Full-featured wrapper around YouTube's private API. Supports YouTube, YouTube Music and YouTube Studio (WIP).",
"main": "./dist/index.js",
"browser": "./bundle/browser.js",
"types": "./dist",
@@ -12,7 +12,8 @@
"contributors": [
"Wykerd (https://github.com/wykerd/)",
"MasterOfBob777 (https://github.com/MasterOfBob777)",
"patrickkfkan (https://github.com/patrickkfkan)"
"patrickkfkan (https://github.com/patrickkfkan)",
"akkadaska (https://github.com/akkadaska)"
],
"directories": {
"test": "./test",
@@ -39,7 +40,7 @@
"license": "MIT",
"dependencies": {
"@protobuf-ts/runtime": "^2.7.0",
"jintr": "^0.2.0",
"jintr": "^0.3.1",
"linkedom": "^0.14.12",
"undici": "^5.7.0"
},
@@ -70,6 +71,7 @@
"youtube-dl",
"youtube-downloader",
"youtube-music",
"youtube-studio",
"innertubeapi",
"innertube",
"unofficial",

View File

@@ -24,11 +24,11 @@ import { YTNodeConstructor } from './helpers';
${import_list.join('\n')}
const map: Record<string, YTNodeConstructor> = {
export const YTNodes = {
${json.join(',\n ')}
};
export const YTNodes = map;
const map: Record<string, YTNodeConstructor> = YTNodes;
/**
* @param name - Name of the node to be parsed

View File

@@ -1,63 +1,54 @@
import Session, { SessionOptions } from './core/Session';
import type { ParsedResponse } from './parser';
import type { ActionsResponse } from './core/Actions';
import Search from './parser/youtube/Search';
import Channel from './parser/youtube/Channel';
import Playlist from './parser/youtube/Playlist';
import Library from './parser/youtube/Library';
import History from './parser/youtube/History';
import Comments from './parser/youtube/Comments';
import NotificationsMenu from './parser/youtube/NotificationsMenu';
import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo';
import NavigationEndpoint from './parser/classes/NavigationEndpoint';
import Channel from './parser/youtube/Channel';
import Comments from './parser/youtube/Comments';
import History from './parser/youtube/History';
import Library from './parser/youtube/Library';
import NotificationsMenu from './parser/youtube/NotificationsMenu';
import Playlist from './parser/youtube/Playlist';
import Search from './parser/youtube/Search';
import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo';
import { ParsedResponse } from './parser';
import { ActionsResponse } from './core/Actions';
import Feed from './core/Feed';
import YTMusic from './core/Music';
import Studio from './core/Studio';
import AccountManager from './core/AccountManager';
import PlaylistManager from './core/PlaylistManager';
import Feed from './core/Feed';
import InteractionManager from './core/InteractionManager';
import FilterableFeed from './core/FilterableFeed';
import YTMusic from './core/Music';
import PlaylistManager from './core/PlaylistManager';
import Studio from './core/Studio';
import TabbedFeed from './core/TabbedFeed';
import Constants from './utils/Constants';
import HomeFeed from './parser/youtube/HomeFeed';
import Proto from './proto/index';
import Constants from './utils/Constants';
import { throwIfMissing, generateRandomString } from './utils/Utils';
import type Actions from './core/Actions';
import type Format from './parser/classes/misc/Format';
export type InnertubeConfig = SessionOptions
import { generateRandomString, throwIfMissing } from './utils/Utils';
export type InnertubeConfig = SessionOptions;
export interface SearchFilters {
/**
* Filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
*/
upload_date?: 'any' | 'last_hour' | 'today' | 'this_week' | 'this_month' | 'this_year';
/**
* Filter results by type, can be: any | video | channel | playlist | movie
*/
type?: 'any' | 'video' | 'channel' | 'playlist' | 'movie';
/**
* Filter videos by duration, can be: any | short | medium | long
*/
duration?: 'any' | 'short' | 'medium' | 'long';
/**
* Filter video results by order, can be: relevance | rating | upload_date | view_count
*/
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' | 'TV_EMBEDDED';
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED';
class Innertube {
session;
account;
playlist;
interact;
music;
studio;
actions;
session: Session;
account: AccountManager;
playlist: PlaylistManager;
interact: InteractionManager;
music: YTMusic;
studio: Studio;
actions: Actions;
constructor(session: Session) {
this.session = session;
@@ -69,18 +60,22 @@ class Innertube {
this.actions = this.session.actions;
}
static async create(config: InnertubeConfig = {}) {
static async create(config: InnertubeConfig = {}): Promise<Innertube> {
return new Innertube(await Session.create(config));
}
/**
* Retrieves video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getInfo(video_id: string, client?: InnerTubeClient) {
async getInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ video_id });
const cpn = generateRandomString(16);
const initial_info = await this.actions.getVideoInfo(video_id, cpn, client);
const continuation = this.actions.next({ video_id });
const initial_info = this.actions.getVideoInfo(video_id, cpn, client);
const continuation = this.actions.execute('/next', { videoId: video_id });
const response = await Promise.all([ initial_info, continuation ]);
return new VideoInfo(response, this.actions, this.session.player, cpn);
@@ -88,8 +83,12 @@ class Innertube {
/**
* Retrieves basic video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getBasicInfo(video_id: string, client?: InnerTubeClient) {
async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ video_id });
const cpn = generateRandomString(16);
const response = await this.actions.getVideoInfo(video_id, cpn, client);
@@ -98,18 +97,27 @@ class Innertube {
/**
* Searches a given query.
* @param query - search query.
* @param filters - search filters.
* @param query - The search query.
* @param filters - Search filters.
*/
async search(query: string, filters: SearchFilters = {}) {
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
throwIfMissing({ query });
const response = await this.actions.search({ query, filters });
const args = {
query,
...{
params: filters ? Proto.encodeSearchFilters(filters) : undefined
}
};
const response = await this.actions.execute('/search', args);
return new Search(this.actions, response.data);
}
/**
* Retrieves search suggestions for a given query.
* @param query - the search query.
* @param query - The search query.
*/
async getSearchSuggestions(query: string): Promise<string[]> {
throwIfMissing({ query });
@@ -134,33 +142,34 @@ class Innertube {
/**
* Retrieves comments for a video.
* @param video_id - the video id.
* @param sort_by - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
* @param video_id - The video id.
* @param sort_by - Sorting options.
*/
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST') {
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.next({ ctoken: payload });
const response = await this.actions.execute('/next', { continuation: payload });
return new Comments(this.actions, response.data);
}
/**
* Retrieves YouTube's home feed (aka recommendations).
*/
async getHomeFeed() {
const response = await this.actions.browse('FEwhat_to_watch');
return new FilterableFeed(this.actions, response.data);
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
return new HomeFeed(this.actions, response.data);
}
/**
* Returns the account's library.
*/
async getLibrary() {
const response = await this.actions.browse('FElibrary');
async getLibrary(): Promise<Library> {
const response = await this.actions.execute('/browse', { browseId: 'FElibrary' });
return new Library(response.data, this.actions);
}
@@ -168,59 +177,66 @@ class Innertube {
* Retrieves watch history.
* Which can also be achieved with {@link getLibrary}.
*/
async getHistory() {
const response = await this.actions.browse('FEhistory');
async getHistory(): Promise<History> {
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
return new History(this.actions, response.data);
}
/**
* Retrieves trending content.
*/
async getTrending() {
const response = await this.actions.browse('FEtrending');
async getTrending(): Promise<TabbedFeed> {
const response = await this.actions.execute('/browse', { browseId: 'FEtrending' });
return new TabbedFeed(this.actions, response.data);
}
/**
* Retrieves subscriptions feed.
*/
async getSubscriptionsFeed() {
const response = await this.actions.browse('FEsubscriptions');
async getSubscriptionsFeed(): Promise<Feed> {
const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions' });
return new Feed(this.actions, response.data);
}
/**
* Retrieves contents for a given channel.
* @param id - channel id
* @param id - Channel id
*/
async getChannel(id: string) {
async getChannel(id: string): Promise<Channel> {
throwIfMissing({ id });
const response = await this.actions.browse(id);
const response = await this.actions.execute('/browse', { browseId: id });
return new Channel(this.actions, response.data);
}
/**
* Retrieves notifications.
*/
async getNotifications() {
const response = await this.actions.notifications('get_notification_menu');
async getNotifications(): Promise<NotificationsMenu> {
const response = await this.actions.execute('/notification/get_notification_menu', { notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' });
return new NotificationsMenu(this.actions, response);
}
/**
* Retrieves unseen notifications count.
*/
async getUnseenNotificationsCount() {
const response = await this.actions.notifications('get_unseen_count');
return response.data.unseenCount;
async getUnseenNotificationsCount(): Promise<number> {
const response = await this.actions.execute('/notification/get_unseen_count');
// TODO: properly parse this
return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;
}
/**
* Retrieves playlist contents.
* @param id - Playlist id
*/
async getPlaylist(id: string) {
async getPlaylist(id: string): Promise<Playlist> {
throwIfMissing({ id });
const response = await this.actions.browse(`VL${id.replace(/VL/g, '')}`);
if (!id.startsWith('VL')) {
id = `VL${id}`;
}
const response = await this.actions.execute('/browse', { browseId: id });
return new Playlist(this.actions, response.data);
}
@@ -229,27 +245,44 @@ class Innertube {
* Returns deciphered streaming data.
*
* 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 - Format options.
*/
async getStreamingData(video_id: string, options: FormatOptions = {}) {
async getStreamingData(video_id: string, options: FormatOptions = {}): Promise<Format> {
const info = await this.getBasicInfo(video_id);
return info.chooseFormat(options);
}
/**
* Downloads a given video. If you only 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.
*/
async download(video_id: string, options?: DownloadOptions) {
async download(video_id: string, options?: DownloadOptions): Promise<ReadableStream<Uint8Array>> {
const info = await this.getBasicInfo(video_id, options?.client);
return info.download(options);
}
call(endpoint: NavigationEndpoint, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
call(endpoint: NavigationEndpoint, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
/**
* Resolves the given URL.
* @param url - The URL.
*/
async resolveURL(url: string): Promise<NavigationEndpoint> {
const response = await this.actions.execute('/navigation/resolve_url', { url, parse: true });
return response.endpoint as NavigationEndpoint;
}
/**
* Utility method to call an endpoint without having to use {@link Actions}.
* @param endpoint -The endpoint to call.
* @param args - Call arguments.
*/
call(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise<ParsedResponse>;
call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise<ActionsResponse>;
call(endpoint: NavigationEndpoint, args?: object): Promise<ActionsResponse | ParsedResponse> {
return endpoint.callTest(this.actions, args);
return endpoint.call(this.actions, args);
}
}
export default Innertube;
export default Innertube;

View File

@@ -1,14 +1,22 @@
import Proto from '../proto/index';
import Actions from './Actions';
import type Actions from './Actions';
import type { ActionsResponse } from './Actions';
import Analytics from '../parser/youtube/Analytics';
import TimeWatched from '../parser/youtube/TimeWatched';
import AccountInfo from '../parser/youtube/AccountInfo';
import Settings from '../parser/youtube/Settings';
import { InnertubeError } from '../utils/Utils';
class AccountManager {
#actions;
channel;
#actions: Actions;
channel: {
editName: (new_name: string) => Promise<ActionsResponse>;
editDescription: (new_description: string) => Promise<ActionsResponse>;
getBasicAnalytics: () => Promise<Analytics>;
};
constructor(actions: Actions) {
this.#actions = actions;
@@ -16,13 +24,30 @@ class AccountManager {
this.channel = {
/**
* Edits channel name.
* @param new_name - The new channel name.
*/
editName: (new_name: string) => this.#actions.channel('channel/edit_name', { new_name }),
editName: (new_name: string) => {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
return this.#actions.execute('/channel/edit_name', {
givenName: new_name,
client: 'ANDROID'
});
},
/**
* Edits channel description.
*
* @param new_description - The new description.
*/
editDescription: (new_description: string) => this.#actions.channel('channel/edit_description', { new_description }),
editDescription: (new_description: string) => {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
return this.#actions.execute('/channel/edit_description', {
givenDescription: new_description,
client: 'ANDROID'
});
},
/**
* Retrieves basic channel analytics.
*/
@@ -33,7 +58,10 @@ class AccountManager {
/**
* Retrieves channel info.
*/
async getInfo() {
async getInfo(): Promise<AccountInfo> {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('/account/accounts_list', { client: 'ANDROID' });
return new AccountInfo(response);
}
@@ -41,7 +69,7 @@ class AccountManager {
/**
* Retrieves time watched statistics.
*/
async getTimeWatched() {
async getTimeWatched(): Promise<TimeWatched> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPtime_watched',
client: 'ANDROID'
@@ -53,7 +81,7 @@ class AccountManager {
/**
* Opens YouTube settings.
*/
async getSettings() {
async getSettings(): Promise<Settings> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPaccount_overview'
});
@@ -64,11 +92,16 @@ class AccountManager {
/**
* Retrieves basic channel analytics.
*/
async getAnalytics() {
async getAnalytics(): Promise<Analytics> {
const info = await this.getInfo();
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
const response = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
const response = await this.#actions.execute('/browse', {
browseId: 'FEanalytics_screen',
client: 'ANDROID',
params
});
return new Analytics(response);
}

View File

@@ -1,70 +1,31 @@
import Proto from '../proto/index';
import Session from './Session';
import Parser, { ParsedResponse } from '../parser/index';
import { InnertubeError } from '../utils/Utils';
import type Session from './Session';
import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
export interface BrowseArgs {
params?: string | null;
is_ytm?: boolean;
is_ctoken?: boolean;
form_data?: {};
client?: string;
}
export interface EngageArgs {
video_id?: string;
channel_id?: string;
comment_id?: string;
comment_action?: string;
params?: string;
text?: string;
target_language?: string;
}
export interface AccountArgs {
new_value?: string | boolean; // TODO: is this correct?
setting_item_id?: string;
client?: string;
}
export interface SearchArgs {
query?: string,
options?: {
period?: string,
duration?: string,
order?: string
},
client?: string,
ctoken?: string,
params?: string
filters?: any // TODO: what is this type??
}
export interface AxioslikeResponse {
export interface ApiResponse {
success: boolean;
status_code: number;
data: any;
}
export type ActionsResponse = Promise<AxioslikeResponse>;
export type ActionsResponse = Promise<ApiResponse>;
class Actions {
#session;
#session: Session;
constructor(session: Session) {
this.#session = session;
}
get session() {
get session(): Session {
return this.#session;
}
/**
* Mimmics the Axios API using Fetch's Response object.
* @param response - The response object.
*/
async #wrap(response: Response) {
async #wrap(response: Response): Promise<ApiResponse> {
return {
success: response.ok,
status_code: response.status,
@@ -72,553 +33,14 @@ class Actions {
};
}
/**
* Covers `/browse` endpoint, mostly used to access
* YouTube's sections such as the home feed, etc
* and sometimes to retrieve continuations.
*
* @param id - browseId or a continuation token
* @param args - additional arguments
*/
async browse(id: string, args: BrowseArgs = {}) {
if (this.#needsLogin(id) && !this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
if (args.params)
data.params = args.params;
if (args.is_ctoken) {
data.continuation = id;
} else {
data.browseId = id;
}
if (args.form_data) {
data.formData = args.form_data;
}
if (args.client) {
data.client = args.client;
}
const response = await this.#session.http.fetch('/browse', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used to perform direct interactions
* on YouTube.
*/
async engage(action: string, args: EngageArgs = {}) {
if (!this.#session.logged_in && !args.hasOwnProperty('text'))
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'like/like':
case 'like/dislike':
case 'like/removelike':
if (!hasKeys(args, 'video_id'))
throw new MissingParamError('Arguments lacks video_id');
data.target = {};
data.target.videoId = args.video_id;
if (args.params) {
data.params = args.params;
}
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
if (!hasKeys(args, 'channel_id'))
throw new MissingParamError('Arguments lacks channel_id');
data.channelIds = [ args.channel_id ];
data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
break;
case 'comment/create_comment':
data.commentText = args.text;
if (!hasKeys(args, 'video_id'))
throw new MissingParamError('Arguments lacks video_id');
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
break;
case 'comment/create_comment_reply':
if (!hasKeys(args, 'comment_id', 'video_id', 'text'))
throw new MissingParamError('Arguments lacks comment_id, video_id or text');
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
data.commentText = args.text;
break;
case 'comment/perform_comment_action':
const target_action = (() => {
switch (args.comment_action) {
case 'like':
return Proto.encodeCommentActionParams(5, args);
case 'dislike':
return Proto.encodeCommentActionParams(4, args);
case 'translate':
return Proto.encodeCommentActionParams(22, args);
default:
break;
}
})();
data.actions = [ target_action ];
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints related to account management.
*/
async account(action: string, args: AccountArgs = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = { client: args.client };
switch (action) {
case 'account/set_setting':
data.newValue = {
boolValue: args.new_value
};
data.settingItemId = args.setting_item_id;
break;
case 'account/accounts_list':
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Endpoint used for search.
*/
async search(args: SearchArgs = {}) {
const data: Record<string, any> = { client: args.client };
if (args.query) {
data.query = args.query;
}
if (args.ctoken) {
data.continuation = args.ctoken;
}
if (args.params) {
data.params = args.params;
}
if (args.filters) {
if (args.client == 'YTMUSIC' && args.filters?.type && args.filters.type !== 'all') {
data.params = Proto.encodeMusicSearchFilters(args.filters);
} else {
data.params = Proto.encodeSearchFilters(args.filters);
}
}
const response = await this.#session.http.fetch('/search', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Endpoint used fo Shorts' sound search.
*/
async searchSound(args: { query: string; }) {
const data = {
query: args.query,
client: 'ANDROID'
};
const response = await this.#session.http.fetch('/sfv/search', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Channel management endpoints.
*/
async channel(action: string, args: { new_name?: string; new_description?: string; client?: string; } = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = { client: args.client || 'ANDROID' };
switch (action) {
case 'channel/edit_name':
data.givenName = args.new_name;
break;
case 'channel/edit_description':
data.description = args.new_description;
break;
case 'channel/get_profile_editor':
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used for playlist management.
*/
async playlist(action: string, args: {
title?: string;
ids?: string[];
playlist_id?: string;
action?: string;
} = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'playlist/create':
data.title = args.title;
data.videoIds = args.ids;
break;
case 'playlist/delete':
data.playlistId = args.playlist_id;
break;
case 'browse/edit_playlist':
if (!hasKeys(args, 'ids'))
throw new MissingParamError('Arguments lacks ids');
data.playlistId = args.playlist_id;
data.actions = args.ids.map((id) => {
switch (args.action) {
case 'ACTION_ADD_VIDEO':
return {
action: args.action,
addedVideoId: id
};
case 'ACTION_REMOVE_VIDEO':
return {
action: args.action,
setVideoId: id
};
default:
break;
}
});
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used for notifications management.
*/
async notifications(action: string, args: {
pref?: string;
channel_id?: string;
ctoken?: string;
params?: string
} = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'modify_channel_preference':
if (!hasKeys(args, 'channel_id', 'pref'))
throw new MissingParamError('Arguments lacks channel_id or pref');
const pref_types = {
PERSONALIZED: 1,
ALL: 2,
NONE: 3
};
if (!Object.keys(pref_types).includes(args.pref.toUpperCase()))
throw new InnertubeError('Invalid preference type', args.pref);
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase() as keyof typeof pref_types]);
break;
case 'get_notification_menu':
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
if (args.ctoken)
data.ctoken = args.ctoken;
break;
case 'record_interactions':
data.serializedRecordNotificationInteractionsRequest = args.params;
break;
case 'get_unseen_count':
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/notification/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers livechat endpoints.
*/
async livechat(action: string, args: {
text?: string;
video_id?: string;
channel_id?: string;
ctoken?: string;
params?: string;
client?: string;
} = {}) {
// TODO: should client be required?
const data: Record<string, any> = { client: args.client };
switch (action) {
case 'live_chat/get_live_chat':
case 'live_chat/get_live_chat_replay':
data.continuation = args.ctoken;
break;
case 'live_chat/send_message':
if (!hasKeys(args, 'channel_id', 'video_id', 'text'))
throw new MissingParamError('Arguments lacks channel_id, video_id or text');
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
data.clientMessageId = uuidv4();
data.richMessage = {
textSegments: [ {
text: args.text
} ]
};
break;
case 'live_chat/get_item_context_menu':
// Note: this is currently broken due to a recent refactor
// TODO: this should be implemented
break;
case 'live_chat/moderate':
data.params = args.params;
break;
case 'updated_metadata':
data.videoId = args.video_id;
if (args.ctoken)
data.continuation = args.ctoken;
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Endpoint used to retrieve video thumbnails.
*/
async thumbnails(args: { video_id: string; }) {
const data = {
client: 'ANDROID',
videoId: args.video_id
};
const response = await this.#session.http.fetch('/thumbnails', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Place Autocomplete endpoint, found it in the APK but
* doesn't seem to be used anywhere on YouTube (maybe for ads?).
*
* Ex:
* ```js
* const places = await session.actions.geo('place_autocomplete', { input: 'San diego cafe' });
* console.info(places.data);
* ```
*/
async geo(action: string, args: { input: string; }) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data = {
input: args.input,
client: 'ANDROID'
};
const response = await this.#session.http.fetch(`/geo/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used to report content.
*/
async flag(action: string, args: { action: string; params?: string; }) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'flag/flag':
data.action = args.action;
break;
case 'flag/get_form':
data.params = args.params;
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers specific YouTube Music endpoints.
*/
async music(action: string, args: { input?: string; }) {
const data = {
input: args.input || '',
client: 'YTMUSIC'
};
const response = await this.#session.http.fetch(`/music/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Mostly used for pagination and specific operations.
*/
async next(args: { video_id?: string; ctoken?: string; client?: string; playlist_id?: string; params?: string } = {}) {
const data: Record<string, any> = { client: args.client };
if (args.ctoken) {
data.continuation = args.ctoken;
}
if (args.video_id) {
data.videoId = args.video_id;
}
if (args.playlist_id) {
data.playlistId = args.playlist_id;
}
if (args.params) {
data.params = args.params;
}
const response = await this.#session.http.fetch('/next', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Used to retrieve video info.
* @param id - The video ID.
* @param cpn - Content Playback Nonce.
* @param client - The client to use.
* @param playlist_id - The playlist ID.
*/
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string) {
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise<ActionsResponse> {
const data: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
@@ -627,7 +49,7 @@ class Actions {
referer: 'https://www.youtube.com',
currentUrl: `/watch?v=${id}`,
autonavState: 'STATE_OFF',
signatureTimestamp: this.#session.player.sts,
signatureTimestamp: this.#session.player?.sts || 0,
autoCaptionsDefaultOn: false,
html5Preference: 'HTML5_PREF_WANTS',
lactMilliseconds: '-1'
@@ -662,33 +84,13 @@ class Actions {
return this.#wrap(response);
}
/**
* Endpoint used to retrieve user mention suggestions.
*/
async getUserMentionSuggestions(args: { input: string; }) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data = {
input: args.input,
client: 'ANDROID'
};
const response = await this.#session.http.fetch('/get_user_mention_suggestions', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Makes calls to the playback tracking API.
* @param url - The URL to call.
* @param client - The client to use.
* @param params - Call parameters.
*/
async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }) {
async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }): Promise<Response> {
const s_url = new URL(url);
s_url.searchParams.set('ver', '2');
@@ -707,20 +109,20 @@ class Actions {
/**
* Executes an API call.
* @param action - endpoint
* @param args - call arguments
* @param action - The endpoint to call.
* @param args - Call arguments
*/
async execute(action: string, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }) : Promise<ParsedResponse>;
async execute(action: string, args: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise<ActionsResponse>;
async execute(action: string, args: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse | ActionsResponse> {
async execute(action: string, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise<ActionsResponse>;
async execute(action: string, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse | ActionsResponse> {
let data;
if (!args.protobuf) {
if (args && !args.protobuf) {
data = { ...args };
if (Reflect.has(data, 'browseId')) {
if (this.#needsLogin(data.browseId) && !this.#session.logged_in)
throw new InnertubeError('You are not signed in');
throw new InnertubeError('You must be signed in to perform this operation.');
}
if (Reflect.has(data, 'override_endpoint'))
@@ -756,23 +158,23 @@ class Actions {
if (data?.client === 'YTMUSIC') {
data.isAudioOnly = true;
}
} else {
} else if (args) {
data = args.serialized_data;
}
const endpoint = Reflect.has(args, 'override_endpoint') ? args.override_endpoint : action;
const endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : action;
const response = await this.#session.http.fetch(endpoint, {
method: 'POST',
body: args.protobuf ? data : JSON.stringify(data),
body: args?.protobuf ? data : JSON.stringify((data || {})),
headers: {
'Content-Type': args.protobuf ?
'Content-Type': args?.protobuf ?
'application/x-protobuf' :
'application/json'
}
});
if (args.parse) {
if (args?.parse) {
return Parser.parseResponse(await response.json());
}
@@ -785,6 +187,8 @@ class Actions {
'FEhistory',
'FEsubscriptions',
'FEmusic_listening_review',
'FEmusic_library_landing',
'SPaccount_overview',
'SPaccount_notifications',
'SPaccount_privacy',
'SPtime_watched'
@@ -792,5 +196,4 @@ class Actions {
}
}
// TODO: maybe do this inferrance in a more elegant way
export default Actions;

View File

@@ -1,41 +1,40 @@
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers';
import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index';
import { Memo, ObservedArray } from '../parser/helpers';
import { InnertubeError } from '../utils/Utils';
import Actions from './Actions';
import { concatMemos, InnertubeError } from '../utils/Utils';
import type Actions from './Actions';
import Post from '../parser/classes/Post';
import BackstagePost from '../parser/classes/BackstagePost';
import Channel from '../parser/classes/Channel';
import CompactVideo from '../parser/classes/CompactVideo';
import GridChannel from '../parser/classes/GridChannel';
import GridPlaylist from '../parser/classes/GridPlaylist';
import GridVideo from '../parser/classes/GridVideo';
import Playlist from '../parser/classes/Playlist';
import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo';
import PlaylistVideo from '../parser/classes/PlaylistVideo';
import Tab from '../parser/classes/Tab';
import Post from '../parser/classes/Post';
import ReelItem from '../parser/classes/ReelItem';
import ReelShelf from '../parser/classes/ReelShelf';
import RichShelf from '../parser/classes/RichShelf';
import Shelf from '../parser/classes/Shelf';
import Tab from '../parser/classes/Tab';
import Video from '../parser/classes/Video';
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction';
import ContinuationItem from '../parser/classes/ContinuationItem';
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults';
import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults';
import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo';
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction';
import ContinuationItem from '../parser/classes/ContinuationItem';
import Video from '../parser/classes/Video';
import type MusicQueue from '../parser/classes/MusicQueue';
import type RichGrid from '../parser/classes/RichGrid';
import type SectionList from '../parser/classes/SectionList';
// TODO: add a way subdivide into sections and return subfeeds?
class Feed {
#page: ParsedResponse;
#continuation?: ObservedArray<ContinuationItem>;
#actions;
#memo;
#actions: Actions;
#memo: Memo;
constructor(actions: Actions, data: any, already_parsed = false) {
if (data.on_response_received_actions || data.on_response_received_endpoints || already_parsed) {
@@ -44,16 +43,14 @@ class Feed {
this.#page = Parser.parseResponse(data);
}
// Xxx: this can be extremely confusing — maybe refactor?
const memo =
this.#page.on_response_received_commands ?
this.#page.on_response_received_commands_memo :
this.#page.on_response_received_endpoints ?
this.#page.on_response_received_endpoints_memo :
this.#page.contents ?
this.#page.contents_memo :
this.#page.on_response_received_actions ?
this.#page.on_response_received_actions_memo : undefined;
const memo = concatMemos(
this.#page.contents_memo,
this.#page.on_response_received_commands_memo,
this.#page.on_response_received_endpoints_memo,
this.#page.on_response_received_actions_memo,
this.#page.sidebar_memo,
this.#page.header_memo
);
if (!memo)
throw new InnertubeError('No memo found in feed');
@@ -66,9 +63,10 @@ class Feed {
* Get all videos on a given page via memo
*/
static getVideosFromMemo(memo: Memo) {
return memo.getType<Video | GridVideo | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
return memo.getType<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
Video,
GridVideo,
ReelItem,
CompactVideo,
PlaylistVideo,
PlaylistPanelVideo,
@@ -118,7 +116,7 @@ class Feed {
/**
* Returns contents from the page.
*/
get contents() {
get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand {
const tab_content = this.#memo.getType(Tab)?.[0]?.content;
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)?.[0];
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)?.[0];
@@ -137,13 +135,13 @@ class Feed {
* Finds shelf by title.
*/
getShelf(title: string) {
return this.shelves.find((shelf) => shelf.title.toString() === title);
return this.shelves.get({ title });
}
/**
* Returns secondary contents from the page.
*/
get secondary_contents() {
get secondary_contents(): SuperParsedResult<YTNode> | undefined {
if (!this.#page.contents.is_node)
return undefined;
@@ -155,21 +153,21 @@ class Feed {
return node.secondary_contents;
}
get actions() {
get actions(): Actions {
return this.#actions;
}
/**
* Get the original page data
*/
get page() {
get page(): ParsedResponse {
return this.#page;
}
/**
* Checks if the feed has continuation.
*/
get has_continuation() {
get has_continuation(): boolean {
return (this.#memo.get('ContinuationItem') || []).length > 0;
}
@@ -183,7 +181,7 @@ class Feed {
if (this.#continuation.length === 0)
throw new InnertubeError('There are no continuations');
const response = await this.#continuation[0].endpoint.call(this.#actions, undefined, true);
const response = await this.#continuation[0].endpoint.call(this.#actions, { parse: true });
return response;
}
@@ -197,7 +195,7 @@ class Feed {
/**
* Retrieves next batch of contents and returns a new {@link Feed} object.
*/
async getContinuation() {
async getContinuation(): Promise<Feed> {
const continuation_data = await this.getContinuationData();
return new Feed(this.actions, continuation_data, true);
}

View File

@@ -1,10 +1,11 @@
import ChipCloudChip from '../parser/classes/ChipCloudChip';
import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar';
import { ObservedArray } from '../parser/helpers';
import { InnertubeError } from '../utils/Utils';
import Actions from './Actions';
import Feed from './Feed';
import type { ObservedArray } from '../parser/helpers';
import { InnertubeError } from '../utils/Utils';
import type Actions from './Actions';
class FilterableFeed extends Feed {
#chips?: ObservedArray<ChipCloudChip>;
@@ -13,9 +14,9 @@ class FilterableFeed extends Feed {
}
/**
* Get filters for the feed
* Returns the filter chips.
*/
get filter_chips() {
get filter_chips(): ObservedArray<ChipCloudChip> {
if (this.#chips)
return this.#chips || [];
@@ -30,21 +31,22 @@ class FilterableFeed extends Feed {
return this.#chips || [];
}
get filters() {
/**
* Returns available filters.
*/
get filters(): string[] {
return this.filter_chips.map((chip) => chip.text.toString()) || [];
}
/**
* Applies given filter and returns a new {@link Feed} object.
*/
async getFilteredFeed(filter: string | ChipCloudChip) {
async getFilteredFeed(filter: string | ChipCloudChip): Promise<Feed> {
let target_filter: ChipCloudChip | undefined;
if (typeof filter === 'string') {
if (!this.filters.includes(filter))
throw new InnertubeError('Filter not found', {
available_filters: this.filters
});
throw new InnertubeError('Filter not found', { available_filters: this.filters });
target_filter = this.filter_chips.find((chip) => chip.text.toString() === filter);
} else if (filter.type === 'ChipCloudChip') {
target_filter = filter;
@@ -54,10 +56,12 @@ class FilterableFeed extends Feed {
if (!target_filter)
throw new InnertubeError('Filter not found');
if (target_filter.is_selected)
return this;
const response = await target_filter.endpoint?.call(this.actions, undefined, true);
const response = await target_filter.endpoint?.call(this.actions, { parse: true });
return new Feed(this.actions, response, true);
}
}

View File

@@ -1,8 +1,10 @@
import Proto from '../proto';
import type Actions from './Actions';
import type { ApiResponse } from './Actions';
import { throwIfMissing } from '../utils/Utils';
import Actions from './Actions';
class InteractionManager {
#actions;
#actions: Actions;
constructor(actions: Actions) {
this.#actions = actions;
@@ -10,55 +12,119 @@ class InteractionManager {
/**
* Likes a given video.
* @param video_id - The video ID
*/
async like(video_id: string) {
async like(video_id: string): Promise<ApiResponse> {
throwIfMissing({ video_id });
const action = await this.#actions.engage('like/like', { video_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/like/like', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Dislikes a given video.
* @param video_id - The video ID
*/
async dislike(video_id: string) {
async dislike(video_id: string): Promise<ApiResponse> {
throwIfMissing({ video_id });
const action = await this.#actions.engage('like/dislike', { video_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/like/dislike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Removes a like/dislike.
* @param video_id - The video ID
*/
async removeLike(video_id: string) {
async removeRating(video_id: string): Promise<ApiResponse> {
throwIfMissing({ video_id });
const action = await this.#actions.engage('like/removelike', { video_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/like/removelike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Subscribes to a given channel.
* @param channel_id - The channel ID
*/
async subscribe(channel_id: string) {
async subscribe(channel_id: string): Promise<ApiResponse> {
throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/subscribe', { channel_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/subscription/subscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'EgIIAhgA'
});
return action;
}
/**
* Unsubscribes from a given channel.
* @param channel_id - The channel ID
*/
async unsubscribe(channel_id: string) {
async unsubscribe(channel_id: string): Promise<ApiResponse>{
throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/unsubscribe', { channel_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/subscription/unsubscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'CgIIAhgA'
});
return action;
}
/**
* Posts a comment on a given video.
* @param video_id - The video ID
* @param text - The comment text
*/
async comment(video_id: string, text: string) {
async comment(video_id: string, text: string): Promise<ApiResponse> {
throwIfMissing({ video_id, text });
const action = await this.#actions.engage('comment/create_comment', { video_id, text });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/comment/create_comment', {
client: 'ANDROID',
commentText: text,
createCommentParams: Proto.encodeCommentParams(video_id)
});
return action;
}
@@ -71,12 +137,11 @@ class InteractionManager {
async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) {
throwIfMissing({ text, target_language });
const response = await await this.#actions.engage('comment/perform_comment_action', {
video_id: args.video_id,
comment_id: args.comment_id,
target_language: target_language,
comment_action: 'translate',
text
const target_action = Proto.encodeCommentActionParams(22, { text, target_language, ...args });
const response = await this.#actions.execute('/comment/perform_comment_action', {
client: 'ANDROID',
actions: [ target_action ]
});
const mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload;
@@ -92,10 +157,29 @@ class InteractionManager {
/**
* Changes notification preferences for a given channel.
* Only works with channels you are subscribed to.
* @param channel_id - The channel ID.
* @param type - The notification type.
*/
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE') {
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE'): Promise<ApiResponse> {
throwIfMissing({ channel_id, type });
const action = await this.#actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const pref_types = {
PERSONALIZED: 1,
ALL: 2,
NONE: 3
};
if (!Object.keys(pref_types).includes(type.toUpperCase()))
throw new Error(`Invalid notification preference type: ${type}`);
const action = await this.#actions.execute('/notification/modify_channel_preference', {
client: 'WEB',
params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types])
});
return action;
}
}

View File

@@ -1,36 +1,36 @@
import Session from './Session';
import TrackInfo from '../parser/ytmusic/TrackInfo';
import Search from '../parser/ytmusic/Search';
import HomeFeed from '../parser/ytmusic/HomeFeed';
import Explore from '../parser/ytmusic/Explore';
import Library from '../parser/ytmusic/Library';
import Artist from '../parser/ytmusic/Artist';
import Album from '../parser/ytmusic/Album';
import Artist from '../parser/ytmusic/Artist';
import Explore from '../parser/ytmusic/Explore';
import HomeFeed from '../parser/ytmusic/HomeFeed';
import Library from '../parser/ytmusic/Library';
import Playlist from '../parser/ytmusic/Playlist';
import Recap from '../parser/ytmusic/Recap';
import Search from '../parser/ytmusic/Search';
import TrackInfo from '../parser/ytmusic/TrackInfo';
import Tab from '../parser/classes/Tab';
import Tabbed from '../parser/classes/Tabbed';
import SingleColumnMusicWatchNextResults from '../parser/classes/SingleColumnMusicWatchNextResults';
import WatchNextTabbedResults from '../parser/classes/WatchNextTabbedResults';
import SectionList from '../parser/classes/SectionList';
import Message from '../parser/classes/Message';
import MusicQueue from '../parser/classes/MusicQueue';
import PlaylistPanel from '../parser/classes/PlaylistPanel';
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf';
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf';
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection';
import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo';
import Message from '../parser/classes/Message';
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf';
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf';
import MusicQueue from '../parser/classes/MusicQueue';
import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem';
import PlaylistPanel from '../parser/classes/PlaylistPanel';
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection';
import SectionList from '../parser/classes/SectionList';
import Tab from '../parser/classes/Tab';
import { observe, ObservedArray, YTNode } from '../parser/helpers';
import { InnertubeError, throwIfMissing, generateRandomString } from '../utils/Utils';
import { observe } from '../parser/helpers';
import Proto from '../proto';
import { generateRandomString, InnertubeError, throwIfMissing } from '../utils/Utils';
import type { ObservedArray, YTNode } from '../parser/helpers';
import type Actions from './Actions';
import type Session from './Session';
class Music {
#session;
#actions;
#session: Session;
#actions: Actions;
constructor(session: Session) {
this.#session = session;
@@ -39,7 +39,7 @@ class Music {
/**
* Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
* @param target - video id or a list item.
* @param target - Video id or a list item.
*/
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
if (target instanceof MusicTwoRowItem) {
@@ -51,7 +51,7 @@ class Music {
throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target);
}
async #fetchInfoFromVideoId(video_id: string) {
async #fetchInfoFromVideoId(video_id: string): Promise<TrackInfo> {
const cpn = generateRandomString(16);
const initial_info = this.#actions.execute('/player', {
@@ -60,7 +60,7 @@ class Music {
videoId: video_id,
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player.sts
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
@@ -74,7 +74,7 @@ class Music {
return new TrackInfo(response, this.#actions, cpn);
}
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined) {
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): Promise<TrackInfo> {
if (!list_item)
throw new InnertubeError('List item cannot be undefined');
@@ -83,17 +83,17 @@ class Music {
const cpn = generateRandomString(16);
const initial_info = list_item.endpoint.callTest(this.#actions, {
const initial_info = list_item.endpoint.call(this.#actions, {
cpn,
client: 'YTMUSIC',
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player.sts
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
const continuation = list_item.endpoint.callTest(this.#actions, {
const continuation = list_item.endpoint.call(this.#actions, {
client: 'YTMUSIC',
enablePersistentPlaylistPanel: true,
override_endpoint: '/next'
@@ -105,12 +105,26 @@ class Music {
/**
* Searches on YouTube Music.
* @param query - Search query.
* @param filters - Search filters.
*/
async search(query: string, filters: {
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
} = {}): Promise<Search> {
throwIfMissing({ query });
const response = await this.#actions.search({ query, filters, client: 'YTMUSIC' });
const payload: {
query: string;
client: string;
params?: string;
} = { query, client: 'YTMUSIC' };
if (filters.type && filters.type !== 'all') {
payload.params = Proto.encodeMusicSearchFilters(filters);
}
const response = await this.#actions.execute('/search', payload);
return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' });
}
@@ -118,7 +132,11 @@ class Music {
* Retrieves the home feed.
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_home'
});
return new HomeFeed(response, this.#actions);
}
@@ -126,20 +144,30 @@ class Music {
* Retrieves the Explore feed.
*/
async getExplore(): Promise<Explore> {
const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_explore'
});
return new Explore(response);
// TODO: return new Explore(response, this.#actions);
}
/**
* Retrieves the Library.
* Retrieves the library.
*/
getLibrary() {
return new Library(this.#actions);
async getLibrary(): Promise<Library> {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_library_landing'
});
return new Library(response, this.#actions);
}
/**
* Retrieves artist's info & content.
* @param artist_id - The artist id.
*/
async getArtist(artist_id: string): Promise<Artist> {
throwIfMissing({ artist_id });
@@ -147,12 +175,17 @@ 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.browse(artist_id, { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: artist_id
});
return new Artist(response, this.#actions);
}
/**
* Retrieves album.
* @param album_id - The album id.
*/
async getAlbum(album_id: string): Promise<Album> {
throwIfMissing({ album_id });
@@ -160,12 +193,17 @@ 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.browse(album_id, { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: album_id
});
return new Album(response, this.#actions);
}
/**
* Retrieves playlist.
* @param playlist_id - The playlist id.
*/
async getPlaylist(playlist_id: string): Promise<Playlist> {
throwIfMissing({ playlist_id });
@@ -174,12 +212,18 @@ class Music {
playlist_id = `VL${playlist_id}`;
}
const response = await this.#actions.browse(playlist_id, { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: playlist_id
});
return new Playlist(response, this.#actions);
}
/**
* Retrieves up next.
* @param video_id - The video id.
* @param automix - Whether to enable automix.
*/
async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
throwIfMissing({ video_id });
@@ -190,13 +234,9 @@ class Music {
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tabs = data.contents_memo.getType(Tab);
const tab = tabs.get({ title: 'Up next' });
const tab = tabs?.[0];
if (!tab)
throw new InnertubeError('Could not find target tab.');
@@ -214,7 +254,7 @@ class Music {
if (!automix_preview_video)
throw new InnertubeError('Automix item not found');
const page = await automix_preview_video.playlist_video?.endpoint.callTest(this.#actions, {
const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
videoId: video_id,
client: 'YTMUSIC',
parse: true
@@ -231,6 +271,7 @@ class Music {
/**
* Retrieves related content.
* @param video_id - The video id.
*/
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
throwIfMissing({ video_id });
@@ -241,29 +282,23 @@ class Music {
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tabs = data.contents_memo.getType(Tab);
const tab = tabs.get({ title: 'Related' });
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (!page)
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
const shelves = page.contents.item().as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf);
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
return shelves;
}
/**
* Retrieves song lyrics.
* @param video_id - The video id.
*/
async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
throwIfMissing({ video_id });
@@ -274,26 +309,19 @@ class Music {
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tabs = data.contents_memo.getType(Tab);
const tab = tabs.get({ title: 'Lyrics' });
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
if (!page)
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (page.contents.item().key('type').string() === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text, video_id);
const section_list = page.contents.item().as(SectionList).contents.array();
const section_list = page.contents.item().as(SectionList).contents;
return section_list.firstOfType(MusicDescriptionShelf);
}
@@ -311,8 +339,9 @@ class Music {
/**
* Retrieves search suggestions for the given query.
* @param query - The query.
*/
async getSearchSuggestions(query: string) {
async getSearchSuggestions(query: string): Promise<ObservedArray<YTNode>> {
const response = await this.#actions.execute('/music/get_search_suggestions', {
parse: true,
input: query,
@@ -321,7 +350,7 @@ class Music {
const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection)?.[0];
if (!search_suggestions_section.contents.is_array)
if (!search_suggestions_section?.contents.is_array)
return observe([] as YTNode[]);
return search_suggestions_section?.contents.array();

View File

@@ -1,6 +1,6 @@
import Session from './Session';
import Constants from '../utils/Constants';
import { OAuthError, uuidv4 } from '../utils/Utils';
import type Session from './Session';
export interface Credentials {
/**
@@ -41,7 +41,7 @@ class OAuth {
/**
* Starts the auth flow in case no valid credentials are available.
*/
async init(credentials?: Credentials) {
async init(credentials?: Credentials): Promise<void> {
this.#credentials = credentials;
if (this.validateCredentials()) {
@@ -55,13 +55,13 @@ class OAuth {
}
}
async cacheCredentials() {
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() {
async #loadCachedCredentials(): Promise<boolean> {
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
if (!data) return false;
@@ -82,14 +82,14 @@ class OAuth {
return true;
}
async removeCache() {
async removeCache(): Promise<void> {
await this.#session.cache?.remove('youtubei_oauth_credentials');
}
/**
* Asks the server for a user code and verification URL.
*/
async #getUserCode() {
async #getUserCode(): Promise<void> {
this.#identity = await this.#getClientIdentity();
const data = {
@@ -117,7 +117,7 @@ class OAuth {
/**
* Polls the authorization server until access is granted by the user.
*/
#startPolling(device_code: string) {
#startPolling(device_code: string): void {
const poller = setInterval(async () => {
const data = {
...this.#identity,
@@ -176,13 +176,13 @@ class OAuth {
/**
* Refresh access token if the same has expired.
*/
async refreshIfRequired() {
async refreshIfRequired(): Promise<void> {
if (this.has_access_token_expired) {
await this.#refreshAccessToken();
}
}
async #refreshAccessToken() {
async #refreshAccessToken(): Promise<void> {
if (!this.#credentials) return;
this.#identity = await this.#getClientIdentity();
@@ -215,7 +215,7 @@ class OAuth {
});
}
async revokeCredentials() {
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), {
@@ -226,7 +226,7 @@ class OAuth {
/**
* Retrieves client identity from YouTube TV.
*/
async #getClientIdentity() {
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();
@@ -241,7 +241,6 @@ class OAuth {
.replace(/\n/g, '')
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
// TODO: check this.
const groups = client_identity?.groups;
if (!groups)
@@ -250,7 +249,7 @@ class OAuth {
return groups;
}
get credentials() {
get credentials(): Credentials | undefined {
return this.#credentials;
}

View File

@@ -1,13 +1,13 @@
import { FetchFunction } from '../utils/HTTPClient';
import { getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils';
import Constants from '../utils/Constants';
import UniversalCache from '../utils/Cache';
// See https://github.com/LuanRT/Jinter
// See: https://github.com/LuanRT/Jinter
import Jinter from 'jintr';
import type { FetchFunction } from '../utils/HTTPClient';
export default class Player {
#nsig_sc;
#sig_sc;
@@ -23,7 +23,7 @@ export default class Player {
this.#player_id = player_id;
}
static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch) {
static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch): Promise<Player> {
const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
const res = await fetch(url);
@@ -66,7 +66,7 @@ export default class Player {
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
}
decipher(url?: string, signature_cipher?: string, cipher?: string) {
decipher(url?: string, signature_cipher?: string, cipher?: string): string {
url = url || signature_cipher || cipher;
if (!url)
@@ -108,7 +108,7 @@ export default class Player {
return url_components.toString();
}
static async fromCache(cache: UniversalCache, player_id: string) {
static async fromCache(cache: UniversalCache, player_id: string): Promise<Player | null> {
const buffer = await cache.get(player_id);
if (!buffer)
@@ -134,13 +134,13 @@ export default class Player {
return new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
}
static async fromSource(cache: UniversalCache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) {
static async fromSource(cache: UniversalCache | 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);
await player.cache(cache);
return player;
}
async cache(cache?: UniversalCache) {
async cache(cache?: UniversalCache): Promise<void> {
if (!cache) return;
const encoder = new TextEncoder();
@@ -161,22 +161,22 @@ export default class Player {
await cache.set(this.#player_id, new Uint8Array(buffer));
}
static extractSigTimestamp(data: string) {
static extractSigTimestamp(data: string): number {
return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0');
}
static extractSigSourceCode(data: string) {
static extractSigSourceCode(data: string): string {
const calls = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
const obj_name = calls?.split('.')?.[0]?.replace(';', '');
const functions = getStringBetweenStrings(data, `var ${obj_name}=`, '};');
const obj_name = calls?.split(/\.|\[/)?.[0]?.replace(';', '')?.trim();
const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};');
if (!functions || !calls)
console.warn(new PlayerError('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);`;
return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`;
}
static extractNSigSourceCode(data: string) {
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)
@@ -185,23 +185,23 @@ export default class Player {
return sc;
}
get url() {
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() {
get sts(): number {
return this.#sig_sc_timestamp;
}
get nsig_sc() {
get nsig_sc(): string {
return this.#nsig_sc;
}
get sig_sc() {
get sig_sc(): string {
return this.#sig_sc;
}
static get LIBRARY_VERSION() {
static get LIBRARY_VERSION(): number {
return 2;
}
}

View File

@@ -1,11 +1,11 @@
import type Feed from './Feed';
import type Actions from './Actions';
import Playlist from '../parser/youtube/Playlist';
import Actions from './Actions';
import Feed from './Feed';
import { InnertubeError, throwIfMissing } from '../utils/Utils';
class PlaylistManager {
#actions;
#actions: Actions;
constructor(actions: Actions) {
this.#actions = actions;
@@ -13,11 +13,20 @@ class PlaylistManager {
/**
* Creates a playlist.
* @param title - The title of the playlist.
* @param video_ids - An array of video IDs to add to the playlist.
*/
async create(title: string, video_ids: string[]) {
async create(title: string, video_ids: string[]): Promise<{ success: boolean; status_code: number; playlist_id: string; data: any }> {
throwIfMissing({ title, video_ids });
const response = await this.#actions.execute('/playlist/create', { title, ids: video_ids, parse: false });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('/playlist/create', {
title,
ids: video_ids,
parse: false
});
return {
success: response.success,
@@ -29,10 +38,14 @@ class PlaylistManager {
/**
* Deletes a given playlist.
* @param playlist_id - The playlist ID.
*/
async delete(playlist_id: string) {
async delete(playlist_id: string): Promise<{ playlist_id: string; success: boolean; status_code: number; data: any }> {
throwIfMissing({ playlist_id });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id });
return {
@@ -45,10 +58,15 @@ class PlaylistManager {
/**
* Adds videos to a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to add to the playlist.
*/
async addVideos(playlist_id: string, video_ids: string[]) {
async addVideos(playlist_id: string, video_ids: string[]): 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 response = await this.#actions.execute('/browse/edit_playlist', {
playlistId: playlist_id,
actions: video_ids.map((id) => ({
@@ -66,11 +84,20 @@ class PlaylistManager {
/**
* Removes videos from a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to remove from the playlist.
*/
async removeVideos(playlist_id: string, video_ids: string[]) {
async removeVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
throwIfMissing({ playlist_id, video_ids });
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const info = await this.#actions.execute('/browse', {
browseId: `VL${playlist_id}`,
parse: true
});
const playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
@@ -115,11 +142,21 @@ class PlaylistManager {
/**
* Moves a video to a new position within a given playlist.
* @param playlist_id - The playlist ID.
* @param moved_video_id - The video ID to move.
* @param predecessor_video_id - The video ID to move the moved video before.
*/
async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string) {
async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string): Promise<{ playlist_id: string; action_result: any; }> {
throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id });
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const info = await this.#actions.execute('/browse', {
browseId: `VL${playlist_id}`,
parse: true
});
const playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
@@ -157,7 +194,10 @@ class PlaylistManager {
movedSetVideoIdPredecessor: set_video_id_1
});
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
const response = await this.#actions.execute('/browse/edit_playlist', {
...payload,
parse: false
});
return {
playlist_id,

View File

@@ -1,26 +1,32 @@
import Player from './Player';
import Proto from '../proto/index';
import Actions from './Actions';
import Constants from '../utils/Constants';
import UniversalCache from '../utils/Cache';
import Constants, { CLIENTS } from '../utils/Constants';
import EventEmitterLike from '../utils/EventEmitterLike';
import Actions from './Actions';
import Player from './Player';
import HTTPClient, { FetchFunction } from '../utils/HTTPClient';
import { DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth';
import Proto from '../proto';
export enum ClientType {
WEB = 'WEB',
MUSIC = 'WEB_REMIX',
ANDROID = 'ANDROID',
ANDROID_MUSIC = 'ANDROID_MUSIC'
ANDROID_MUSIC = 'ANDROID_MUSIC',
ANDROID_CREATOR = 'ANDROID_CREATOR',
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
}
export interface Context {
client: {
hl: string;
gl: string;
remoteHost: string;
remoteHost?: string;
screenDensityFloat: number;
screenHeightPoints: number;
screenPixelDensity: number;
screenWidthPoints: number;
visitorData: string;
userAgent: string;
clientName: string;
@@ -33,15 +39,19 @@ export interface Context {
clientFormFactor: string;
userInterfaceTheme: string;
timeZone: string;
browserName: string;
browserVersion: string;
browserName?: string;
browserVersion?: string;
originalUrl: string;
deviceMake: string;
deviceModel: string;
utcOffsetMinutes: number;
};
user: {
lockedSafetyMode: false;
enableSafetyMode: boolean;
lockedSafetyMode: boolean;
};
thirdParty?: {
embedUrl: string;
};
request: {
useSsl: true;
@@ -49,30 +59,83 @@ export interface Context {
}
export interface SessionOptions {
/**
* Language.
*/
lang?: string;
/**
* Geolocation.
*/
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.
*/
account_index?: number;
/**
* 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;
/**
* Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content.
*/
enable_safety_mode?: boolean;
/**
* Specifies whether to generate the session data locally or retrieve it from YouTube.
* This can be useful if you need more performance.
*/
generate_session_locally?: boolean;
/**
* Platform to use for the session.
*/
device_category?: DeviceCategory;
/**
* InnerTube client type.
*/
client_type?: ClientType;
/**
* The time zone.
*/
timezone?: string;
/**
* Used to cache the deciphering functions from the JS player.
*/
cache?: UniversalCache;
/**
* YouTube cookies.
*/
cookie?: string;
/**
* Fetch function to use.
*/
fetch?: FetchFunction;
}
export interface SessionData {
context: Context;
api_key: string;
api_version: string;
}
export default class Session extends EventEmitterLike {
#api_version;
#key;
#context;
#player;
#api_version: string;
#key: string;
#context: Context;
#account_index: number;
#player?: Player;
oauth;
http;
logged_in;
actions;
cache;
oauth: OAuth;
http: HTTPClient;
logged_in: boolean;
actions: Actions;
cache?: UniversalCache;
constructor(context: Context, api_key: string, api_version: string, player: Player, cookie?: string, fetch?: FetchFunction, cache?: UniversalCache) {
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: UniversalCache) {
super();
this.#context = context;
this.#account_index = account_index;
this.#key = api_key;
this.#api_version = api_version;
this.#player = player;
@@ -101,32 +164,69 @@ export default class Session extends EventEmitterLike {
}
static async create(options: SessionOptions = {}) {
const { context, api_key, api_version } = await Session.getSessionData(options.lang, options.device_category, options.client_type, options.timezone, options.fetch);
return new Session(context, api_key, api_version, await Player.create(options.cache, options.fetch), options.cookie, options.fetch, options.cache);
const { context, api_key, api_version, account_index } = await Session.getSessionData(
options.lang,
options.location,
options.account_index,
options.enable_safety_mode,
options.generate_session_locally,
options.device_category,
options.client_type,
options.timezone,
options.fetch
);
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
);
}
static async getSessionData(
lang = 'en-US',
lang = '',
location = '',
account_index = 0,
enable_safety_mode = false,
generate_session_locally = false,
device_category: DeviceCategory = 'desktop',
client_name: ClientType = ClientType.WEB,
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
fetch: FetchFunction = globalThis.fetch
) {
let session_data: SessionData;
if (generate_session_locally) {
session_data = this.#generateSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode });
} else {
session_data = await this.#retrieveSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode }, fetch);
}
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;
}, fetch: FetchFunction = globalThis.fetch): Promise<SessionData> {
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
const res = await fetch(url, {
headers: {
'accept-language': lang,
'accept-language': options.lang || 'en-US',
'user-agent': getRandomUserAgent('desktop'),
'accept': '*/*',
'referer': 'https://www.youtube.com/sw.js',
'cookie': `PREF=tz=${tz.replace('/', '.')}`
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')}`
}
});
if (!res.ok) {
throw new SessionError(`Failed to get session data: ${res.status}`);
}
if (!res.ok)
throw new SessionError(`Failed to retrieve session data: ${res.status}`);
const text = await res.text();
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
@@ -137,33 +237,34 @@ export default class Session extends EventEmitterLike {
const [ [ device_info ], api_key ] = ytcfg;
const id = generateRandomString(11);
const timestamp = Math.floor(Date.now() / 1000);
const visitor_data = Proto.encodeVisitorData(id, timestamp);
const context: Context = {
client: {
hl: device_info[0],
gl: device_info[2],
gl: options.location || device_info[2],
remoteHost: device_info[3],
visitorData: visitor_data,
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: device_info[13],
userAgent: device_info[14],
clientName: client_name,
clientName: options.client_name,
clientVersion: device_info[16],
osName: device_info[17],
osVersion: device_info[18],
platform: device_category.toUpperCase(),
platform: options.device_category.toUpperCase(),
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
timeZone: device_info[79],
timeZone: device_info[79] || options.time_zone,
browserName: device_info[86],
browserVersion: device_info[87],
originalUrl: Constants.URLS.API.BASE,
originalUrl: Constants.URLS.YT_BASE,
deviceMake: device_info[11],
deviceModel: device_info[12],
utcOffsetMinutes: new Date().getTimezoneOffset()
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false
},
request: {
@@ -174,6 +275,52 @@ export default class Session extends EventEmitterLike {
return { context, api_key, api_version };
}
static #generateSessionData(options: {
lang: string;
location: string;
time_zone: string;
device_category: DeviceCategory;
client_name: string;
enable_safety_mode: boolean
}): SessionData {
const id = generateRandomString(11);
const timestamp = Math.floor(Date.now() / 1000);
const context: Context = {
client: {
hl: options.lang || 'en',
gl: options.location || 'US',
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: Proto.encodeVisitorData(id, timestamp),
userAgent: getRandomUserAgent('desktop'),
clientName: options.client_name,
clientVersion: CLIENTS.WEB.VERSION,
osName: 'Windows',
osVersion: '10.0',
platform: options.device_category.toUpperCase(),
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
timeZone: options.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);
@@ -205,9 +352,12 @@ export default class Session extends EventEmitterLike {
});
}
async signOut() {
/**
* Signs out of the current account and revokes the credentials.
*/
async signOut(): Promise<Response | undefined> {
if (!this.logged_in)
throw new InnertubeError('You are not signed in');
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.oauth.revokeCredentials();
this.logged_in = false;
@@ -215,31 +365,41 @@ export default class Session extends EventEmitterLike {
return response;
}
get key() {
/**
* InnerTube API key.
*/
get key(): string {
return this.#key;
}
get api_version() {
/**
* InnerTube API version.
*/
get api_version(): string {
return this.#api_version;
}
get client_version() {
get client_version(): string {
return this.#context.client.clientVersion;
}
get client_name() {
get client_name(): string {
return this.#context.client.clientName;
}
get context() {
get account_index(): number {
return this.#account_index;
}
get context(): Context {
return this.#context;
}
get player() {
get player(): Player | undefined {
return this.#player;
}
get lang() {
get lang(): string {
return this.#context.client.hl;
}
}

View File

@@ -1,15 +1,16 @@
import Proto from '../proto';
import Session from './Session';
import { AxioslikeResponse } from './Actions';
import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
import { Constants } from '../utils';
import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
export interface UploadResult {
import type { ApiResponse } from './Actions';
import type Session from './Session';
interface UploadResult {
status: string;
scottyResourceId: string;
}
export interface InitialUploadData {
interface InitialUploadData {
frontend_upload_id: string;
upload_id: string;
upload_url: string;
@@ -18,6 +19,17 @@ export interface InitialUploadData {
}
export interface VideoMetadata {
title?: string;
description?: string;
tags?: string[];
category?: number;
license?: string;
age_restricted?: boolean;
made_for_kids?: boolean;
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
}
export interface UploadedVideoMetadata {
title?: string;
description?: string;
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
@@ -25,7 +37,7 @@ export interface VideoMetadata {
}
class Studio {
#session;
#session: Session;
constructor(session: Session) {
this.#session = session;
@@ -39,7 +51,10 @@ class Studio {
* const response = await yt.studio.setThumbnail(video_id, buffer);
* ```
*/
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<AxioslikeResponse> {
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
if (!video_id || !buffer)
throw new MissingParamError('One or more parameters are missing.');
@@ -53,6 +68,34 @@ class Studio {
return response;
}
/**
* Updates given video's metadata.
* @example
* ```ts
* const response = await yt.studio.updateVideoMetadata('videoid', {
* tags: [ 'astronomy', 'NASA', 'APOD' ],
* title: 'Artemis Mission',
* description: 'A nicely written description...',
* category: 27,
* license: 'creative_commons'
* // ...
* });
* ```
*/
async updateVideoMetadata(video_id: string, metadata: VideoMetadata): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
protobuf: true,
serialized_data: payload
});
return response;
}
/**
* Uploads a video to YouTube.
* @example
@@ -61,7 +104,10 @@ class Studio {
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
* ```
*/
async upload(file: BodyInit, metadata: VideoMetadata = {}): Promise<AxioslikeResponse> {
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const initial_data = await this.#getInitialUploadData();
const upload_result = await this.#uploadVideo(initial_data.upload_url, file);
@@ -128,7 +174,7 @@ class Studio {
return data;
}
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: VideoMetadata) {
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadata) {
const metadata_payload = {
resourceId: {
scottyResourceId: {

View File

@@ -1,11 +1,13 @@
import Tab from '../parser/classes/Tab';
import { InnertubeError } from '../utils/Utils';
import Actions from './Actions';
import Feed from './Feed';
import { InnertubeError } from '../utils/Utils';
import type Actions from './Actions';
import type { ObservedArray } from '../parser/helpers';
class TabbedFeed extends Feed {
#tabs;
#actions;
#tabs: ObservedArray<Tab>;
#actions: Actions;
constructor(actions: Actions, data: any, already_parsed = false) {
super(actions, data, already_parsed);
@@ -13,11 +15,11 @@ class TabbedFeed extends Feed {
this.#tabs = this.page.contents_memo.getType(Tab);
}
get tabs() {
get tabs(): string[] {
return this.#tabs.map((tab) => tab.title.toString());
}
async getTab(title: string) {
async getTabByName(title: string): Promise<TabbedFeed> {
const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
if (!tab)
@@ -28,13 +30,24 @@ class TabbedFeed extends Feed {
const response = await tab.endpoint.call(this.#actions);
if (!response)
throw new InnertubeError('Failed to call endpoint');
return new TabbedFeed(this.#actions, response.data, false);
}
async getTabByURL(url: string): Promise<TabbedFeed> {
const tab = this.#tabs.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url);
if (!tab)
throw new InnertubeError(`Tab "${url}" not found`);
if (tab.selected)
return this;
const response = await tab.endpoint.call(this.#actions);
return new TabbedFeed(this.#actions, response.data, false);
}
get title() {
get title(): string | undefined {
return this.page.contents_memo.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
}
}

View File

@@ -1,6 +1,25 @@
# Parser
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data. Also [drastically improves](https://github.com/LuanRT/YouTube.js/blob/main/lib/parser/youtube/Library.js#L44) how API calls are made and handled.
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data. Also [drastically improves](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube/Library.ts#L69) how API calls are made and handled.
<ol>
<li>
<a href="#api">API</a>
</li>
<li>
<a href="#usage">Usage</a>
<ul>
<li><a href="#observedarray">ObservedArray</a></li>
<li><a href="#superparsedresponse">SuperParsedResponse</a></li>
<li><a href="#ytnode">YTNode</a></li>
<li><a href="#memo">Memo</a></li>
</ul>
</li>
<li><a href="#adding-new-nodes">Adding new nodes</a></li>
<li><a href="#how-it-works">How it works</a></li>
</ol>
___
## API
@@ -20,7 +39,7 @@ Responsible for parsing individual nodes.
| --- | --- | --- |
| data | `any` | The data |
| requireArray | `?boolean` | Whether the response should be an array |
| validTypes | `YTNodeConstructor<T> | YTNodeConstructor<T>[] | undefined` | The types of YTNodes are allowed |
| validTypes | `YTNodeConstructor<T> \| YTNodeConstructor<T>[] \| undefined` | Types of `YTNode` allowed |
When `requireArray` is `true`, the response will be an `ObservedArray<YTNodes>`.
@@ -43,6 +62,8 @@ Unlike `parse`, this can be used to parse the entire response object.
| --- | --- | --- |
| data | `object` | Raw InnerTube response |
## Usage
## ObservedArray
You may use `ObservedArray<T extends YTNode>` as a normal array, but it provides additional methods for typesafe access and casting.
@@ -58,13 +79,13 @@ const firstVideo = feed.firstOfType(GridVideo);
// We may cast the whole array to a GridVideo[] and throw if we have any non-GridVideo elements:
const allVideos = feed.as(GridVideo);
// There's some extra methods for ObservedArray<T extends YTNode>
// There are some extra methods for ObservedArray<T extends YTNode>
// which we use internally but not documented here (yet).
// see the source code for more details.
```
## SuperParsedResponse
Represents a parsed response in an unknown state. Either a `YTNode` or a `ObservedArray<YTNode>` or `null`.
Represents a parsed response in an unknown state. Either a `YTNode` or an `ObservedArray<YTNode>` or `null`.
You will need to assert the type and unwrap the response to get the actual value.
@@ -116,14 +137,14 @@ if (node.is(TwoColumnSearchResults, VideoList)) {
```
### Accessing properties without casting
Sometimes multiple nodes have the same properties and we don't want to check the type of the node before accessing the property, for example the property "contents" is used by many node types, and we may add more in the future, as such we want to only assert the property instead of casting to a specific type.
Sometimes multiple nodes have the same properties and we don't want to check the type of the node before accessing the property, for example, the property "contents" is used by many node types, and we may add more in the future, as such we want to only assert the property instead of casting to a specific type.
```ts
// Accesing a property on a node which you aren't sure if it exists.
// Accessing a property on a node which you aren't sure if it exists.
const prop = node.key("contents");
// This returns the value wrapped into a `Maybe` type
// which you can use to find the type of the value
// note however, this throws an error if the key doesn't exist
// note, however, this throws an error if the key doesn't exist
// we may want to check for the key before accessing it.
if (node.hasKey("contents")) {
const prop = node.key("contents");
@@ -146,7 +167,7 @@ if (prop.isInstanceof(Text)) {
});
}
// There's some special methods for using with the parser —
// There are some special methods for using with the parser —
// such as getting the value as a YTNode.
const prop = node.key("contents");
if (prop.isNode()) {
@@ -171,7 +192,7 @@ const prop = node.key("contents");
if (prop.isObserved()) {
const array = prop.observed();
// Now we may use the all the ObservedArray methods as normal,
// Now we may use all the ObservedArray methods as normal,
// like finding nodes of a certain type for example.
const results = array.filterType(GridVideo);
}
@@ -187,7 +208,7 @@ if (prop.isParsed()) {
const videos = results.filterType(Video);
}
// Sometimes we just want to debug something and not interested in finding the type.
// Sometimes we just want to debug something and are not interested in finding the type.
// This will, however, warn you when being used.
const prop = node.key("contents");
const value = prop.any();
@@ -200,7 +221,7 @@ if (prop.isArray()) {
// This will return Maybe[]
}
// Or if you want zero typesafety you can use the `array` method.
// Or if you want zero type safety you can use the `array` method.
const prop = node.key("contents");
if (prop.isArray()) {
const array = prop.array();
@@ -221,13 +242,16 @@ const videos = response.contents_memo.getType(Video);
`Memo` extends `Map<string, YTNode[]>` and can be used as a normal `Map` too if you want.
## Adding new nodes
Instructions can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md).
## How it works
If you decompile a YouTube client and analize it for a while you will notice that it has classes named `protos/youtube/api/innertube/MusicItemRenderer`, `protos/youtube/api/innertube/SectionListRenderer`, etc.
If you decompile a YouTube client and analyze it for a while you will notice that it has classes named `protos/youtube/api/innertube/MusicItemRenderer`, `protos/youtube/api/innertube/SectionListRenderer`, etc.
These classes are used to parse objects from the response, map them into models and generate the UI. The website works in a similar way, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
These classes are used to parse objects from the response, map them into models and generate the UI. The website works similarly, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
Here we're taking a similar approach, the parser goes through all the renderers and parses their inner element(s). The final result is a nicely structured JSON, and on top of that it also parses navigation endpoints which allows us to make an API call with all required parameters in one line and even emulate client actions (eg; clicking a button).
Here we're taking a similar approach, the parser goes through all the renderers and parses their inner element(s). The final result is a nicely structured JSON, and on top of that, it also parses navigation endpoints which allow us to make an API call with all required parameters in one line and even emulate client actions (eg; clicking a button).
Here is your average, arguably ugly InnerTube response:
<details>

View File

@@ -6,17 +6,21 @@ import { YTNode } from '../helpers';
class Button extends YTNode {
static type = 'Button';
text: string;
text?: string;
label;
tooltip;
icon_type;
label?: string;
tooltip?: string;
icon_type?: string;
is_disabled?: boolean;
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.text = new Text(data.text).toString();
if (data.text) {
this.text = new Text(data.text).toString();
}
if (data.accessibility?.label) {
this.label = data.accessibility?.label;
@@ -30,6 +34,10 @@ class Button extends YTNode {
this.icon_type = data.icon?.iconType;
}
if (Reflect.has(data, 'isDisabled')) {
this.is_disabled = data.isDisabled;
}
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command);
}
}

View File

@@ -1,20 +1,28 @@
import Parser from '../index';
import Author from './misc/Author';
import Thumbnail from './misc/Thumbnail';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import type Button from './Button';
import type ChannelHeaderLinks from './ChannelHeaderLinks';
import type SubscribeButton from './SubscribeButton';
import { YTNode } from '../helpers';
class C4TabbedHeader extends YTNode {
static type = 'C4TabbedHeader';
author;
banner;
tv_banner;
mobile_banner;
subscribers;
sponsor_button;
subscribe_button;
header_links;
author: Author;
banner: Thumbnail[];
tv_banner: Thumbnail[];
mobile_banner: Thumbnail[];
subscribers: Text;
videos_count: Text;
sponsor_button: Button | null;
subscribe_button: SubscribeButton | null;
header_links: ChannelHeaderLinks | null;
channel_handle: Text;
channel_id: string;
constructor(data: any) {
super();
@@ -23,13 +31,16 @@ class C4TabbedHeader extends YTNode {
navigationEndpoint: data.navigationEndpoint
}, data.badges, data.avatar);
this.banner = data.banner ? Thumbnail.fromResponse(data.banner) : [];
this.tv_banner = data.tvBanner ? Thumbnail.fromResponse(data.tvBanner) : [];
this.mobile_banner = data.mobileBanner ? Thumbnail.fromResponse(data.mobileBanner) : [];
this.banner = Thumbnail.fromResponse(data.banner);
this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);
this.subscribers = new Text(data.subscriberCountText);
this.sponsor_button = data.sponsorButton ? Parser.parseItem(data.sponsorButton) : undefined;
this.subscribe_button = data.subscribeButton ? Parser.parseItem(data.subscribeButton) : undefined;
this.header_links = data.headerLinks ? Parser.parse(data.headerLinks) : undefined;
this.videos_count = new Text(data.videosCountText);
this.sponsor_button = Parser.parseItem<Button>(data.sponsorButton);
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
this.header_links = Parser.parseItem<ChannelHeaderLinks>(data.headerLinks);
this.channel_handle = new Text(data.channelHandleText);
this.channel_id = data.channelId;
}
}

View File

@@ -6,8 +6,8 @@ class Card extends YTNode {
teaser;
content;
card_id: string;
feature: string;
card_id: string | null;
feature: string | null;
cue_ranges: {
start_card_active_ms: string;
@@ -18,10 +18,10 @@ class Card extends YTNode {
constructor(data: any) {
super();
this.teaser = Parser.parse(data.teaser);
this.content = Parser.parse(data.content);
this.card_id = data.cardId;
this.feature = data.feature;
this.teaser = Parser.parseItem(data.teaser);
this.content = Parser.parseItem(data.content);
this.card_id = data.cardId || null;
this.feature = data.feature || null;
this.cue_ranges = data.cueRanges.map((cr: any) => ({
start_card_active_ms: cr.startCardActiveMs,
@@ -32,4 +32,4 @@ class Card extends YTNode {
}
}
export default Card;
export default Card;

View File

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

View File

@@ -0,0 +1,25 @@
import Parser from '..';
import { YTNode } from '../helpers';
import Thumbnail from './misc/Thumbnail';
class CarouselItem extends YTNode {
static type = 'CarouselItem';
items: YTNode[];
background_color: string;
layout_style: string;
pagination_thumbnails: Thumbnail[];
paginator_alignment: string;
constructor (data: any) {
super();
this.items = Parser.parseArray(data.carouselItems);
this.background_color = data.backgroundColor;
this.layout_style = data.layoutStyle;
this.pagination_thumbnails = Thumbnail.fromResponse(data.paginationThumbnails);
this.paginator_alignment = data.paginatorAlignment;
}
}
export default CarouselItem;

View File

@@ -1,6 +1,11 @@
import Parser from '..';
import Text from './misc/Text';
import Author from './misc/Author';
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text';
import type SubscribeButton from './SubscribeButton';
import { YTNode } from '../helpers';
class Channel extends YTNode {
@@ -10,7 +15,10 @@ class Channel extends YTNode {
author: Author;
subscribers: Text;
videos: Text;
long_byline: Text;
short_byline: Text;
endpoint: NavigationEndpoint;
subscribe_button: SubscribeButton | null;
description_snippet: Text;
constructor(data: any) {
@@ -22,9 +30,13 @@ 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);
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<SubscribeButton>(data.subscribeButton);
this.description_snippet = new Text(data.descriptionSnippet);
}
}

View File

@@ -1,7 +1,11 @@
import Parser from '../index';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text';
import Parser from '../index';
import type Button from './Button';
import { YTNode } from '../helpers';
class ChannelAboutFullMetadata extends YTNode {
@@ -11,13 +15,20 @@ class ChannelAboutFullMetadata extends YTNode {
name: Text;
avatar: Thumbnail[];
canonical_channel_url: string;
primary_links: {
endpoint: NavigationEndpoint;
icon: Thumbnail[];
title: Text;
}[];
views: Text;
joined: Text;
description: Text;
email_reveal: NavigationEndpoint;
can_reveal_email: boolean;
country: Text;
buttons;
buttons: Button[];
constructor(data: any) {
super();
@@ -25,13 +36,20 @@ class ChannelAboutFullMetadata extends YTNode {
this.name = new Text(data.title);
this.avatar = Thumbnail.fromResponse(data.avatar);
this.canonical_channel_url = data.canonicalChannelUrl;
this.primary_links = data.primaryLinks.map((link: any) => ({
endpoint: new NavigationEndpoint(link.navigationEndpoint),
icon: Thumbnail.fromResponse(link.icon),
title: new Text(link.title)
}));
this.views = new Text(data.viewCountText);
this.joined = 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.parse(data.actionButtons);
this.buttons = Parser.parseArray<Button>(data.actionButtons);
}
}

View File

@@ -0,0 +1,21 @@
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import { YTNode } from '../helpers';
class Chapter extends YTNode {
static type = 'Chapter';
title: Text;
time_range_start_millis: number;
thumbnail: Thumbnail[];
constructor(data: any) {
super();
this.title = new Text(data.title);
this.time_range_start_millis = data.timeRangeStartMillis;
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
}
}
export default Chapter;

View File

@@ -0,0 +1,27 @@
import { YTNode } from '../helpers';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
class CompactStation extends YTNode {
static type = 'CompactStation';
title: Text;
description: Text;
video_count: Text;
endpoint: NavigationEndpoint;
thumbnail: Thumbnail[];
constructor(data: any) {
super();
this.title = new Text(data.title);
this.description = new Text(data.description);
this.video_count = new Text(data.videoCountText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
}
}
export default CompactStation;

View File

@@ -4,6 +4,8 @@ import Author from './misc/Author';
import { timeToSeconds } from '../../utils/Utils';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import type Menu from './menus/Menu';
import { YTNode } from '../helpers';
class CompactVideo extends YTNode {
@@ -25,7 +27,7 @@ class CompactVideo extends YTNode {
thumbnail_overlays;
endpoint: NavigationEndpoint;
menu;
menu: Menu | null;
constructor(data: any) {
super();
@@ -43,9 +45,9 @@ class CompactVideo extends YTNode {
seconds: timeToSeconds(new Text(data.lengthText).toString())
};
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.menu = Parser.parse(data.menu);
this.menu = Parser.parseItem<Menu>(data.menu);
}
get best_thumbnail() {

View File

@@ -0,0 +1,19 @@
import Parser from '..';
import { YTNode } from '../helpers';
import type Button from './Button';
import type MultiMarkersPlayerBar from './MultiMarkersPlayerBar';
class DecoratedPlayerBar extends YTNode {
static type = 'DecoratedPlayerBar';
player_bar: MultiMarkersPlayerBar | null;
player_bar_action_button: Button | null;
constructor(data: any) {
super();
this.player_bar = Parser.parseItem<MultiMarkersPlayerBar>(data.playerBar);
this.player_bar_action_button = Parser.parseItem<Button>(data.playerBarActionButton);
}
}
export default DecoratedPlayerBar;

View File

@@ -0,0 +1,38 @@
import Parser from '..';
import { YTNode } from '../helpers';
import Text from './misc/Text';
import NavigationEndpoint from './NavigationEndpoint';
class DefaultPromoPanel extends YTNode {
static type = 'DefaultPromoPanel';
title: Text;
description: Text;
endpoint: NavigationEndpoint;
large_form_factor_background_thumbnail;
small_form_factor_background_thumbnail;
scrim_color_values: number[];
min_panel_display_duration_ms: number;
min_video_play_duration_ms: number;
scrim_duration: number;
metadata_order: string;
panel_layout: string;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.description = new Text(data.description);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.large_form_factor_background_thumbnail = Parser.parseItem(data.largeFormFactorBackgroundThumbnail);
this.small_form_factor_background_thumbnail = Parser.parseItem(data.smallFormFactorBackgroundThumbnail);
this.scrim_color_values = data.scrimColorValues;
this.min_panel_display_duration_ms = data.minPanelDisplayDurationMs;
this.min_video_play_duration_ms = data.minVideoPlayDurationMs;
this.scrim_duration = data.scrimDuration;
this.metadata_order = data.metadataOrder;
this.panel_layout = data.panelLayout;
}
}
export default DefaultPromoPanel;

View File

@@ -0,0 +1,41 @@
import Parser from '..';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import Button from './Button';
import HorizontalCardList from './HorizontalCardList';
import { YTNode } from '../helpers';
class ExpandableMetadata extends YTNode {
static type = 'ExpandableMetadata';
header: {
collapsed_title: Text;
collapsed_thumbnail: Thumbnail[];
collapsed_label: Text;
expanded_title: Text;
};
expanded_content: HorizontalCardList | null;
expand_button: Button | null;
collapse_button: Button | null;
constructor(data: any) {
super();
this.header = {
collapsed_title: new Text(data.header.collapsedTitle),
collapsed_thumbnail: Thumbnail.fromResponse(data.header.collapsedThumbnail),
collapsed_label: new Text(data.header.collapsedLabel),
expanded_title: new Text(data.header.expandedTitle)
};
this.expanded_content = Parser.parseItem<HorizontalCardList>(data.expandedContent);
this.expand_button = Parser.parseItem<Button>(data.expandButton);
this.collapse_button = Parser.parseItem<Button>(data.collapseButton);
}
}
export default ExpandableMetadata;

View File

@@ -15,7 +15,7 @@ class ExpandableTab extends YTNode {
this.title = data.title;
this.endpoint = new NavigationEndpoint(data.endpoint);
this.selected = data.selected; // If this.selected then we may have content else we do not
this.content = data.content ? Parser.parse(data.content) : null;
this.content = data.content ? Parser.parseItem(data.content) : null;
}
}

View File

@@ -8,7 +8,7 @@ class ExpandedShelfContents extends YTNode {
constructor(data: any) {
super();
this.items = Parser.parse(data.items);
this.items = Parser.parseArray(data.items);
}
// XXX: alias for consistency

View File

@@ -1,5 +1,6 @@
import Parser from '../index';
import { YTNode } from '../helpers';
import ChipCloudChip from './ChipCloudChip';
class FeedFilterChipBar extends YTNode {
static type = 'FeedFilterChipBar';
@@ -8,7 +9,7 @@ class FeedFilterChipBar extends YTNode {
constructor(data: any) {
super();
this.contents = Parser.parse(data.contents);
this.contents = Parser.parseArray<ChipCloudChip>(data.contents, ChipCloudChip);
}
}

View File

@@ -0,0 +1,15 @@
import Parser from '..';
import { YTNode } from '../helpers';
class GameCard extends YTNode {
static type = 'GameCard';
game;
constructor(data: any) {
super();
this.game = Parser.parseItem(data.game);
}
}
export default GameCard;

View File

@@ -0,0 +1,26 @@
import { YTNode } from '../helpers';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
class GameDetails extends YTNode {
static type = 'GameDetails';
title: Text;
box_art: Thumbnail[];
box_art_overlay_text: Text;
endpoint: NavigationEndpoint;
is_official_box_art: boolean;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.box_art = Thumbnail.fromResponse(data.boxArt);
this.box_art_overlay_text = new Text(data.boxArtOverlayText);
this.endpoint = new NavigationEndpoint(data.endpoint);
this.is_official_box_art = data.isOfficialBoxArt;
}
}
export default GameDetails;

View File

@@ -5,23 +5,34 @@ class Grid extends YTNode {
static type = 'Grid';
items;
is_collapsible: boolean;
visible_row_count: string;
target_id: string;
is_collapsible?: boolean;
visible_row_count?: string;
target_id?: string;
continuation: string | null;
header?;
constructor(data: any) {
super();
this.items = Parser.parse(data.items);
this.is_collapsible = data.isCollapsible;
this.visible_row_count = data.visibleRowCount;
this.target_id = data.targetId;
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
this.items = Parser.parseArray(data.items);
if (data.header) {
this.header = Parser.parse(data.header);
}
if (data.isCollapsible) {
this.is_collapsible = data.isCollapsible;
}
if (data.visibleRowCount) {
this.visible_row_count = data.visibleRowCount;
}
if (data.targetId) {
this.target_id = data.targetId;
}
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
}
// XXX: alias for consistency

View File

@@ -3,6 +3,9 @@ import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import Author from './misc/Author';
import type Menu from './menus/Menu';
import { YTNode } from '../helpers';
class GridVideo extends YTNode {
@@ -14,12 +17,12 @@ class GridVideo extends YTNode {
thumbnail_overlays;
rich_thumbnail;
published: Text;
duration: Text | string;
duration: Text | null;
author: Author;
views: Text;
short_view_count: Text;
endpoint: NavigationEndpoint;
menu;
menu: Menu | null;
constructor(data: any) {
super();
@@ -27,15 +30,15 @@ class GridVideo extends YTNode {
this.id = data.videoId;
this.title = new Text(data.title);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.rich_thumbnail = data.richThumbnail && Parser.parse(data.richThumbnail);
this.published = new Text(data.publishedTimeText);
this.duration = data.lengthText ? new Text(data.lengthText) : length_alt?.text ? new Text(length_alt.text) : '';
this.duration = data.lengthText ? new Text(data.lengthText) : length_alt?.text ? new Text(length_alt.text) : null;
this.author = data.shortBylineText && new Author(data.shortBylineText, data.ownerBadges);
this.views = new Text(data.viewCountText);
this.short_view_count = new Text(data.shortViewCountText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.menu = Parser.parse(data.menu);
this.menu = Parser.parseItem<Menu>(data.menu);
}
}

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers';
class HeatMarker extends YTNode {
static type = 'HeatMarker';
time_range_start_millis: number;
marker_duration_millis: number;
heat_marker_intensity_score_normalized: number;
constructor(data: any) {
super();
this.time_range_start_millis = data.timeRangeStartMillis;
this.marker_duration_millis = data.markerDurationMillis;
this.heat_marker_intensity_score_normalized = data.heatMarkerIntensityScoreNormalized;
}
}
export default HeatMarker;

View File

@@ -0,0 +1,25 @@
import Parser from '..';
import type HeatMarker from './HeatMarker';
import { YTNode } from '../helpers';
class Heatmap extends YTNode {
static type = 'Heatmap';
max_height_dp: number;
min_height_dp: number;
show_hide_animation_duration_millis: number;
heat_markers: HeatMarker[];
heat_markers_decorations: any;
constructor(data: any) {
super();
this.max_height_dp = data.maxHeightDp;
this.min_height_dp = data.minHeightDp;
this.show_hide_animation_duration_millis = data.showHideAnimationDurationMillis;
this.heat_markers = Parser.parseArray<HeatMarker>(data.heatMarkers);
this.heat_markers_decorations = Parser.parseArray(data.heatMarkersDecorations);
}
}
export default Heatmap;

View File

@@ -4,7 +4,7 @@ import { YTNode } from '../helpers';
class Panel {
static type = 'Panel';
thumbnail: {
thumbnail?: {
image: {
url: string;
width: number;
@@ -43,13 +43,15 @@ class Panel {
};
constructor(data: any) {
this.thumbnail = {
image: data.thumbnail.image.sources,
endpoint: new NavigationEndpoint(data.thumbnail.onTap),
on_long_press_endpoint: new NavigationEndpoint(data.thumbnail.onLongPress),
content_mode: data.thumbnail.contentMode,
crop_options: data.thumbnail.cropOptions
};
if (data.thumbnail) {
this.thumbnail = {
image: data.thumbnail.image.sources,
endpoint: new NavigationEndpoint(data.thumbnail.onTap),
on_long_press_endpoint: new NavigationEndpoint(data.thumbnail.onLongPress),
content_mode: data.thumbnail.contentMode,
crop_options: data.thumbnail.cropOptions
};
}
this.background_image = {
image: data.backgroundImage.image.sources,

View File

@@ -1,5 +1,8 @@
import Parser from '../index';
import { YTNode } from '../helpers';
import SearchRefinementCard from './SearchRefinementCard';
import Button from './Button';
import MacroMarkersListItem from './MacroMarkersListItem';
class HorizontalCardList extends YTNode {
static type = 'HorizontalCardList';
@@ -11,10 +14,10 @@ class HorizontalCardList extends YTNode {
constructor(data: any) {
super();
this.cards = Parser.parse(data.cards);
this.header = Parser.parse(data.header);
this.previous_button = Parser.parse(data.previousButton);
this.next_button = Parser.parse(data.nextButton);
this.cards = Parser.parseArray<SearchRefinementCard | MacroMarkersListItem>(data.cards);
this.header = Parser.parseItem(data.header);
this.previous_button = Parser.parseItem<Button>(data.previousButton, Button);
this.next_button = Parser.parseItem<Button>(data.nextButton, Button);
}
}

View File

@@ -10,7 +10,7 @@ class HorizontalList extends YTNode {
constructor(data: any) {
super();
this.visible_item_count = data.visibleItemCount;
this.items = Parser.parse(data.items);
this.items = Parser.parseArray(data.items);
}
// XXX: alias for consistency

View File

@@ -0,0 +1,38 @@
import Parser from '..';
import { ObservedArray, YTNode } from '../helpers';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import SubscribeButton from './SubscribeButton';
import MetadataBadge from './MetadataBadge';
import Button from './Button';
class InteractiveTabbedHeader extends YTNode {
static type = 'InteractiveTabbedHeader';
header_type: string;
title: Text;
description: Text;
metadata: Text;
badges: MetadataBadge[];
box_art: Thumbnail[];
banner: Thumbnail[];
buttons: ObservedArray<SubscribeButton | Button>;
auto_generated: Text;
constructor(data: any) {
super();
this.header_type = data.type;
this.title = new Text(data.title);
this.description = new Text(data.description);
this.metadata = new Text(data.metadata);
this.badges = Parser.parseArray<MetadataBadge>(data.badges, MetadataBadge);
this.box_art = Thumbnail.fromResponse(data.boxArt);
this.banner = Thumbnail.fromResponse(data.banner);
this.buttons = Parser.parseArray<SubscribeButton | Button>(data.buttons, [ SubscribeButton, Button ]);
this.auto_generated = new Text(data.autoGenerated);
}
}
export default InteractiveTabbedHeader;

View File

@@ -3,17 +3,18 @@ import ItemSectionHeader from './ItemSectionHeader';
import { YTNode } from '../helpers';
import ItemSectionTabbedHeader from './ItemSectionTabbedHeader';
import CommentsHeader from './comments/CommentsHeader';
class ItemSection extends YTNode {
static type = 'ItemSection';
header: ItemSectionHeader | ItemSectionTabbedHeader | null;
header: CommentsHeader | ItemSectionHeader | ItemSectionTabbedHeader | null;
contents;
target_id;
constructor(data: any) {
super();
this.header = Parser.parseItem<ItemSectionHeader | ItemSectionTabbedHeader>(data.header, [ ItemSectionHeader, ItemSectionTabbedHeader ]);
this.header = Parser.parseItem<CommentsHeader | ItemSectionHeader | ItemSectionTabbedHeader>(data.header);
this.contents = Parser.parse(data.contents, true);
if (data.targetId || data.sectionIdentifier) {

View File

@@ -0,0 +1,29 @@
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import { YTNode } from '../helpers';
class MacroMarkersListItem extends YTNode {
static type = 'MacroMarkersListItem';
title: Text;
time_description: Text;
thumbnail: Thumbnail[];
on_tap_endpoint: NavigationEndpoint;
layout: string;
is_highlighted: boolean;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.time_description = new Text(data.timeDescription);
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
this.on_tap_endpoint = new NavigationEndpoint(data.onTap);
this.layout = data.layout;
this.is_highlighted = data.isHighlighted;
}
}
export default MacroMarkersListItem;

View File

@@ -5,7 +5,8 @@ class MetadataBadge extends YTNode {
icon_type?: string;
style?: string;
tooltip: string | null;
label?: string;
tooltip?: string;
constructor(data: any) {
super();
@@ -18,7 +19,13 @@ class MetadataBadge extends YTNode {
this.style = data.style;
}
this.tooltip = data?.tooltip || data?.iconTooltip || null;
if (data?.label) {
this.style = data.label;
}
if (data?.tooltip || data?.iconTooltip) {
this.tooltip = data.tooltip || data.iconTooltip;
}
}
}

View File

@@ -0,0 +1,44 @@
import Parser from '..';
import type Chapter from './Chapter';
import type Heatmap from './Heatmap';
import { observe, ObservedArray, YTNode } from '../helpers';
class Marker extends YTNode {
static type = 'Marker';
marker_key: string;
value: {
heatmap?: Heatmap | null;
chapters?: Chapter[];
};
constructor (data: any) {
super();
this.marker_key = data.key;
this.value = {};
if (data.value.heatmap) {
this.value.heatmap = Parser.parseItem<Heatmap>(data.value.heatmap);
}
if (data.value.chapters) {
this.value.chapters = Parser.parseArray<Chapter>(data.value.chapters);
}
}
}
class MultiMarkersPlayerBar extends YTNode {
static type = 'MultiMarkersPlayerBar';
markers_map: ObservedArray<Marker>;
constructor(data: any) {
super();
this.markers_map = observe(data.markersMap?.map((marker: { key: string; value: { [key: string ]: any }}) => new Marker(marker)));
}
}
export { Marker };
export default MultiMarkersPlayerBar;

View File

@@ -36,12 +36,12 @@ class MusicDetailHeader extends YTNode {
this.thumbnails = Thumbnail.fromResponse(data.thumbnail.croppedSquareThumbnailRenderer.thumbnail);
this.badges = Parser.parse(data.subtitleBadges);
const author = this.subtitle.runs?.find((run) => (run as TextRun)?.endpoint?.browse?.id.startsWith('UC'));
const author = this.subtitle.runs?.find((run) => (run as TextRun)?.endpoint?.payload?.browseId.startsWith('UC'));
if (author) {
this.author = {
name: (author as TextRun).text,
channel_id: (author as TextRun).endpoint?.browse?.id,
channel_id: (author as TextRun).endpoint?.payload?.browseId,
endpoint: (author as TextRun).endpoint
};
}

View File

@@ -80,7 +80,9 @@ class MusicResponsiveListItem extends YTNode {
this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : null;
switch (this.endpoint?.browse?.page_type) {
const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
switch (page_type) {
case 'MUSIC_PAGE_TYPE_ALBUM':
this.item_type = 'album';
this.#parseAlbum();
@@ -139,7 +141,7 @@ class MusicResponsiveListItem extends YTNode {
}
#parseSong() {
this.id = this.#playlist_item_data.video_id || this.endpoint?.watch?.video_id;
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
const duration_text =
@@ -151,21 +153,21 @@ class MusicResponsiveListItem extends YTNode {
seconds: timeToSeconds(duration_text)
});
const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun ||
this.#flex_columns[2]?.key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun;
const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('MPR')) as TextRun ||
this.#flex_columns[2]?.key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('MPR')) as TextRun;
if (album) {
this.album = {
id: album.endpoint?.browse?.id,
id: album.endpoint?.payload?.browseId,
name: album.text,
endpoint: album.endpoint
};
}
const artists = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[];
const artists = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun[];
if (artists) {
this.artists = artists.map((artist) => ({
name: artist.text,
channel_id: artist.endpoint?.browse?.id,
channel_id: artist.endpoint?.payload?.browseId,
endpoint: artist.endpoint
}));
}
@@ -176,11 +178,11 @@ class MusicResponsiveListItem extends YTNode {
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
this.views = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => run.text.match(/(.*?) views/))?.text;
const authors = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[];
const authors = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun[];
if (authors) {
this.authors = authors.map((author) => ({
name: author.text,
channel_id: author.endpoint?.browse?.id,
channel_id: author.endpoint?.payload?.browseId,
endpoint: author.endpoint
}));
}
@@ -194,7 +196,7 @@ class MusicResponsiveListItem extends YTNode {
}
#parseArtist() {
this.id = this.endpoint?.browse?.id;
this.id = this.endpoint?.payload?.browseId;
this.name = this.#flex_columns[0].key('title').instanceof(Text).toString();
this.subtitle = this.#flex_columns[1].key('title').instanceof(Text);
this.subscribers = this.subtitle.runs?.find((run) => (/^(\d*\.)?\d+[M|K]? subscribers?$/i).test(run.text))?.text || '';
@@ -207,13 +209,13 @@ class MusicResponsiveListItem extends YTNode {
}
#parseAlbum() {
this.id = this.endpoint?.browse?.id;
this.id = this.endpoint?.payload?.browseId;
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun;
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun;
author && (this.author = {
name: author.text,
channel_id: author.endpoint?.browse?.id,
channel_id: author.endpoint?.payload?.browseId,
endpoint: author.endpoint
});
@@ -221,7 +223,7 @@ class MusicResponsiveListItem extends YTNode {
}
#parsePlaylist() {
this.id = this.endpoint?.browse?.id;
this.id = this.endpoint?.payload?.browseId;
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
const item_count_run = this.#flex_columns[1].key('title')
@@ -229,12 +231,12 @@ class MusicResponsiveListItem extends YTNode {
this.item_count = item_count_run ? item_count_run.text : undefined;
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun;
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun;
if (author) {
this.author = {
name: author.text,
channel_id: author.endpoint?.browse?.id,
channel_id: author.endpoint?.payload?.browseId,
endpoint: author.endpoint
};
}

View File

@@ -6,6 +6,7 @@ class MusicSideAlignedItem extends YTNode {
static type = 'MusicSideAlignedItem';
start_items?;
end_items?;
constructor(data: any) {
super();
@@ -13,6 +14,10 @@ class MusicSideAlignedItem extends YTNode {
if (data.startItems) {
this.start_items = Parser.parseArray(data.startItems);
}
if (data.endItems) {
this.end_items = Parser.parseArray(data.endItems);
}
}
}

View File

@@ -48,13 +48,15 @@ class MusicTwoRowItem extends YTNode {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.id =
this.endpoint?.browse?.id ||
this.endpoint?.watch?.video_id;
this.endpoint?.payload?.browseId ||
this.endpoint?.payload?.videoId;
this.subtitle = new Text(data.subtitle);
this.badges = Parser.parse(data.subtitleBadges);
switch (this.endpoint?.browse?.page_type) {
const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
switch (page_type) {
case 'MUSIC_PAGE_TYPE_ARTIST':
this.item_type = 'artist';
break;
@@ -65,7 +67,7 @@ class MusicTwoRowItem extends YTNode {
this.item_type = 'album';
break;
default:
if (this.endpoint?.watch_playlist) {
if (this.endpoint?.metadata?.api_url === '/next') {
this.item_type = 'endpoint';
} else if (this.subtitle.runs?.[0]) {
if (this.subtitle.runs[0].text !== 'Song') {
@@ -87,11 +89,11 @@ class MusicTwoRowItem extends YTNode {
const item_count_run = this.subtitle.runs?.find((run) => run.text.match(/\d+ songs|song/));
this.item_count = item_count_run ? (item_count_run as TextRun).text : null;
} else if (this.item_type == 'album') {
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
if (artists) {
this.artists = artists.map((artist: any) => ({
name: artist.text,
channel_id: artist.endpoint.browse.id,
channel_id: artist.endpoint?.payload?.browseId,
endpoint: artist.endpoint
}));
}
@@ -101,20 +103,20 @@ class MusicTwoRowItem extends YTNode {
} else if (this.item_type == 'video') {
this.views = this?.subtitle.runs?.find((run) => run?.text.match(/(.*?) views/))?.text || 'N/A';
const author = this.subtitle.runs?.find((run: any) => run.endpoint?.browse?.id?.startsWith('UC'));
const author = this.subtitle.runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));
if (author) {
this.author = {
name: (author as TextRun)?.text,
channel_id: (author as TextRun)?.endpoint?.browse?.id,
channel_id: (author as TextRun)?.endpoint?.payload?.browseId,
endpoint: (author as TextRun)?.endpoint
};
}
} else if (this.item_type == 'song') {
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
if (artists) {
this.artists = artists.map((artist: any) => ({
name: (artist as TextRun)?.text,
channel_id: (artist as TextRun)?.endpoint?.browse?.id,
channel_id: (artist as TextRun)?.endpoint?.payload?.browseId,
endpoint: (artist as TextRun)?.endpoint
}));
}

View File

@@ -1,7 +1,7 @@
// TODO: refactor this
import { YTNode } from '../helpers';
import Parser, { ParsedResponse } from '../index';
import Actions, { ActionsResponse } from '../../core/Actions';
import { YTNode } from '../helpers';
import CreatePlaylistDialog from './CreatePlaylistDialog';
class NavigationEndpoint extends YTNode {
@@ -14,34 +14,9 @@ class NavigationEndpoint extends YTNode {
url?: string;
api_url?: string;
page_type?: string;
send_post?: boolean; // TODO: is this a boolean?
send_post?: boolean;
};
// TODO: these should be given proper types, currently infered
browse?: {
id: string,
params: string | null,
base_url: string | null,
page_type: string | null,
form_data?: {}
};
watch;
search;
subscribe;
unsubscribe;
like;
perform_comment_action;
offline_video;
continuation;
feedback;
watch_playlist;
playlist_edit;
add_to_playlist;
create_playlist;
get_report_form;
live_chat_item_context_menu;
send_live_chat_vote;
constructor(data: any) {
super();
@@ -85,156 +60,10 @@ class NavigationEndpoint extends YTNode {
this.metadata.send_post = data.commandMetadata.webCommandMetadata.sendPost;
}
if (data?.browseEndpoint) {
const configs = data?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig;
this.browse = {
id: data?.browseEndpoint?.browseId || null,
params: data?.browseEndpoint.params || null,
base_url: data?.browseEndpoint?.canonicalBaseUrl || null,
page_type: configs?.pageType || null
};
}
if (data?.watchEndpoint) {
const configs = data?.watchEndpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig;
this.watch = {
video_id: data?.watchEndpoint?.videoId,
playlist_id: data?.watchEndpoint.playlistId || null,
params: data?.watchEndpoint.params || null,
index: data?.watchEndpoint.index || null,
supported_onesie_config: data?.watchEndpoint?.watchEndpointSupportedOnesieConfig,
music_video_type: configs?.musicVideoType || null
};
}
if (data?.searchEndpoint) {
this.search = {
query: data.searchEndpoint.query,
params: data.searchEndpoint.params
};
}
if (data?.subscribeEndpoint) {
this.subscribe = {
channel_ids: data.subscribeEndpoint.channelIds,
params: data.subscribeEndpoint.params
};
}
if (data?.unsubscribeEndpoint) {
this.unsubscribe = {
channel_ids: data.unsubscribeEndpoint.channelIds,
params: data.unsubscribeEndpoint.params
};
}
if (data?.likeEndpoint) {
this.like = {
status: data.likeEndpoint.status,
target: {
video_id: data.likeEndpoint.target.videoId,
playlist_id: data.likeEndpoint.target.playlistId
},
params:
data.likeEndpoint?.removeLikeParams ||
data.likeEndpoint?.likeParams ||
data.likeEndpoint?.dislikeParams
};
}
if (data?.performCommentActionEndpoint) {
this.perform_comment_action = {
action: data?.performCommentActionEndpoint.action
};
}
if (data?.offlineVideoEndpoint) {
this.offline_video = {
video_id: data.offlineVideoEndpoint.videoId,
on_add_command: {
get_download_action: {
video_id: data.offlineVideoEndpoint.videoId,
params: data.offlineVideoEndpoint.onAddCommand.getDownloadActionCommand.params
}
}
};
}
if (data?.continuationCommand) {
this.continuation = {
request: data?.continuationCommand?.request || null,
token: data?.continuationCommand?.token || null
};
}
if (data?.feedbackEndpoint) {
this.feedback = {
token: data.feedbackEndpoint.feedbackToken
};
}
if (data?.watchPlaylistEndpoint) {
this.watch_playlist = {
playlist_id: data.watchPlaylistEndpoint?.playlistId,
params: data.watchPlaylistEndpoint?.params || null
};
}
if (data?.playlistEditEndpoint) {
this.playlist_edit = {
playlist_id: data.playlistEditEndpoint.playlistId,
actions: data.playlistEditEndpoint.actions.map((item: any) => ({
action: item.action,
removed_video_id: item.removedVideoId
}))
};
}
if (data?.addToPlaylistEndpoint) {
this.add_to_playlist = {
video_id: data.addToPlaylistEndpoint.videoId
};
}
if (data?.addToPlaylistServiceEndpoint) {
this.add_to_playlist = {
video_id: data.addToPlaylistServiceEndpoint.videoId
};
}
if (data?.createPlaylistEndpoint) {
if (data?.createPlaylistEndpoint.createPlaylistDialog) {
this.dialog = Parser.parseItem(data?.createPlaylistEndpoint.createPlaylistDialog, CreatePlaylistDialog);
}
this.create_playlist = {
// Nothing to put here - data.createPlaylistEndpoint has only one prop `createPlaylistDialog`
// Which was already parsed and referred to by `this.dialog`. But still useful to have this as
// A quick indicator of what the endpoint does.
};
}
if (data?.getReportFormEndpoint) {
this.get_report_form = {
params: data.getReportFormEndpoint.params
};
}
if (data?.liveChatItemContextMenuEndpoint) {
this.live_chat_item_context_menu = {
params: data?.liveChatItemContextMenuEndpoint?.params
};
}
if (data?.sendLiveChatVoteEndpoint) {
this.send_live_chat_vote = {
params: data.sendLiveChatVoteEndpoint.params
};
}
if (data?.liveChatItemContextMenuEndpoint) {
this.live_chat_item_context_menu = {
params: data.liveChatItemContextMenuEndpoint.params
};
}
}
@@ -247,68 +76,24 @@ class NavigationEndpoint extends YTNode {
return '/browse';
case 'watchEndpoint':
return '/player';
case 'searchEndpoint':
return '/search';
case 'watchPlaylistEndpoint':
return '/next';
case 'liveChatItemContextMenuEndpoint':
return 'live_chat/get_item_context_menu';
}
}
callTest(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
callTest(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
callTest(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<ParsedResponse | ActionsResponse> {
call(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
call(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
call(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<ParsedResponse | ActionsResponse> {
if (!actions)
throw new Error('An active caller must be provided');
if (!this.metadata.api_url)
throw new Error('Expected an api_url, but none was found, this is a bug.');
return actions.execute(this.metadata.api_url, { ...this.payload, ...args });
}
// TODO: replace client with an enum or something
async #call(actions: Actions, client?: string) {
if (!actions)
throw new Error('An active caller must be provided');
if (this.continuation) {
switch (this.continuation.request) {
case 'CONTINUATION_REQUEST_TYPE_BROWSE': {
return await actions.browse(this.continuation.token, { is_ctoken: true });
}
case 'CONTINUATION_REQUEST_TYPE_SEARCH': {
return await actions.search({ ctoken: this.continuation.token });
}
case 'CONTINUATION_REQUEST_TYPE_WATCH_NEXT': {
return await actions.next({ ctoken: this.continuation.token });
}
default:
throw new Error(`${this.continuation.request} not implemented`);
}
}
if (this.search) {
return await actions.search({ query: this.search.query, params: this.search.params, client });
}
if (this.browse) {
return await actions.browse(this.browse.id, { ...this.browse, client });
}
if (this.like) {
if (!this.metadata.api_url)
throw new Error('Like endpoint requires an api_url, but was not parsed from the response.');
const response = await actions.engage(this.metadata.api_url, { video_id: this.like.target.video_id, params: this.like.params });
return response;
}
}
async call(actions: Actions, client: string | undefined, parse: true) : Promise<ParsedResponse | undefined>;
async call(actions: Actions, client?: string, parse?: false) : Promise<ActionsResponse | undefined>;
async call(actions: Actions, client?: string, parse?: boolean): Promise<ParsedResponse | ActionsResponse | undefined> {
const result = await this.#call(actions, client);
if (parse && result)
return Parser.parseResponse(result.data);
return this.#call(actions, client);
}
}
export default NavigationEndpoint;

View File

@@ -34,6 +34,7 @@ class PlayerMicroformat extends YTNode {
publish_date: string;
upload_date: string;
available_countries: string[];
start_timestamp: Date | null;
constructor(data: any) {
super();
@@ -65,6 +66,7 @@ class PlayerMicroformat extends YTNode {
this.publish_date = data.publishDate;
this.upload_date = data.uploadDate;
this.available_countries = data.availableCountries;
this.start_timestamp = data.liveBroadcastDetails?.startTimestamp ? new Date(data.liveBroadcastDetails.startTimestamp) : null;
}
}

View File

@@ -3,6 +3,7 @@ import Menu from './menus/Menu';
import Button from './Button';
import WatchNextEndScreen from './WatchNextEndScreen';
import PlayerOverlayAutoplay from './PlayerOverlayAutoplay';
import type DecoratedPlayerBar from './DecoratedPlayerBar';
import { YTNode } from '../helpers';
@@ -16,6 +17,7 @@ class PlayerOverlay extends YTNode {
fullscreen_engagement;
actions;
browser_media_session;
decorated_player_bar;
constructor(data: any) {
super();
@@ -26,6 +28,7 @@ class PlayerOverlay extends YTNode {
this.fullscreen_engagement = Parser.parse(data.fullscreenEngagement);
this.actions = Parser.parseArray(data.actions);
this.browser_media_session = Parser.parseItem(data.browserMediaSession);
this.decorated_player_bar = Parser.parseItem<DecoratedPlayerBar>(data.decoratedPlayerBarRenderer);
}
}

View File

@@ -0,0 +1,15 @@
import { YTNode } from '../helpers';
import Thumbnail from './misc/Thumbnail';
class PlaylistCustomThumbnail extends YTNode {
static type = 'PlaylistCustomThumbnail';
thumbnail: Thumbnail[];
constructor(data: any) {
super();
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
}
}
export default PlaylistCustomThumbnail;

View File

@@ -54,14 +54,14 @@ class PlaylistPanelVideo extends YTNode {
seconds: timeToSeconds(new Text(data.lengthText).toString())
};
const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.browse?.id.startsWith('MPR'));
const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('MPR'));
const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));
this.author = new Text(data.shortBylineText).toString();
if (album) {
this.album = {
id: (album as TextRun).endpoint?.browse?.id,
id: (album as TextRun).endpoint?.payload?.browseId,
name: (album as TextRun).text,
year: new Text(data.longBylineText).runs?.slice(-1)[0].text,
endpoint: (album as TextRun).endpoint
@@ -71,13 +71,13 @@ class PlaylistPanelVideo extends YTNode {
if (artists) {
this.artists = artists.map((artist) => ({
name: (artist as TextRun).text,
channel_id: (artist as TextRun).endpoint?.browse?.id,
channel_id: (artist as TextRun).endpoint?.payload?.browseId,
endpoint: (artist as TextRun).endpoint
}));
}
this.badges = Parser.parse(data.badges);
this.menu = Parser.parse(data.menu);
this.badges = Parser.parseArray(data.badges);
this.menu = Parser.parseItem(data.menu);
this.set_video_id = data.playlistSetVideoId;
}
}

View File

@@ -19,7 +19,7 @@ class PlaylistSidebarPrimaryInfo extends YTNode {
this.stats = data.stats.map((stat: any) => new Text(stat));
this.thumbnail_renderer = Parser.parse(data.thumbnailRenderer);
this.title = new Text(data.title);
this.menu = data.menu && Parser.parse(data.menu);
this.menu = Parser.parseItem(data.menu);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.description = new Text(data.description);
}

View File

@@ -3,6 +3,8 @@ import Parser from '../index';
import Thumbnail from './misc/Thumbnail';
import PlaylistAuthor from './misc/PlaylistAuthor';
import NavigationEndpoint from './NavigationEndpoint';
import type Menu from './menus/Menu';
import { YTNode } from '../helpers';
class PlaylistVideo extends YTNode {
@@ -17,7 +19,7 @@ class PlaylistVideo extends YTNode {
set_video_id: string | undefined;
endpoint: NavigationEndpoint;
is_playable: boolean;
menu;
menu: Menu | null;
duration: {
text: string;
@@ -35,7 +37,7 @@ class PlaylistVideo extends YTNode {
this.set_video_id = data?.setVideoId;
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.is_playable = data.isPlayable;
this.menu = Parser.parse(data.menu);
this.menu = Parser.parseItem<Menu>(data.menu);
this.duration = {
text: new Text(data.lengthText).text,
seconds: parseInt(data.lengthSeconds)

View File

@@ -8,7 +8,7 @@ class ProfileColumnStats extends YTNode {
constructor(data: any) {
super();
this.items = Parser.parse(data.items);
this.items = Parser.parseArray(data.items);
}
// XXX: alias for consistency
@@ -17,4 +17,4 @@ class ProfileColumnStats extends YTNode {
}
}
export default ProfileColumnStats;
export default ProfileColumnStats;

View File

@@ -0,0 +1,28 @@
import Parser from '..';
import { YTNode } from '../helpers';
import Button from './Button';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
class RecognitionShelf extends YTNode {
static type = 'RecognitionShelf';
title: Text;
subtitle: Text;
avatars: Thumbnail[];
button: Button | null;
surface: string;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.subtitle = new Text(data.subtitle);
this.avatars = data.avatars.map((avatar: any) => new Thumbnail(avatar));
this.button = Parser.parseItem<Button>(data.button, Button);
this.surface = data.surface;
}
}
export default RecognitionShelf;

View File

@@ -12,8 +12,8 @@ class RichGrid extends YTNode {
super();
// XXX: we don't parse the masthead since it is usually an advertisement
// XXX: reflowOptions aren't parsed, I think its only used internally for layout
this.header = Parser.parse(data.header);
this.contents = Parser.parse(data.contents);
this.header = Parser.parseItem(data.header);
this.contents = Parser.parseArray(data.contents);
}
}

View File

@@ -8,8 +8,7 @@ class RichItem extends YTNode {
constructor(data: any) {
super();
// TODO: check this
this.content = Parser.parse(data.content);
this.content = Parser.parseItem(data.content);
}
}

View File

@@ -5,12 +5,16 @@ class RichListHeader extends YTNode {
static type = 'RichListHeader';
title: Text;
subtitle: Text;
title_style: string | undefined;
icon_type: string;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.icon_type = data.icon.iconType;
this.subtitle = new Text(data.subtitle);
this.title_style = data?.titleStyle?.style;
this.icon_type = data?.icon?.iconType;
}
}

View File

@@ -4,11 +4,11 @@ import { YTNode } from '../helpers';
class RichSection extends YTNode {
static type = 'RichSection';
contents;
content;
constructor(data: any) {
super();
this.contents = Parser.parse(data.content);
this.content = Parser.parseItem(data.content);
}
}

View File

@@ -13,7 +13,7 @@ class RichShelf extends YTNode {
constructor(data: any) {
super();
this.title = new Text(data.title);
this.contents = Parser.parse(data.contents);
this.contents = Parser.parseArray(data.contents);
this.endpoint = data.endpoint ? new NavigationEndpoint(data.endpoint) : null;
}
}

View File

@@ -4,9 +4,9 @@ import { YTNode } from '../helpers';
class SectionList extends YTNode {
static type = 'SectionList';
target_id;
target_id?: string;
contents;
continuation;
continuation?: string;
header;
constructor(data: any) {
@@ -15,7 +15,8 @@ class SectionList extends YTNode {
this.target_id = data.targetId;
}
this.contents = Parser.parse(data.contents);
// TODO: this should be Parser#parseArray
this.contents = Parser.parseArray(data.contents);
if (data.continuations) {
if (data.continuations[0].nextContinuationData) {

View File

@@ -7,10 +7,10 @@ class Shelf extends YTNode {
static type = 'Shelf';
title: Text;
endpoint;
content;
icon_type;
menu;
endpoint?: NavigationEndpoint;
content: YTNode | null;
icon_type?: string;
menu?: YTNode | null;
constructor(data: any) {
super();
@@ -20,14 +20,14 @@ class Shelf extends YTNode {
this.endpoint = new NavigationEndpoint(data.endpoint);
}
this.content = Parser.parse(data.content) || null;
this.content = Parser.parseItem(data.content) || null;
if (data.icon?.iconType) {
this.icon_type = data.icon?.iconType;
}
if (data.menu) {
this.menu = Parser.parse(data.menu);
this.menu = Parser.parseItem(data.menu);
}
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers';
import Thumbnail from './misc/Thumbnail';
class ThumbnailLandscapePortrait extends YTNode {
static type = 'ThumbnailLandscapePortrait';
landscape: Thumbnail[];
portrait: Thumbnail[];
constructor (data: any) {
super();
this.landscape = Thumbnail.fromResponse(data.landscape);
this.portrait = Thumbnail.fromResponse(data.portrait);
}
}
export default ThumbnailLandscapePortrait;

View File

@@ -0,0 +1,23 @@
import Text from './misc/Text';
import { YTNode } from '../helpers';
class TimedMarkerDecoration extends YTNode {
static type = 'TimedMarkerDecoration';
visible_time_range_start_millis: number;
visible_time_range_end_millis: number;
decoration_time_millis: number;
label: Text;
icon: string;
constructor(data: any) {
super();
this.visible_time_range_start_millis = data.visibleTimeRangeStartMillis;
this.visible_time_range_end_millis = data.visibleTimeRangeEndMillis;
this.decoration_time_millis = data.decorationTimeMillis;
this.label = new Text(data.label);
this.icon = data.icon;
}
}
export default TimedMarkerDecoration;

View File

@@ -0,0 +1,29 @@
import Parser from '..';
import { YTNode } from '../helpers';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import SubscribeButton from './SubscribeButton';
class TopicChannelDetails extends YTNode {
static type = 'TopicChannelDetails';
title: Text;
avatar: Thumbnail[];
subtitle: Text;
subscribe_button: SubscribeButton | null;
endpoint: NavigationEndpoint;
constructor (data: any) {
super();
this.title = new Text(data.title);
this.avatar = Thumbnail.fromResponse(data.thumbnail);
this.subtitle = new Text(data.title);
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton, SubscribeButton);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
}
export default TopicChannelDetails;

View File

@@ -10,9 +10,9 @@ class TwoColumnWatchNextResults extends YTNode {
constructor(data: any) {
super();
this.results = Parser.parse(data.results?.results.contents, true);
this.secondary_results = Parser.parse(data.secondaryResults?.secondaryResults.results, true);
this.conversation_bar = Parser.parse(data?.conversationBar);
this.results = Parser.parseArray(data.results?.results.contents);
this.secondary_results = Parser.parseArray(data.secondaryResults?.secondaryResults.results);
this.conversation_bar = Parser.parseItem(data?.conversationBar);
}
}

View File

@@ -1,4 +1,5 @@
import Parser from '../index';
import Text from './misc/Text';
import { YTNode } from '../helpers';
class UniversalWatchCard extends YTNode {
@@ -7,13 +8,17 @@ class UniversalWatchCard extends YTNode {
header;
call_to_action;
sections;
collapsed_label?: Text;
constructor(data: any) {
super();
// TODO: use parseItem / parseArray for these
this.header = Parser.parse(data.header);
this.call_to_action = Parser.parse(data.callToAction);
this.sections = Parser.parse(data.sections);
this.header = Parser.parseItem(data.header);
this.call_to_action = Parser.parseItem(data.callToAction);
this.sections = Parser.parseArray(data.sections);
if (data.collapsedLabel) {
this.collapsed_label = new Text(data.collapsedLabel);
}
}
}

View File

@@ -13,7 +13,7 @@ class VerticalWatchCardList extends YTNode {
constructor(data: any) {
super();
this.items = Parser.parse(data.items);
this.items = Parser.parseArray(data.items);
this.contents = this.items; // XXX: alias for consistency
this.view_all_text = new Text(data.viewAllText);
this.view_all_endpoint = new NavigationEndpoint(data.viewAllEndpoint);

View File

@@ -1,8 +1,12 @@
import Parser from '../index';
import Parser from '..';
import Text from './misc/Text';
import Author from './misc/Author';
import Menu from './menus/Menu';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import MetadataBadge from './MetadataBadge';
import ExpandableMetadata from './ExpandableMetadata';
import { timeToSeconds } from '../../utils/Utils';
import { YTNode } from '../helpers';
@@ -16,11 +20,13 @@ class Video extends YTNode {
text: Text;
hover_text: Text;
}[];
expandable_metadata: ExpandableMetadata | null;
thumbnails: Thumbnail[];
thumbnail_overlays;
rich_thumbnail;
author: Author;
badges: MetadataBadge[];
endpoint: NavigationEndpoint;
published: Text;
view_count: Text;
@@ -34,7 +40,8 @@ class Video extends YTNode {
show_action_menu: boolean;
is_watched: boolean;
menu;
menu: Menu | null;
search_video_result_entity_key: string;
constructor(data: any) {
super();
@@ -52,10 +59,13 @@ class Video extends YTNode {
hover_text: new Text(snippet.snippetHoverText)
})) || [];
this.expandable_metadata = Parser.parseItem<ExpandableMetadata>(data.expandableMetadata);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
this.rich_thumbnail = data.richThumbnail && Parser.parse(data.richThumbnail);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.rich_thumbnail = data.richThumbnail ? Parser.parseItem(data.richThumbnail) : null;
this.author = new Author(data.ownerText, data.ownerBadges, data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail);
this.badges = Parser.parseArray(data.badges, MetadataBadge);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.published = new Text(data.publishedTimeText);
this.view_count = new Text(data.viewCountText);
@@ -73,7 +83,8 @@ class Video extends YTNode {
this.show_action_menu = data.showActionMenu;
this.is_watched = data.isWatched || false;
this.menu = Parser.parse(data.menu);
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
this.search_video_result_entity_key = data.searchVideoResultEntityKey;
}
get description(): string {
@@ -83,22 +94,30 @@ class Video extends YTNode {
return this.description_snippet?.toString() || '';
}
/*
Get is_live() {
return this.badges.some((badge) => badge.style === 'BADGE_STYLE_TYPE_LIVE_NOW');
get is_live(): boolean {
return this.badges.some((badge) => {
if (badge.label === 'BADGE_STYLE_TYPE_LIVE_NOW' || badge.style === 'LIVE')
return true;
});
}
*/
get is_upcoming(): boolean | undefined {
return this.upcoming && this.upcoming > new Date();
}
/*
Get has_captions() {
return this.badges.some((badge) => badge.label === 'CC');
}*/
get is_premiere(): boolean {
return this.badges.some((badge) => badge.style === 'PREMIERE');
}
get best_thumbnail(): Thumbnail | undefined{
get is_4k(): boolean {
return this.badges.some((badge) => badge.style === '4K');
}
get has_captions(): boolean {
return this.badges.some((badge) => badge.style === 'CC');
}
get best_thumbnail(): Thumbnail | undefined {
return this.thumbnails[0];
}
}

View File

@@ -0,0 +1,11 @@
import Video from './Video';
class VideoCard extends Video {
static type = 'VideoCard';
constructor(data: any) {
super(data);
}
}
export default VideoCard;

View File

@@ -13,9 +13,9 @@ class WatchCardHeroVideo extends YTNode {
constructor(data: any) {
super();
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.call_to_action_button = Parser.parse(data.callToActionButton);
this.hero_image = Parser.parse(data.heroImage);
this.label = data.accessibility.accessibilityData.label;
this.call_to_action_button = Parser.parseItem(data.callToActionButton);
this.hero_image = Parser.parseItem(data.heroImage);
this.label = data.lengthText?.accessibility.accessibilityData.label || '';
}
}

View File

@@ -8,7 +8,7 @@ class WatchCardSectionSequence extends YTNode {
constructor(data: any) {
super();
this.lists = Parser.parse(data.lists);
this.lists = Parser.parseArray(data.lists);
}
}

View File

@@ -82,7 +82,7 @@ class Comment extends YTNode {
if (button.is_toggled)
throw new InnertubeError('This comment is already liked', { comment_id: this.comment_id });
const response = await button.endpoint.callTest(this.#actions, { parse: false });
const response = await button.endpoint.call(this.#actions, { parse: false });
return response;
}
@@ -98,7 +98,7 @@ class Comment extends YTNode {
if (button.is_toggled)
throw new InnertubeError('This comment is already disliked', { comment_id: this.comment_id });
const response = await button.endpoint.callTest(this.#actions, { parse: false });
const response = await button.endpoint.call(this.#actions, { parse: false });
return response;
}
@@ -125,7 +125,7 @@ class Comment extends YTNode {
commentText: text
};
const response = await dialog_button.endpoint.callTest(this.#actions, payload);
const response = await dialog_button.endpoint.call(this.#actions, payload);
return response;
}

View File

@@ -35,7 +35,7 @@ class CommentThread extends YTNode {
throw new InnertubeError('This comment has no replies.', { comment_id: this.comment?.comment_id });
const continuation = this.#replies.key('contents').parsed().array().get({ type: 'ContinuationItem' })?.as(ContinuationItem);
const response = await continuation?.endpoint.callTest(this.#actions, { parse: true });
const response = await continuation?.endpoint.call(this.#actions, { parse: true });
this.replies = response?.on_response_received_endpoints_memo?.getType(Comment).map((comment) => {
comment.setActions(this.#actions);
@@ -60,7 +60,7 @@ class CommentThread extends YTNode {
if (!this.#actions)
throw new InnertubeError('Actions not set for this CommentThread.');
const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).callTest(this.#actions, { parse: true });
const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).call(this.#actions, { parse: true });
this.replies = response?.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
comment.setActions(this.#actions);

View File

@@ -5,19 +5,34 @@ import { YTNode } from '../../helpers';
class CommentsEntryPointHeader extends YTNode {
static type = 'CommentsEntryPointHeader';
header;
comment_count;
teaser_avatar;
teaser_content;
simplebox_placeholder;
header?: Text;
comment_count?: Text;
teaser_avatar?: Thumbnail[];
teaser_content?: Text;
simplebox_placeholder?: Text;
constructor(data: any) {
super();
this.header = new Text(data.headerText);
this.comment_count = new Text(data.commentCount);
this.teaser_avatar = Thumbnail.fromResponse(data.teaserAvatar || data.simpleboxAvatar);
this.teaser_content = new Text(data.teaserContent);
this.simplebox_placeholder = new Text(data.simpleboxPlaceholder);
if (data.header) {
this.header = new Text(data.headerText);
}
if (data.commentCount) {
this.comment_count = new Text(data.commentCount);
}
if (data.teaserAvatar || data.simpleboxAvatar) {
this.teaser_avatar = Thumbnail.fromResponse(data.teaserAvatar || data.simpleboxAvatar);
}
if (data.teaserContent) {
this.teaser_content = new Text(data.teaserContent);
}
if (data.simpleboxPlaceholder) {
this.simplebox_placeholder = new Text(data.simpleboxPlaceholder);
}
}
}

View File

@@ -0,0 +1,14 @@
import { YTNode } from '../../helpers';
class RemoveChatItemAction extends YTNode {
static type = 'RemoveChatItemAction';
target_item_id: string;
constructor(data: any) {
super();
this.target_item_id = data.targetItemId;
}
}
export default RemoveChatItemAction;

View File

@@ -0,0 +1,14 @@
import { YTNode } from '../../helpers';
class RemoveChatItemByAuthorAction extends YTNode {
static type = 'RemoveChatItemByAuthorAction';
external_channel_id: string;
constructor(data: any) {
super();
this.external_channel_id = data.externalChannelId;
}
}
export default RemoveChatItemByAuthorAction;

View File

@@ -1,6 +1,8 @@
import Text from '../../misc/Text';
import Parser from '../../../index';
import { YTNode } from '../../../helpers';
import { ObservedArray, YTNode } from '../../../helpers';
import NavigationEndpoint from '../../NavigationEndpoint';
import Button from '../../Button';
class LiveChatAutoModMessage extends YTNode {
static type = 'LiveChatAutoModMessage';
@@ -8,12 +10,16 @@ class LiveChatAutoModMessage extends YTNode {
auto_moderated_item;
header_text: Text;
menu_endpoint?: NavigationEndpoint;
moderation_buttons: ObservedArray<Button>;
timestamp: number;
id: string;
constructor(data: any) {
super();
this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
this.moderation_buttons = Parser.parseArray<Button>(data.moderationButtons, [ Button ]);
this.auto_moderated_item = Parser.parse(data.autoModeratedItem);
this.header_text = new Text(data.headerText);
this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);

View File

@@ -23,6 +23,7 @@ class LiveChatPaidSticker extends YTNode {
sticker: Thumbnail[];
purchase_amount: string;
context_menu: NavigationEndpoint;
menu_endpoint?: NavigationEndpoint;
timestamp: number;
constructor(data: any) {
@@ -42,7 +43,8 @@ class LiveChatPaidSticker extends YTNode {
this.author_name_text_color = data.authorNameTextColor;
this.sticker = Thumbnail.fromResponse(data.sticker);
this.purchase_amount = new Text(data.purchaseAmountText).toString();
this.context_menu = new NavigationEndpoint(data.contextMenuEndpoint);
this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
this.context_menu = this.menu_endpoint;
this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
}
}

View File

@@ -0,0 +1,16 @@
import { YTNode } from '../../../helpers';
import Text from '../../misc/Text';
class LiveChatRestrictedParticipation extends YTNode {
message: Text;
icon_type?: string;
constructor(data: any) {
super();
this.message = new Text(data.message);
this.icon_type = data?.icon?.iconType;
// TODO: parse onClickCommand
}
}
export default LiveChatRestrictedParticipation;

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