Compare commits

...

15 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
LuanRT
c9856a8359 fix: search continuations not being parsed correctly (#173)
* feat: add `TitleAndButtonListHeader`

* fix: continuations not being parsed correctly

* chore: add a test

* chore(package): bump version to 2.0.2

* chore: lint
2022-09-08 21:31:07 -03:00
LuanRT
4b29ad74de chore(docs): rephrase a few things 2022-09-07 03:23:51 -03:00
Patrick Kan
60730a5531 fix: Music#getArtist() and DropdownItem (#170)
* fix: `Music#getArtist()` fails for private artist

* fix: `DropdownItem` inconsistent prop naming
2022-09-06 14:29:29 -03:00
36 changed files with 610 additions and 210 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.0",
"version": "2.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "2.0.0",
"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.0",
"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",
@@ -69,17 +69,18 @@
"youtubedl",
"youtube-dl",
"youtube-downloader",
"innertube",
"youtube-music",
"innertubeapi",
"innertube",
"unofficial",
"downloader",
"livechat",
"studio",
"upload",
"ytmusic",
"dislike",
"search",
"comment",
"music",
"like",
"api"
]
}

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,10 +141,10 @@ 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'))
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
throw new InnertubeError('Invalid artist id', artist_id);
const response = await this.#actions.browse(artist_id, { client: 'YTMUSIC' });
@@ -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

@@ -225,7 +225,7 @@ const videos = response.contents_memo.getType(Video);
If you decompile a YouTube client and analize it for a while you will notice that it has classes named `protos/youtube/api/innertube/MusicItemRenderer`, `protos/youtube/api/innertube/SectionListRenderer`, etc.
These classes are used to parse objects from the response (which consists of protobuf messages) and also build requests. The website works in a similar way, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
These classes are used to parse objects from the response, map them into models and generate the UI. The website works in a similar way, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
Here we're taking a similar approach, the parser goes through all the renderers and parses their inner element(s). The final result is a nicely structured JSON, and on top of that it also parses navigation endpoints which allows us to make an API call with all required parameters in one line and even emulate client actions (eg; clicking a button).

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

