Compare commits

...

27 Commits

Author SHA1 Message Date
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
68 changed files with 941 additions and 597 deletions

146
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,45 +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>
@@ -57,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>
@@ -73,7 +53,6 @@
___
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
<ol>
@@ -95,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 across 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.
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
@@ -138,7 +114,6 @@ npm install github:LuanRT/YouTube.js
**TODO:** Deno install instructions (esm.sh possibly?)
<!-- USAGE -->
## Usage
Create an InnerTube instance:
```ts
@@ -245,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)
@@ -260,7 +235,7 @@ const yt = await Innertube.create({
<details>
<summary>methods</summary>
<summary>Methods</summary>
<p>
* [.getInfo(video_id, client?)](#getinfo)
@@ -418,8 +393,8 @@ Retrieves YouTube's home feed.
- `<home_feed>#posts`
- Returns all posts in the home feed.
- `<home_feed>#shelfs`
- Returns all shelfs in the home feed.
- `<home_feed>#shelves`
- Returns all shelves in the home feed.
- `<home_feed>#filters`
- Returns available filters.
@@ -497,11 +472,16 @@ Retrieves contents for a given channel.
<p>
- `<channel>#getVideos()`
- `<channel>#getShorts()`
- `<channel>#getLiveStreams()`
- `<channel>#getPlaylists()`
- `<channel>#getHome()`
- `<channel>#getCommunity()`
- `<channel>#getChannels()`
- `<channel>#getAbout()`
- `<channel>#getContinuation()`
- `<channel>#filters`
- `<channel>#page`
</p>
</details>
@@ -592,48 +572,60 @@ 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(YTNodes.MusicCarouselShelf);
import { Innertube, YTNodes } from 'youtubei.js';
// Say we have a button and want to “click” it
const button = albums.as(YTNodes.MusicCarouselShelf).header?.more_content;
if (button) {
// To do that, we can call its navigation endpoint:
const page = await button.endpoint.call(yt.actions, { parse: true, client: 'YTMUSIC' });
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!
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
@@ -651,10 +643,12 @@ const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.Musi
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.
/**
* 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);
@@ -669,23 +663,21 @@ const sections = tab.content?.as(YTNodes.SectionList).contents.array().as(YTNode
console.info('Sections:', sections);
```
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]
@@ -695,13 +687,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

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

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

579
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -49,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'
@@ -188,6 +188,7 @@ class Actions {
'FEsubscriptions',
'FEmusic_listening_review',
'FEmusic_library_landing',
'SPaccount_overview',
'SPaccount_notifications',
'SPaccount_privacy',
'SPtime_watched'

View File

@@ -29,6 +29,7 @@ import AppendContinuationItemsAction from '../parser/classes/actions/AppendConti
import ContinuationItem from '../parser/classes/ContinuationItem';
import Video from '../parser/classes/Video';
import ReelItem from '../parser/classes/ReelItem';
class Feed {
#page: ParsedResponse;
@@ -63,9 +64,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,

View File

@@ -11,9 +11,6 @@ import Playlist from '../parser/ytmusic/Playlist';
import Recap from '../parser/ytmusic/Recap';
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';
@@ -61,7 +58,7 @@ class Music {
videoId: video_id,
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player.sts
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
@@ -89,7 +86,7 @@ class Music {
client: 'YTMUSIC',
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player.sts
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
@@ -235,13 +232,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.');
@@ -287,20 +280,16 @@ 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, { client: 'YTMUSIC', parse: true });
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;
}
@@ -318,13 +307,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: '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.');
@@ -334,7 +319,7 @@ class Music {
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);
}

View File

@@ -167,13 +167,13 @@ export default class Player {
static extractSigSourceCode(data: 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) {

View File

@@ -56,6 +56,7 @@ export interface Context {
export interface SessionOptions {
lang?: string;
location?: string;
account_index?: number;
device_category?: DeviceCategory;
client_type?: ClientType;
@@ -78,7 +79,7 @@ export default class Session extends EventEmitterLike {
actions;
cache;
constructor(context: Context, api_key: string, api_version: string, account_index: number, 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;
@@ -112,6 +113,7 @@ export default class Session extends EventEmitterLike {
static async create(options: SessionOptions = {}) {
const { context, api_key, api_version, account_index } = await Session.getSessionData(
options.lang,
options.location,
options.account_index,
options.device_category,
options.client_type,
@@ -123,6 +125,7 @@ export default class Session extends EventEmitterLike {
static async getSessionData(
lang = 'en-US',
location = '',
account_index = 0,
device_category: DeviceCategory = 'desktop',
client_name: ClientType = ClientType.WEB,
@@ -157,7 +160,7 @@ export default class Session extends EventEmitterLike {
const context: Context = {
client: {
hl: device_info[0],
gl: device_info[2],
gl: location || device_info[2],
remoteHost: device_info[3],
screenDensityFloat: 1,
screenHeightPoints: 720,

View File

@@ -17,7 +17,7 @@ class TabbedFeed extends Feed {
return this.#tabs.map((tab) => tab.title.toString());
}
async getTab(title: string) {
async getTabByName(title: string) {
const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
if (!tab)
@@ -28,8 +28,19 @@ 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) {
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);
}

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

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

View File

@@ -3,7 +3,7 @@ import { YTNode } from '../helpers';
import Thumbnail from './misc/Thumbnail';
export default class CarouselItem extends YTNode {
class CarouselItem extends YTNode {
static type = 'CarouselItem';
items: YTNode[];
@@ -20,4 +20,6 @@ export default class CarouselItem extends YTNode {
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

@@ -4,7 +4,7 @@ import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
export default class CompactStation extends YTNode {
class CompactStation extends YTNode {
static type = 'CompactStation';
title: Text;
@@ -22,4 +22,6 @@ export default class CompactStation extends YTNode {
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

@@ -4,7 +4,7 @@ import { YTNode } from '../helpers';
import Text from './misc/Text';
import NavigationEndpoint from './NavigationEndpoint';
export default class DefaultPromoPanel extends YTNode {
class DefaultPromoPanel extends YTNode {
static type = 'DefaultPromoPanel';
title: Text;
@@ -33,4 +33,6 @@ export default class DefaultPromoPanel extends YTNode {
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

@@ -2,7 +2,7 @@ import Parser from '../index';
import { YTNode } from '../helpers';
import ChipCloudChip from './ChipCloudChip';
export default class FeedFilterChipBar extends YTNode {
class FeedFilterChipBar extends YTNode {
static type = 'FeedFilterChipBar';
contents;
@@ -11,4 +11,6 @@ export default class FeedFilterChipBar extends YTNode {
super();
this.contents = Parser.parseArray<ChipCloudChip>(data.contents, ChipCloudChip);
}
}
}
export default FeedFilterChipBar;

View File

@@ -1,7 +1,7 @@
import Parser from '..';
import { YTNode } from '../helpers';
export default class GameCard extends YTNode {
class GameCard extends YTNode {
static type = 'GameCard';
game;
@@ -10,4 +10,6 @@ export default class GameCard extends YTNode {
super();
this.game = Parser.parseItem(data.game);
}
}
}
export default GameCard;

View File

@@ -4,7 +4,7 @@ import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
export default class GameDetails extends YTNode {
class GameDetails extends YTNode {
static type = 'GameDetails';
title: Text;
@@ -21,4 +21,6 @@ export default class GameDetails extends YTNode {
this.endpoint = new NavigationEndpoint(data.endpoint);
this.is_official_box_art = data.isOfficialBoxArt;
}
}
}
export default GameDetails;

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

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

@@ -7,7 +7,7 @@ import SubscribeButton from './SubscribeButton';
import MetadataBadge from './MetadataBadge';
import Button from './Button';
export default class InteractiveTabbedHeader extends YTNode {
class InteractiveTabbedHeader extends YTNode {
static type = 'InteractiveTabbedHeader';
header_type: string;
@@ -33,4 +33,6 @@ export default class InteractiveTabbedHeader extends YTNode {
this.buttons = Parser.parseArray<SubscribeButton | Button>(data.buttons, [ SubscribeButton, Button ]);
this.auto_generated = new Text(data.autoGenerated);
}
}
}
export default InteractiveTabbedHeader;

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

@@ -76,6 +76,8 @@ class NavigationEndpoint extends YTNode {
return '/browse';
case 'watchEndpoint':
return '/player';
case 'searchEndpoint':
return '/search';
case 'watchPlaylistEndpoint':
return '/next';
case 'liveChatItemContextMenuEndpoint':

View File

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

View File

@@ -76,8 +76,8 @@ class PlaylistPanelVideo extends YTNode {
}));
}
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

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

@@ -5,7 +5,7 @@ import Button from './Button';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
export default class RecognitionShelf extends YTNode {
class RecognitionShelf extends YTNode {
static type = 'RecognitionShelf';
title: Text;
@@ -23,4 +23,6 @@ export default class RecognitionShelf extends YTNode {
this.button = Parser.parseItem<Button>(data.button, Button);
this.surface = data.surface;
}
}
}
export default RecognitionShelf;

View File

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

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

@@ -1,7 +1,7 @@
import { YTNode } from '../helpers';
import Thumbnail from './misc/Thumbnail';
export default class ThumbnailLandscapePortrait extends YTNode {
class ThumbnailLandscapePortrait extends YTNode {
static type = 'ThumbnailLandscapePortrait';
landscape: Thumbnail[];
@@ -12,4 +12,6 @@ export default class ThumbnailLandscapePortrait extends YTNode {
this.landscape = Thumbnail.fromResponse(data.landscape);
this.portrait = Thumbnail.fromResponse(data.portrait);
}
}
}
export default ThumbnailLandscapePortrait;

View File

@@ -6,7 +6,7 @@ import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import SubscribeButton from './SubscribeButton';
export default class TopicChannelDetails extends YTNode {
class TopicChannelDetails extends YTNode {
static type = 'TopicChannelDetails';
title: Text;
@@ -24,4 +24,6 @@ export default class TopicChannelDetails extends YTNode {
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton, SubscribeButton);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
}
}
export default TopicChannelDetails;

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,9 +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';
@@ -17,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;
@@ -35,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();
@@ -53,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.parseArray(data.thumbnailOverlays);
this.rich_thumbnail = data.richThumbnail ? Parser.parse(data.richThumbnail) : null;
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);
@@ -75,6 +84,7 @@ class Video extends YTNode {
this.show_action_menu = data.showActionMenu;
this.is_watched = data.isWatched || false;
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
this.search_video_result_entity_key = data.searchVideoResultEntityKey;
}
get description(): string {
@@ -84,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

@@ -1,9 +1,11 @@
import Video from './Video';
export default class VideoCard extends 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.lengthText.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

@@ -1,4 +1,5 @@
import Player from '../../../core/Player';
import { InnertubeError } from '../../../utils/Utils';
class Format {
itag: string;
@@ -73,7 +74,8 @@ class Format {
* Decipher the streaming url of the format.
* @returns Deciphered URL.
*/
decipher(player: Player): string {
decipher(player: Player | undefined): string {
if (!player) throw new InnertubeError('Cannot decipher format, this session appears to have no valid player.');
return player.decipher(this.url, this.signature_cipher, this.cipher);
}
}

View File

@@ -3,9 +3,15 @@ import NavigationEndpoint from '../NavigationEndpoint';
class TextRun {
text: string;
endpoint: NavigationEndpoint | undefined;
bold: boolean;
italics: boolean;
strikethrough: boolean;
constructor(data: any) {
this.text = data.text;
this.bold = Boolean(data.bold);
this.italics = Boolean(data.italics);
this.strikethrough = Boolean(data.strikethrough);
this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : undefined;
}
}

View File

@@ -359,6 +359,10 @@ export type ObservedArray<T extends YTNode = YTNode> = Array<T> & {
* Returns all objects that match the rule.
*/
getAll: (rule: object, del_items?: boolean) => T[];
/**
* Returns the first object to match the condition.
*/
matchCondition: (condition: (node: T) => boolean) => T | undefined;
/**
* Removes the item at the given index.
*/
@@ -412,6 +416,14 @@ export function observe<T extends YTNode>(obj: Array<T>) {
);
}
if (prop == 'matchCondition') {
return (condition: (node: T) => boolean) => (
target.find((obj) => {
return condition(obj);
})
);
}
if (prop == 'filterType') {
return (...types: YTNodeConstructor<YTNode>[]) => {
return observe(target.filter((node: YTNode) => {

View File

@@ -73,6 +73,7 @@ import { default as Endscreen } from './classes/Endscreen';
import { default as EndscreenElement } from './classes/EndscreenElement';
import { default as EndScreenPlaylist } from './classes/EndScreenPlaylist';
import { default as EndScreenVideo } from './classes/EndScreenVideo';
import { default as ExpandableMetadata } from './classes/ExpandableMetadata';
import { default as ExpandableTab } from './classes/ExpandableTab';
import { default as ExpandedShelfContents } from './classes/ExpandedShelfContents';
import { default as FeedFilterChipBar } from './classes/FeedFilterChipBar';
@@ -137,6 +138,7 @@ import { default as LiveChatItemList } from './classes/LiveChatItemList';
import { default as LiveChatMessageInput } from './classes/LiveChatMessageInput';
import { default as LiveChatParticipant } from './classes/LiveChatParticipant';
import { default as LiveChatParticipantsList } from './classes/LiveChatParticipantsList';
import { default as MacroMarkersListItem } from './classes/MacroMarkersListItem';
import { default as Menu } from './classes/menus/Menu';
import { default as MenuNavigationItem } from './classes/menus/MenuNavigationItem';
import { default as MenuServiceItem } from './classes/menus/MenuServiceItem';
@@ -363,6 +365,7 @@ export const YTNodes = {
EndscreenElement,
EndScreenPlaylist,
EndScreenVideo,
ExpandableMetadata,
ExpandableTab,
ExpandedShelfContents,
FeedFilterChipBar,
@@ -427,6 +430,7 @@ export const YTNodes = {
LiveChatMessageInput,
LiveChatParticipant,
LiveChatParticipantsList,
MacroMarkersListItem,
Menu,
MenuNavigationItem,
MenuServiceItem,

View File

@@ -9,9 +9,14 @@ import MicroformatData from '../classes/MicroformatData';
import SubscribeButton from '../classes/SubscribeButton';
import Tab from '../classes/Tab';
import FeedFilterChipBar from '../classes/FeedFilterChipBar';
import ChipCloudChip from '../classes/ChipCloudChip';
import FilterableFeed from '../../core/FilterableFeed';
import Feed from '../../core/Feed';
import { InnertubeError } from '../../utils/Utils';
class Channel extends TabbedFeed {
export default class Channel extends TabbedFeed {
header;
metadata;
subscribe_button;
@@ -37,28 +42,66 @@ class Channel extends TabbedFeed {
this.current_tab = tab;
}
/**
* Applies given filter to the list.
* @param filter - The filter to apply
*/
async applyFilter(filter: string | ChipCloudChip) {
let target_filter: ChipCloudChip | undefined;
const filter_chipbar = this.memo.getType(FeedFilterChipBar)?.[0];
if (typeof filter === 'string') {
target_filter = filter_chipbar?.contents.get({ text: filter });
if (!target_filter)
throw new InnertubeError(`Filter ${filter} not found`, { available_filters: this.filters });
} else if (filter instanceof ChipCloudChip) {
target_filter = filter;
}
if (!target_filter)
throw new InnertubeError('Invalid filter', filter);
const page = await target_filter.endpoint?.call(this.actions, { parse: true });
return new FilteredChannelList(this.actions, page, true);
}
get filters(): string[] {
return this.memo.getType(FeedFilterChipBar)?.[0]?.contents.filterType(ChipCloudChip).map((chip) => chip.text) || [];
}
async getHome() {
const tab = await this.getTabByURL('featured');
return new Channel(this.actions, tab.page, true);
}
async getVideos() {
const tab = await this.getTab('Videos');
const tab = await this.getTabByURL('videos');
return new Channel(this.actions, tab.page, true);
}
async getShorts() {
const tab = await this.getTabByURL('shorts');
return new Channel(this.actions, tab.page, true);
}
async getLiveStreams() {
const tab = await this.getTabByURL('streams');
return new Channel(this.actions, tab.page, true);
}
async getPlaylists() {
const tab = await this.getTab('Playlists');
return new Channel(this.actions, tab.page, true);
}
async getHome() {
const tab = await this.getTab('Home');
const tab = await this.getTabByURL('playlists');
return new Channel(this.actions, tab.page, true);
}
async getCommunity() {
const tab = await this.getTab('Community');
const tab = await this.getTabByURL('community');
return new Channel(this.actions, tab.page, true);
}
async getChannels() {
const tab = await this.getTab('Channels');
const tab = await this.getTabByURL('channels');
return new Channel(this.actions, tab.page, true);
}
@@ -67,9 +110,77 @@ class Channel extends TabbedFeed {
* Note that this does not return a new {@link Channel} object.
*/
async getAbout() {
const tab = await this.getTab('About');
const tab = await this.getTabByURL('about');
return tab.memo.getType(ChannelAboutFullMetadata)?.[0];
}
/**
* Retrives list continuation.
*/
async getContinuation() {
const page = await super.getContinuationData();
return new ChannelListContinuation(this.actions, page, true);
}
}
export default Channel;
export class ChannelListContinuation extends Feed {
contents;
constructor(actions: Actions, data: any, already_parsed = false) {
super(actions, data, already_parsed);
this.contents =
this.page.on_response_received_actions?.[0] ||
this.page.on_response_received_endpoints?.[0];
}
/**
* Retrieves list continuation.
*/
async getContinuation() {
const page = await super.getContinuationData();
return new ChannelListContinuation(this.actions, page, true);
}
}
export class FilteredChannelList extends FilterableFeed {
applied_filter: ChipCloudChip | undefined;
contents;
constructor(actions: Actions, data: any, already_parsed = false) {
super(actions, data, already_parsed);
this.applied_filter = this.memo.getType(ChipCloudChip).get({ is_selected: true });
// Removes the filter chipbar from the actions list
if (
this.page.on_response_received_actions &&
this.page.on_response_received_actions.length > 1
) {
this.page.on_response_received_actions.shift();
}
this.contents = this.page.on_response_received_actions?.[0];
}
/**
* Applies given filter to the list.
* @param filter - The filter to apply
*/
async applyFilter(filter: string | ChipCloudChip) {
const feed = await super.getFilteredFeed(filter);
return new FilteredChannelList(this.actions, feed.page, true);
}
/**
* Retrieves list continuation.
*/
async getContinuation() {
const page = await super.getContinuationData();
// Keep the filters
page?.on_response_received_actions_memo.set('FeedFilterChipBar', this.memo.getType(FeedFilterChipBar));
page?.on_response_received_actions_memo.set('ChipCloudChip', this.memo.getType(ChipCloudChip));
return new FilteredChannelList(this.actions, page, true);
}
}

View File

@@ -39,10 +39,10 @@ class Library {
}
async #getAll(shelf: Shelf): Promise<Playlist | History | Feed> {
if (!shelf.menu?.item().as(Menu).hasKey('top_level_buttons'))
if (!shelf.menu?.as(Menu).hasKey('top_level_buttons'))
throw new InnertubeError(`The ${shelf.title.text} shelf doesn't have more items`);
const button = shelf.menu.item().as(Menu).top_level_buttons.get({ text: 'See all' });
const button = shelf.menu.as(Menu).top_level_buttons.get({ text: 'See all' });
if (!button)
throw new InnertubeError('Did not find target button.');

View File

@@ -7,6 +7,7 @@ import VideoOwner from '../classes/VideoOwner';
import PlaylistMetadata from '../classes/PlaylistMetadata';
import PlaylistSidebarPrimaryInfo from '../classes/PlaylistSidebarPrimaryInfo';
import PlaylistSidebarSecondaryInfo from '../classes/PlaylistSidebarSecondaryInfo';
import PlaylistCustomThumbnail from '../classes/PlaylistCustomThumbnail';
import PlaylistVideoThumbnail from '../classes/PlaylistVideoThumbnail';
import PlaylistHeader from '../classes/PlaylistHeader';
@@ -30,8 +31,8 @@ class Playlist extends Feed {
this.info = {
...this.page.metadata.item().as(PlaylistMetadata),
...{
author: secondary_info?.owner.item().as(VideoOwner).author,
thumbnails: primary_info?.thumbnail_renderer.item().as(PlaylistVideoThumbnail).thumbnail as Thumbnail[],
author: secondary_info?.owner.item().as(VideoOwner).author ?? header?.author,
thumbnails: primary_info?.thumbnail_renderer.item().as(PlaylistVideoThumbnail, PlaylistCustomThumbnail).thumbnail as Thumbnail[],
total_items: this.#getStat(0, primary_info),
views: this.#getStat(1, primary_info),
last_updated: this.#getStat(2, primary_info),

View File

@@ -1,19 +1,15 @@
import Actions from '../../core/Actions';
import { observe, ObservedArray, YTNode } from '../helpers';
import { ObservedArray, YTNode } from '../helpers';
import { InnertubeError } from '../../utils/Utils';
import Feed from '../../core/Feed';
import SectionList from '../classes/SectionList';
import ItemSection from '../classes/ItemSection';
import HorizontalCardList from '../classes/HorizontalCardList';
import RichListHeader from '../classes/RichListHeader';
import SearchRefinementCard from '../classes/SearchRefinementCard';
import TwoColumnSearchResults from '../classes/TwoColumnSearchResults';
import UniversalWatchCard from '../classes/UniversalWatchCard';
import WatchCardHeroVideo from '../classes/WatchCardHeroVideo';
import WatchCardSectionSequence from '../classes/WatchCardSectionSequence';
class Search extends Feed {
export default class Search extends Feed {
results: ObservedArray<YTNode> | null | undefined;
refinements;
estimated_results;
@@ -24,30 +20,16 @@ class Search extends Feed {
super(actions, data, already_parsed);
const contents =
this.page.contents?.item().as(TwoColumnSearchResults).primary_contents?.item().as(SectionList).contents.array() ||
this.page.contents_memo.getType(SectionList)?.[0]?.contents ||
this.page.on_response_received_commands?.[0].contents;
const secondary_contents_maybe = this.page.contents?.item().key('secondary_contents');
const secondary_contents = secondary_contents_maybe?.isParsed() ? secondary_contents_maybe.parsed().item().key('contents').parsed().array() : undefined;
this.results = contents.firstOfType(ItemSection)?.contents;
const card_list = this.results?.get({ type: 'HorizontalCardList' }, true)?.as(HorizontalCardList);
const universal_watch_card = secondary_contents?.firstOfType(UniversalWatchCard);
this.refinements = this.page.refinements || [];
this.estimated_results = this.page.estimated_results;
this.watch_card = {
header: universal_watch_card?.header.item() || null,
call_to_action: universal_watch_card?.call_to_action.item().as(WatchCardHeroVideo) || null,
sections: universal_watch_card?.sections.array().filterType(WatchCardSectionSequence) || []
};
this.refinement_cards = {
header: card_list?.header.item().as(RichListHeader) || null,
cards: card_list?.cards.array().filterType(SearchRefinementCard) || observe([] as SearchRefinementCard[])
};
this.watch_card = this.page?.contents_memo.getType(UniversalWatchCard)?.[0];
this.refinement_cards = this.results?.get({ type: 'HorizontalCardList' }, true)?.as(HorizontalCardList);
}
/**
@@ -57,7 +39,8 @@ class Search extends Feed {
let target_card: SearchRefinementCard | undefined;
if (typeof card === 'string') {
target_card = this.refinement_cards.cards.get({ query: card });
if (!this.refinement_cards) throw new InnertubeError('No refinement cards found.');
target_card = this.refinement_cards?.cards.get({ query: card })?.as(SearchRefinementCard);
if (!target_card)
throw new InnertubeError(`Refinement card "${card}" not found`, { available_cards: this.refinement_card_queries });
} else if (card.type === 'SearchRefinementCard') {
@@ -71,8 +54,11 @@ class Search extends Feed {
return new Search(this.actions, page, true);
}
/**
* Returns a list of refinement card queries.
*/
get refinement_card_queries() {
return this.refinement_cards.cards.map((card) => card.query);
return this.refinement_cards?.cards.as(SearchRefinementCard).map((card) => card.query);
}
/**
@@ -82,6 +68,4 @@ class Search extends Feed {
const continuation = await this.getContinuationData();
return new Search(this.actions, continuation, true);
}
}
export default Search;
}

View File

@@ -31,7 +31,7 @@ class Settings {
if (!tab)
throw new InnertubeError('Target tab not found');
const contents = tab.content?.as(SectionList).contents.array().as(ItemSection);
const contents = tab.content?.as(SectionList).contents.as(ItemSection);
this.introduction = contents?.shift()?.contents?.get({ type: 'PageIntroduction' })?.as(PageIntroduction);

View File

@@ -19,7 +19,7 @@ class TimeWatched {
if (!tab)
throw new InnertubeError('Could not find target tab.');
this.contents = tab.content?.as(SectionList).contents.array().as(ItemSection);
this.contents = tab.content?.as(SectionList).contents.as(ItemSection);
}
get page(): ParsedResponse {

View File

@@ -92,7 +92,7 @@ class VideoInfo {
* @param data - API response.
* @param cpn - Client Playback Nonce
*/
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, player: Player, cpn: string) {
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, player?: Player, cpn?: string) {
this.#actions = actions;
this.#player = player;
this.#cpn = cpn;
@@ -492,7 +492,7 @@ class VideoInfo {
throw new InnertubeError('Index and init ranges not available', { format });
const url = new URL(format.decipher(this.#player));
url.searchParams.set('cpn', this.#cpn);
url.searchParams.set('cpn', this.#cpn || '');
set.appendChild(this.#el(document, 'Representation', {
id: format.itag,
@@ -522,7 +522,7 @@ class VideoInfo {
throw new InnertubeError('Index and init ranges not available', { format });
const url = new URL(format.decipher(this.#player));
url.searchParams.set('cpn', this.#cpn);
url.searchParams.set('cpn', this.#cpn || '');
set.appendChild(this.#el(document, 'Representation', {
id: format.itag,

View File

@@ -28,8 +28,8 @@ class Explore {
if (!section_list)
throw new InnertubeError('Target tab did not have any content.');
this.top_buttons = section_list.contents.array().firstOfType(Grid)?.items.as(MusicNavigationButton) || ([] as MusicNavigationButton[]);
this.sections = section_list.contents.array().getAll({ type: 'MusicCarouselShelf' }) as MusicCarouselShelf[];
this.top_buttons = section_list.contents.firstOfType(Grid)?.items.as(MusicNavigationButton) || ([] as MusicNavigationButton[]);
this.sections = section_list.contents.getAll({ type: 'MusicCarouselShelf' }) as MusicCarouselShelf[];
}
get page(): ParsedResponse {

View File

@@ -33,7 +33,7 @@ class HomeFeed {
}
this.#continuation = tab.content?.as(SectionList).continuation;
this.sections = tab.content?.as(SectionList).contents.array().as(MusicCarouselShelf);
this.sections = tab.content?.as(SectionList).contents.as(MusicCarouselShelf);
}
/**

View File

@@ -30,7 +30,7 @@ class Library {
const section_list = this.#page.contents_memo.getType(SectionList)?.[0];
this.header = section_list?.header?.item().as(MusicSideAlignedItem);
this.contents = section_list?.contents?.array().as(Grid, MusicShelf);
this.contents = section_list?.contents?.as(Grid, MusicShelf);
this.#continuation = this.contents?.find((list: Grid | MusicShelf) => list.continuation)?.continuation;
}

View File

@@ -61,7 +61,7 @@ class Playlist {
/**
* Retrieves related playlists
*/
async getRelated() {
async getRelated(): Promise<MusicCarouselShelf> {
let section_continuation = this.#page.contents_memo.get('SectionList')?.[0].as(SectionList).continuation;
while (section_continuation) {
@@ -74,19 +74,15 @@ class Playlist {
const section_list = data.continuation_contents?.as(SectionListContinuation);
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
const related = sections?.filter(
(section) =>
section.is(MusicCarouselShelf) ? section.header?.title.toString() === 'Related playlists' :
section.title.toString() === 'Related playlists'
)[0];
const related = sections?.matchCondition((section) => section.is(MusicCarouselShelf))?.as(MusicCarouselShelf);
if (related)
return related.contents || [];
return related;
section_continuation = section_list?.continuation;
}
return [];
throw new InnertubeError('Target section not found.');
}
async getSuggestions(refresh = true) {
@@ -115,9 +111,7 @@ class Playlist {
const section_list = page.continuation_contents?.as(SectionListContinuation);
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
const suggestions = sections?.filter(
(section) => section.is(MusicShelf) && section.title.toString() === 'Suggestions'
)[0] as MusicShelf | undefined;
const suggestions = sections?.matchCondition((section) => section.is(MusicShelf))?.as(MusicShelf);
return {
items: suggestions?.contents || [],

View File

@@ -37,7 +37,7 @@ class Recap {
if (!tab)
throw new InnertubeError('Target tab not found');
this.sections = tab.content?.as(SectionList).contents.array().as(ItemSection, MusicCarouselShelf, Message);
this.sections = tab.content?.as(SectionList).contents.as(ItemSection, MusicCarouselShelf, Message);
}
/**

View File

@@ -49,7 +49,7 @@ class Search {
this.header = tab_content.hasKey('header') ? tab_content.header?.item().as(ChipCloud) : null;
const shelves = tab_content.contents.array().as(MusicShelf, ItemSection);
const shelves = tab_content.contents.as(MusicShelf, ItemSection);
const item_section = shelves.firstOfType(ItemSection);
this.did_you_mean = item_section?.contents?.firstOfType(DidYouMean) || null;

View File

@@ -4,9 +4,7 @@ import Constants from '../../utils/Constants';
import { InnertubeError } from '../../utils/Utils';
import Tab from '../classes/Tab';
import Tabbed from '../classes/Tabbed';
import WatchNextTabbedResults from '../classes/WatchNextTabbedResults';
import SingleColumnMusicWatchNextResults from '../classes/SingleColumnMusicWatchNextResults';
import MicroformatData from '../classes/MicroformatData';
import PlayerOverlay from '../classes/PlayerOverlay';
import PlaylistPanel from '../classes/PlaylistPanel';
@@ -70,8 +68,7 @@ class TrackInfo {
this.#playback_tracking = info.playback_tracking;
if (next) {
const single_col = next.contents.item().as(SingleColumnMusicWatchNextResults);
const tabbed_results = single_col.contents.item().as(Tabbed).contents.item().as(WatchNextTabbedResults);
const tabbed_results = next.contents_memo.getType(WatchNextTabbedResults)?.[0];
this.tabs = tabbed_results.tabs.array().as(Tab);
this.current_video_endpoint = next.current_video_endpoint;
@@ -84,14 +81,17 @@ class TrackInfo {
/**
* Retrieves contents of the given tab.
*/
async getTab(title: string) {
async getTab(title_or_page_type: string) {
if (!this.tabs)
throw new InnertubeError('Could not find any tab');
const target_tab = this.tabs.get({ title });
const target_tab =
this.tabs.get({ title: title_or_page_type }) ||
this.tabs.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === title_or_page_type) ||
this.tabs?.[0];
if (!target_tab)
throw new InnertubeError(`Tab "${title}" not found`, { available_tabs: this.available_tabs });
throw new InnertubeError(`Tab "${title_or_page_type}" not found`, { available_tabs: this.available_tabs });
if (target_tab.content)
return target_tab.content;
@@ -101,7 +101,7 @@ class TrackInfo {
if (page.contents.item().key('type').string() === 'Message')
return page.contents.item().as(Message);
return page.contents.item().as(SectionList).contents.array();
return page.contents.item().as(SectionList).contents;
}
/**
@@ -140,7 +140,7 @@ class TrackInfo {
* Retrieves related content.
*/
async getRelated(): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
const tab = await this.getTab('Related') as ObservedArray<MusicDescriptionShelf | MusicDescriptionShelf>;
const tab = await this.getTab('MUSIC_PAGE_TYPE_TRACK_RELATED') as ObservedArray<MusicDescriptionShelf | MusicDescriptionShelf>;
return tab;
}
@@ -148,7 +148,7 @@ class TrackInfo {
* Retrieves lyrics.
*/
async getLyrics(): Promise<MusicDescriptionShelf | undefined> {
const tab = await this.getTab('Lyrics') as ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>;
const tab = await this.getTab('MUSIC_PAGE_TYPE_TRACK_LYRICS') as ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>;
return tab.firstOfType(MusicDescriptionShelf);
}

View File

@@ -48,7 +48,7 @@ export const CLIENTS = Object.freeze({
},
YTMUSIC_ANDROID: {
NAME: 'ANDROID_MUSIC',
VERSION: '5.17.51'
VERSION: '5.34.51'
},
TV_EMBEDDED: {
NAME: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',

View File

@@ -48,7 +48,7 @@ export default class HTTPClient {
const request_headers = new Headers(headers);
request_headers.set('Accept', '*/*');
request_headers.set('Accept-Language', `en-${this.#session.context.client.gl || 'US'}`);
request_headers.set('Accept-Language', `${this.#session.context.client.hl}-${this.#session.context.client.gl}`);
request_headers.set('x-goog-visitor-id', this.#session.context.client.visitorData || '');
request_headers.set('x-origin', request_url.origin);
request_headers.set('x-youtube-client-version', this.#session.context.client.clientVersion || '');

View File

@@ -10,5 +10,16 @@ export const VIDEOS = [
{
ID: 'I1qsF0WQy8c',
QUERY: 'mkbhd',
},
{
ID: 'OqiXFXlYFi8',
QUERY: 'formatted comment text'
}
];
export const CHANNELS = [
{
ID: 'UC_x5XG1OV2P6uZZ5FSM9Ttw',
NAME: 'Linus Tech Tips'
}
];

View File

@@ -1,7 +1,8 @@
import fs from 'fs';
import Innertube from '..';
import { VIDEOS } from './constants';
import { CHANNELS, VIDEOS } from './constants';
import { streamToIterable } from '../src/utils/Utils';
import TextRun from '../src/parser/classes/misc/TextRun';
describe('YouTube.js Tests', () => {
let yt: Innertube;
@@ -68,6 +69,19 @@ describe('YouTube.js Tests', () => {
threads = await yt.getComments(VIDEOS[1].ID);
expect(threads.contents.length).toBeGreaterThan(0);
});
it('should parse formatted comments', async () => {
const threads = await yt.getComments(VIDEOS[3].ID);
const authorComment = threads.contents.find(t => t.comment?.author_is_channel_owner)
expect(authorComment).not.toBeUndefined();
expect(authorComment!.comment?.content.runs?.length).toBeGreaterThan(0)
const runs = authorComment!.comment!.content.runs! as TextRun[]
expect(runs[0].bold).toBeTruthy()
expect(runs[2].italics).toBeTruthy()
expect(runs[4].strikethrough).toBeTruthy()
})
it('should retrieve next batch of comments', async () => {
const next = await threads.getContinuation();
@@ -90,6 +104,18 @@ describe('YouTube.js Tests', () => {
expect(playlist.items.length).toBeLessThanOrEqual(100);
});
it('should retrieve channel', async () => {
const channel = await yt.getChannel(CHANNELS[0].ID);
expect(channel.videos.length).toBeGreaterThan(0);
expect(channel.shelves.length).toBeGreaterThan(0);
const videos_tab = await channel.getVideos();
expect(videos_tab.videos.length).toBeGreaterThan(0);
const filtered_list = await videos_tab.applyFilter('Popular');
expect(filtered_list.videos.length).toBeGreaterThan(0);
});
it('should retrieve home feed', async () => {
const homefeed = await yt.getHomeFeed();
expect(homefeed.header).toBeDefined();