Compare commits

...

28 Commits

Author SHA1 Message Date
LuanRT
aefecd061e chore(release): v2.2.3 2022-09-23 03:19:54 -03:00
LuanRT
7485726f1e refactor: fix a few parsing inconsistencies 2022-09-23 03:06:21 -03:00
LuanRT
9e703abe3a chore(deps): bump jintr to 0.3.1 2022-09-22 18:44:16 -03:00
LuanRT
affbe84284 fix: include thirdParty prop for requests using TV_EMBEDDED (#198)
* dev: update `Context` interface

* dev: include `thirdParty` prop in requests using `TV_EMBEDDED`
2022-09-18 16:58:51 -03:00
Daniel Wykerd
fcbdae3e34 fix: browser example (#197) 2022-09-18 12:46:19 -03:00
LuanRT
059c858021 chore(docs): add a note about streaming data [skip ci] 2022-09-17 21:29:33 -03:00
LuanRT
4ecd3360e0 chore(release): v2.2.1 2022-09-17 20:47:55 -03:00
LuanRT
08e9527931 chore: update proto [skip ci] 2022-09-17 20:07:23 -03:00
LuanRT
a9f03a1523 fix: like/dislike methods not working correctly 2022-09-17 19:49:05 -03:00
LuanRT
c8980c7985 chore(docs): fix typo 2022-09-17 19:28:46 -03:00
LuanRT
2e5688f235 feat: add TVHTML5_SIMPLY_EMBEDDED_PLAYER client (#193)
* feat: add `TV_EMBEDDED` client

See #191, this should help bypassing some age restricted videos.

* dev(VideoInfo): update format options interface

* dev: set `clientScreen` to `EMBED`

* dev: update API ref

* dev: update `Context` interface
2022-09-17 19:15:20 -03:00
LuanRT
dcf2b720a0 fix: minor parsing issues and other improvements (#194)
* feat: add `ConfirmDialog`

This usually appears in the `playability_status` object.

* fix(PlayerErrorMessage): check if `iconType` exists before parsing

* chore(parser): fix a few inconsistencies

* feat(ytmusic): add `MetadataScreen`

TODO: Check TrackInfo, YouTube Music is probably getting some minor UI updates.
2022-09-17 19:14:46 -03:00
SALVADOR, 1 M. LIGAYAO
a90f5eb853 fix: add missing import in index.ts (#188)
* added missing import in index.ts

* commit changes suggested by LuanRT

Co-authored-by: LuanRT <luan.lrt4@gmail.com>

* removed extra require

Co-authored-by: Salvador Ligayao <futuremr.ligayao03@gmail.com>
Co-authored-by: LuanRT <luan.lrt4@gmail.com>
2022-09-17 01:30:35 -03:00
Supertiger
c6482e07b9 docs: fix a small typo in api/session.md (#189)
It says "key" twice, someone forgot to rename one of them to "api_version" :)
2022-09-16 12:21:57 -03:00
LuanRT
2de77c8f2c fix: make cookie auth possible again
See #105
2022-09-14 14:52:10 -03:00
LuanRT
2aaa209906 chore(docs): fix typo [skip ci] 2022-09-13 03:03:22 -03:00
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
50 changed files with 742 additions and 247 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,9 +40,11 @@
[![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]
<br>
[![Donate](https://img.shields.io/badge/donate-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white)][github-sponsors]
</div>
<!-- SPONSORS -->
@@ -292,7 +294,7 @@ Retrieves video info, including playback data and even layout elements such as m
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The id of the video |
| client? | `InnerTubeClient` | `WEB`, `ANDROID` or `YTMUSIC` |
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC`, `YTMUSIC_ANDROID` or `TV_EMBEDDED` |
<details>
<summary>Methods & Getters</summary>
@@ -347,7 +349,7 @@ Suitable for cases where you only need basic video metadata. Also, it is faster
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The id of the video |
| client? | `InnerTubeClient` | `WEB`, `ANDROID` or `YTMUSIC` |
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC_ANDROID`, `YTMUSIC`, `TV_EMBEDDED` |
<a name="search"></a>
### search(query, filters?)
@@ -527,6 +529,14 @@ Retrieves playlist contents.
### getStreamingData(video_id, options)
Returns deciphered streaming data.
**Note:**
It is recommended to retrieve streaming data from a `VideoInfo`/`TrackInfo` object instead if you want to select formats manually, example:
```ts
const info = await yt.getBasicInfo('somevideoid');
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
console.info('Playback url:', url);
```
**Returns**: `Promise.<object>`
| Param | Type | Description |
@@ -569,7 +579,7 @@ For example, you may want to call an endpoint directly, that can be achieved wit
const payload = {
videoId: 'jLTOuvBTLxA',
client: 'YTMUSIC', // InnerTube client, can be ANDROID, YTMUSIC, WEB
client: 'YTMUSIC', // InnerTube client, can be ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB or TV_EMBEDDED
parse: true // tells YouTube.js to parse the response, this is not sent to InnerTube.
};

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)

View File

@@ -41,7 +41,7 @@ InnerTube API key.
**Returns:** `string`
<a name="api_version"></a>
### key
### api_version
InnerTube API version.
@@ -80,4 +80,4 @@ Player script object.
Client language.
**Returns:** `string`
**Returns:** `string`

View File

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

View File

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

View File

@@ -10,6 +10,11 @@ if (getRuntime() === 'node') {
Reflect.set(globalThis, 'Response', undici.Response);
Reflect.set(globalThis, 'FormData', undici.FormData);
Reflect.set(globalThis, 'File', undici.File);
try {
// eslint-disable-next-line
const { ReadableStream } = require('node:stream/web');
Reflect.set(globalThis, 'ReadableStream', ReadableStream);
} catch { /* do nothing */ }
}
import Innertube from './src/Innertube';

44
package-lock.json generated
View File

@@ -1,28 +1,26 @@
{
"name": "youtubei.js",
"version": "2.0.2",
"version": "2.2.3"
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "2.0.2",
"version": "2.2.3",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"@protobuf-ts/runtime": "^2.7.0",
"jintr": "^0.1.9",
"jintr": "^0.3.1",
"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.3.1",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-0.3.1.tgz",
"integrity": "sha512-AUcq8fKL4BE9jDx8TizZmJ9UOvk1CHKFW0nQcWaOaqk9tkLS9S10fNmusTWGEYTncn7U43nXrCbhYko/ylqrSg==",
"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.3.1",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-0.3.1.tgz",
"integrity": "sha512-AUcq8fKL4BE9jDx8TizZmJ9UOvk1CHKFW0nQcWaOaqk9tkLS9S10fNmusTWGEYTncn7U43nXrCbhYko/ylqrSg==",
"requires": {
"acorn": "^8.8.0"
}

View File

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

@@ -48,7 +48,7 @@ export interface SearchFilters {
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
}
export type InnerTubeClient = 'ANDROID' | 'YTMUSIC_ANDROID' | 'WEB' | 'YTMUSIC';
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'TV_EMBEDDED';
class Innertube {
session;

View File

@@ -64,11 +64,11 @@ class Actions {
/**
* Mimmics the Axios API using Fetch's Response object.
*/
async #wrap(response: Response, protobuf?: boolean) {
async #wrap(response: Response) {
return {
success: response.ok,
status_code: response.status,
data: protobuf ? await response.text() : JSON.parse(await response.text())
data: JSON.parse(await response.text())
};
}
@@ -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: {
@@ -763,7 +776,7 @@ class Actions {
return Parser.parseResponse(await response.json());
}
return this.#wrap(response, args.protobuf);
return this.#wrap(response);
}
#needsLogin(id: string) {

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.
* Retrieves 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

@@ -25,6 +25,7 @@ export interface Context {
userAgent: string;
clientName: string;
clientVersion: string;
clientScreen?: string,
androidSdkVersion?: string;
osName: string;
osVersion: string;
@@ -42,6 +43,9 @@ export interface Context {
user: {
lockedSafetyMode: false;
};
thirdParty?: {
embedUrl: string;
};
request: {
useSsl: true;
};

View File

@@ -89,7 +89,7 @@ const is_null = response.is_null;
## YTNode
All renderers returned by InnerTube are converted to this generic class and then extended for the specific renderers.
This class is allows us a typesafe way to use data returned by the InnerTube API.
This class is what allows us a typesafe way to use data returned by the InnerTube API.
Here's how to use this class to access returned data:

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

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

View File

@@ -0,0 +1,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,24 @@
import Parser from '..';
import Text from './misc/Text';
import Button from './Button';
import { YTNode } from '../helpers';
class ConfirmDialog extends YTNode {
static type = 'ConfirmDialog';
title: Text;
confirm_button: Button | null;
cancel_button: Button | null;
dialog_messages: Text[];
constructor (data: any) {
super();
this.title = new Text(data.title);
this.confirm_button = Parser.parseItem<Button>(data.confirmButton, Button);
this.cancel_button = Parser.parseItem<Button>(data.cancelButton, Button);
this.dialog_messages = data.dialogMessages.map((txt: any) => new Text(txt));
}
}
export default ConfirmDialog;

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

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

@@ -1,6 +1,8 @@
import Parser from '../index';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import Button from './Button';
import { YTNode } from '../helpers';
class PlayerErrorMessage extends YTNode {
@@ -8,17 +10,17 @@ class PlayerErrorMessage extends YTNode {
subreason: Text;
reason: Text;
proceed_button;
proceed_button: Button | null;
thumbnails: Thumbnail[];
icon_type: string;
icon_type: string | null;
constructor(data: any) {
super();
this.subreason = new Text(data.subreason);
this.reason = new Text(data.reason);
this.proceed_button = Parser.parse(data.proceedButton);
this.proceed_button = Parser.parseItem<Button>(data.proceedButton, Button);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.icon_type = data.icon.iconType;
this.icon_type = data.icon?.iconType || null;
}
}

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

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

View File

@@ -0,0 +1,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

@@ -1,6 +1,7 @@
import Parser from '../index';
import Text from './misc/Text';
import NavigationEndpoint from './NavigationEndpoint';
import SubscriptionNotificationToggleButton from './SubscriptionNotificationToggleButton';
import { YTNode } from '../helpers';
class SubscribeButton extends YTNode {
@@ -14,7 +15,7 @@ class SubscribeButton extends YTNode {
show_preferences: boolean;
subscribed_text: Text;
unsubscribed_text: Text;
notification_preference_button;
notification_preference_button: SubscriptionNotificationToggleButton | null;
endpoint: NavigationEndpoint;
constructor(data: any) {
@@ -27,7 +28,7 @@ class SubscribeButton extends YTNode {
this.show_preferences = data.showPreferences;
this.subscribed_text = new Text(data.subscribedButtonText);
this.unsubscribed_text = new Text(data.unsubscribedButtonText);
this.notification_preference_button = Parser.parse(data.notificationPreferenceButton);
this.notification_preference_button = Parser.parseItem<SubscriptionNotificationToggleButton>(data.notificationPreferenceButton, SubscriptionNotificationToggleButton);
this.endpoint = new NavigationEndpoint(data.serviceEndpoints?.[0] || data.onSubscribeEndpoints?.[0]);
}
}

View File

@@ -1,6 +1,7 @@
import Parser from '../index';
import Text from './misc/Text';
import Author from './misc/Author';
import Menu from './menus/Menu';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import { timeToSeconds } from '../../utils/Utils';
@@ -53,8 +54,8 @@ class Video extends YTNode {
})) || [];
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
this.rich_thumbnail = data.richThumbnail && Parser.parse(data.richThumbnail);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.rich_thumbnail = data.richThumbnail ? Parser.parse(data.richThumbnail) : null;
this.author = new Author(data.ownerText, data.ownerBadges, data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.published = new Text(data.publishedTimeText);
@@ -73,7 +74,7 @@ class Video extends YTNode {
this.show_action_menu = data.showActionMenu;
this.is_watched = data.isWatched || false;
this.menu = Parser.parse(data.menu);
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
}
get description(): string {

View File

@@ -1,6 +1,7 @@
import Parser from '../index';
import Text from './misc/Text';
import Button from './Button';
import VideoOwner from './VideoOwner';
import SubscribeButton from './SubscribeButton';
import MetadataRowContainer from './MetadataRowContainer';
import { YTNode } from '../helpers';
@@ -8,7 +9,7 @@ import { YTNode } from '../helpers';
class VideoSecondaryInfo extends YTNode {
static type = 'VideoSecondaryInfo';
owner; // TODO: VideoOwner?
owner: VideoOwner | null;// TODO: VideoOwner?
description: Text;
subscribe_button;
metadata: MetadataRowContainer | null;
@@ -19,7 +20,7 @@ class VideoSecondaryInfo extends YTNode {
constructor(data: any) {
super();
this.owner = Parser.parse(data.owner);
this.owner = Parser.parseItem<VideoOwner>(data.owner);
this.description = new Text(data.description);
this.subscribe_button = Parser.parseItem<SubscribeButton | Button>(data.subscribeButton, [ SubscribeButton, Button ]);
this.metadata = Parser.parseItem<MetadataRowContainer>(data.metadataRowContainer, MetadataRowContainer);

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';
@@ -258,7 +259,8 @@ export default class Parser {
} : null,
playability_status: data.playabilityStatus ? {
status: data.playabilityStatus.status as string,
error_screen: Parser.parse(data.playabilityStatus.errorScreen),
error_screen: Parser.parseItem(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';
@@ -52,6 +55,7 @@ import { default as CompactLink } from './classes/CompactLink';
import { default as CompactMix } from './classes/CompactMix';
import { default as CompactPlaylist } from './classes/CompactPlaylist';
import { default as CompactVideo } from './classes/CompactVideo';
import { default as ConfirmDialog } from './classes/ConfirmDialog';
import { default as ContinuationItem } from './classes/ContinuationItem';
import { default as CopyLink } from './classes/CopyLink';
import { default as CreatePlaylistDialog } from './classes/CreatePlaylistDialog';
@@ -88,6 +92,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 +121,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';
@@ -138,6 +144,7 @@ import { default as MetadataBadge } from './classes/MetadataBadge';
import { default as MetadataRow } from './classes/MetadataRow';
import { default as MetadataRowContainer } from './classes/MetadataRowContainer';
import { default as MetadataRowHeader } from './classes/MetadataRowHeader';
import { default as MetadataScreen } from './classes/MetadataScreen';
import { default as MicroformatData } from './classes/MicroformatData';
import { default as Mix } from './classes/Mix';
import { default as Movie } from './classes/Movie';
@@ -146,6 +153,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 +191,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 +218,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 +291,13 @@ const map: Record<string, YTNodeConstructor> = {
CtaGoToCreatorStudio,
DataModelSection,
StatRow,
AudioOnlyPlayability,
AutomixPreviewVideo,
BackstageImage,
BackstagePost,
BackstagePostThread,
BrowseFeedActions,
BrowserMediaSession,
Button,
C4TabbedHeader,
CallToActionButton,
@@ -303,6 +315,7 @@ const map: Record<string, YTNodeConstructor> = {
ChildVideo,
ChipCloud,
ChipCloudChip,
CollaboratorInfoCardContent,
CollageHeroImage,
AuthorCommentBadge,
Comment,
@@ -317,6 +330,7 @@ const map: Record<string, YTNodeConstructor> = {
CompactMix,
CompactPlaylist,
CompactVideo,
ConfirmDialog,
ContinuationItem,
CopyLink,
CreatePlaylistDialog,
@@ -353,6 +367,7 @@ const map: Record<string, YTNodeConstructor> = {
AddBannerToLiveChatCommand,
AddChatItemAction,
AddLiveChatTickerItemAction,
LiveChatAutoModMessage,
LiveChatBanner,
LiveChatBannerHeader,
LiveChatBannerPoll,
@@ -381,6 +396,7 @@ const map: Record<string, YTNodeConstructor> = {
UpdateToggleButtonTextAction,
UpdateViewershipAction,
LiveChatAuthorBadge,
LiveChatDialog,
LiveChatHeader,
LiveChatItemList,
LiveChatMessageInput,
@@ -403,6 +419,7 @@ const map: Record<string, YTNodeConstructor> = {
MetadataRow,
MetadataRowContainer,
MetadataRowHeader,
MetadataScreen,
MicroformatData,
Mix,
Movie,
@@ -411,6 +428,7 @@ const map: Record<string, YTNodeConstructor> = {
MusicCarouselShelfBasicHeader,
MusicDescriptionShelf,
MusicDetailHeader,
MusicDownloadStateBadge,
MusicEditablePlaylistDetailHeader,
MusicElementHeader,
MusicHeader,
@@ -448,6 +466,7 @@ const map: Record<string, YTNodeConstructor> = {
PlaylistMetadata,
PlaylistPanel,
PlaylistPanelVideo,
PlaylistPanelVideoWrapper,
PlaylistSidebar,
PlaylistSidebarPrimaryInfo,
PlaylistSidebarSecondaryInfo,
@@ -474,6 +493,7 @@ const map: Record<string, YTNodeConstructor> = {
SearchSuggestionsSection,
SecondarySearchContainer,
SectionList,
SegmentedLikeDislikeButton,
SettingBoolean,
SettingsCheckbox,
SettingsOptions,

View File

@@ -24,7 +24,7 @@ class Search extends Feed {
super(actions, data, already_parsed);
const contents =
this.page.contents?.item().as(TwoColumnSearchResults).primary_contents.item().as(SectionList).contents.array() ||
this.page.contents?.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');
@@ -59,7 +59,7 @@ class Search extends Feed {
if (typeof card === 'string') {
target_card = this.refinement_cards.cards.get({ query: card });
if (!target_card)
throw new InnertubeError('Refinement card not found!', { available_cards: this.refinement_card_queries });
throw new InnertubeError(`Refinement card "${card}" not found`, { available_cards: this.refinement_card_queries });
} else if (card.type === 'SearchRefinementCard') {
target_card = card;
} else {

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';
@@ -45,9 +46,9 @@ export interface FormatOptions {
*/
format?: string;
/**
* InnerTube client, can be ANDROID, WEB or YTMUSIC
* InnerTube client, can be ANDROID, WEB, YTMUSIC, YTMUSIC_ANDROID or TV_EMBEDDED
*/
client?: 'ANDROID' | 'WEB' | 'YTMUSIC'
client?: 'ANDROID' | 'WEB' | 'YTMUSIC' | 'YTMUSIC_ANDROID' | 'TV_EMBEDDED'
}
export interface DownloadOptions extends FormatOptions {
@@ -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);
@@ -224,7 +229,8 @@ class VideoInfo {
* Likes the video.
*/
async like() {
const button = this.primary_info?.menu?.top_level_buttons?.get({ button_id: 'TOGGLE_BUTTON_ID_TYPE_LIKE' })?.as(ToggleButton);
const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton);
const button = segmented_like_dislike_button?.like_button?.as(ToggleButton);
if (!button)
throw new InnertubeError('Like button not found', { video_id: this.basic_info.id });
@@ -241,7 +247,8 @@ class VideoInfo {
* Dislikes the video.
*/
async dislike() {
const button = this.primary_info?.menu?.top_level_buttons?.get({ button_id: 'TOGGLE_BUTTON_ID_TYPE_DISLIKE' })?.as(ToggleButton);
const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton);
const button = segmented_like_dislike_button?.dislike_button?.as(ToggleButton);
if (!button)
throw new InnertubeError('Dislike button not found', { video_id: this.basic_info.id });
@@ -258,7 +265,17 @@ class VideoInfo {
* Removes like/dislike.
*/
async removeLike() {
const button = this.primary_info?.menu?.top_level_buttons?.get({ is_toggled: true })?.as(ToggleButton);
let button;
const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton);
const like_button = segmented_like_dislike_button?.like_button?.as(ToggleButton);
const dislike_button = segmented_like_dislike_button?.dislike_button?.as(ToggleButton);
if (like_button?.is_toggled) {
button = like_button;
} else if (dislike_button?.is_toggled) {
button = dislike_button;
}
if (!button)
throw new InnertubeError('This video is not liked/disliked', { video_id: this.basic_info.id });

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

@@ -1,4 +1,4 @@
// @generated by protobuf-ts 2.8.0
// @generated by protobuf-ts 2.7.0
// @generated from protobuf file "youtube.proto" (package "youtube", syntax proto2)
// tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";

View File

@@ -6,7 +6,8 @@ export const URLS = Object.freeze({
YT_UPLOAD: 'https://upload.youtube.com/',
API: Object.freeze({
BASE: 'https://youtubei.googleapis.com',
PRODUCTION: 'https://youtubei.googleapis.com/youtubei/',
PRODUCTION_1: 'https://www.youtube.com/youtubei/',
PRODUCTION_2: 'https://youtubei.googleapis.com/youtubei/',
STAGING: 'https://green-youtubei.sandbox.googleapis.com/youtubei/',
RELEASE: 'https://release-youtubei.sandbox.googleapis.com/youtubei/',
TEST: 'https://test-youtubei.sandbox.googleapis.com/youtubei/',
@@ -48,6 +49,10 @@ export const CLIENTS = Object.freeze({
YTMUSIC_ANDROID: {
NAME: 'ANDROID_MUSIC',
VERSION: '5.17.51'
},
TV_EMBEDDED: {
NAME: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',
VERSION: '2.0'
}
});
export const STREAM_HEADERS = Object.freeze({

View File

@@ -27,7 +27,7 @@ export default class HTTPClient {
input: URL | Request | string,
init?: RequestInit & HTTPClientInit
) {
const innertube_url = Constants.URLS.API.PRODUCTION + this.#session.api_version;
const innertube_url = Constants.URLS.API.PRODUCTION_1 + this.#session.api_version;
const baseURL = init?.baseURL || innertube_url;
const request_url =
@@ -143,6 +143,12 @@ export default class HTTPClient {
ctx.client.clientName = Constants.CLIENTS.YTMUSIC_ANDROID.NAME;
ctx.client.androidSdkVersion = Constants.CLIENTS.ANDROID.SDK_VERSION;
break;
case 'TV_EMBEDDED':
ctx.client.clientVersion = Constants.CLIENTS.TV_EMBEDDED.VERSION;
ctx.client.clientName = Constants.CLIENTS.TV_EMBEDDED.NAME;
ctx.client.clientScreen = 'EMBED';
ctx.thirdParty = { embedUrl: Constants.URLS.YT_BASE };
break;
default:
break;
}

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