@@ -8,7 +8,7 @@ class DropdownItem extends YTNode {
label: string;
selected: boolean;
value?: number | string;
iconType?: string;
icon_type?: string;
description?: string;
endpoint?: NavigationEndpoint;
@@ -29,7 +29,7 @@ class DropdownItem extends YTNode {
}
if (data.icon?.iconType) {
this.iconType = data.icon?.iconType;
this.icon_type = data.icon?.iconType;
}
if (data.descriptionText) {

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,15 @@
import Text from './misc/Text';
import { YTNode } from '../helpers';
class TitleAndButtonListHeader extends YTNode {
static type = 'TitleAndButtonListHeader';
title: Text;
constructor(data: any) {
super();
this.title = new Text(data.title);
}
}
export default TitleAndButtonListHeader;

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';
@@ -244,6 +252,7 @@ import { default as ThumbnailOverlayResumePlayback } from './classes/ThumbnailOv
import { default as ThumbnailOverlaySidePanel } from './classes/ThumbnailOverlaySidePanel';
import { default as ThumbnailOverlayTimeStatus } from './classes/ThumbnailOverlayTimeStatus';
import { default as ThumbnailOverlayToggleButton } from './classes/ThumbnailOverlayToggleButton';
import { default as TitleAndButtonListHeader } from './classes/TitleAndButtonListHeader';
import { default as ToggleButton } from './classes/ToggleButton';
import { default as ToggleMenuServiceItem } from './classes/ToggleMenuServiceItem';
import { default as Tooltip } from './classes/Tooltip';
@@ -280,11 +289,13 @@ const map: Record<string, YTNodeConstructor> = {
CtaGoToCreatorStudio,
DataModelSection,
StatRow,
AudioOnlyPlayability,
AutomixPreviewVideo,
BackstageImage,
BackstagePost,
BackstagePostThread,
BrowseFeedActions,
BrowserMediaSession,
Button,
C4TabbedHeader,
CallToActionButton,
@@ -302,6 +313,7 @@ const map: Record<string, YTNodeConstructor> = {
ChildVideo,
ChipCloud,
ChipCloudChip,
CollaboratorInfoCardContent,
CollageHeroImage,
AuthorCommentBadge,
Comment,
@@ -352,6 +364,7 @@ const map: Record<string, YTNodeConstructor> = {
AddBannerToLiveChatCommand,
AddChatItemAction,
AddLiveChatTickerItemAction,
LiveChatAutoModMessage,
LiveChatBanner,
LiveChatBannerHeader,
LiveChatBannerPoll,
@@ -380,6 +393,7 @@ const map: Record<string, YTNodeConstructor> = {
UpdateToggleButtonTextAction,
UpdateViewershipAction,
LiveChatAuthorBadge,
LiveChatDialog,
LiveChatHeader,
LiveChatItemList,
LiveChatMessageInput,
@@ -410,6 +424,7 @@ const map: Record<string, YTNodeConstructor> = {
MusicCarouselShelfBasicHeader,
MusicDescriptionShelf,
MusicDetailHeader,
MusicDownloadStateBadge,
MusicEditablePlaylistDetailHeader,
MusicElementHeader,
MusicHeader,
@@ -447,6 +462,7 @@ const map: Record<string, YTNodeConstructor> = {
PlaylistMetadata,
PlaylistPanel,
PlaylistPanelVideo,
PlaylistPanelVideoWrapper,
PlaylistSidebar,
PlaylistSidebarPrimaryInfo,
PlaylistSidebarSecondaryInfo,
@@ -473,6 +489,7 @@ const map: Record<string, YTNodeConstructor> = {
SearchSuggestionsSection,
SecondarySearchContainer,
SectionList,
SegmentedLikeDislikeButton,
SettingBoolean,
SettingsCheckbox,
SettingsOptions,
@@ -508,6 +525,7 @@ const map: Record<string, YTNodeConstructor> = {
ThumbnailOverlaySidePanel,
ThumbnailOverlayTimeStatus,
ThumbnailOverlayToggleButton,
TitleAndButtonListHeader,
ToggleButton,
ToggleMenuServiceItem,
Tooltip,

View File

@@ -1,15 +1,17 @@
import Actions from '../../core/Actions';
import Feed from '../../core/Feed';
import { observe, ObservedArray, YTNode } from '../helpers';
import { InnertubeError } from '../../utils/Utils';
import HorizontalCardList from '../classes/HorizontalCardList';
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';
import { observe, ObservedArray, YTNode } from '../helpers';
class Search extends Feed {
results: ObservedArray<YTNode> | null | undefined;
@@ -22,11 +24,11 @@ class Search extends Feed {
super(actions, data, already_parsed);
const contents =
this.page.contents.item().as(TwoColumnSearchResults).primary_contents.item().key('contents').parsed().array() ||
this.page.contents?.item().as(TwoColumnSearchResults).primary_contents.item().as(SectionList).contents.array() ||
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;
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;
@@ -64,7 +66,7 @@ class Search extends Feed {
throw new InnertubeError('Invalid refinement card!');
}
const page = await target_card.endpoint.call(this.actions);
const page = await target_card.endpoint.call(this.actions, undefined, true);
return new Search(this.actions, page, true);
}
@@ -81,4 +83,5 @@ class Search extends Feed {
return new Search(this.actions, continuation, true);
}
}
export default Search;
export default Search;

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

@@ -7,6 +7,7 @@ import MusicCarouselShelf from '../classes/MusicCarouselShelf';
import MusicPlaylistShelf from '../classes/MusicPlaylistShelf';
import MusicImmersiveHeader from '../classes/MusicImmersiveHeader';
import MusicVisualHeader from '../classes/MusicVisualHeader';
import MusicHeader from '../classes/MusicHeader';
class Artist {
#page;
@@ -19,7 +20,7 @@ class Artist {
this.#page = Parser.parseResponse((response as AxioslikeResponse).data);
this.#actions = actions;
this.header = this.page.header.item().as(MusicImmersiveHeader, MusicVisualHeader);
this.header = this.page.header.item().as(MusicImmersiveHeader, MusicVisualHeader, MusicHeader);
const music_shelf = this.#page.contents_memo.get('MusicShelf') as MusicShelf[] || [];
const music_carousel_shelf = this.#page.contents_memo.get('MusicCarouselShelf') as MusicCarouselShelf[] || [];

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,20 +20,20 @@ describe('YouTube.js Tests', () => {
expect(search.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 search continuation', async () => {
const search = await yt.search(VIDEOS[0].QUERY);
const next = await search.getContinuation()
expect(next.results?.length).toBeLessThanOrEqual(35);
expect(next.videos.length).toBeLessThanOrEqual(35);
expect(next.playlists.length).toBeLessThanOrEqual(35);
expect(next.channels.length).toBeLessThanOrEqual(35);
expect(next.has_continuation).toBe(true);
});
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', () => {
@@ -77,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();
@@ -102,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> {