Compare commits

..

12 Commits

Author SHA1 Message Date
LuanRT
ab028ba1ec chore(package) release v2.1.0 2022-09-13 02:40:31 -03:00
LuanRT
f2f48af1bc feat(Music): add automix support and other minor improvements (#184)
* dev(NavigationEndpoint): add `/player` endpoint

* dev: add AudioOnlyPlayability, BrowserMediaSession and MusicDownloadStateBadge

* dev: allow endpoints to be overridden

* dev: minor parser changes

* dev(TrackInfo): add `<info>#getTab(title?)`

* dev: allow `Music#getInfo()` to accept list items

* dev: revert a few changes, I probably overcomplicated this.

* dev: add tests

* dev: add `TrackInfo#getUpNext()`, `TrackInfo#getRelated()` and `TrackInfo#getLyrics()`

* docs: update API ref

* fix(docs): formatting inconsistencies
2022-09-13 02:26:13 -03:00
LuanRT
3a7da21fd1 fix: improve sig extraction (#183)
* dev: improve sig decipher code extraction

* chore(deps): update Jinter to 0.2.0
2022-09-13 01:36:27 -03:00
LuanRT
89794d65da fix: likes not being parsed correctly 2022-09-11 22:44:27 -03:00
LuanRT
91847ae3cc feat(LiveChat): add SegmentedLikeDislikeButton and LiveChatDialog (#181)
* feat: add `LiveChatDialog`

* feat: add `SegmentedLikeDislikeButton`
2022-09-10 14:54:13 -03:00
LuanRT
eb44b71939 feat: add CollaboratorInfoCardContent renderer parser (#180) 2022-09-10 04:09:38 -03:00
LuanRT
88ebb5e2ae fix: replace s placeholders in playback tracking urls 2022-09-10 03:32:43 -03:00
LuanRT
b237b6af4e Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-09-10 03:29:30 -03:00
Nico K
9e618cc576 fix: LiveChatAuthorBadge where MetadataBadge was expected (#179)
* fix `LiveChatAuthorBadge` where `MetadataBadge` was expected

* add "failsafe" for author badges
2022-09-09 19:30:20 -03:00
LuanRT
daf95cfe87 chore: update contribution guidelines 2022-09-09 17:21:48 -03:00
Patrick Kan
bc03c91df9 feat: add PlaylistPanelVideoWrapper parser (#176)
* feat: add `PlaylistPanelVideoWrapper` parser

* fix: `PlaylistPanelVideoWrapper` no counterpart
2022-09-09 15:30:21 -03:00
Akazawa Daisuke
e00be25bf4 feat: add LiveChatAutoModMessage (#177) 2022-09-09 15:29:36 -03:00
31 changed files with 565 additions and 197 deletions

View File

@@ -12,10 +12,7 @@ ___
* [Create a PR](#changes-2)
* [Run tests](#test)
* [Lint your code](#lint)
* [Build for node](#build-1)
* [Bundle for browsers](#build-2)
* [Compile proto file](#build-3)
* [Build parser map](#build-4)
* [Build](#build)
## Issues
@@ -62,42 +59,23 @@ npm run test
```bash
npm run lint
```
Or
```bash
npm run lint:fix
```
<a id="build-1"></a>
#### Build for Node
<a id="build"></a>
#### Build
```bash
# Node
npm run build:node
```
<a id="build-2"></a>
#### Build for browsers
```bash
# Browser
npm run build:browser
```
Or:
```bash
npm run build:browser:prod
```
<a id="build-3"></a>
#### Compile proto file
# Protobuf
npm run build:proto
```bash
// TODO
```
<a id="build-4"></a>
#### Build parser map
```bash
# Parser map
npm run build:parser-map
```
```

View File

@@ -40,7 +40,7 @@
[![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]
[![Codefactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)][codefactor]
[![Monthly downloads](https://img.shields.io/npm/dm/youtubei.js)][npm]
[![Downloads](https://img.shields.io/npm/dt/youtubei.js)][npm]
[![Say thanks](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)][say-thanks]
</div>

View File

@@ -5,7 +5,7 @@ YouTube Music class.
## API
* Music
* [.getInfo(video_id)](#getinfo)
* [.getInfo(target)](#getinfo)
* [.search(query, filters?)](#search)
* [.getHomeFeed()](#gethomefeed)
* [.getExplore()](#getexplore)
@@ -14,13 +14,13 @@ YouTube Music class.
* [.getAlbum(album_id)](#getalbum)
* [.getPlaylist(playlist_id)](#getplaylist)
* [.getLyrics(video_id)](#getlyrics)
* [.getUpNext(video_id)](#getupnext)
* [.getUpNext(video_id, automix?)](#getupnext)
* [.getRelated(video_id)](#getrelated)
* [.getRecap()](#getrecap)
* [.getSearchSuggestions(query)](#getsearchsuggestions)
<a name="getinfo"></a>
### getInfo(video_id)
### getInfo(target)
Retrieves track info.
@@ -28,7 +28,29 @@ Retrieves track info.
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| target | `string` or `MusicTwoRowItem` | video id or list item |
<details>
<summary>Methods & Getters</summary>
<p>
- `<info>#getTab(title)`
- Retrieves contents of the given tab.
- `<info>#getUpNext(automix?)`
- Retrieves up next.
- `<info>#getRelated()`
- Retrieves related content.
- `<info>#getLyrics()`
- Retrieves song lyrics.
- `<info>#available_tabs`
- Returns available tabs.
</p>
</details>
<a name="search"></a>
### search(query, filters?)
@@ -211,14 +233,14 @@ Retrieves given playlist.
Retrieves song lyrics.
**Returns:** `Promise.<{ text: string; footer: object; }>`
**Returns:** `Promise.<MusicDescriptionShelf | undefined>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
<a name="getupnext"></a>
### getUpNext(video_id)
### getUpNext(video_id, automix?)
Retrieves up next content.
@@ -227,6 +249,7 @@ Retrieves up next content.
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| automix? | `boolean` | if automix should be fetched |
<a name="getrelated"></a>
### getRelated(video_id)

44
package-lock.json generated
View File

@@ -1,28 +1,26 @@
{
"name": "youtubei.js",
"version": "2.0.2",
"version": "2.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "2.0.2",
"version": "2.1.0",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"@protobuf-ts/runtime": "^2.7.0",
"jintr": "^0.1.9",
"jintr": "^0.2.0",
"linkedom": "^0.14.12",
"undici": "^5.7.0"
},
"devDependencies": {
"@protobuf-ts/plugin": "^2.7.0",
"@types/flat": "^5.0.2",
"@types/jest": "^28.1.7",
"@types/node": "^17.0.45",
"@types/user-agents": "^1.0.2",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"esbuild": "^0.14.49",
@@ -1263,12 +1261,6 @@
"@babel/types": "^7.3.0"
}
},
"node_modules/@types/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-3zsplnP2djeps5P9OyarTxwRpMLoe5Ash8aL9iprw0JxB+FAHjY+ifn4yZUuW4/9hqtnmor6uvjSRzJhiVbrEQ==",
"dev": true
},
"node_modules/@types/graceful-fs": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
@@ -1336,12 +1328,6 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true
},
"node_modules/@types/user-agents": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/user-agents/-/user-agents-1.0.2.tgz",
"integrity": "sha512-WOoL2UJTI6RxV8RB2kS3ZhxjjijI5G1i7mgU7mtlm4LsC1XGCfiV56h+GV4VZnAUkkkLQ4gbFGR/dggT01n0RA==",
"dev": true
},
"node_modules/@types/yargs": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz",
@@ -3974,9 +3960,9 @@
}
},
"node_modules/jintr": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-0.1.9.tgz",
"integrity": "sha512-0fB9LtzM/Pf7rGI8TViLM2cfLQTRWAdRjwmYWpMamHPAmX083zR2kj6cXTqtJ4v90+//r+SdZY9RSwTQsNetQw==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-0.2.0.tgz",
"integrity": "sha512-b1TmzzCnVivT6ftmRFtvT/pPu/t/pCDH/GA8yzXKfOGOVG6X/pmrRU9vdWgV1I/EJTKt/i/cvx6MfmGMjlhWMA==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
@@ -6294,12 +6280,6 @@
"@babel/types": "^7.3.0"
}
},
"@types/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-3zsplnP2djeps5P9OyarTxwRpMLoe5Ash8aL9iprw0JxB+FAHjY+ifn4yZUuW4/9hqtnmor6uvjSRzJhiVbrEQ==",
"dev": true
},
"@types/graceful-fs": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
@@ -6367,12 +6347,6 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true
},
"@types/user-agents": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/user-agents/-/user-agents-1.0.2.tgz",
"integrity": "sha512-WOoL2UJTI6RxV8RB2kS3ZhxjjijI5G1i7mgU7mtlm4LsC1XGCfiV56h+GV4VZnAUkkkLQ4gbFGR/dggT01n0RA==",
"dev": true
},
"@types/yargs": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz",
@@ -8206,9 +8180,9 @@
}
},
"jintr": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-0.1.9.tgz",
"integrity": "sha512-0fB9LtzM/Pf7rGI8TViLM2cfLQTRWAdRjwmYWpMamHPAmX083zR2kj6cXTqtJ4v90+//r+SdZY9RSwTQsNetQw==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-0.2.0.tgz",
"integrity": "sha512-b1TmzzCnVivT6ftmRFtvT/pPu/t/pCDH/GA8yzXKfOGOVG6X/pmrRU9vdWgV1I/EJTKt/i/cvx6MfmGMjlhWMA==",
"requires": {
"acorn": "^8.8.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "2.0.2",
"version": "2.1.0",
"description": "Full-featured wrapper around YouTube's private API.",
"main": "./dist/index.js",
"browser": "./bundle/browser.js",
@@ -39,16 +39,16 @@
"license": "MIT",
"dependencies": {
"@protobuf-ts/runtime": "^2.7.0",
"jintr": "^0.1.9",
"jintr": "^0.2.0",
"linkedom": "^0.14.12",
"undici": "^5.7.0"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"@protobuf-ts/plugin": "^2.7.0",
"@types/jest": "^28.1.7",
"@types/node": "^17.0.45",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"esbuild": "^0.14.49",
"eslint": "^8.19.0",
"eslint-plugin-tsdoc": "^0.2.16",

View File

@@ -618,7 +618,7 @@ class Actions {
/**
* Used to retrieve video info.
*/
async getVideoInfo(id: string, cpn?: string, client?: string) {
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string) {
const data: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
@@ -647,6 +647,10 @@ class Actions {
data.cpn = cpn;
}
if (playlist_id) {
data.playlistId = playlist_id;
}
const response = await this.#session.http.fetch('/player', {
method: 'POST',
body: JSON.stringify(data),
@@ -719,6 +723,9 @@ class Actions {
throw new InnertubeError('You are not signed in');
}
if (Reflect.has(data, 'override_endpoint'))
delete data.override_endpoint;
if (Reflect.has(data, 'parse'))
delete data.parse;
@@ -745,11 +752,17 @@ class Actions {
data.continuation = data.token;
delete data.token;
}
if (data?.client === 'YTMUSIC') {
data.isAudioOnly = true;
}
} else {
data = args.serialized_data;
}
const response = await this.#session.http.fetch(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),
headers: {

View File

@@ -1,7 +1,6 @@
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';
@@ -11,39 +10,94 @@ import Album from '../parser/ytmusic/Album';
import Playlist from '../parser/ytmusic/Playlist';
import Recap from '../parser/ytmusic/Recap';
import Parser from '../parser/index';
import { observe, YTNode } from '../parser/helpers';
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 Message from '../parser/classes/Message';
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 MusicTwoRowItem from '../parser/classes/MusicTwoRowItem';
import { observe, ObservedArray, YTNode } from '../parser/helpers';
import { InnertubeError, throwIfMissing, generateRandomString } from '../utils/Utils';
class Music {
#session;
#actions;
constructor(session: Session) {
this.#session = session;
this.#actions = session.actions;
}
/**
* Retrieves track info.
* Retrives track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
* @param target - video id or a list item.
*/
async getInfo(video_id: string) {
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
if (target instanceof MusicTwoRowItem) {
return this.#fetchInfoFromListItem(target);
} else if (typeof target === 'string') {
return this.#fetchInfoFromVideoId(target);
}
throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target);
}
async #fetchInfoFromVideoId(video_id: string) {
const cpn = generateRandomString(16);
const initial_info = await this.#actions.getVideoInfo(video_id, cpn, 'YTMUSIC');
const continuation = this.#actions.execute('/next', { client: 'YTMUSIC', videoId: video_id });
const initial_info = this.#actions.execute('/player', {
cpn,
client: 'YTMUSIC',
videoId: video_id,
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player.sts
}
}
});
const continuation = this.#actions.execute('/next', {
client: 'YTMUSIC',
videoId: video_id
});
const response = await Promise.all([ initial_info, continuation ]);
return new TrackInfo(response, this.#actions, cpn);
}
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined) {
if (!list_item)
throw new InnertubeError('List item cannot be undefined');
if (!list_item.endpoint)
throw new Error('This item does not have an endpoint.');
const cpn = generateRandomString(16);
const initial_info = list_item.endpoint.callTest(this.#actions, {
cpn,
client: 'YTMUSIC',
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player.sts
}
}
});
const continuation = list_item.endpoint.callTest(this.#actions, {
client: 'YTMUSIC',
enablePersistentPlaylistPanel: true,
override_endpoint: '/next'
});
const response = await Promise.all([ initial_info, continuation ]);
return new TrackInfo(response, this.#actions, cpn);
@@ -54,7 +108,7 @@ class Music {
*/
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' });
return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' });
@@ -63,7 +117,7 @@ class Music {
/**
* Retrieves the home feed.
*/
async getHomeFeed() {
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' });
return new HomeFeed(response, this.#actions);
}
@@ -71,7 +125,7 @@ class Music {
/**
* Retrieves the Explore feed.
*/
async getExplore() {
async getExplore(): Promise<Explore> {
const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' });
return new Explore(response);
// TODO: return new Explore(response, this.#actions);
@@ -87,7 +141,7 @@ class Music {
/**
* Retrieves artist's info & content.
*/
async getArtist(artist_id: string) {
async getArtist(artist_id: string): Promise<Artist> {
throwIfMissing({ artist_id });
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
@@ -100,7 +154,7 @@ class Music {
/**
* Retrieves album.
*/
async getAlbum(album_id: string) {
async getAlbum(album_id: string): Promise<Album> {
throwIfMissing({ album_id });
if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
@@ -113,7 +167,7 @@ class Music {
/**
* Retrieves playlist.
*/
async getPlaylist(playlist_id: string) {
async getPlaylist(playlist_id: string): Promise<Playlist> {
throwIfMissing({ playlist_id });
if (!playlist_id.startsWith('VL')) {
@@ -124,53 +178,17 @@ class Music {
return new Playlist(response, this.#actions);
}
/**
* Retrieves song lyrics.
*/
async getLyrics(video_id: string) {
throwIfMissing({ video_id });
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
const data = Parser.parseResponse(response.data);
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tab = tabs.get({ title: '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.');
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 description_shelf = section_list.firstOfType(MusicDescriptionShelf);
return {
text: description_shelf?.description.toString(),
footer: description_shelf?.footer
};
}
/**
* Retrieves up next.
*/
async getUpNext(video_id: string) {
async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
throwIfMissing({ video_id });
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
const data = Parser.parseResponse(response.data);
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
@@ -188,7 +206,25 @@ class Music {
if (!music_queue || !music_queue.content)
throw new InnertubeError('Music queue was empty, the given id is probably invalid.', music_queue);
const playlist_panel = music_queue.content.item().as(PlaylistPanel);
const playlist_panel = music_queue.content.as(PlaylistPanel);
if (!playlist_panel.playlist_id && automix) {
const automix_preview_video = playlist_panel.contents.firstOfType(AutomixPreviewVideo);
if (!automix_preview_video)
throw new InnertubeError('Automix item not found');
const page = await automix_preview_video.playlist_video?.endpoint.callTest(this.#actions, {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
if (!page)
throw new InnertubeError('Could not fetch automix');
return page.contents_memo.getType(PlaylistPanel)?.[0];
}
return playlist_panel;
}
@@ -196,12 +232,14 @@ class Music {
/**
* Retrieves related content.
*/
async getRelated(video_id: string) {
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
throwIfMissing({ video_id });
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
const data = Parser.parseResponse(response.data);
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
@@ -224,7 +262,45 @@ class Music {
return shelves;
}
async getRecap() {
/**
* Retrieves song lyrics.
*/
async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
throwIfMissing({ video_id });
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tab = tabs.get({ title: '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.');
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();
return section_list.firstOfType(MusicDescriptionShelf);
}
/**
* Retrieves recap.
*/
async getRecap(): Promise<Recap> {
const response = await this.#actions.execute('/browse', {
browseId: 'FEmusic_listening_review',
client: 'YTMUSIC_ANDROID'

View File

@@ -166,20 +166,21 @@ export default class Player {
}
static extractSigSourceCode(data: string) {
const funcs = getStringBetweenStrings(data, 'this.audioTracks};var', '};');
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}=`, '};');
if (!funcs || !calls)
throw new PlayerError('Failed to extract signature decipher algorithm');
if (!functions || !calls)
console.warn(new PlayerError('Failed to extract signature decipher algorithm'));
return `function descramble_sig(a) { a = a.split(""); ${funcs}}${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) {
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)
throw new PlayerError('Failed to extract n-token decipher algorithm');
console.warn(new PlayerError('Failed to extract n-token decipher algorithm'));
return sc;
}

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import { YTNode } from '../helpers';
class CollaboratorInfoCardContent extends YTNode {
static type = 'CollaboratorInfoCardContent';
channel_avatar: Thumbnail[];
custom_text: Text;
channel_name: Text;
subscriber_count: Text;
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.channel_avatar = Thumbnail.fromResponse(data.channelAvatar);
this.custom_text = new Text(data.customText);
this.channel_name = new Text(data.channelName);
this.subscriber_count = new Text(data.subscriberCountText);
this.endpoint = new NavigationEndpoint(data.endpoint);
}
}
export default CollaboratorInfoCardContent;

View File

@@ -0,0 +1,19 @@
import Parser from '..';
import Text from './misc/Text';
import Button from './Button';
import { YTNode } from '../helpers';
class LiveChatDialog extends YTNode {
static type = 'LiveChatDialog';
confirm_button: Button | null;
dialog_messages: Text[];
constructor (data: any) {
super();
this.confirm_button = Parser.parseItem<Button>(data.confirmButton, Button);
this.dialog_messages = data.dialogMessages.map((el: any) => new Text(el));
}
}
export default LiveChatDialog;

View File

@@ -0,0 +1,16 @@
import { YTNode } from '../helpers';
class MusicDownloadStateBadge extends YTNode {
static type = 'MusicDownloadStateBadge';
playlist_id: string;
supported_download_states: string[];
constructor(data: any) {
super();
this.playlist_id = data.playlistId;
this.supported_download_states = data.supportedDownloadStates;
}
}
export default MusicDownloadStateBadge;

View File

@@ -1,4 +1,5 @@
import Parser from '../index';
import MusicPlayButton from './MusicPlayButton';
import { YTNode } from '../helpers';
class MusicItemThumbnailOverlay extends YTNode {
@@ -10,7 +11,7 @@ class MusicItemThumbnailOverlay extends YTNode {
constructor(data: any) {
super();
this.content = Parser.parse(data.content);
this.content = Parser.parseItem<MusicPlayButton>(data.content, MusicPlayButton);
this.content_position = data.contentPosition;
this.display_style = data.displayStyle;
}

View File

@@ -1,14 +1,15 @@
import Parser from '../index';
import PlaylistPanel from './PlaylistPanel';
import { YTNode } from '../helpers';
class MusicQueue extends YTNode {
static type = 'MusicQueue';
content;
content: PlaylistPanel | null;
constructor(data: any) {
super();
this.content = Parser.parse(data.content);
this.content = Parser.parseItem<PlaylistPanel>(data.content, PlaylistPanel);
}
}

View File

@@ -3,12 +3,16 @@
import Parser from '../index';
import Text from './misc/Text';
import { timeToSeconds } from '../../utils/Utils';
import TextRun from './misc/TextRun';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay';
import MusicResponsiveListItemFlexColumn from './MusicResponsiveListItemFlexColumn';
import MusicResponsiveListItemFixedColumn from './MusicResponsiveListItemFixedColumn';
import Menu from './menus/Menu';
import { timeToSeconds } from '../../utils/Utils';
import { YTNode } from '../helpers';
import TextRun from './misc/TextRun';
class MusicResponsiveListItem extends YTNode {
static type = 'MusicResponsiveListItem';
@@ -66,8 +70,8 @@ class MusicResponsiveListItem extends YTNode {
constructor(data: any) {
super();
this.#flex_columns = Parser.parseArray(data.flexColumns);
this.#fixed_columns = Parser.parseArray(data.fixedColumns);
this.#flex_columns = Parser.parseArray<MusicResponsiveListItemFlexColumn>(data.flexColumns, MusicResponsiveListItemFlexColumn);
this.#fixed_columns = Parser.parseArray<MusicResponsiveListItemFixedColumn>(data.fixedColumns, MusicResponsiveListItemFixedColumn);
this.#playlist_item_data = {
video_id: data?.playlistItemData?.videoId || null,
@@ -109,8 +113,8 @@ class MusicResponsiveListItem extends YTNode {
this.thumbnails = data.thumbnail ? Thumbnail.fromResponse(data.thumbnail.musicThumbnailRenderer?.thumbnail) : [];
this.badges = Parser.parseArray(data.badges);
this.menu = Parser.parse(data.menu);
this.overlay = Parser.parse(data.overlay);
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
this.overlay = Parser.parseItem<MusicItemThumbnailOverlay>(data.overlay, MusicItemThumbnailOverlay);
}
#parseOther() {

View File

@@ -5,6 +5,9 @@ import Text from './misc/Text';
import TextRun from './misc/TextRun';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay';
import Menu from './menus/Menu';
import { YTNode } from '../helpers';
class MusicTwoRowItem extends YTNode {
@@ -118,8 +121,8 @@ class MusicTwoRowItem extends YTNode {
}
this.thumbnail = Thumbnail.fromResponse(data.thumbnailRenderer.musicThumbnailRenderer.thumbnail);
this.thumbnail_overlay = Parser.parse(data.thumbnailOverlay);
this.menu = Parser.parse(data.menu);
this.thumbnail_overlay = Parser.parseItem<MusicItemThumbnailOverlay>(data.thumbnailOverlay, MusicItemThumbnailOverlay);
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
}
}

View File

@@ -245,6 +245,10 @@ class NavigationEndpoint extends YTNode {
switch (name) {
case 'browseEndpoint':
return '/browse';
case 'watchEndpoint':
return '/player';
case 'watchPlaylistEndpoint':
return '/next';
}
}

View File

@@ -14,6 +14,8 @@ class PlayerOverlay extends YTNode {
share_button;
add_to_menu;
fullscreen_engagement;
actions;
browser_media_session;
constructor(data: any) {
super();
@@ -22,6 +24,8 @@ class PlayerOverlay extends YTNode {
this.share_button = Parser.parseItem<Button>(data.shareButton, Button);
this.add_to_menu = Parser.parseItem<Menu>(data.addToMenu, Menu);
this.fullscreen_engagement = Parser.parse(data.fullscreenEngagement);
this.actions = Parser.parseArray(data.actions);
this.browser_media_session = Parser.parseItem(data.browserMediaSession);
}
}

View File

@@ -4,6 +4,7 @@ import PlaylistPanelVideo from './PlaylistPanelVideo';
import { YTNode } from '../helpers';
import AutomixPreviewVideo from './AutomixPreviewVideo';
import PlaylistPanelVideoWrapper from './PlaylistPanelVideoWrapper';
class PlaylistPanel extends YTNode {
static type = 'PlaylistPanel';
@@ -22,7 +23,7 @@ class PlaylistPanel extends YTNode {
super();
this.title = data.title;
this.title_text = new Text(data.titleText);
this.contents = Parser.parseArray<PlaylistPanelVideo | AutomixPreviewVideo>(data.contents, [ PlaylistPanelVideo, AutomixPreviewVideo ]);
this.contents = Parser.parseArray<PlaylistPanelVideoWrapper | PlaylistPanelVideo | AutomixPreviewVideo>(data.contents);
this.playlist_id = data.playlistId;
this.is_infinite = data.isInfinite;
this.continuation = data.continuations?.[0]?.nextRadioContinuationData?.continuation || data.continuations?.[0]?.nextContinuationData?.continuation;

View File

@@ -0,0 +1,18 @@
import Parser from '..';
import { YTNode } from '../helpers';
import PlaylistPanelVideo from './PlaylistPanelVideo';
class PlaylistPanelVideoWrapper extends YTNode {
static type = 'PlaylistPanelVideoWrapper';
primary: PlaylistPanelVideo | null;
counterpart: Array<PlaylistPanelVideo | null>;
constructor(data: any) {
super();
this.primary = Parser.parseItem<PlaylistPanelVideo>(data.primaryRenderer);
this.counterpart = data.counterpart?.map((item: any) => Parser.parseItem<PlaylistPanelVideo>(item.counterpartRenderer)) || [];
}
}
export default PlaylistPanelVideoWrapper;

View File

@@ -0,0 +1,18 @@
import Parser from '..';
import ToggleButton from './ToggleButton';
import { YTNode } from '../helpers';
class SegmentedLikeDislikeButton extends YTNode {
static type = 'SegmentedLikeDislikeButton';
like_button: ToggleButton | null;
dislike_button: ToggleButton | null;
constructor (data: any) {
super();
this.like_button = Parser.parseItem<ToggleButton>(data.likeButton, ToggleButton);
this.dislike_button = Parser.parseItem<ToggleButton>(data.dislikeButton, ToggleButton);
}
}
export default SegmentedLikeDislikeButton;

View File

@@ -0,0 +1,24 @@
import Text from '../../misc/Text';
import Parser from '../../../index';
import { YTNode } from '../../../helpers';
class LiveChatAutoModMessage extends YTNode {
static type = 'LiveChatAutoModMessage';
auto_moderated_item;
header_text: Text;
timestamp: number;
id: string;
constructor(data: any) {
super();
this.auto_moderated_item = Parser.parse(data.autoModeratedItem);
this.header_text = new Text(data.headerText);
this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
this.id = data.id;
}
}
export default LiveChatAutoModMessage;

View File

@@ -2,6 +2,7 @@ import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import NavigationEndpoint from '../../NavigationEndpoint';
import MetadataBadge from '../../MetadataBadge';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import Parser from '../../../index';
import { YTNode } from '../../../helpers';
@@ -14,7 +15,7 @@ class LiveChatPaidMessage extends YTNode {
id: string;
name: Text;
thumbnails: Thumbnail[];
badges: MetadataBadge[];
badges: LiveChatAuthorBadge[] | MetadataBadge[];
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
@@ -38,13 +39,13 @@ class LiveChatPaidMessage extends YTNode {
id: data.authorExternalChannelId,
name: new Text(data.authorName),
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
badges: Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge),
badges: Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]),
is_moderator: null,
is_verified: null,
is_verified_artist: null
};
const badges = Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge);
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
this.author.badges = badges;
this.author.is_moderator = badges?.some((badge: any) => badge.icon_type == 'MODERATOR') || null;

View File

@@ -2,6 +2,7 @@ import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import NavigationEndpoint from '../../NavigationEndpoint';
import MetadataBadge from '../../MetadataBadge';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import Parser from '../../../index';
import { YTNode } from '../../../helpers';
@@ -14,7 +15,7 @@ class LiveChatTextMessage extends YTNode {
id: string;
name: Text;
thumbnails: Thumbnail[];
badges: MetadataBadge[];
badges: LiveChatAuthorBadge[] | MetadataBadge[];
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
@@ -32,13 +33,13 @@ class LiveChatTextMessage extends YTNode {
id: data.authorExternalChannelId,
name: new Text(data.authorName),
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
badges: [] as MetadataBadge[],
badges: [] as LiveChatAuthorBadge[] | [] as MetadataBadge[],
is_moderator: null,
is_verified: null,
is_verified_artist: null
};
const badges = Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge);
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
this.author.badges = badges;
this.author.is_moderator = badges ? badges.some((badge) => badge.icon_type == 'MODERATOR') : null;

View File

@@ -2,6 +2,7 @@ import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import NavigationEndpoint from '../../NavigationEndpoint';
import MetadataBadge from '../../MetadataBadge';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import Parser from '../../../index';
import { YTNode } from '../../../helpers';
@@ -12,7 +13,7 @@ class LiveChatTickerPaidMessageItem extends YTNode {
author: {
id: string;
thumbnails: Thumbnail[];
badges: MetadataBadge[];
badges: LiveChatAuthorBadge[] | MetadataBadge[];
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
@@ -31,13 +32,13 @@ class LiveChatTickerPaidMessageItem extends YTNode {
this.author = {
id: data.authorExternalChannelId,
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
badges: Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge),
badges: Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]),
is_moderator: null,
is_verified: null,
is_verified_artist: null
};
const badges = Parser.parseArray<MetadataBadge>(data.authorBadges, MetadataBadge);
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
this.author.badges = badges;
this.author.is_moderator = badges?.some((badge) => badge.icon_type == 'MODERATOR') || null;

View File

@@ -15,6 +15,7 @@ import { YTNode, YTNodeConstructor, SuperParsedResult, ObservedArray, observe, M
import package_json from '../../package.json';
import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem';
import AudioOnlyPlayability from './classes/AudioOnlyPlayability';
export class AppendContinuationItemsAction extends YTNode {
static readonly type = 'appendContinuationItemsAction';
@@ -259,6 +260,7 @@ export default class Parser {
playability_status: data.playabilityStatus ? {
status: data.playabilityStatus.status as string,
error_screen: Parser.parse(data.playabilityStatus.errorScreen),
audio_only_playablility: Parser.parseItem<AudioOnlyPlayability>(data.playabilityStatus.audioOnlyPlayability, AudioOnlyPlayability),
embeddable: !!data.playabilityStatus.playableInEmbed || false,
reason: data.playabilityStatus?.reason || ''
} : undefined,
@@ -373,10 +375,6 @@ export default class Parser {
static parse<T extends YTNode = YTNode>(data: any, requireArray: true, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) : ObservedArray<T> | null;
static parse<T extends YTNode = YTNode>(data: any, requireArray?: false | undefined, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) : SuperParsedResult<T>;
/**
* Parses the `contents` property of the response as well as its nodes.
*/
static parse<T extends YTNode = YTNode>(data: any, requireArray?: boolean, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
if (!data) return null;

View File

@@ -16,11 +16,13 @@ import { default as AnalyticsVodCarouselCard } from './classes/analytics/Analyti
import { default as CtaGoToCreatorStudio } from './classes/analytics/CtaGoToCreatorStudio';
import { default as DataModelSection } from './classes/analytics/DataModelSection';
import { default as StatRow } from './classes/analytics/StatRow';
import { default as AudioOnlyPlayability } from './classes/AudioOnlyPlayability';
import { default as AutomixPreviewVideo } from './classes/AutomixPreviewVideo';
import { default as BackstageImage } from './classes/BackstageImage';
import { default as BackstagePost } from './classes/BackstagePost';
import { default as BackstagePostThread } from './classes/BackstagePostThread';
import { default as BrowseFeedActions } from './classes/BrowseFeedActions';
import { default as BrowserMediaSession } from './classes/BrowserMediaSession';
import { default as Button } from './classes/Button';
import { default as C4TabbedHeader } from './classes/C4TabbedHeader';
import { default as CallToActionButton } from './classes/CallToActionButton';
@@ -38,6 +40,7 @@ import { default as ChannelVideoPlayer } from './classes/ChannelVideoPlayer';
import { default as ChildVideo } from './classes/ChildVideo';
import { default as ChipCloud } from './classes/ChipCloud';
import { default as ChipCloudChip } from './classes/ChipCloudChip';
import { default as CollaboratorInfoCardContent } from './classes/CollaboratorInfoCardContent';
import { default as CollageHeroImage } from './classes/CollageHeroImage';
import { default as AuthorCommentBadge } from './classes/comments/AuthorCommentBadge';
import { default as Comment } from './classes/comments/Comment';
@@ -88,6 +91,7 @@ import { default as LiveChat } from './classes/LiveChat';
import { default as AddBannerToLiveChatCommand } from './classes/livechat/AddBannerToLiveChatCommand';
import { default as AddChatItemAction } from './classes/livechat/AddChatItemAction';
import { default as AddLiveChatTickerItemAction } from './classes/livechat/AddLiveChatTickerItemAction';
import { default as LiveChatAutoModMessage } from './classes/livechat/items/LiveChatAutoModMessage';
import { default as LiveChatBanner } from './classes/livechat/items/LiveChatBanner';
import { default as LiveChatBannerHeader } from './classes/livechat/items/LiveChatBannerHeader';
import { default as LiveChatBannerPoll } from './classes/livechat/items/LiveChatBannerPoll';
@@ -116,6 +120,7 @@ import { default as UpdateTitleAction } from './classes/livechat/UpdateTitleActi
import { default as UpdateToggleButtonTextAction } from './classes/livechat/UpdateToggleButtonTextAction';
import { default as UpdateViewershipAction } from './classes/livechat/UpdateViewershipAction';
import { default as LiveChatAuthorBadge } from './classes/LiveChatAuthorBadge';
import { default as LiveChatDialog } from './classes/LiveChatDialog';
import { default as LiveChatHeader } from './classes/LiveChatHeader';
import { default as LiveChatItemList } from './classes/LiveChatItemList';
import { default as LiveChatMessageInput } from './classes/LiveChatMessageInput';
@@ -146,6 +151,7 @@ import { default as MusicCarouselShelf } from './classes/MusicCarouselShelf';
import { default as MusicCarouselShelfBasicHeader } from './classes/MusicCarouselShelfBasicHeader';
import { default as MusicDescriptionShelf } from './classes/MusicDescriptionShelf';
import { default as MusicDetailHeader } from './classes/MusicDetailHeader';
import { default as MusicDownloadStateBadge } from './classes/MusicDownloadStateBadge';
import { default as MusicEditablePlaylistDetailHeader } from './classes/MusicEditablePlaylistDetailHeader';
import { default as MusicElementHeader } from './classes/MusicElementHeader';
import { default as MusicHeader } from './classes/MusicHeader';
@@ -183,6 +189,7 @@ import { default as PlaylistInfoCardContent } from './classes/PlaylistInfoCardCo
import { default as PlaylistMetadata } from './classes/PlaylistMetadata';
import { default as PlaylistPanel } from './classes/PlaylistPanel';
import { default as PlaylistPanelVideo } from './classes/PlaylistPanelVideo';
import { default as PlaylistPanelVideoWrapper } from './classes/PlaylistPanelVideoWrapper';
import { default as PlaylistSidebar } from './classes/PlaylistSidebar';
import { default as PlaylistSidebarPrimaryInfo } from './classes/PlaylistSidebarPrimaryInfo';
import { default as PlaylistSidebarSecondaryInfo } from './classes/PlaylistSidebarSecondaryInfo';
@@ -209,6 +216,7 @@ import { default as SearchSuggestion } from './classes/SearchSuggestion';
import { default as SearchSuggestionsSection } from './classes/SearchSuggestionsSection';
import { default as SecondarySearchContainer } from './classes/SecondarySearchContainer';
import { default as SectionList } from './classes/SectionList';
import { default as SegmentedLikeDislikeButton } from './classes/SegmentedLikeDislikeButton';
import { default as SettingBoolean } from './classes/SettingBoolean';
import { default as SettingsCheckbox } from './classes/SettingsCheckbox';
import { default as SettingsOptions } from './classes/SettingsOptions';
@@ -281,11 +289,13 @@ const map: Record<string, YTNodeConstructor> = {
CtaGoToCreatorStudio,
DataModelSection,
StatRow,
AudioOnlyPlayability,
AutomixPreviewVideo,
BackstageImage,
BackstagePost,
BackstagePostThread,
BrowseFeedActions,
BrowserMediaSession,
Button,
C4TabbedHeader,
CallToActionButton,
@@ -303,6 +313,7 @@ const map: Record<string, YTNodeConstructor> = {
ChildVideo,
ChipCloud,
ChipCloudChip,
CollaboratorInfoCardContent,
CollageHeroImage,
AuthorCommentBadge,
Comment,
@@ -353,6 +364,7 @@ const map: Record<string, YTNodeConstructor> = {
AddBannerToLiveChatCommand,
AddChatItemAction,
AddLiveChatTickerItemAction,
LiveChatAutoModMessage,
LiveChatBanner,
LiveChatBannerHeader,
LiveChatBannerPoll,
@@ -381,6 +393,7 @@ const map: Record<string, YTNodeConstructor> = {
UpdateToggleButtonTextAction,
UpdateViewershipAction,
LiveChatAuthorBadge,
LiveChatDialog,
LiveChatHeader,
LiveChatItemList,
LiveChatMessageInput,
@@ -411,6 +424,7 @@ const map: Record<string, YTNodeConstructor> = {
MusicCarouselShelfBasicHeader,
MusicDescriptionShelf,
MusicDetailHeader,
MusicDownloadStateBadge,
MusicEditablePlaylistDetailHeader,
MusicElementHeader,
MusicHeader,
@@ -448,6 +462,7 @@ const map: Record<string, YTNodeConstructor> = {
PlaylistMetadata,
PlaylistPanel,
PlaylistPanelVideo,
PlaylistPanelVideoWrapper,
PlaylistSidebar,
PlaylistSidebarPrimaryInfo,
PlaylistSidebarSecondaryInfo,
@@ -474,6 +489,7 @@ const map: Record<string, YTNodeConstructor> = {
SearchSuggestionsSection,
SecondarySearchContainer,
SectionList,
SegmentedLikeDislikeButton,
SettingBoolean,
SettingsCheckbox,
SettingsOptions,

View File

@@ -16,6 +16,7 @@ import ItemSection from '../classes/ItemSection';
import PlayerOverlay from '../classes/PlayerOverlay';
import ToggleButton from '../classes/ToggleButton';
import CommentsEntryPointHeader from '../classes/comments/CommentsEntryPointHeader';
import SegmentedLikeDislikeButton from '../classes/SegmentedLikeDislikeButton';
import ContinuationItem from '../classes/ContinuationItem';
import PlayerMicroformat from '../classes/PlayerMicroformat';
import MicroformatData from '../classes/MicroformatData';
@@ -153,9 +154,11 @@ class VideoInfo {
this.player_overlays = next?.player_overlays.item().as(PlayerOverlay);
this.basic_info.like_count = this.primary_info?.menu?.top_level_buttons?.get({ icon_type: 'LIKE' })?.as(ToggleButton)?.like_count;
this.basic_info.is_liked = this.primary_info?.menu?.top_level_buttons?.get({ icon_type: 'LIKE' })?.as(ToggleButton)?.is_toggled;
this.basic_info.is_disliked = this.primary_info?.menu?.top_level_buttons?.get({ icon_type: 'DISLIKE' })?.as(ToggleButton)?.is_toggled;
const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton);
this.basic_info.like_count = segmented_like_dislike_button?.like_button?.as(ToggleButton)?.like_count;
this.basic_info.is_liked = segmented_like_dislike_button?.like_button?.as(ToggleButton)?.is_toggled;
this.basic_info.is_disliked = segmented_like_dislike_button?.dislike_button?.as(ToggleButton)?.is_toggled;
const comments_entry_point = results.get({ target_id: 'comments-entry-point' })?.as(ItemSection);
@@ -196,7 +199,9 @@ class VideoInfo {
rt: 0
};
const response = await this.#actions.stats(this.#playback_tracking.videostats_playback_url, {
const url = this.#playback_tracking.videostats_playback_url.replace('https://s.', 'https://www.');
const response = await this.#actions.stats(url, {
client_name: Constants.CLIENTS.WEB.NAME,
client_version: Constants.CLIENTS.WEB.VERSION
}, url_params);

View File

@@ -9,8 +9,16 @@ 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';
import SectionList from '../classes/SectionList';
import MusicQueue from '../classes/MusicQueue';
import MusicCarouselShelf from '../classes/MusicCarouselShelf';
import MusicDescriptionShelf from '../classes/MusicDescriptionShelf';
import AutomixPreviewVideo from '../classes/AutomixPreviewVideo';
import Message from '../classes/Message';
import { ObservedArray } from '../helpers';
// TODO: add a way to get specific tabs
class TrackInfo {
#page: [ ParsedResponse, ParsedResponse? ];
#actions: Actions;
@@ -73,6 +81,77 @@ class TrackInfo {
}
}
/**
* Retrieves contents of the given tab.
*/
async getTab(title: string) {
if (!this.tabs)
throw new InnertubeError('Could not find any tab');
const target_tab = this.tabs.get({ title });
if (!target_tab)
throw new InnertubeError(`Tab "${title}" not found`, { available_tabs: this.available_tabs });
if (target_tab.content)
return target_tab.content;
const page = await target_tab.endpoint.callTest(this.#actions, { client: 'YTMUSIC', parse: true });
if (page.contents.item().key('type').string() === 'Message')
return page.contents.item().as(Message);
return page.contents.item().as(SectionList).contents.array();
}
/**
* Retrieves up next.
*/
async getUpNext(automix = true): Promise<PlaylistPanel> {
const music_queue = await this.getTab('Up next') as MusicQueue;
if (!music_queue || !music_queue.content)
throw new InnertubeError('Music queue was empty, the video id is probably invalid.', music_queue);
const playlist_panel = music_queue.content.as(PlaylistPanel);
if (!playlist_panel.playlist_id && automix) {
const automix_preview_video = playlist_panel.contents.firstOfType(AutomixPreviewVideo);
if (!automix_preview_video)
throw new InnertubeError('Automix item not found');
const page = await automix_preview_video.playlist_video?.endpoint.callTest(this.#actions, {
videoId: this.basic_info.id,
client: 'YTMUSIC',
parse: true
});
if (!page)
throw new InnertubeError('Could not fetch automix');
return page.contents_memo.getType(PlaylistPanel)?.[0];
}
return playlist_panel;
}
/**
* Retrieves related content.
*/
async getRelated(): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
const tab = await this.getTab('Related') as ObservedArray<MusicDescriptionShelf | MusicDescriptionShelf>;
return tab;
}
/**
* Retrieves lyrics.
*/
async getLyrics(): Promise<MusicDescriptionShelf | undefined> {
const tab = await this.getTab('Lyrics') as ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>;
return tab.firstOfType(MusicDescriptionShelf);
}
/**
* Adds the song to the watch history.
*/
@@ -87,7 +166,9 @@ class TrackInfo {
rt: 0
};
const response = await this.#actions.stats(this.#playback_tracking.videostats_playback_url, {
const url = this.#playback_tracking.videostats_playback_url.replace('https://s.', 'https://music.');
const response = await this.#actions.stats(url, {
client_name: Constants.CLIENTS.YTMUSIC.NAME,
client_version: Constants.CLIENTS.YTMUSIC.VERSION
}, url_params);
@@ -95,6 +176,10 @@ class TrackInfo {
return response;
}
get available_tabs(): string[] {
return this.tabs ? this.tabs.map((tab) => tab.title) : [];
}
get page() {
return this.#page;
}

View File

@@ -11,7 +11,7 @@ describe('YouTube.js Tests', () => {
});
describe('Search', () => {
it('should search on YouTube', async () => {
it('should search', async () => {
const search = await yt.search(VIDEOS[0].QUERY);
expect(search.results?.length).toBeLessThanOrEqual(35);
expect(search.videos.length).toBeLessThanOrEqual(35);
@@ -20,7 +20,7 @@ describe('YouTube.js Tests', () => {
expect(search.has_continuation).toBe(true);
});
it('should retrieve YouTube search continuation', async () => {
it('should retrieve search continuation', async () => {
const search = await yt.search(VIDEOS[0].QUERY);
const next = await search.getContinuation()
expect(next.results?.length).toBeLessThanOrEqual(35);
@@ -30,20 +30,10 @@ describe('YouTube.js Tests', () => {
expect(next.has_continuation).toBe(true);
});
it('should search on YouTube Music', async () => {
const search = await yt.music.search(VIDEOS[1].QUERY);
expect(search.songs?.contents.length).toBeLessThanOrEqual(3);
});
it('should retrieve YouTube search suggestions', async () => {
it('should retrieve search suggestions', async () => {
const suggestions = await yt.getSearchSuggestions(VIDEOS[0].QUERY);
expect(suggestions.length).toBeLessThanOrEqual(10);
});
it('should retrieve YouTube Music search suggestions', async () => {
const suggestions = await yt.music.getSearchSuggestions(VIDEOS[1].QUERY);
expect(suggestions.length).toBeLessThanOrEqual(10);
});
});
describe('Comments', () => {
@@ -87,15 +77,10 @@ describe('YouTube.js Tests', () => {
});
describe('General', () => {
it('should retrieve playlist with YouTube', async () => {
it('should retrieve playlist', async () => {
const playlist = await yt.getPlaylist('PLLw0AzOz95FU7w2juhPECP9NyGhbZmz_t');
expect(playlist.items.length).toBeLessThanOrEqual(100);
});
it('should retrieve playlist with YouTube Music', async () => {
const playlist = await yt.music.getPlaylist('PLVbEymL-83SyVXXqT7fYX5sEvELvyGjL7');
expect(playlist.items?.length).toBeLessThanOrEqual(100);
});
it('should retrieve home feed', async () => {
const homefeed = await yt.getHomeFeed();
@@ -112,6 +97,46 @@ describe('YouTube.js Tests', () => {
expect(result).toBeTruthy();
}, 30000);
});
describe('YouTube Music', () => {
let search: any;
it('should search', async () => {
search = await yt.music.search(VIDEOS[1].QUERY);
expect(search.songs?.contents.length).toBeLessThanOrEqual(3);
});
it('should retrieve search suggestions', async () => {
const suggestions = await yt.music.getSearchSuggestions(VIDEOS[1].QUERY);
expect(suggestions.length).toBeLessThanOrEqual(10);
});
it('should retrieve track info', async () => {
const info = await yt.music.getInfo(VIDEOS[1].ID);
expect(info.basic_info.id).toBe(VIDEOS[1].ID);
});
it('should retrieve the "Related" tab', async () => {
const info = await yt.music.getInfo(VIDEOS[1].ID);
const related = await info.getRelated();
expect((related as any).length).toBeGreaterThan(3);
});
it('should retrieve albums', async () => {
const album = await yt.music.getAlbum(search.albums?.contents[0]?.id);
expect(album.contents).toBeDefined();
});
it('should retrieve artists', async () => {
const artist = await yt.music.getArtist(search.artists?.contents[0]?.id);
expect(artist.sections).toBeDefined();
});
it('should retrieve playlists', async () => {
const playlist = await yt.music.getPlaylist(search.playlists?.contents[0]?.id);
expect(playlist.items).toBeDefined();
});
});
});
async function download(id: string, yt: Innertube): Promise<boolean> {