Compare commits

..

83 Commits

Author SHA1 Message Date
LuanRT
f0565ec924 fix(package): add missing comma 2022-05-28 04:48:47 -03:00
LuanRT
15437e3937 chore(release): v1.4.3
- `Innertube#actions` and `Innertube#oauth` are now public classes so power users can have more control over the instance.
- Implemented all endpoints reverse engineered from the YouTube APK.
- The player script is now cached in the OS tmp folder to avoid permission problems.
- Added support for almost all YouTube search filters.
- Added support for editing channel name and description.
- Added support for retrieving Time Watched and basic channel analytics.
- Added support for comment translation.
- Typings are now generated directly from jsdocs.
- The initial Innertube configuration is now extracted from `/sw.js_data` and the visitor data is generated by the library.
- Refactored the entire library to improve maintainability and performance.
2022-05-28 04:46:30 -03:00
LuanRT
c7c0ac8b54 chore(docs): add examples for editing channel name and description 2022-05-28 04:02:03 -03:00
LuanRT
1e23cdb510 chore: fix typos 2022-05-27 17:28:58 -03:00
LuanRT
a85e9ef667 refactor!: welp, a lot of stuff
- Use the OS temp folder to cache the player, closes #57.
- Added support for editing channel name, closes #40.
- Added support for editing channel description.
- Added support for retrieving basic channel analytics, closes #54.
- Moved `Innertube#getAccountInfo()` to `Innertube#account`, and renamed it to `getInfo()`.
- `getInfo()` is now able to return email, channel id, etc.
- Improved jsdoc.
2022-05-27 08:17:16 -03:00
LuanRT
865b6870a1 refactor!: change getSearchSuggestions response schema 2022-05-27 07:35:00 -03:00
LuanRT
7284425618 chore: remove unneeded code 2022-05-25 04:03:05 -03:00
LuanRT
05f74fe004 feat: implement get_user_mention_suggestions endpoint 2022-05-25 03:56:57 -03:00
LuanRT
864f10f2e9 feat: implement geo/place_autocomplete endpoint
Found this while decompiling the YouTube APK. It is basically Google's Place Autocomplete API, but tweaked for Innertube.
2022-05-25 03:50:34 -03:00
LuanRT
369e1048d1 feat: implement /thumbnails endpoint 2022-05-25 02:29:55 -03:00
LuanRT
b1cf5d33b8 feat: implement channel management endpoints, #40 2022-05-25 01:57:54 -03:00
LuanRT
19008e126d chore: update tests 2022-05-24 06:37:27 -03:00
LuanRT
c525163f28 chore: update type definitions 2022-05-24 06:20:56 -03:00
LuanRT
155dc9bd15 refactor!: change how requests are handled 2022-05-24 06:19:13 -03:00
LuanRT
5560ba3ce4 chore: rephrase comment 2022-05-19 05:14:38 -03:00
LuanRT
6aaf9c70b9 refactor: use /sw.js_data to retrieve initial session data
Seems like the `/sw.js` service worker endpoint has a few peculiarities, see #55
2022-05-19 05:02:22 -03:00
LuanRT
e0c7496e37 style(tests): use single quotes 2022-05-18 07:38:46 -03:00
LuanRT
fa79e5cad2 fix: add default function to obj literals to avoid unexpected errors 2022-05-18 06:24:03 -03:00
LuanRT
98a2b49395 chore: update .eslintignore 2022-05-18 06:01:07 -03:00
LuanRT
17978193d0 chore: update type definitions 2022-05-18 05:58:02 -03:00
LuanRT
13f571a6dc chore: update workflows 2022-05-18 05:57:15 -03:00
LuanRT
9f3f8ad820 style: format code 2022-05-18 05:56:28 -03:00
LuanRT
2ba7a5c64e chore: update dev dependencies 2022-05-18 05:54:05 -03:00
LuanRT
d7d1c96d8c chore: use jest for tests 2022-05-18 05:53:09 -03:00
LuanRT
0219c075c7 chore: add linter 2022-05-18 05:51:54 -03:00
LuanRT
759351c38e feat: add basic channel analytics protobuf message 2022-05-16 15:47:15 -03:00
LuanRT
6312e97f95 chore: use timestamp in seconds for visitorData
YouTube also accepts timestamps in milliseconds, but since all clients generate visitorData with timestamps in seconds then the library should do the same.
2022-05-15 21:49:28 -03:00
LuanRT
c60babcf25 chore: update typings 2022-05-15 18:46:52 -03:00
LuanRT
c48cfcd8a0 chore(docs): add search filters examples 2022-05-15 16:13:54 -03:00
LuanRT
594202d61d chore(package): fix repo url 2022-05-12 18:05:57 -03:00
LuanRT
7a5490452a chore: remove uneeded jsdoc param 2022-05-12 14:47:03 -03:00
LuanRT
b4bb44b797 fix: add missing await key, #51 2022-05-11 06:29:46 -03:00
LuanRT
43f3c3fbf8 feat: add type search filter
The `no_filters` protobuf message was also implemented so playlists, channels, etc can be retrived from a search without any filter. #44
2022-05-11 06:14:25 -03:00
LuanRT
b48ae0b8d3 chore: update search filter protobuf message 2022-05-11 06:09:41 -03:00
LuanRT
8cf3e67f79 chore: fix getTrending() jsdoc, #50 2022-05-11 03:11:43 -03:00
LuanRT
ffa243bc07 chore: update type definitions 2022-05-09 18:47:17 -03:00
LuanRT
a08580eeee chore(docs): rephrase 2022-05-09 18:43:38 -03:00
LuanRT
039ebb7c0c chore(docs): remove unneeded stuff 2022-05-09 18:37:23 -03:00
LuanRT
46a385aa06 chore: fix major bugs and improve error handling
Seems like some methods weren't working due to a typo in the browseId, this commit should fix it. Also, additional checks were added so unexpected errors aren't thrown.
2022-05-09 18:30:22 -03:00
LuanRT
f656ccd690 chore: remove unneeded code 2022-05-09 15:15:28 -03:00
LuanRT
ddd276d99f chore: update .gitignore 2022-05-08 22:59:03 -03:00
LuanRT
5fbeaeabb6 chore: update Utils.js jsdoc 2022-05-08 22:58:41 -03:00
LuanRT
18e62f6ff8 chore: rename variable 2022-05-08 22:35:58 -03:00
LuanRT
6235985871 fix: use polling interval provided by the OAuth server 2022-05-08 22:34:40 -03:00
LuanRT
4eef0ddab0 chore: update jsdoc 2022-05-08 21:51:16 -03:00
LuanRT
6127690b4c docs: oops, forgot the hyperlink 2022-05-08 05:59:21 -03:00
LuanRT
b6cfdb733c feat: generate types using jsdoc, #50 2022-05-08 05:56:33 -03:00
LuanRT
b565213f11 docs: fix typos and reword some stuff 2022-05-08 05:53:05 -03:00
LuanRT
a5c9c9d863 feat: add support for comment translation 2022-05-06 17:50:33 -03:00
LuanRT
cf95d82d3e chore: update comment action protobuf schemas 2022-05-06 17:49:28 -03:00
LuanRT
00e0131672 docs: add git to installation instructions 2022-05-06 02:12:39 -03:00
LuanRT
2315306d9f chore: oops 2022-05-05 16:23:46 -03:00
LuanRT
1dfd4b6263 chore: add more metadata to the error class 2022-05-05 16:21:43 -03:00
LuanRT
b0a861dec8 refactor: generate sessions manually
Session generation has been moved to `core/SessionBuilder.js`, which retrieves & generates all the required data to create a valid session. This should also decrease initialization time by over 600 milliseconds!
2022-05-05 04:33:24 -03:00
LuanRT
4943685e57 refactor: simplify the player class 2022-05-05 04:17:11 -03:00
LuanRT
b773f5668c feat: add visitor data protobuf schema 2022-05-05 04:13:46 -03:00
LuanRT
4fd7371cf3 chore: update tests 2022-05-05 04:12:41 -03:00
LuanRT
16bb879689 chore: use prettyPrint parameter to reduce response sizes 2022-05-02 21:15:36 -03:00
LuanRT
a852cd22c8 chore: generate cpn for videoplayback urls 2022-05-02 21:05:17 -03:00
LuanRT
90bb3e20c0 feat: implement sound search endpoint 2022-05-02 05:07:11 -03:00
LuanRT
eab40c0034 chore: move getTimeWatched() placeholder to Innertube.account 2022-05-02 03:54:14 -03:00
LuanRT
19f7336a48 chore: add jsdoc for debug mode option 2022-05-02 02:10:11 -03:00
LuanRT
75895e5492 chore: update deciphers jsdoc 2022-05-02 01:49:37 -03:00
LuanRT
0cdfac1812 feat: add sound info protobuf schema and remove required keys, #38 2022-05-02 00:22:22 -03:00
LuanRT
446966fb2d chore(docs): add contributors list 2022-05-01 19:50:24 -03:00
LuanRT
29897981f0 feat: finalize protobuf encoder for comment translations 2022-05-01 17:49:23 -03:00
LuanRT
7e8a517de9 chore: add .gitignore file 2022-05-01 17:14:52 -03:00
LuanRT
a8b9487b58 feat: add comment translation protobuf schema 2022-05-01 17:00:56 -03:00
LuanRT
80a338e5ff chore: update compiled proto messages 2022-05-01 03:48:18 -03:00
LuanRT
e2ca022a47 chore: add jsdoc to protobuf encoders 2022-05-01 03:16:45 -03:00
luan.lrt4@gmail.com
2ebcd49f02 chore: remove unneeded async key 2022-05-01 00:14:18 -03:00
luan.lrt4@gmail.com
98a62c31da chore: remove unneeded code 2022-04-30 23:39:52 -03:00
luan.lrt4@gmail.com
1bfe2676d8 refactor!: handle all request errors in Request.js and add debug mode 2022-04-30 23:16:17 -03:00
luan.lrt4@gmail.com
4db0a0358f fix: remove unneeded if statement, #43 2022-04-29 18:49:44 -03:00
luan.lrt4@gmail.com
6bdccb89e5 chore: update protobuf messages 2022-04-28 03:12:10 -03:00
luan.lrt4@gmail.com
bbfecdb015 chore(docs): update badge 2022-04-28 01:52:41 -03:00
luan.lrt4@gmail.com
f79d4b635d feat: full support for playlist management, closes #36 2022-04-26 04:27:03 -03:00
luan.lrt4@gmail.com
283c06e64f chore: remove unneeded semicolon 2022-04-26 04:05:02 -03:00
luan.lrt4@gmail.com
5c572dba66 chore(docs): update badges 2022-04-26 03:52:29 -03:00
luan.lrt4@gmail.com
aa943a46a8 chore: update workflows 2022-04-25 02:44:54 -03:00
luan.lrt4@gmail.com
d634892b01 chore: update tests 2022-04-24 22:58:29 -03:00
luan.lrt4@gmail.com
2010714f50 fix: uncaught exception when retrieving private playlists 2022-04-24 22:52:21 -03:00
luan.lrt4@gmail.com
c6c96fd223 chore(docs): rephrasing 2022-04-22 16:03:04 -03:00
76 changed files with 13436 additions and 2429 deletions

7
.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.github
test/
cache/
lib/proto/messages.js
coverage/
node_modules/

45
.eslintrc.yml Normal file
View File

@@ -0,0 +1,45 @@
env:
commonjs: true
es2021: true
node: true
extends: eslint:recommended
parserOptions:
ecmaVersion: latest
rules:
max-len:
- error
-
code: 200
ignoreComments: true
ignoreTrailingComments: true
ignoreStrings: true
ignoreTemplateLiterals: true
ignoreRegExpLiterals: true
quotes: [error, single]
no-template-curly-in-string: error
no-unreachable-loop: error
no-unused-private-class-members: error
no-prototype-builtins: 'off'
no-async-promise-executor: 'off'
no-case-declarations: 'off'
no-return-assign: 'off'
no-floating-decimal: error
no-implied-eval: error
arrow-spacing: error
no-invalid-this: error
no-lone-blocks: error
no-new-func: error
no-new-wrappers: error
no-new: error
no-void: error
no-octal-escape: error
no-self-compare: error
no-sequences: error
no-throw-literal: error
no-unmodified-loop-condition: error
no-useless-call: error
no-useless-concat: error
no-useless-escape: error
no-useless-return: error

2
.github/FUNDING.yml vendored
View File

@@ -9,4 +9,4 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
custom: [ 'https://www.buymeacoffee.com/luanrt' ]

17
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Lint
on: [push, pull_request]
jobs:
eslint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: npm install and lint
run: |
npm install
npm run lint

View File

@@ -1,6 +1,3 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Build
on:
@@ -26,4 +23,4 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm test
- run: npm run test

View File

@@ -15,5 +15,5 @@ jobs:
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.'
stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. Remove the stale label or comment or this will be closed in 2 days'
days-before-stale: 6
days-before-close: 2
days-before-stale: 30
days-before-close: 4

52
.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# YouTube player cache directory
cache/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
.npmignore
# Coverage directory used by tools like istanbul
coverage
*.lcov
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

1207
README.md

File diff suppressed because it is too large Load Diff

7
jest.config.js Normal file
View File

@@ -0,0 +1,7 @@
'use strict';
module.exports = {
roots: [ '<rootDir>/test' ],
testMatch: [ '**/*.test.js' ],
testTimeout: 10000
};

View File

@@ -7,9 +7,12 @@ const CancelToken = Axios.CancelToken;
const EventEmitter = require('events');
const OAuth = require('./core/OAuth');
const Player = require('./core/Player');
const Actions = require('./core/Actions');
const Livechat = require('./core/Livechat');
const SessionBuilder = require('./core/SessionBuilder');
const AccountManager = require('./core/AccountManager');
const PlaylistManager = require('./core/PlaylistManager');
const InteractionManager = require('./core/InteractionManager');
const Utils = require('./utils/Utils');
const Request = require('./utils/Request');
@@ -19,286 +22,89 @@ const Proto = require('./proto');
const NToken = require('./deciphers/NToken');
const Signature = require('./deciphers/Signature');
/**
* Innertube instance.
* @namespace
*/
class Innertube {
#oauth;
#player;
#retry_count;
/**
* @example
* ```js
* const Innertube = require('youtubei.js');
* const youtube = await new Innertube();
* ```
*
* @param {object} [config]
* @param {string} [config.gl]
* @param {string} [config.cookie]
* @param {boolean} [config.debug]
*
* @returns {Innertube}
* @constructor
*/
constructor(config) {
this.config = config || {};
this.#retry_count = 0;
return this.#init();
}
async #init() {
const response = await Axios.get(Constants.URLS.YT_BASE, Constants.DEFAULT_HEADERS(this.config)).catch((error) => error);
if (response instanceof Error) throw new Utils.InnertubeError('Could not retrieve Innertube session', { message: response.message, status_code: response.status || 0 });
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') || ''}}`);
if (data.INNERTUBE_CONTEXT) {
this.key = data.INNERTUBE_API_KEY;
this.version = data.INNERTUBE_API_VERSION;
this.context = data.INNERTUBE_CONTEXT;
this.player_url = data.PLAYER_JS_URL;
this.logged_in = data.LOGGED_IN;
this.sts = data.STS;
this.context.client.hl = 'en';
this.context.client.gl = this.config.gl || 'US';
/**
* @event Innertube#auth - Fired when signing in to an account.
* @event Innertube#update-credentials - Fired when the access token is no longer valid.
* @type {EventEmitter}
*/
this.ev = new EventEmitter();
this.#oauth = new OAuth(this.ev);
this.#player = new Player(this);
await this.#player.init();
if (this.logged_in && this.config.cookie) {
this.auth_apisid = Utils.getStringBetweenStrings(this.config.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
this.request = new Request(this);
this.#initMethods();
} else {
this.#retry_count += 1;
if (this.#retry_count >= 10)
throw new Utils.ParsingError('No InnerTubeContext shell provided in ytconfig.', {
data_snippet: response.data.slice(0, 300),
status_code: response.status || 0
});
return this.#init();
const session = await new SessionBuilder(this.config).build();
this.key = session.key;
this.version = session.api_version;
this.context = session.context;
this.logged_in = false;
this.player_url = session.player.url;
this.sts = session.player.sts;
this.#player = session.player;
/**
* @fires Innertube#auth - fired when signing in to an account.
* @fires Innertube#update-credentials - fired when the access token is no longer valid.
* @type {EventEmitter}
*/
this.ev = new EventEmitter();
this.oauth = new OAuth(this.ev);
if (this.config.cookie) {
this.auth_apisid = Utils.getStringBetweenStrings(this.config.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
this.request = new Request(this);
this.actions = new Actions(this);
this.account = new AccountManager(this.actions);
this.playlist = new PlaylistManager(this.actions);
this.interact = new InteractionManager(this.actions);
return this;
}
#initMethods() {
this.account = {
info: () => this.getAccountInfo(),
settings: {
notifications: {
/**
* Notify about activity from the channels you're subscribed to.
*
* @param {boolean} new_value
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setSubscriptions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'account_notifications', new_value),
/**
* Recommended content notifications.
*
* @param {boolean} new_value
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setRecommendedVideos: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'account_notifications', new_value),
/**
* Notify about activity on your channel.
*
* @param {boolean} new_value
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setChannelActivity: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'account_notifications', new_value),
/**
* Notify about replies to your comments.
*
* @param {boolean} new_value
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setCommentReplies: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'account_notifications', new_value),
/**
* Notify when others mention your channel.
*
* @param {boolean} new_value
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setMentions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'account_notifications', new_value),
/**
* Notify when others share your content on their channels.
*
* @param {boolean} new_value
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setSharedContent: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'account_notifications', new_value)
},
privacy: {
/**
* If set to true, your subscriptions won't be visible to others.
*
* @param {boolean} new_value
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setSubscriptionsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'account_privacy', new_value),
/**
* If set to true, saved playlists won't appear on your channel.
*
* @param {boolean} new_value
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setSavedPlaylistsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'account_privacy', new_value)
}
}
}
this.interact = {
/**
* Likes a given video.
*
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
like: (video_id) => Actions.engage(this, 'like/like', { video_id }),
/**
* Diskes a given video.
*
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
dislike: (video_id) => Actions.engage(this, 'like/dislike', { video_id }),
/**
* Removes a like/dislike.
*
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
removeLike: (video_id) => Actions.engage(this, 'like/removelike', { video_id }),
/**
* Posts a comment on a given video.
*
* @param {string} video_id
* @param {string} text
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
comment: (video_id, text) => Actions.engage(this, 'comment/create_comment', { video_id, text }),
/**
* Subscribes to a given channel.
*
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
subscribe: (channel_id) => Actions.engage(this, 'subscription/subscribe', { channel_id }),
/**
* Unsubscribes from a given channel.
*
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
unsubscribe: (channel_id) => Actions.engage(this, 'subscription/unsubscribe', { channel_id }),
/**
* Changes notification preferences for a given channel.
* Only works with channels you are subscribed to.
*
* @param {string} channel_id
* @param {string} type PERSONALIZED | ALL | NONE
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }),
};
this.playlist = {
/**
* Creates a playlist.
*
* @param {string} title
* @param {string} video_id - Note that a video must be supplied, empty playlists cannot be created.
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
create: (title, video_id) => Actions.engage(this, 'playlist/create', { title, video_id }),
/**
* Deletes a given playlist.
*
* @param {string} playlist_id
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
delete: (playlist_id) => Actions.engage(this, 'playlist/delete', { playlist_id }),
/**
* Adds an array of videos to a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
addVideos: (playlist_id, video_ids) => Actions.engage(this, 'browse/edit_playlist', { action: 'ACTION_ADD_VIDEO', playlist_id, video_ids })
};
}
/**
* Internal method to perform changes on an account's settings.
*
* @param {string} setting_id
* @param {string} type
* @param {string} new_value
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
async #setSetting(setting_id, type, new_value) {
const response = await Actions.browse(this, type);
if (!response.success) return response;
const contents = ({
account_notifications: () => Utils.findNode(response.data, 'contents', 'Your preferences', 13, false).options,
account_privacy: () => Utils.findNode(response.data, 'contents', 'settingsSwitchRenderer', 13, false).options
})[type.trim()]();
const option = contents.find((option) => option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemIdForClient == setting_id);
const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId;
const set_setting = await Actions.account(this, 'account/set_setting', { new_value: type == 'account_privacy' ? !new_value : new_value, setting_item_id });
return {
success: set_setting.success,
status_code: set_setting.status_code,
}
}
/**
* Signs-in to a google account.
* Signs in to a google account.
*
* @param {object} auth_info
* @param {string} auth_info.access_token - Token used to sign in.
* @param {string} auth_info.refresh_token - Token used to get a new access token.
* @param {Date} auth_info.expires - Access token's expiration date, which is usually 24hrs-ish
* @param {Date} auth_info.expires - Access token's expiration date, which is usually 24hrs-ish.
*
* @returns {Promise.<void>}
*/
signIn(auth_info = {}) {
return new Promise(async (resolve) => {
this.#oauth.init(auth_info);
this.oauth.init(auth_info);
if (this.#oauth.isValidAuthInfo()) {
await this.#oauth.checkTokenValidity();
if (this.oauth.isValidAuthInfo()) {
await this.oauth.checkTokenValidity();
this.#updateCredentials();
return resolve();
}
this.ev.on('auth', (data) => {
if (data.status === 'SUCCESS') {
this.#updateCredentials();
@@ -307,60 +113,47 @@ class Innertube {
});
});
}
#updateCredentials() {
this.access_token = this.#oauth.getAccessToken();
this.refresh_token = this.#oauth.getRefreshToken();
this.access_token = this.oauth.getAccessToken();
this.refresh_token = this.oauth.getRefreshToken();
this.logged_in = true;
}
/**
* Signs out of your account.
* Signs out of an account.
* @returns {Promise.<{ success: boolean; status_code: number }>}
*/
async signOut() {
if (!this.logged_in) throw new Utils.InnertubeError('You are not signed in');
const response = await this.#oauth.revokeAccessToken();
const response = await this.oauth.revokeAccessToken();
response.success && (this.logged_in = false);
return response;
}
/**
* Retrieves account details.
* @returns {Promise.<{ name: string; photo: Array<object>; country: string; language: string; }>}
*/
async getAccountInfo() {
const response = await Actions.account(this, 'account/account_menu');
if (!response.success) throw new Utils.InnertubeError('Could not get account info', response);
const menu = Utils.findNode(response, 'actions', 'multiPageMenuRenderer', 6, false);
return {
name: menu.header.activeAccountHeaderRenderer.accountName.simpleText,
photo: menu.header.activeAccountHeaderRenderer.accountPhoto.thumbnails,
country: menu.sections[1].multiPageMenuSectionRenderer.items[2].compactLinkRenderer.subtitle.simpleText,
language: menu.sections[1].multiPageMenuSectionRenderer.items[1].compactLinkRenderer.subtitle.simpleText
}
}
/**
* Searches on YouTube.
* Searches a given query.
*
* @param {string} query - Search query.
* @param {object} options - Search options.
* @param {string} options.client - Client used to perform the search, can be: `YTMUSIC` or `YOUTUBE`.
* @param {string} options.period - Filter videos uploaded within a period, can be: any | hour | day | week | month | year
* @param {string} options.order - Filter results by order, can be: relevance | rating | age | views
* @param {string} options.duration - Filter video results by duration, can be: any | short | long
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: [] } |
* { results: { songs: []; videos: []; albums: []; community_playlists: [] } }>}
* @param {string} query - search query.
* @param {object} [options] - search options.
* @param {string} [options.client] - client used to perform the search, can be: `YTMUSIC` or `YOUTUBE`.
* @param {object} [options.filters] - search filters.
* @param {string} [options.filters.upload_date] - filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
* @param {string} [options.filters.type] - filter results by type, can be: any | video | channel | playlist | movie
* @param {string} [options.filters.duration] - filter videos by duration, can be: any | short | medium | long
* @param {string} [options.filters.sort_by] - filter video results by order, can be: relevance | rating | upload_date | view_count
*
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: object[] } |
* { results: { songs: object[]; videos: object[]; albums: object[]; community_playlists: object[] } }>}
*/
async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) {
const response = await Actions.search(this, options.client, { query, options });
if (!response.success) throw new Utils.InnertubeError('Could not search on YouTube', response);
async search(query, options = { client: 'YOUTUBE' }) {
Utils.throwIfMissing({ query });
const response = await this.actions.search({ query, options, client: options.client });
const results = new Parser(this, response.data, {
query, client: options.client,
query,
client: options.client,
data_type: 'SEARCH'
}).parse();
@@ -368,159 +161,175 @@ class Innertube {
}
/**
* Retrieves search suggestions.
* Retrieves search suggestions for a given query.
*
* @param {string} input - The search query.
* @param {object} [options] - Search options.
* @param {string} [options.client='YOUTUBE'] - Client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
* @returns {Promise.<[{ text: string; bold_text: string }]>}
* @param {string} query - the search query.
* @param {object} [options] - search options.
* @param {string} [options.client='YOUTUBE'] - client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
*
* @returns {Promise.<{ query: string; results: string[] }>}
*/
async getSearchSuggestions(input, options = { client: 'YOUTUBE' }) {
const response = await Actions.getSearchSuggestions(this, options.client, input);
if (!response.success) throw new Utils.InnertubeError('Could not get search suggestions', response);
async getSearchSuggestions(query, options = { client: 'YOUTUBE' }) {
Utils.throwIfMissing({ query });
const response = await this.actions.getSearchSuggestions(options.client, query);
if (options.client === 'YTMUSIC' && !response.data.contents) return [];
const suggestions = new Parser(this, response.data, {
input, client: options.client,
client: options.client,
data_type: 'SEARCH_SUGGESTIONS'
}).parse();
return suggestions;
}
/**
* Retrieves video info.
*
* @param {string} video_id - Video id
* @param {string} video_id - the video id.
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>}
*/
async getDetails(video_id) {
if (!video_id) throw new Utils.MissingParamError('Video id is missing');
const response = await Actions.getVideoInfo(this, { id: video_id });
const continuation = await Actions.next(this, { video_id });
continuation.success && (response.continuation = continuation.data);
Utils.throwIfMissing({ video_id });
const response = await this.actions.getVideoInfo(video_id);
const continuation = await this.actions.next({ video_id });
response.continuation = continuation.data;
const details = new Parser(this, response, {
client: 'YOUTUBE',
client: 'YOUTUBE',
data_type: 'VIDEO_INFO'
}).parse();
details.like = () => Actions.engage(this, 'like/like', { video_id });
details.dislike = () => Actions.engage(this, 'like/dislike', { video_id });
details.removeLike = () => Actions.engage(this, 'like/removelike', { video_id });
details.subscribe = () => Actions.engage(this, 'subscription/subscribe', { channel_id: details.metadata.channel_id });
details.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { channel_id: details.metadata.channel_id });
details.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text });
const livechat_ctoken = continuation.data.contents?.twoColumnWatchNextResults
?.conversationBar?.liveChatRenderer?.continuations?.find((continuation) => continuation.reloadContinuationData)
.reloadContinuationData.continuation;
details.like = () => this.actions.engage('like/like', { video_id });
details.dislike = () => this.actions.engage('like/dislike', { video_id });
details.removeLike = () => this.actions.engage('like/removelike', { video_id });
details.subscribe = () => this.actions.engage('subscription/subscribe', { channel_id: details.metadata.channel_id });
details.unsubscribe = () => this.actions.engage('subscription/unsubscribe', { channel_id: details.metadata.channel_id });
details.comment = (text) => this.actions.engage('comment/create_comment', { video_id, text });
details.getComments = (sort_by) => this.getComments(video_id, sort_by);
details.getLivechat = () => new Livechat(this, continuation.data.contents?.twoColumnWatchNextResults?.conversationBar?.liveChatRenderer?.continuations?.find((continuation) => continuation.reloadContinuationData).reloadContinuationData.continuation, details.metadata.channel_id, video_id);
details.setNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' });
details.getLivechat = () => new Livechat(this, livechat_ctoken, details.metadata.channel_id, video_id);
details.setNotificationPreferences = (type) => this.actions.notifications('modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' });
return details;
}
/**
* Retrieves comments for a video.
* Retrieves comments for a given video.
*
* @param {string} video_id - Video id
* @param {string} [sort_by] - Can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
* @return {Promise.<{ page_count: number; comment_count: number; items: []; }>}
* @param {string} video_id - the video id.
* @param {string} [sort_by] - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
* @return {Promise.<{ page_count: number; comment_count: number; items: object[]; }>}
*/
async getComments(video_id, sort_by) {
Utils.throwIfMissing({ video_id });
const payload = Proto.encodeCommentsSectionParams(video_id, {
sort_by: sort_by || 'TOP_COMMENTS'
});
const response = await Actions.next(this, { continuation_token: payload });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve comments', response);
const response = await this.actions.next({ ctoken: payload });
const comments = new Parser(this, response.data, {
video_id,
client: 'YOUTUBE',
client: 'YOUTUBE',
data_type: 'COMMENTS'
}).parse();
return comments;
}
/**
* Retrieves contents for a given channel. (WIP)
*
* @param {string} id - The id of the channel.
* @param {string} id - channel id
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>}
*/
async getChannel(id) {
const response = await Actions.browse(this, 'channel', { browse_id: id });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve channel info.', response);
Utils.throwIfMissing({ id });
const response = await this.actions.browse(id);
const channel_info = new Parser(this, response.data, {
client: 'YOUTUBE',
client: 'YOUTUBE',
data_type: 'CHANNEL'
}).parse();
return channel_info;
}
/**
* Retrieves your watch history.
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
* Retrieves watch history.
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
*/
async getHistory() {
const response = await Actions.browse(this, 'history');
if (!response.success) throw new Utils.InnertubeError('Could not retrieve watch history', response);
const response = await this.actions.browse('FEhistory');
const history = new Parser(this, response, {
client: 'YOUTUBE',
client: 'YOUTUBE',
data_type: 'HISTORY'
}).parse();
return history;
}
/**
* Retrieves YouTube's home feed (aka recommendations).
* @returns {Promise.<{ videos: [{ id: string; title: string; description: string; channel: string; metadata: object }] }>}
* Retrieves home feed (aka recommendations).
* @returns {Promise.<{ videos: { id: string; title: string; description: string; channel: string; metadata: object }[] }>}
*/
async getHomeFeed() {
const response = await Actions.browse(this, 'home_feed');
if (!response.success) throw new Utils.InnertubeError('Could not retrieve home feed', response);
const response = await this.actions.browse('FEwhat_to_watch');
const homefeed = new Parser(this, response, {
client: 'YOUTUBE',
client: 'YOUTUBE',
data_type: 'HOMEFEED'
}).parse();
return homefeed;
}
/**
* Retrieves trending content.
* @returns {Promise.<{ now: { content: [{ title: string; videos: []; }] };
* music: { getVideos: Promise.<Array>; }; gaming: { getVideos: Promise.<Array>; };
* gaming: { getVideos: Promise.<Array>; }; }>}
* @returns {Promise.<{ now: { content: { title: string; videos: object[]; }[] };
* music: { getVideos: Promise.<Array.<object>>; }; gaming: { getVideos: Promise.<Array.<object>>; };
* movies: { getVideos: Promise.<Array.<object>>; } }>}
*/
async getTrending() {
const response = await Actions.browse(this, 'trending');
if (!response.success) throw new Utils.InnertubeError('Could not retrieve trending content', response);
const response = await this.actions.browse('FEtrending');
const trending = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'TRENDING'
}).parse();
return trending;
}
/**
* @todo finish this
* WIP
*/
async getLibrary() {
const response = await this.actions.browse('FElibrary');
const library = new Parser(this, response.data, {
client: 'YOUTUBE',
data_type: 'LIBRARY'
}).parse();
return library;
}
/**
* Retrieves your subscriptions feed.
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
* Retrieves subscriptions feed.
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
*/
async getSubscriptionsFeed() {
const response = await Actions.browse(this, 'subscriptions_feed');
if (!response.success) throw new Utils.InnertubeError('Could not retrieve subscriptions feed', response);
const response = await this.actions.browse('FEsubscriptions');
const subsfeed = new Parser(this, response, {
client: 'YOUTUBE',
client: 'YOUTUBE',
data_type: 'SUBSFEED'
}).parse();
@@ -528,28 +337,26 @@ class Innertube {
}
/**
* Retrieves your notifications.
* @returns {Promise.<{ items: [{ title: string; sent_time: string; channel_name: string; channel_thumbnail: {}; video_thumbnail: {}; video_url: string; read: boolean; notification_id: string }] }>}
* Retrieves notifications.
* @returns {Promise.<{ items: { title: string; sent_time: string; channel_name: string; channel_thumbnail: object; video_thumbnail: object; video_url: string; read: boolean; notification_id: string }[] }>}
*/
async getNotifications() {
const response = await Actions.notifications(this, 'get_notification_menu');
if (!response.success) throw new Utils.InnertubeError('Could not fetch notifications', response);
const response = await this.actions.notifications('get_notification_menu');
const notifications = new Parser(this, response.data, {
client: 'YOUTUBE',
client: 'YOUTUBE',
data_type: 'NOTIFICATIONS'
}).parse();
return notifications;
}
/**
* Retrieves unseen notifications count.
* @returns {Promise.<number>} unseen notifications count.
* @returns {Promise.<number>}
*/
async getUnseenNotificationsCount() {
const response = await Actions.notifications(this, 'get_unseen_count');
if (!response.success) throw new Utils.InnertubeError('Could not get unseen notifications count', response);
const response = await this.actions.notifications('get_unseen_count');
return response.data.unseenCount;
}
@@ -557,40 +364,41 @@ class Innertube {
* Retrieves lyrics for a given song if available.
*
* @param {string} video_id
* @returns {Promise.<string>} Song lyrics
* @returns {Promise.<string>}
*/
async getLyrics(video_id) {
const continuation = await Actions.next(this, { video_id: video_id, ytmusic: true });
if (!continuation.success) throw new Utils.InnertubeError('Could not retrieve lyrics', continuation);
Utils.throwIfMissing({ video_id });
const continuation = await this.actions.next({ video_id: video_id, client: 'YTMUSIC' });
const lyrics_tab = Utils.findNode(continuation, 'contents', 'Lyrics', 8, false);
const response = await this.actions.browse(lyrics_tab.endpoint?.browseEndpoint.browseId, { client: 'YTMUSIC' });
if (!response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id });
const lyrics_tab = Utils.findNode(continuation, 'contents', 'Lyrics', 8, false);
const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.endpoint?.browseEndpoint.browseId });
if (!response.success || !response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id });
const lyrics = Utils.findNode(response.data, 'contents', 'runs', 6, false);
return lyrics.runs[0].text;
}
/**
* Parses a given playlist.
* Retrieves the contents of a given playlist.
*
* @param {string} playlist_id - the id of the playlist.
* @param {object} options - `YOUTUBE` | `YTMUSIC`
* @param {string} options.client - client used to parse the playlist, can be: `YTMUSIC` | `YOUTUBE`
*
* @param {string} playlist_id - The id of the playlist.
* @param {object} options - { client: YOUTUBE | YTMUSIC }
* @param {string} options.client - Client used to parse the playlist, can be: `YTMUSIC` | `YOUTUBE`
* @returns {Promise.<
* { title: string; description: string; total_items: string; last_updated: string; views: string; items: [] } |
* { title: string; description: string; total_items: number; duration: string; year: string; items: [] }>}
*/
async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) {
const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { ytmusic: options.client == 'YTMUSIC', browse_id: `VL${playlist_id}` });
if (!response.success) throw new Utils.InnertubeError('Could not get playlist', response);
Utils.throwIfMissing({ playlist_id });
const response = await this.actions.browse(`VL${playlist_id}`, { client: options.client });
const playlist = new Parser(this, response.data, {
client: options.client,
client: options.client,
data_type: 'PLAYLIST'
}).parse();
return playlist;
}
@@ -599,6 +407,7 @@ class Innertube {
*
* @param {object} options
* @param {object} video_data
*
* @returns {object.<{ selected_format: {}; formats: [] }>}
*/
#chooseFormat(options, video_data) {
@@ -612,7 +421,7 @@ class Innertube {
format.url = format.url || format.signatureCipher || format.cipher;
if (format.signatureCipher || format.cipher) {
format.url = new Signature(format.url, this.#player).decipher();
format.url = new Signature(format.url, this.#player.signature_decipher).decipher();
}
const url_components = new URL(format.url);
@@ -620,7 +429,7 @@ class Innertube {
url_components.searchParams.set('ratebypass', 'yes');
if (url_components.searchParams.get('n')) {
url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc, url_components.searchParams.get('n')).transform());
url_components.searchParams.set('n', new NToken(this.#player.ntoken_decipher, url_components.searchParams.get('n')).transform());
}
format.url = url_components.toString();
@@ -631,9 +440,6 @@ class Innertube {
delete format.signatureCipher;
});
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
let format;
let bitrates;
let filtered_formats;
@@ -644,21 +450,17 @@ class Innertube {
'videoandaudio': formats.filter((format) => format.has_video && format.has_audio)
})[options.type] || formats.filter((format) => format.has_video && format.has_audio);
if (options.type != 'videoandaudio') {
let streams;
let streams;
options.type != 'audio' &&
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4')));
options.type != 'audio' &&
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4')));
streams == undefined || streams.length == 0 &&
(streams = filtered_formats.filter((format) => format.quality == 'medium'));
!streams || !streams.length &&
(streams = filtered_formats.filter((format) => format.quality == 'medium'));
bitrates = streams.map((format) => format.bitrate);
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
} else {
format = filtered_formats[0];
}
bitrates = streams.map((format) => format.bitrate);
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
return { selected_format: format, formats };
}
@@ -667,21 +469,26 @@ class Innertube {
* An alternative to {@link download}.
* Returns deciphered streaming data.
*
* @param {string} id - Video id
* @param {object} options - Download options.
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
* @param {string} options.format - File format
* @returns {Promise.<{ selected_format: {}; formats: [] }>}
* @param {string} video_id - video id
* @param {object} options - download options.
* @param {string} options.quality - video quality; 360p, 720p, 1080p, etc...
* @param {string} options.type - download type, can be: video, audio or videoandaudio
* @param {string} options.format - file format
*
* @returns {Promise.<{ selected_format: object; formats: object[] }>}
*/
async getStreamingData(id, options = {}) {
async getStreamingData(video_id, options = {}) {
Utils.throwIfMissing({ video_id });
options.quality = options.quality || '360p';
options.type = options.type || 'videoandaudio';
options.format = options.format || 'mp4';
const data = await Actions.getVideoInfo(this, { id });
const data = await this.actions.getVideoInfo(video_id);
const streaming_data = this.#chooseFormat(options, data);
if (!streaming_data.selected_format) throw new Utils.NoStreamingDataError('Could not find any suitable format.', { id, options });
if (!streaming_data.selected_format)
throw new Utils.NoStreamingDataError('Could not find any suitable format.', { video_id, options });
return streaming_data;
}
@@ -689,25 +496,31 @@ class Innertube {
/**
* Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}.
*
* @param {string} id - Video id
* @param {object} options - Download options.
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
* @param {string} options.format - File format
* @return {ReadableStream}
* @param {string} video_id - video id
* @param {object} options - download options.
* @param {string} [options.quality] - video quality; 360p, 720p, 1080p, etc...
* @param {string} [options.type] - download type, can be: video, audio or videoandaudio
* @param {string} [options.format] - file format
* @param {object} [options.range] - download range, indicates which bytes should be downloaded.
* @param {number} options.range.start - the beginning of the range.
* @param {number} options.range.end - the end of the range.
*
* @return {Stream.PassThrough}
*/
download(id, options = {}) {
if (!id) throw new Utils.MissingParamError('Video id is missing');
download(video_id, options = {}) {
Utils.throwIfMissing({ video_id });
options.quality = options.quality || '360p';
options.type = options.type || 'videoandaudio';
options.format = options.format || 'mp4';
let cancel;
let cancelled = false;
const cpn = Utils.generateRandomString(16);
const stream = new Stream.PassThrough();
Actions.getVideoInfo(this, { id }).then(async (video_data) => {
this.actions.getVideoInfo(video_id, cpn).then(async (video_data) => {
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED')
return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
if (!video_data.streamingData)
@@ -722,7 +535,7 @@ class Innertube {
stream.emit('info', { video_details, selected_format: format, formats });
if (options.type == 'videoandaudio' && !options.range) {
const response = await Axios.get(format.url, {
const response = await Axios.get(`${format.url}&cpn=${cpn}`, {
responseType: 'stream',
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
headers: Constants.STREAM_HEADERS
@@ -771,12 +584,12 @@ class Innertube {
let must_end = false;
stream.emit('start');
const downloadChunk = async () => {
(chunk_end >= format.contentLength || options.range) && (must_end = true);
options.range && (format.contentLength = options.range.end);
const response = await Axios.get(`${format.url}&range=${chunk_start}-${chunk_end || ''}`, {
const response = await Axios.get(`${format.url}&cpn=${cpn}&range=${chunk_start}-${chunk_end || ''}`, {
responseType: 'stream',
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
headers: Constants.STREAM_HEADERS

251
lib/core/AccountManager.js Normal file
View File

@@ -0,0 +1,251 @@
'use strict';
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
const Proto = require('../proto');
/** @namespace */
class AccountManager {
#actions;
/**
* @param {Actions} actions
* @constructor
*/
constructor (actions) {
this.#actions = actions;
/** @namespace */
this.channel = {
/**
* Edits channel name.
*
* @param {string} new_name
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editName: (new_name) => this.#actions.channel('channel/edit_name', { new_name }),
/**
* Edits channel description.
*
* @param {string} new_description
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editDescription: (new_description) => this.#actions.channel('channel/edit_description', { new_description }),
/**
* Retrieves basic channel analytics.
* @borrows AccountManager#getAnalytics as getBasicAnalytics
*/
getBasicAnalytics: () => this.getAnalytics()
}
/** @namespace */
this.settings = {
notifications: {
/**
* Notify about activity from the channels you're subscribed to.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptions: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'SPaccount_notifications', option),
/**
* Recommended content notifications.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setRecommendedVideos: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'SPaccount_notifications', option),
/**
* Notify about activity on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setChannelActivity: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'SPaccount_notifications', option),
/**
* Notify about replies to your comments.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setCommentReplies: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'SPaccount_notifications', option),
/**
* Notify when others mention your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setMentions: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'SPaccount_notifications', option),
/**
* Notify when others share your content on their channels.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSharedContent: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'SPaccount_notifications', option)
},
privacy: {
/**
* If set to true, your subscriptions won't be visible to others.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptionsPrivate: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'SPaccount_privacy', option),
/**
* If set to true, saved playlists won't appear on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSavedPlaylistsPrivate: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'SPaccount_privacy', option)
}
}
}
/**
* Internal method to perform changes on an account's settings.
*
* @param {string} setting_id
* @param {string} type
* @param {string} new_value
* @private
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async #setSetting(setting_id, type, new_value) {
Utils.throwIfMissing({ setting_id, type, new_value });
const values = { ON: true, OFF: false };
if (!values.hasOwnProperty(new_value))
throw new Utils.InnertubeError('Invalid option', { option: new_value, available_options: Object.keys(values) });
const response = await this.#actions.browse(type);
const contents = ({
SPaccount_notifications: () => Utils.findNode(response.data, 'contents', 'Your preferences', 13, false).options,
SPaccount_privacy: () => Utils.findNode(response.data, 'contents', 'settingsSwitchRenderer', 13, false).options
})[type.trim()]();
const option = contents.find((option) => option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemIdForClient == setting_id);
const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId;
const set_setting = await this.#actions.account('account/set_setting', {
new_value: type == 'SPaccount_privacy' ? !values[new_value] : values[new_value],
setting_item_id
});
return set_setting;
}
/**
* Retrieves channel info.
* @returns {Promise.<{ name: string; email: string; channel_id: string; subscriber_count: string; photo: object[]; }>}
*/
async getInfo() {
const response = await this.#actions.account('account/accounts_list', { client: 'ANDROID' });
const account_item_section_renderer = Utils.findNode(response.data, 'contents', 'accountItem', 8, false);
const profile = account_item_section_renderer.accountItem.serviceEndpoint.signInEndpoint.directSigninUserProfile;
const name = profile.accountName;
const email = profile.email;
const photo = profile.accountPhoto.thumbnails;
const subscriber_count = account_item_section_renderer.accountItem.accountByline.runs.map((run) => run.text).join('');
const channel_id = response.data.contents[0].accountSectionListRenderer.footers[0].accountChannelRenderer.navigationEndpoint.browseEndpoint.browseId;
return { name, email, channel_id, subscriber_count, photo };
}
/**
* Retrieves time watched statistics.
* @returns {Promise.<[{ title: string; time: string; }]>}
*/
async getTimeWatched() {
const response = await this.#actions.browse('SPtime_watched', { client: 'ANDROID' });
const rows = Utils.findNode(response.data, 'contents', 'statRowRenderer', 11, false);
const stats = rows.map((row) => {
const renderer = row.statRowRenderer;
if (renderer) {
return {
title: renderer.title.runs.map((run) => run.text).join(''),
time: renderer.contents.runs.map((run) => run.text).join('')
}
}
}).filter((stat) => stat);
return stats;
}
/**
* Retrieves basic channel analytics.
*
* @returns {Promise.<{ metrics: { title: string; subtitle: string; metric_value: string;
* comparison_indicator: object; series_configuration: object; }[]; top_content: { views: string;
* published: string; thumbnails: object[]; duration: string; is_short: boolean }[]; }>}
*/
async getAnalytics() {
const info = await this.getInfo();
const params = Proto.encodeChannelAnalyticsParams(info.channel_id);
const action = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
const contents = Utils.findNode(action.data, 'contents', 'elementRenderer', 11, false);
const analytics = {
metrics: {},
top_content: {}
}
contents.forEach((el) => {
const element = el.elementRenderer.newElement;
const model = element.type.componentType.model;
const key = Object.keys(model)[0];
switch (key) {
case 'analyticsRootModel':
const sections = model.analyticsRootModel.analyticsKeyMetricsData.dataModel.sections;
analytics.metrics = sections.map((section) => ({
title: section.title,
subtitle: section.subtitle,
metric_value: section.metricValue,
comparison_indicator: section.comparisonIndicator,
series_configuration: section.seriesConfiguration
}));
break;
case 'analyticsVodCarouselCardModel':
const video_carousel = model.analyticsVodCarouselCardModel.videoCarouselData;
analytics.top_content = video_carousel?.videos.map((video) => ({
title: video.videoTitle,
metadata: {
views: video.videoDescription.split('·')[0].trim(),
published: video.videoDescription.split('·')[1].trim(),
thumbnails: video.thumbnailDetails.thumbnails,
duration: video.formattedLength,
is_short: video.isShort
}
})) || [];
break;
default:
break;
}
});
return analytics;
}
}
module.exports = AccountManager;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
'use strict';
const Utils = require('../utils/Utils');
/** @namespace */
class InteractionManager {
#actions;
/**
* @param {Actions} actions
* @constructor
*/
constructor(actions) {
this.#actions = actions;
}
/**
* Likes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async like(video_id) {
Utils.throwIfMissing({ video_id });
const action = await this.#actions.engage('like/like', { video_id });
return action;
}
/**
* Dislikes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async dislike(video_id) {
Utils.throwIfMissing({ video_id });
const action = await this.#actions.engage('like/dislike', { video_id });
return action;
}
/**
* Removes a like/dislike.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async removeLike(video_id) {
Utils.throwIfMissing({ video_id });
const action = await this.actions.engage('like/removelike', { video_id });
return action;
}
/**
* Subscribes to a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async subscribe(channel_id) {
Utils.throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/subscribe', { channel_id });
return action;
}
/**
* Unsubscribes from a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async unsubscribe(channel_id) {
Utils.throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/unsubscribe', { channel_id });
return action;
}
/**
* Posts a comment on a given video.
*
* @param {string} video_id
* @param {string} text
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async comment(video_id, text) {
Utils.throwIfMissing({ video_id, text });
const action = await this.#actions.engage('comment/create_comment', { video_id, text });
return action;
}
/**
* Translates a given text using YouTube's comment translate feature.
*
* @param {string} text
* @param {string} target_language - an ISO language code
* @param {object} [args] - optional arguments
* @param {string} [args.video_id]
* @param {string} [args.comment_id]
*
* @returns {Promise.<{ success: boolean; status_code: number; translated_content: string; data: object; }>}
*/
async translate(text, target_language, args = {}) {
Utils.throwIfMissing({ text, target_language });
const response = await await this.#actions.engage('comment/perform_comment_action', {
video_id: args.video_id,
comment_id: args.comment_id,
target_language: target_language,
comment_action: 'translate',
text
});
const translated_content = Utils.findNode(response.data, 'frameworkUpdates', 'content', 7, false);
return {
success: response.success,
status_code: response.status_code,
translated_content: translated_content.content,
data: response.data
}
}
/**
* Changes notification preferences for a given channel.
* Only works with channels you are subscribed to.
*
* @param {string} channel_id
* @param {string} type - `PERSONALIZED` | `ALL` | `NONE`
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async setNotificationPreferences(channel_id, type) {
Utils.throwIfMissing({ channel_id, type });
const action = await this.#actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' });
return action;
}
}
module.exports = InteractionManager;

View File

@@ -1,6 +1,5 @@
'use strict';
const Actions = require('./Actions');
const EventEmitter = require('events');
class Livechat extends EventEmitter {
@@ -27,7 +26,7 @@ class Livechat extends EventEmitter {
async #poll() {
if (!this.running) return;
const livechat = await Actions.livechat(this.session, 'live_chat/get_live_chat', { ctoken: this.ctoken });
const livechat = await this.session.actions.livechat('live_chat/get_live_chat', { ctoken: this.ctoken });
if (!livechat.success) {
this.emit('error', { message: `Failed polling livechat: ${livechat.message}. Retrying...` });
return await this.#poll();
@@ -46,9 +45,9 @@ class Livechat extends EventEmitter {
this.message_queue = [];
const data = { video_id: this.video_id };
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
if (this.metadata_ctoken) data.ctoken = this.metadata_ctoken;
const updated_metadata = await Actions.livechat(this.session, 'updated_metadata', data);
const updated_metadata = await this.session.actions.livechat('updated_metadata', data);
if (!updated_metadata.success) {
this.emit('error', { message: `Failed polling livechat metadata: ${livechat.message}.` });
}
@@ -89,16 +88,16 @@ class Livechat extends EventEmitter {
}
async sendMessage(text) {
const message = await Actions.livechat(this.session, 'live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id });
const message = await this.session.actions.livechat('live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id });
if (!message.success) return message;
const deleteMessage = async () => {
const menu = await Actions.livechat(this.session, 'live_chat/get_item_context_menu', { params: { params: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params, pbj: 1 } });
const menu = await this.session.actions.livechat('live_chat/get_item_context_menu', { params: { params: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params, pbj: 1 } });
if (!menu.success) return menu;
const chat_item_menu = menu.data.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0];
const cmd = await Actions.livechat(this.session, 'live_chat/moderate', { cmd_params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params });
const cmd = await this.session.actions.livechat('live_chat/moderate', { params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params });
if (!cmd.success) return cmd;
return { success: true, status_code: cmd.status_code };
@@ -123,10 +122,9 @@ class Livechat extends EventEmitter {
/**
* Blocks a user.
* @todo Implement this method.
* @param {object} msg_params
* @todo Implement this method
*/
async blockUser(msg_params) {
async blockUser() {
throw new Error('Not implemented');
}

View File

@@ -4,19 +4,20 @@ const Axios = require('axios');
const Constants = require('../utils/Constants');
const Uuid = require('uuid');
/** @namespace */
class OAuth {
#scope = Constants.OAUTH.SCOPE;
#model_name = Constants.OAUTH.MODEL_NAME;
#grant_type = Constants.OAUTH.GRANT_TYPE;
#oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
#oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
#oauth_revoke_url = `${Constants.URLS.YT_BASE}/o/oauth2/revoke`;
#auth_info = {};
#refresh_interval = 5;
#polling_interval = 5;
#ev = null;
/**
* @param {EventEmitter} ev
* @constructor
*/
constructor(ev) {
this.#ev = ev;
}
@@ -46,9 +47,9 @@ class OAuth {
const data = {
client_id: this.client_id,
scope: this.#scope,
scope: Constants.OAUTH.SCOPE,
device_id: Uuid.v4(),
model_name: this.#model_name
model_name: Constants.OAUTH.MODEL_NAME
};
const response = await Axios.post(this.#oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
@@ -61,7 +62,7 @@ class OAuth {
verification_url: response.data.verification_url
});
this.refresh_interval = response.data.interval;
this.#polling_interval = response.data.interval;
this.#waitForAuth(response.data.device_code);
}
@@ -77,7 +78,7 @@ class OAuth {
client_id: this.client_id,
client_secret: this.client_secret,
code: device_code,
grant_type: this.#grant_type
grant_type: Constants.OAUTH.GRANT_TYPE
};
setTimeout(async () => {
@@ -121,7 +122,7 @@ class OAuth {
status: 'SUCCESS'
});
}
}, 1000 * this.#refresh_interval);
}, 1000 * this.#polling_interval);
}
/**
@@ -197,23 +198,31 @@ class OAuth {
const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS()).catch((error) => error);
const response = await Axios.get(script_url).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const client_identity = response.data.replace(/\n/g, '').match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
return client_identity.groups;
}
/**
* Returns the access token.
* @returns {string}
*/
getAccessToken() {
return this.#auth_info.access_token;
}
/**
* Returns the refresh token.
* @returns {string}
*/
getRefreshToken() {
return this.#auth_info.refresh_token;
}
/**
* Checks if the auth info is valid.
* Checks if the auth info format is valid.
* @returns {boolean} true | false
*/
isValidAuthInfo() {

View File

@@ -1,49 +1,127 @@
'use strict';
const os = require('os');
const Fs = require('fs');
const Axios = require('axios');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
/** @namespace */
class Player {
constructor(session) {
this.session = session;
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
this.tmp_cache_dir = __dirname.slice(0, -8) + 'cache';
#player_id;
#player_url;
#player_path;
#ntoken_decipher_sc;
#signature_decipher_sc;
#signature_timestamp;
#cache_dir;
/**
* Represents the YouTube Web player script.
* @param {string} id - the id of the player.
* @constructor
*/
constructor(id) {
this.#player_id = id;
this.#cache_dir = `${os.tmpdir()}/cache`;
this.#player_url = Constants.URLS.YT_BASE + '/s/player/' + this.#player_id + '/player_ias.vflset/en_US/base.js';
this.#player_path = `${this.#cache_dir}/${this.#player_id}.js`;
}
async init() {
if (Fs.existsSync(`${this.tmp_cache_dir}/${this.player_name}.js`)) {
const player_data = Fs.readFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`).toString();
this.sig_decipher_sc = this.#getSigDecipherCode(player_data);
this.ntoken_sc = this.#getNEncoder(player_data);
if (this.isCached()) {
const player_data = Fs.readFileSync(this.#player_path).toString();
this.#signature_timestamp = this.#extractSigTimestamp(player_data);
this.#signature_decipher_sc = this.#extractSigDecipherSc(player_data);
this.#ntoken_decipher_sc = this.#extractNTokenSc(player_data);
} else {
const response = await Axios.get(`${Constants.URLS.YT_BASE}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message);
const response = await Axios.get(this.#player_url, { headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
if (response instanceof Error) throw new Utils.InnertubeError('Could not download js player', { player_id: this.#player_id });
this.#signature_timestamp = this.#extractSigTimestamp(response.data);
this.#signature_decipher_sc = this.#extractSigDecipherSc(response.data);
this.#ntoken_decipher_sc = this.#extractNTokenSc(response.data);
try {
// Deletes old players
Fs.existsSync(this.tmp_cache_dir) && Fs.rmSync(this.tmp_cache_dir, { recursive: true });
// Caches the current player so we don't have to download it all the time.
Fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
Fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data);
} catch (err) {}
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
this.ntoken_sc = this.#getNEncoder(response.data);
// Delete the old player
Fs.existsSync(this.#cache_dir) &&
Fs.rmSync(this.#cache_dir, { recursive: true });
// Cache the current player
Fs.mkdirSync(this.#cache_dir, { recursive: true });
Fs.writeFileSync(this.#player_path, response.data);
} finally { /* do nothing */ }
}
return this;
}
#getSigDecipherCode(data) {
/**
* Returns the current player's url.
* @readonly
* @returns {string}
*/
get url() {
return this.#player_url;
}
/**
* Returns the signature timestamp.
* @readonly
* @returns {string}
*/
get sts() {
return this.#signature_timestamp;
}
/**
* Returns the n-token decipher algorithm.
* @readonly
* @returns {string}
*/
get ntoken_decipher() {
return this.#ntoken_decipher_sc;
}
/**
* Returns the signature decipher algorithm.
* @readonly
* @returns {string}
*/
get signature_decipher() {
return this.#signature_decipher_sc;
}
/**
* Extracts the signature timestamp from the player source code.
* @returns {number}
*/
#extractSigTimestamp(data) {
return parseInt(Utils.getStringBetweenStrings(data, 'signatureTimestamp:', ','));
}
/**
* Extracts the signature decipher algorithm.
* @returns {string}
*/
#extractSigDecipherSc(data) {
const sig_alg_sc = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
const sig_data = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
return sig_alg_sc + sig_data;
}
#getNEncoder(data) {
/**
* Extracts the n-token decipher algorithm.
* @returns {string}
*/
#extractNTokenSc(data) {
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
}
isCached() {
return Fs.existsSync(this.#player_path);
}
}
module.exports = Player;

115
lib/core/PlaylistManager.js Normal file
View File

@@ -0,0 +1,115 @@
'use strict';
const Utils = require('../utils/Utils');
/** @namespace */
class PlaylistManager {
#actions;
/**
* @param {Actions} actions
* @constructor
*/
constructor (actions) {
this.#actions = actions;
}
/**
* Creates a playlist.
*
* @param {string} title
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async create(title, video_ids) {
Utils.throwIfMissing({ title, video_ids });
const response = await this.#actions.playlist('playlist/create', { title, ids: video_ids });
return {
success: response.success,
status_code: response.status_code,
playlist_id: response.data.playlistId,
data: response.data
}
}
/**
* Deletes a given playlist.
* @param {string} playlist_id
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async delete(playlist_id) {
Utils.throwIfMissing({ playlist_id });
const response = await this.#actions.playlist('playlist/delete', { playlist_id });
return {
success: response.success,
status_code: response.status_code,
playlist_id,
data: response.data
}
}
/**
* Adds videos to a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async addVideos(playlist_id, video_ids) {
Utils.throwIfMissing({ playlist_id, video_ids });
const response = await this.#actions.playlist('browse/edit_playlist', {
ids: video_ids,
action: 'ACTION_ADD_VIDEO',
playlist_id
});
return {
success: response.success,
status_code: response.status_code,
playlist_id,
data: response.data
}
}
/**
* Removes videos from a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async removeVideos(playlist_id, video_ids) {
Utils.throwIfMissing({ playlist_id, video_ids });
const plinfo = await this.#actions.browse(`VL${playlist_id}`);
const list = Utils.findNode(plinfo.data, 'contents', 'contents', 13, false);
if (!list.isEditable) throw new Utils.InnertubeError('This playlist cannot be edited.', playlist_id);
const videos = list.contents.filter((item) => video_ids.includes(item.playlistVideoRenderer.videoId));
const set_video_ids = videos.map((video) => video.playlistVideoRenderer.setVideoId);
const response = await this.#actions.playlist('browse/edit_playlist', {
ids: set_video_ids,
action: 'ACTION_REMOVE_VIDEO',
playlist_id
});
return {
success: response.success,
status_code: response.status_code,
playlist_id,
data: response.data
}
}
}
module.exports = PlaylistManager;

146
lib/core/SessionBuilder.js Normal file
View File

@@ -0,0 +1,146 @@
'use strict';
const Axios = require('axios');
const Player = require('./Player');
const Proto = require('../proto');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
const UserAgent = require('user-agents');
/** @namespace */
class SessionBuilder {
#config;
#key;
#client_name;
#client_version;
#api_version;
#remote_host;
#context;
#player;
/**
* @param {object} config
* @constructor
*/
constructor(config) {
this.#config = config;
}
async build() {
const data = await Promise.all([
this.#getYtConfig(),
this.#getPlayerId()
]);
const ytcfg = data[0][0][2];
this.#key = ytcfg[1];
this.#api_version = `v${ytcfg[0][0][6]}`;
this.#client_name = Constants.CLIENTS.WEB.NAME;
this.#client_version = ytcfg[0][0][16];
this.#remote_host = ytcfg[0][0][3];
this.#player = await new Player(data[1]).init();
this.#context = this.#buildContext();
return this;
}
/**
* Builds a valid context object.
* @returns
*/
#buildContext() {
const user_agent = new UserAgent({ deviceCategory: 'desktop' });
const id = Utils.generateRandomString(11);
const timestamp = Math.floor(Date.now() / 1000);
const visitor_data = Proto.encodeVisitorData(id, timestamp);
const context = {
client: {
hl: 'en',
gl: this.#config.gl || 'US',
remoteHost: this.#remote_host,
deviceMake: user_agent.vendor,
deviceModel: user_agent.platform,
visitorData: visitor_data,
userAgent: user_agent.toString(),
clientName: this.#client_name,
clientVersion: this.#client_version,
originalUrl: Constants.URLS.YT_BASE
},
user: { lockedSafetyMode: false },
request: { useSsl: true }
}
return context;
}
/**
* Retrieves initial configuration such as keys,
* client data, etc.
* @returns Promise.<object>
*/
async #getYtConfig() {
const response = await Axios.get(`${Constants.URLS.YT_BASE}/sw.js_data`).catch((err) => err);
if (response instanceof Error)
throw new Utils.InnertubeError('Could not retrieve configuration data', {
status_code: response?.response?.status || 0,
message: response.message
});
return JSON.parse(response.data.replace(')]}\'', ''));
}
/**
* Retrives the YouTube player id.
* @returns {Promise.<string>
*/
async #getPlayerId() {
const response = await Axios.get(`${Constants.URLS.YT_BASE}/iframe_api`).catch((err) => err);
if (response instanceof Error)
throw new Utils.InnertubeError('Could not retrieve js player id', {
status_code: response?.response?.status || 0,
message: response.message
});
return Utils.getStringBetweenStrings(response.data, 'player\\/', '\\/');
}
/** @readonly */
get key() {
return this.#key;
}
/** @readonly */
get context() {
return this.#context;
}
/** @readonly */
get api_version() {
return this.#api_version;
}
/** @readonly */
get client_version() {
return this.#client_version;
}
/** @readonly */
get client_name() {
return this.#client_name;
}
/** @readonly */
get player() {
return this.#player;
}
}
module.exports = SessionBuilder;

View File

@@ -11,7 +11,7 @@ class NToken {
/**
* Solves throttling challange by transforming the n token.
* @returns {string} transformed token.
* @returns {string}
*/
transform() {
let n_token = this.n.split('');
@@ -52,19 +52,22 @@ class NToken {
transformations[data.index](transformations[param_index[0]], transformations[param_index[1]], base64_dia);
});
} catch (err) {
console.error(`Could not transform n-token (${this.n}), download may be throttled:`, err.message);
console.error(new Utils.ParsingError('Could not transform n-token, download may be throttled.', {
original_token: this.n,
stack: err.stack
}));
return this.n;
}
return n_token.join('');
}
#getFunc(el) {
return el.match(Constants.FUNCS_REGEX);
return el.match(Constants.NTOKEN_REGEX.FUNCTIONS);
}
/**
* Takes the n-transform data, refines it, and then returns a readable json array.
* @returns {object}
* @returns {Array}
*/
#getTransformationData() {
const data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;

View File

@@ -1,74 +1,90 @@
'use strict';
const Constants = require('../utils/Constants');
const QueryString = require('querystring');
class Signature {
constructor(url, player) {
constructor(url, sig_decipher_sc) {
this.url = url;
this.player = player;
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
this.sig_decipher_sc = sig_decipher_sc;
}
/**
* Deciphers signature.
* @returns {string}
*/
decipher() {
let actions;
const args = QueryString.parse(this.url);
const signature = args.s.split('');
const functions = this.#getFunctions();
function splice(arr, end) {
arr.splice(0, end);
}
function swap(arr, index) {
let origArrI = arr[0];
arr[0] = arr[index % arr.length];
arr[index % arr.length] = origArrI;
}
function reverse(arr) {
arr.reverse();
}
let actions;
let signature = args.s.split('');
while ((actions = this.actions_regex.exec(this.player.sig_decipher_sc)) !== null) {
switch (actions[1]) {
/**
* Decides what function should be used to modify the
* the signature.
*/
while ((actions = Constants.SIG_REGEX.ACTIONS.exec(this.sig_decipher_sc)) !== null) {
const action = actions.groups;
switch (action.name) {
case functions[0]:
reverse(signature, actions[2]);
this.#reverse(signature);
break;
case functions[1]:
splice(signature, actions[2]);
this.#splice(signature, action.param);
break;
case functions[2]:
swap(signature, actions[2]);
this.#swap(signature, action.param);
break;
default:
}
}
const url_components = new URL(args.url);
args.sp ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
args.sp ?
url_components.searchParams.set(args.sp, signature.join('')) :
url_components.searchParams.set('signature', signature.join(''));
return url_components.toString();
}
/**
* Extracts the functions used to modify the signature
* and returns them in the correct order.
*
* @returns {Array.<string>}
*/
#getFunctions() {
let func;
let func_name = [];
while ((func = this.func_regex.exec(this.player.sig_decipher_sc)) !== null) {
if (func[0].includes('reverse()')) {
func_name[0] = func[1];
let functions = [];
while ((func = Constants.SIG_REGEX.FUNCTIONS.exec(this.sig_decipher_sc)) !== null) {
if (func[0].includes('reverse')) {
functions[0] = func[1];
} else if (func[0].includes('splice')) {
func_name[1] = func[1];
functions[1] = func[1];
} else {
func_name[2] = func[1];
functions[2] = func[1];
}
}
return func_name;
return functions;
}
#swap(arr, index) {
let origArrI = arr[0];
arr[0] = arr[index % arr.length];
arr[index % arr.length] = origArrI;
}
#splice(arr, end) {
arr.splice(0, end);
}
#reverse(arr) {
arr.reverse();
}
}

View File

@@ -1,7 +1,6 @@
'use strict';
const Utils = require('../utils/Utils');
const Actions = require('../core/Actions');
const Constants = require('../utils/Constants');
const YTDataItems = require('./youtube');
const YTMusicDataItems = require('./ytmusic');
@@ -28,6 +27,7 @@ class Parser {
PLAYLIST: () => this.#processPlaylist(),
SUBSFEED: () => this.#processSubscriptionFeed(),
HOMEFEED: () => this.#processHomeFeed(),
LIBRARY: () => this.#processLibrary(), //WIP
TRENDING: () => this.#processTrending(),
HISTORY: () => this.#processHistory(),
COMMENTS: () => this.#processComments(),
@@ -68,9 +68,8 @@ class Parser {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.search(this.session, 'YOUTUBE', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not get continuation', response);
const response = await this.session.actions.search({ ctoken });
const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
return parseItems(continuation_items);
};
@@ -84,7 +83,7 @@ class Parser {
#processMusicSearch() {
const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
const contents = Utils.findNode(tabs, '0', 'contents', 5);
const did_you_mean_item = contents.find((content) => content.itemSectionRenderer);
const did_you_mean_renderer = did_you_mean_item?.itemSectionRenderer.contents[0].didYouMeanRenderer;
@@ -110,7 +109,7 @@ class Parser {
['Community playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
['Artists']: () => YTMusicDataItems.ArtistResultItem.parse(section.contents),
['Albums']: () => YTMusicDataItems.AlbumResultItem.parse(section.contents)
})[section_title]();
}[section_title] || (() => {}))();
processed_data.results[section_title.replace(/ /g, '_').toLowerCase()] = section_items;
}
@@ -120,7 +119,7 @@ class Parser {
}
#processSearchSuggestions() {
return YTDataItems.SearchSuggestionItem.parse(this.data[1], this.data[0]);
return YTDataItems.SearchSuggestionItem.parse(JSON.parse(this.data.replace(')]}\'', '')));
}
#processMusicSearchSuggestions() {
@@ -130,12 +129,12 @@ class Parser {
#processPlaylist() {
const details = this.data.sidebar.playlistSidebarRenderer.items[0];
const metadata = {
title: this.data.metadata.playlistMetadataRenderer.title,
description: details.playlistSidebarPrimaryInfoRenderer.description.simpleText || 'N/A',
total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0].text,
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1].text,
description: details.playlistSidebarPrimaryInfoRenderer?.description?.simpleText || 'N/A',
total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0]?.text || 'N/A',
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1]?.text || 'N/A',
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
}
@@ -290,16 +289,16 @@ class Parser {
response.items = items.map((item) => {
const comment = YTDataItems.CommentThread.parseItem(item);
if (comment) {
comment.like = () => Actions.engage(this.session, 'comment/perform_comment_action', { comment_action: 'like', comment_id: comment.metadata.id, video_id: this.args.video_id }),
comment.dislike = () => Actions.engage(this.session, 'comment/perform_comment_action', { comment_action: 'dislike', comment_id: comment.metadata.id, video_id: this.args.video_id }),
comment.reply = (text) => Actions.engage(this.session, 'comment/create_comment_reply', { text, comment_id: comment.metadata.id, video_id: this.args.video_id });
comment.like = () => this.session.actions.engage('comment/perform_comment_action', { comment_action: 'like', comment_id: comment.metadata.id, video_id: this.args.video_id });
comment.dislike = () => this.session.actions.engage('comment/perform_comment_action', { comment_action: 'dislike', comment_id: comment.metadata.id, video_id: this.args.video_id });
comment.reply = (text) => this.session.actions.engage('comment/create_comment_reply', { text, comment_id: comment.metadata.id, video_id: this.args.video_id });
comment.report = async () => {
const payload = Utils.findNode(item, 'commentThreadRenderer', 'params', 10, false);
const form = await Actions.flag(this.session, 'flag/get_form', { params: payload.params });
const form = await this.session.actions.flag('flag/get_form', { params: payload.params });
const action = Utils.findNode(form, 'actions', 'flagAction', 13, false);
const flag = await Actions.flag(this.session, 'flag/flag', { action: action.flagAction });
const flag = await this.session.actions.flag('flag/flag', { action: action.flagAction });
return flag;
};
@@ -307,21 +306,41 @@ class Parser {
comment.getReplies = async () => {
if (comment.metadata.reply_count === 0) throw new Utils.InnertubeError('This comment has no replies', comment);
const payload = Proto.encodeCommentRepliesParams(this.args.video_id, comment.metadata.id);
const next = await Actions.next(this.session, { continuation_token: payload });
const next = await this.session.actions.next({ ctoken: payload });
return parseComments(next.data);
};
comment.translate = async (target_language) => {
const response = await this.session.actions.engage('comment/perform_comment_action', {
text: comment.text,
comment_action: 'translate',
comment_id: comment.metadata.id,
video_id: this.args.video_id,
target_language
});
const translated_content = Utils.findNode(response.data, 'frameworkUpdates', 'content', 7, false);
return {
success: response.success,
status_code: response.status_code,
translated_content: translated_content.content
}
}
return comment;
}
}).filter((c) => c);
response.comment = (text) => this.session.actions.engage('comment/create_comment', { video_id: this.args.video_id, text });
response.getContinuation = async () => {
const continuation_item = items.find((item) => item.continuationItemRenderer);
if (!continuation_item) throw new Utils.InnertubeError('You\'ve reached the end');
const is_reply = !!continuation_item.continuationItemRenderer.button;
const payload = Utils.findNode(continuation_item, 'continuationItemRenderer', 'token', is_reply && 5 || 3);
const next = await Actions.next(this.session, { continuation_token: payload.token });
const next = await this.session.actions.next({ ctoken: payload.token });
return parseComments(next.data);
};
@@ -342,9 +361,8 @@ class Parser {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
const response = await this.session.actions.browse(ctoken, { is_ctoken: true });
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
}
@@ -354,6 +372,33 @@ class Parser {
return parseItems(contents);
}
#processLibrary() { // TODO: Finish this
const profile_data = Utils.findNode(this.data, 'contents', 'profileColumnRenderer', 3);
const stats_data = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnStatsRenderer);
const stats_items = stats_data.profileColumnStatsRenderer.items;
const userinfo = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnUserInfoRenderer);
const stats = {};
stats_items.forEach((item) => {
const label = item.profileColumnStatsEntryRenderer.label.runs.map((run) => run.text).join('');
stats[label.toLowerCase()] = parseInt(item.profileColumnStatsEntryRenderer.value.simpleText);
});
const profile = {
name: userinfo.profileColumnUserInfoRenderer?.title?.simpleText,
thumbnails: userinfo.profileColumnUserInfoRenderer?.thumbnail.thumbnails,
stats
}
// const content = Utils.findNode(this.data, 'contents', 'content', 8, false);
// console.info(content[0].itemSectionRenderer.contents[0].shelfRenderer);
return {
profile
}
}
#processSubscriptionFeed() {
const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false);
@@ -379,9 +424,8 @@ class Parser {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
const response = await this.session.actions.browse(ctoken, { is_ctoken: true });
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false);
subsfeed.items = [];
@@ -445,7 +489,7 @@ class Parser {
#processNotifications() {
const contents = this.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications', response);
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications');
const parseItems = (items) => {
const parsed_items = YTDataItems.NotificationItem.parse(items);
@@ -454,9 +498,8 @@ class Parser {
const citem = items.find((item) => item.continuationItemRenderer);
const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken;
const response = await Actions.notifications(this.session, 'get_notification_menu', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
const response = await this.session.actions.notifications('get_notification_menu', { ctoken });
return parseItems(response.data.actions[0].appendContinuationItemsAction.continuationItems);
}
@@ -470,7 +513,7 @@ class Parser {
const tabs = Utils.findNode(this.data, 'contents', 'tabRenderer', 4, false);
const categories = {};
const trending = tabs.map((tab) => {
tabs.forEach((tab) => {
const tab_renderer = tab.tabRenderer;
const tab_content = tab_renderer?.content;
const category_title = tab_renderer.title.toLowerCase();
@@ -488,8 +531,7 @@ class Parser {
} else { // The rest can only be fetched with additional calls
const params = tab_renderer.endpoint.browseEndpoint.params;
categories[category_title].getVideos = async () => {
const response = await Actions.browse(this.session, 'trending', { params });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve category videos', response);
const response = await this.session.actions.browse('FEtrending', { params });
const tabs = Utils.findNode(response, 'contents', 'tabRenderer', 4, false);
const tab = tabs.find((tab) => tab.tabRenderer.title === tab_renderer.title);
@@ -530,9 +572,8 @@ class Parser {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
const response = await this.session.actions.browse(ctoken, { is_ctoken: true });
history.items = [];
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);

View File

@@ -6,7 +6,6 @@ class CommentThread {
static parseItem(item) {
if (item.commentThreadRenderer || item.commentRenderer) {
const comment = item?.commentThreadRenderer?.comment || item;
const replies = item?.commentThreadRenderer?.replies;
const like_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.likeButton;
const dislike_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.dislikeButton;

View File

@@ -1,11 +1,11 @@
'use strict';
class SearchSuggestionItem {
static parse(data, bold_text) {
return data.map((item) => ({
text: item.trim(),
bold_text: bold_text.trim().toLowerCase()
}));
static parse(data) {
return {
query: data[0],
results: data[1].map((res) => res[0])
}
}
}

View File

@@ -18,7 +18,7 @@ class VideoResultItem {
channel: {
id: renderer?.ownerText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: renderer?.ownerText?.runs[0]?.text,
url: `${Constants.URLS.YT_BASE}${renderer.ownerText.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
url: `${Constants.URLS.YT_BASE}${renderer?.ownerText?.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
},
metadata: {
view_count: renderer?.viewCountText?.simpleText || 'N/A',

View File

@@ -2,7 +2,10 @@
class MusicSearchSuggestionItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
return {
query: this.parseItem(data[0]).runs[0].text.trim(),
results: data.map((item) => this.parseItem(item).runs.map((run) => run.text).join('').trim())
}
}
static parseItem(item) {
@@ -11,11 +14,8 @@ class MusicSearchSuggestionItem {
item.historySuggestionRenderer &&
(suggestion = item.historySuggestionRenderer.suggestion) ||
(suggestion = item.searchSuggestionRenderer.suggestion);
return {
text: suggestion.runs.map((run) => run.text).join('').trim(),
bold_text: suggestion.runs[0].text.trim()
};
return suggestion;
}
}

View File

@@ -21,7 +21,7 @@ class TopResultItem {
artist: () => ArtistResultItem.parseItem(item),
album: () => AlbumResultItem.parseItem(item),
single: () => AlbumResultItem.parseItem(item)
}[type])();
}[type] || (() => {}))();
parsed_item && (parsed_item.type = type);

View File

@@ -3,23 +3,112 @@
const messages = require('./messages');
class Proto {
static encodeSearchFilter(period, duration, order) {
const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 };
const durations = { 'any': null, 'short': 1, 'long': 2 };
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 };
const buf = messages.SearchFilter.encode({
number: orders[order],
filter: {
param_0: periods[period],
param_1: (period == 'hour' && order == 'relevance') ? null : 1,
param_2: durations[duration]
}
});
/**
* Encodes visitor data.
*
* @param {string} id
* @param {number} timestamp
*
* @returns {string}
*/
static encodeVisitorData(id, timestamp) {
const buf = messages.VisitorData.encode({ id, timestamp });
return encodeURIComponent(Buffer.from(buf).toString('base64').replace(/\/|\+/g, '_'));
}
/**
* Encodes basic channel analytics parameters.
*
* @param {string} channel_id
* @returns {string}
*/
static encodeChannelAnalyticsParams(channel_id) {
const buf = messages.ChannelAnalytics.encode({ params: { channel_id } });
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes search filters.
*
* @param {object} filters
* @param {string} [filters.upload_date] - any | last_hour | today | this_week | this_month | this_year
* @param {string} [filters.type] - any | video | channel | playlist | movie
* @param {string} [filters.duration] - any | short | medium | long
* @param {string} [filters.sort_by] - relevance | rating | upload_date | view_count
* @todo implement remaining filters.
*
* @returns {string}
*/
static encodeSearchFilter(filters) {
const upload_dates = {
'any': null,
'last_hour': 1,
'today': 2,
'this_week': 3,
'this_month': 4,
'this_year': 5
};
const types = {
'any': null,
'video': 1,
'channel': 2,
'playlist': 3,
'movie': 4
};
const durations = {
'any': null,
'short': 1,
'medium': 2,
'long': 3
};
const orders = {
'relevance': null,
'rating': 1,
'upload_date': 2,
'view_count': 3
};
const data = {};
filters &&
(data.filters = {}) ||
(data.no_filter = 0);
if (filters) {
if (filters.upload_date) {
if (!['video', 'movie'].includes(filters.type))
throw new Error('Cannot use upload date filter with type ' + filters.type);
}
filters.upload_date &&
(data.filters.param_0 = upload_dates[filters.upload_date]);
filters.type &&
(data.filters.param_1 = types[filters.type]);
filters.duration &&
(data.filters.param_2 = durations[filters.duration]);
filters.sort_by &&
(filters.sort_by !== 'relevance') &&
(data.filter = orders[filters.sort_by]);
}
const buf = messages.SearchFilter.encode(data);
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes livechat message parameters.
*
* @param {string} channel_id
* @param {string} video_id
*
* @returns {string}
*/
static encodeMessageParams(channel_id, video_id) {
const buf = messages.LiveMessageParams.encode({
params: { ids: { channel_id, video_id } },
@@ -29,6 +118,16 @@ class Proto {
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
}
/**
* Encodes comment section parameters.
*
* @param {string} video_id
* @param {object} options
* @param {string} options.type
* @param {string} options.sort_by
*
* @returns {string}
*/
static encodeCommentsSectionParams(video_id, options = {}) {
const sort_menu = { TOP_COMMENTS: 0, NEWEST_FIRST: 1 };
@@ -48,6 +147,14 @@ class Proto {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes replies thread parameters.
*
* @param {string} video_id
* @param {string} comment_id
*
* @returns {string}
*/
static encodeCommentRepliesParams(video_id, comment_id) {
const buf = messages.GetCommentsSectionParams.encode({
ctx: { video_id },
@@ -66,6 +173,12 @@ class Proto {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes comment parameters.
*
* @param {string} video_id
* @returns {string}
*/
static encodeCommentParams(video_id) {
const buf = messages.CreateCommentParams.encode({
video_id, params: { index: 0 },
@@ -75,6 +188,14 @@ class Proto {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes comment reply parameters.
*
* @param {string} comment_id
* @param {string} video_id
*
* @return {string}
*/
static encodeCommentReplyParams(comment_id, video_id) {
const buf = messages.CreateCommentReplyParams.encode({
video_id, comment_id,
@@ -85,17 +206,51 @@ class Proto {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
static encodeCommentActionParams(type, comment_id, video_id) {
const buf = messages.PeformCommentActionParams.encode({
type, comment_id, video_id,
unk_num: 2, unk_num_1: 0, unk_num_2: 0,
unk_num_3: "0", unk_num_4: 0,
unk_num_5: 12, unk_num_6: 0,
});
/**
* Encodes comment action parameters.
*
* @param {string} type
* @param {string} comment_id
* @param {string} video_id
* @param {string} [text]
* @param {string} [target_language]
*
* @returns {string}
*/
static encodeCommentActionParams(type, args = {}) {
const data = {};
data.type = type;
data.video_id = args.video_id || '';
data.comment_id = args.comment_id || '';
data.unk_num = 2;
if (args.hasOwnProperty('text')) {
args.comment_id && (delete data.unk_num);
data.translate_comment_params = {
params: {
comment: {
text: args.text
}
},
comment_id: args.comment_id || '',
target_language: args.target_language
}
}
const buf = messages.PeformCommentActionParams.encode(data);
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes notification preference parameters.
*
* @param {string} channel_id
* @param {number} index
*
* @returns {string}
*/
static encodeNotificationPref(channel_id, index) {
const buf = messages.NotificationPreferences.encode({
channel_id, pref_id: { index },
@@ -104,6 +259,29 @@ class Proto {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes sound info parameters.
*
* @param {string} id
* @returns {string}
*/
static encodeSoundInfoParams(id) {
const data = {
sound: {
params: {
ids: {
id_1: id,
id_2: id,
id_3: id
}
}
}
}
const buf = messages.SoundInfoParams.encode(data);
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
}
module.exports = Proto;

File diff suppressed because it is too large Load Diff

View File

@@ -1,120 +1,179 @@
syntax = "proto2";
package proto;
message NotificationPreferences {
required string channel_id = 1;
message Preference {
required int32 index = 1;
}
Preference pref_id = 2;
optional int32 number_0 = 3;
optional int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
required string channel_id = 1;
required string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
optional int32 number_0 = 2;
optional int32 number_1 = 3;
}
message GetCommentsSectionParams {
message Context {
string video_id = 2;
}
Context ctx = 2;
required int32 unk_param = 3;
message Params {
optional string unk_token = 1;
message Options {
required string video_id = 4;
required int32 sort_by = 6;
required int32 type = 15;
}
message RepliesOptions {
required string comment_id = 2;
message UnkOpts {
required int32 unk_param = 1;
}
UnkOpts unkopts = 4;
optional string channel_id = 5;
required string video_id = 6;
required int32 unk_param_1 = 8;
required int32 unk_param_2 = 9;
}
optional Options opts = 4;
optional RepliesOptions replies_opts = 3;
optional int32 page = 5;
required string target = 8;
}
Params params = 6;
}
message CreateCommentParams {
required string video_id = 2;
message Params {
required int32 index = 1;
}
Params params = 5;
required int32 number = 10;
}
message CreateCommentReplyParams {
required string video_id = 2;
required string comment_id = 4;
message UnknownParams {
required int32 unk_num = 1;
}
UnknownParams params = 5;
optional int32 unk_num = 10;
}
message PeformCommentActionParams {
required int32 type = 1;
optional int32 unk_num = 2;
required string comment_id = 3;
required string video_id = 5;
optional int32 unk_num_1 = 6;
optional int32 unk_num_2 = 7;
optional string unk_num_3 = 9;
optional int32 unk_num_4 = 10;
optional int32 unk_num_5 = 21;
optional string channel_id = 23;
optional int32 unk_num_6 = 30;
}
message SearchFilter {
int32 number = 1;
message Filter {
int32 param_0 = 1;
int32 param_1 = 2;
int32 param_2 = 3;
}
Filter filter = 2;
syntax = "proto2";
package youtube;
message VisitorData {
string id = 1;
int32 timestamp = 5;
}
message ChannelAnalytics {
message Params {
string channel_id = 1001;
}
Params params = 32;
}
message InnertubePayload {
message Context {
message Client {
int32 unkparam = 16;
string client_version = 17;
string client_name = 18;
}
Client client = 1;
}
Context context = 1;
optional string target = 2;
message SoundSearchParams {
string target_id = 2;
string query = 3;
}
optional SoundSearchParams sound_search_params = 16;
}
message SoundInfoParams {
message Sound {
message Params {
message Ids {
string id_1 = 1;
string id_2 = 2;
string id_3 = 3;
}
Ids ids = 2;
}
Params params = 1;
}
Sound sound = 94;
}
message NotificationPreferences {
string channel_id = 1;
message Preference {
int32 index = 1;
}
Preference pref_id = 2;
optional int32 number_0 = 3;
optional int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
string channel_id = 1;
string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
optional int32 number_0 = 2;
optional int32 number_1 = 3;
}
message GetCommentsSectionParams {
message Context {
string video_id = 2;
}
Context ctx = 2;
int32 unk_param = 3;
message Params {
optional string unk_token = 1;
message Options {
string video_id = 4;
int32 sort_by = 6;
int32 type = 15;
}
message RepliesOptions {
string comment_id = 2;
message UnkOpts {
int32 unk_param = 1;
}
UnkOpts unkopts = 4;
optional string channel_id = 5;
string video_id = 6;
int32 unk_param_1 = 8;
int32 unk_param_2 = 9;
}
optional Options opts = 4;
optional RepliesOptions replies_opts = 3;
optional int32 page = 5;
string target = 8;
}
Params params = 6;
}
message CreateCommentParams {
string video_id = 2;
message Params {
int32 index = 1;
}
Params params = 5;
int32 number = 10;
}
message CreateCommentReplyParams {
string video_id = 2;
string comment_id = 4;
message UnknownParams {
int32 unk_num = 1;
}
UnknownParams params = 5;
optional int32 unk_num = 10;
}
message PeformCommentActionParams {
int32 type = 1;
string comment_id = 3;
string video_id = 5;
optional int32 unk_num = 2;
optional string channel_id = 23;
message TranslateCommentParams {
message Params {
message Comment {
string text = 1;
}
Comment comment = 1;
}
Params params = 3;
string comment_id = 2;
string target_language = 4;
}
optional TranslateCommentParams translate_comment_params = 31;
}
message SearchFilter {
optional int32 filter = 1; // almost always sort_by
optional int32 no_filter = 19;
message Filters {
optional int32 param_0 = 1;
optional int32 param_1 = 2;
optional int32 param_2 = 3;
}
optional Filters filters = 2;
}

View File

@@ -1,7 +1,5 @@
'use strict';
const Utils = require('./Utils');
module.exports = {
URLS: {
YT_BASE: 'https://www.youtube.com',
@@ -21,59 +19,40 @@ module.exports = {
'origin': 'https://www.youtube.com',
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
'content-type': 'application/json',
'referer': `https://www.youtube.com/tv`,
'referer': 'https://www.youtube.com/tv',
'accept-language': 'en-US'
}
},
REGEX: {
AUTH_SCRIPT: /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/,
CLIENT_IDENTITY: /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/
AUTH_SCRIPT: /<script id="base-js" src="(.*?)" nonce=".*?"><\/script>/,
CLIENT_IDENTITY: /.+?={};var .+?={clientId:"(?<id>.+?)",.+?:"(?<secret>.+?)"},/
}
},
DEFAULT_HEADERS: (config) => {
return {
headers: {
'Cookie': config?.cookie || '',
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
'Referer': 'https://www.google.com/',
'Accept': 'text/html',
'Accept-Language': `en-${config?.gl || 'US'}`,
'Accept-Encoding': 'gzip'
}
};
CLIENTS: {
WEB: {
NAME: 'WEB'
},
YTMUSIC: {
NAME: 'WEB_REMIX',
VERSION: '1.20211213.00.00'
},
ANDROID: {
NAME: 'ANDROID',
VERSION: '17.17.32'
}
},
STREAM_HEADERS: {
'Accept': '*/*',
'User-Agent': Utils.getRandomUserAgent('desktop').userAgent,
'Connection': 'keep-alive',
'Origin': 'https://www.youtube.com',
'Referer': 'https://www.youtube.com',
'accept': '*/*',
'connection': 'keep-alive',
'origin': 'https://www.youtube.com',
'referer': 'https://www.youtube.com',
'DNT': '?1'
},
INNERTUBE_HEADERS_BASE: {
'accept': '*/*',
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json',
},
VIDEO_INFO_REQBODY: (id, sts, context) => {
return {
playbackContext: {
contentPlaybackContext: {
'currentUrl': '/watch?v=' + id,
'vis': 0,
'splay': false,
'autoCaptionsDefaultOn': false,
'autonavState': 'STATE_OFF',
'html5Preference': 'HTML5_PREF_WANTS',
'signatureTimestamp': sts,
'referer': 'https://www.youtube.com',
'lactMilliseconds': '-1'
}
},
context: context,
videoId: id
};
},
YTMUSIC_VERSION: '1.20211213.00.00',
METADATA_KEYS: [
'embed', 'view_count', 'average_rating', 'allow_ratings',
'length_seconds', 'channel_id', 'channel_url',
@@ -104,11 +83,15 @@ module.exports = {
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
},
SIG_REGEX: {
ACTIONS: /;.{2}\.(?<name>.{2})\(.*?,(?<param>.*?)\)/g,
FUNCTIONS: /(?<name>.{2}):function\(.*?\){(.*?)}/g
},
NTOKEN_REGEX: {
CALLS: /c\[(.*?)\]\((.+?)\)/g,
PLACEHOLDERS: /c\[(.*?)\]=c/g,
FUNCTIONS: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/
},
FUNCS_REGEX: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/,
FUNCS: {
PUSH: 'd.push(e)',
REVERSE_1: 'd.reverse()',
@@ -122,4 +105,4 @@ module.exports = {
TRANSLATE_1: 'function(d,e){for(var f',
TRANSLATE_2: 'function(d,e,f){var'
}
};
};

View File

@@ -4,50 +4,119 @@ const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
/** @namespace */
class Request {
constructor (session) {
/**
* @param {Innertube} session
* @constructor
*/
constructor(session) {
this.session = session;
this.instance = Axios.create({
baseURL: Constants.URLS.YT_BASE_API + session.version,
headers: Constants.INNERTUBE_HEADERS_BASE,
params: { key: session.key },
params: { key: session.key, prettyPrint: false },
validateStatus: () => true,
timeout: 15000
});
this.#setupInterceptor();
return this.instance;
}
#setupInterceptor() {
this.instance.interceptors.request.use((config) => {
const is_ytmusic = config.data.includes(Constants.URLS.YT_MUSIC);
const is_json_payload = typeof config.data == 'object';
config.headers['user-agent'] = Utils.getRandomUserAgent('desktop').userAgent;
config.headers['accept-language'] = `en-${this.session.config.gl || 'US'}`;
config.headers['x-goog-visitor-id'] = this.session.context.client.visitorData || ''
config.headers['x-goog-visitor-id'] = this.session.context.client.visitorData || '';
config.headers['x-youtube-client-version'] = this.session.context.client.clientVersion;
config.headers['x-origin'] = is_ytmusic && Constants.URLS.YT_MUSIC || Constants.URLS.YT_BASE;
config.headers['origin'] = is_ytmusic && Constants.URLS.YT_MUSIC || Constants.URLS.YT_BASE;
is_ytmusic && (config.baseURL = Constants.URLS.YT_MUSIC_BASE_API + this.session.version);
if (is_json_payload) {
config.data = {
context: JSON.parse(JSON.stringify(this.session.context)), // deep copies the context object
...config.data
};
this.#adjustContext(config.data.context, config.data.client);
config.headers['x-youtube-client-version'] = config.data.context.client.clientVersion;
config.headers['x-origin'] = config.data.context.client.originalUrl;
config.headers['origin'] = config.data.context.client.originalUrl;
config.data.client == 'YTMUSIC' &&
(config.baseURL = Constants.URLS.YT_MUSIC_BASE_API + this.session.version);
delete config.data.client;
}
if (this.session.logged_in) {
const cookie = this.session.config.cookie;
const token = cookie
&& this.session.auth_apisid
|| this.session.access_token;
const token = cookie &&
this.session.auth_apisid ||
this.session.access_token;
config.headers.cookie = cookie || '';
config.headers.authorization = cookie && token || `Bearer ${token}`;
!cookie && (delete config.params.key);
}
this.session.config.debug &&
console.info('\n', '[' + config.method.toUpperCase() + ']', '>', config.baseURL + config.url, '\n', config?.data, '\n');
return config;
}, (error) => Promise.reject(error));
}, (error) => {
throw new Utils.InnertubeError(error.message, error);
});
/**
* Standardizes the API response and catches all errors.
*/
this.instance.interceptors.response.use((res) => {
const response = {
success: res.status === 200,
status_code: res.status,
data: res.data
};
if (res.status !== 200)
throw new Utils.InnertubeError(`Request to ${res.config.url} failed with status code ${res.status} ${res.statusText}`, response);
return response;
});
this.instance.interceptors.response.use(undefined, (error) => {
if (error.info) return Promise.reject(error);
throw new Utils.InnertubeError('Could not complete this operation', error.message);
});
}
/**
* Adjusts the context according to the given client.
* @todo refactor this?
* @returns
*/
#adjustContext(ctx, client) {
switch (client) {
case 'YTMUSIC':
ctx.client.originalUrl = Constants.URLS.YT_MUSIC;
ctx.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION;
ctx.client.clientName = Constants.CLIENTS.YTMUSIC.NAME;
break;
case 'ANDROID':
ctx.client.originalUrl = Constants.URLS.YT_BASE;
ctx.client.clientVersion = Constants.CLIENTS.ANDROID.VERSION;
ctx.client.clientName = Constants.CLIENTS.ANDROID.NAME;
break;
default:
break;
}
}
}
module.exports = Request;
module.exports = Request;

View File

@@ -4,42 +4,54 @@ const Crypto = require('crypto');
const UserAgent = require('user-agents');
const Flatten = require('flat');
function InnertubeError(message, info) {
this.info = info || {};
this.stack = Error(message).stack;
/** @namespace */
class InnertubeError extends Error {
constructor (message, info) {
super(message);
info && (this.info = info);
this.date = new Date();
this.version = require('../../package.json').version;
}
}
InnertubeError.prototype = Object.create(Error.prototype);
InnertubeError.prototype.constructor = InnertubeError;
class ParsingError extends InnertubeError {};
class DownloadError extends InnertubeError {};
class MissingParamError extends InnertubeError {};
class UnavailableContentError extends InnertubeError {};
class NoStreamingDataError extends InnertubeError {};
class ParsingError extends InnertubeError {}
class DownloadError extends InnertubeError {}
class MissingParamError extends InnertubeError {}
class UnavailableContentError extends InnertubeError {}
class NoStreamingDataError extends InnertubeError {}
/**
* Utility to help access deep properties of an object.
*
* @param {object} obj - The object.
* @param {string} key - Key of the property being accessed.
* @param {string} target - Anything that might be inside of the property.
* @param {number} depth - Maximum number of nested objects to flatten.
* @param {boolean} safe - If set to true arrays will be preserved.
* @param {object} obj - the object.
* @param {string} key - key of the property being accessed.
* @param {string} target - anything that might be inside of the property.
* @param {number} depth - maximum number of nested objects to flatten.
* @param {boolean} safe - if set to true arrays will be preserved.
*
* @returns {object|Array.<any>}
*/
function findNode(obj, key, target, depth, safe = true) {
const flat_obj = Flatten(obj, { safe, maxDepth: depth || 2 });
const result = Object.keys(flat_obj).find((entry) => entry.includes(key) && JSON.stringify(flat_obj[entry] || '{}').includes(target));
if (!result) throw new ParsingError(`Expected to find "${key}" with content "${target}" but got ${result}`, { key, target, data_snippet: `${JSON.stringify(flat_obj).slice(0, 300)}..` });
if (!result) throw new ParsingError(`Expected to find "${key}" with content "${target}" but got ${result}`, { key, target, data_snippet: `${JSON.stringify(flat_obj, null, 4).slice(0, 300)}..` });
return flat_obj[result];
}
function escapeStringRegexp(string) {
return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d');
}
/**
* Gets a string between two delimiters.
* Finds a string between two delimiters.
*
* @param {string} data - The data.
* @param {string} start_string - Start string.
* @param {string} end_string - End string.
* @param {string} data - the data.
* @param {string} start_string - start string.
* @param {string} end_string - end string.
*
* @returns {string}
*/
function getStringBetweenStrings(data, start_string, end_string) {
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, 's');
@@ -47,10 +59,6 @@ function getStringBetweenStrings(data, start_string, end_string) {
return match ? match[1] : undefined;
}
function escapeStringRegexp(string) {
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}
/**
* Returns a random user agent.
*
@@ -85,6 +93,23 @@ function generateSidAuth(sid) {
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
}
/**
* Generates a random string with a given length.
*
* @param {number} length
* @returns {string}
*/
function generateRandomString(length) {
const result = [];
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
for (let i = 0; i < length; i++) {
result.push(alphabet.charAt(Math.floor(Math.random() * alphabet.length)));
}
return result.join('');
}
/**
* Converts time (h:m:s) to seconds.
*
@@ -110,6 +135,29 @@ function camelToSnake(string) {
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* Checks if a given client is valid.
*
* @param {string} client
* @returns {boolean}
*/
function isValidClient(client) {
return [ 'YOUTUBE', 'YTMUSIC' ].includes(client);
}
/**
* Throws an error if given parameters are undefined.
*
* @param {object} params
* @returns
*/
function throwIfMissing(params) {
for (const [key, value] of Object.entries(params)) {
if (!value)
throw new MissingParamError(`${key} is missing`);
}
}
/**
* Turns the ntoken transform data into a valid json array
*
@@ -126,7 +174,7 @@ function refineNTokenData(data) {
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
}
const errors = { UnavailableContentError, ParsingError, DownloadError, InnertubeError, MissingParamError, NoStreamingDataError };
const functions = { findNode, getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, refineNTokenData };
const errors = { InnertubeError, UnavailableContentError, ParsingError, DownloadError, MissingParamError, NoStreamingDataError };
const functions = { findNode, getRandomUserAgent, generateSidAuth, generateRandomString, getStringBetweenStrings, camelToSnake, isValidClient, throwIfMissing, timeToSeconds, refineNTokenData };
module.exports = { ...functions, ...errors };

6982
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "youtubei.js",
"version": "1.4.2",
"description": "A full-featured library that allows you to get detailed info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!",
"version": "1.4.3",
"description": "A full-featured wrapper around YouTube's private API. Allows you to retrieve info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!",
"main": "index.js",
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
"funding": "https://ko-fi.com/luanrt",
@@ -10,7 +10,10 @@
"node": ">=14"
},
"scripts": {
"test": "node test"
"test": "jest",
"lint": "eslint ./",
"lint:fix": "eslint --fix ./",
"build:types": "npx tsc"
},
"types": "./typings/index.d.ts",
"directories": {
@@ -26,9 +29,15 @@
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
"devDependencies": {
"@types/node": "^17.0.31",
"typescript": "^4.6.4",
"eslint": "^8.15.0",
"jest": "^28.1.0"
},
"repository": {
"type": "git",
"url": "git+https//github.com/LuanRT/YouTube.js.git"
"url": "git+https://github.com/LuanRT/YouTube.js.git"
},
"bugs": {
"url": "https://github.com/LuanRT/YouTube.js/issues"
@@ -36,21 +45,23 @@
"homepage": "https://github.com/LuanRT/YouTube.js#readme",
"keywords": [
"yt",
"dl",
"ytdl",
"youtube",
"youtube-dl",
"youtubedl",
"youtube-dl",
"youtube-downloader",
"innertube",
"innertubeapi",
"unofficial",
"downloader",
"livechat",
"ytmusic",
"dislike",
"search",
"comment",
"music",
"like",
"api",
"dl"
"api"
]
}

View File

@@ -1,17 +1,26 @@
'use strict';
module.exports = {
test_url: 's=t%3DQ%3DAv2TLJ2sbQFV5msp4j7v71gS1rsXNd6QH2V1KpxGlaOD%3DIC46mVzTVTW_2zttE32HKH7XO1jkyfOJs58avqMLKdvRdgIQRw8JQ0qOA&sp=sig&url=https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback%3Fexpire%3D1635863482%26ei%3DWveAYdqsB6KPobIPjtWwYA%26ip%3D128.201.98.50%26id%3Do-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7%26itag%3D18%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DG3%26mm%3D31%252C29%26mn%3Dsn-hxtxgcg-8qjl%252Csn-gpv7dned%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D1%26pl%3D24%26initcwndbps%3D397500%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dv9CYauI2ycUgrV6wOERCNxsG%26gir%3Dyes%26clen%3D7275579%26ratebypass%3Dyes%26dur%3D218.290%26lmt%3D1540416860737282%26mt%3D1635841731%26fvip%3D4%26fexp%3D24001373%252C24007246%26c%3DWEB%26txp%3D5531432%26n%3DD8yGa-DC5m2Dwv--%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cratebypass%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX',
expected_url: 'https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback?expire=1635863482&ei=WveAYdqsB6KPobIPjtWwYA&ip=128.201.98.50&id=o-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7&itag=18&source=youtube&requiressl=yes&mh=G3&mm=31%2C29&mn=sn-hxtxgcg-8qjl%2Csn-gpv7dned&ms=au%2Crdu&mv=m&mvi=1&pl=24&initcwndbps=397500&vprv=1&mime=video%2Fmp4&ns=v9CYauI2ycUgrV6wOERCNxsG&gir=yes&clen=7275579&ratebypass=yes&dur=218.290&lmt=1540416860737282&mt=1635841731&fvip=4&fexp=24001373%2C24007246&c=WEB&txp=5531432&n=D8yGa-DC5m2Dwv--&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX&sig=AOq0QJ8wRQIgdRvdKLMqva85sJOfykj1OX7HKH23Ettz2_WTVTzVm64CIQDOalGxpK1V2Ht6dNXsr1Sg17v7j4psm5VFQbs2JLT2vA%3D%3D',
original_ntoken: 'PqjqqJjdB9K821VIisj',
expected_ntoken: 'AxwyS-osUl1WhMUd1',
client_version: '2.20211101.01.00',
test_video_id: 'dQw4w9WgXcQ',
test_song_id: 'UGB_Bsm5Unk',
sig_decipher_sc: `fB={RP:function(a,b){a.splice(0,b)},
VIDEOS: [
{
ID: 'bUHZ2k9DYHY',
QUERY: 'Space DOES NOT expand everywhere'
},
{
ID: 'WSeNSzJ2-Jw',
QUERY: 'Scary Monsters and Nice Sprites Official Audio'
}
],
DECIPHERS: {
SIG: {
ORIGINAL_URL: 's=t%3DQ%3DAv2TLJ2sbQFV5msp4j7v71gS1rsXNd6QH2V1KpxGlaOD%3DIC46mVzTVTW_2zttE32HKH7XO1jkyfOJs58avqMLKdvRdgIQRw8JQ0qOA&sp=sig&url=https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback%3Fexpire%3D1635863482%26ei%3DWveAYdqsB6KPobIPjtWwYA%26ip%3D128.201.98.50%26id%3Do-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7%26itag%3D18%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DG3%26mm%3D31%252C29%26mn%3Dsn-hxtxgcg-8qjl%252Csn-gpv7dned%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D1%26pl%3D24%26initcwndbps%3D397500%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dv9CYauI2ycUgrV6wOERCNxsG%26gir%3Dyes%26clen%3D7275579%26ratebypass%3Dyes%26dur%3D218.290%26lmt%3D1540416860737282%26mt%3D1635841731%26fvip%3D4%26fexp%3D24001373%252C24007246%26c%3DWEB%26txp%3D5531432%26n%3DD8yGa-DC5m2Dwv--%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cratebypass%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX',
DECIPHERED_URL: 'https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback?expire=1635863482&ei=WveAYdqsB6KPobIPjtWwYA&ip=128.201.98.50&id=o-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7&itag=18&source=youtube&requiressl=yes&mh=G3&mm=31%2C29&mn=sn-hxtxgcg-8qjl%2Csn-gpv7dned&ms=au%2Crdu&mv=m&mvi=1&pl=24&initcwndbps=397500&vprv=1&mime=video%2Fmp4&ns=v9CYauI2ycUgrV6wOERCNxsG&gir=yes&clen=7275579&ratebypass=yes&dur=218.290&lmt=1540416860737282&mt=1635841731&fvip=4&fexp=24001373%2C24007246&c=WEB&txp=5531432&n=D8yGa-DC5m2Dwv--&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX&sig=AOq0QJ8wRQIgdRvdKLMqva85sJOfykj1OX7HKH23Ettz2_WTVTzVm64CIQDOalGxpK1V2Ht6dNXsr1Sg17v7j4psm5VFQbs2JLT2vA%3D%3D',
ALGORITHM: `fB={RP:function(a,b){a.splice(0,b)},
Td:function(a){a.reverse()},
kq:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c};fB.kq(a,35);fB.RP(a,2);fB.kq(a,46);fB.Td(a,6);`,
n_scramble_sc: `var b=a.split(""),c=[-470482026,-691770757,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},
kq:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c};fB.kq(a,35);fB.RP(a,2);fB.kq(a,46);fB.Td(a,6);`
},
N: {
ORIGINAL_TOKEN: 'PqjqqJjdB9K821VIisj',
DECIPHERED_TOKEN: 'AxwyS-osUl1WhMUd1',
ALGORITHM: `var b=a.split(""),c=[-470482026,-691770757,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},
b,258876269,-1426380890,318754300,-68090711,-2064438462,-1886316521,1913911047,1635047330,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},
-1815897225,1940621629,-714586149,-1723898467,null,778601498,2145333248,1245726977,1952270083,268207944,244274044,null,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(0,1,d.splice(e,1,d[0])[0])},
null,-762271981,604636391,1087224318,-931565987,-338396815,function(d,e){for(e=(e%d.length+d.length)%d.length;e--;)d.unshift(d.pop())},
@@ -22,4 +31,6 @@ null,-762271981,604636391,1087224318,-931565987,-338396815,function(d,e){for(e=(
"push",function(d,e){for(var f=64,h=[];++f-h.length-32;)switch(f){case 58:f=96;continue;case 91:f=44;break;case 65:f=47;continue;case 46:f=153;case 123:f-=58;default:h.push(String.fromCharCode(f))}d.forEach(function(l,m,n){this.push(n[m]=h[(h.indexOf(l)-h.indexOf(this[m])+m-32+f--)%h.length])},e.split(""))}];
c[17]=c;c[24]=c;c[26]=c;try{c[45](c[17],c[38]),c[12](c[44],c[29]),c[45](c[26],c[0]),c[51](c[41],c[13]),c[12](c[41],c[27]),c[12](c[26],c[11]),c[39](c[17],c[49]),c[9](c[38],c[47]),c[26](c[40],c[0]),c[7](c[8],c[44]),c[14](c[54],c[0]),c[18](c[3],c[25]),c[7](c[33],c[36]),c[15](c[19],c[14]),c[7](c[19],c[9]),c[7](c[6],c[12]),c[41](c[33],c[35]),c[7](c[40],c[5]),c[50](c[42]),c[13](c[14],c[17]),c[6](c[35],c[51]),c[26](c[48],c[50]),c[26](c[35],c[0]),c[6](c[21],c[46]),c[15](c[21],c[42]),c[1](c[2],c[43]),c[15](c[2],
c[31]),c[1](c[21],c[25]),c[22](c[30],c[17]),c[15](c[44],c[46]),c[22](c[44],c[11]),c[22](c[23],c[38]),c[1](c[23],c[14]),c[35](c[23],c[44]),c[11](c[53],c[20]),c[9](c[51]),c[31](c[51],c[28]),c[18](c[51],c[35]),c[46](c[53],c[6]),c[52](c[51],c[49]),c[11](c[53],c[15])}catch(d){return"enhanced_except_75MBkOz-_w8_"+a} return b.join("");`
};
}
}
}

View File

@@ -1,81 +0,0 @@
'use strict';
const Fs = require('fs');
const Innertube = require('..');
const NToken = require('../lib/deciphers/NToken');
const Signature = require('../lib/deciphers/Signature');
const Constants = require('./constants');
let failed_tests_count = 0;
async function performTests() {
const youtube = await new Innertube().catch((error) => error);
assert(!(youtube instanceof Error), `should retrieve Innertube configuration data`, youtube);
if (!(youtube instanceof Error)) {
const homefeed = await youtube.getHomeFeed().catch((error) => error);
assert(!(homefeed instanceof Error), `should retrieve recommendations`, homefeed);
const ytsearch = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
assert(!(ytsearch instanceof Error), `should search on YouTube`, ytsearch);
const ytmsearch = await youtube.search('Logic - Obediently Yours', { client: 'YTMUSIC' }).catch((error) => error);
assert(!(ytmsearch instanceof Error), `should search on YouTube Music`, ytmsearch);
const ytsearch_suggestions = await youtube.getSearchSuggestions('test', { client: 'YOUTUBE' }).catch((error) => error);
assert(!(ytsearch_suggestions instanceof Error), `should retrieve YouTube search suggestions`, ytsearch_suggestions);
const ytmsearch_suggestions = await youtube.getSearchSuggestions('test', { client: 'YTMUSIC' }).catch((error) => error);
assert(!(ytmsearch_suggestions instanceof Error), `should retrieve YouTube Music search suggestions`, ytmsearch_suggestions);
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details);
const comments = await youtube.getComments(Constants.test_video_id).catch((error) => error);
assert(!(comments instanceof Error), `should retrieve comments for ${Constants.test_video_id}`, comments);
const ytplaylist = await youtube.getPlaylist(ytmsearch?.results?.community_playlists[0].id, { client: 'YOUTUBE' }).catch((error) => error);
assert(!(ytplaylist instanceof Error), `should retrieve and parse playlist with YouTube`, ytplaylist);
const ytmplaylist = await youtube.getPlaylist(ytmsearch?.results?.community_playlists[0].id, { client: 'YTMUSIC' }).catch((error) => error);
assert(!(ytmplaylist instanceof Error), `should retrieve and parse playlist with YouTube Music`, ytmplaylist);
const lyrics = await youtube.getLyrics(Constants.test_song_id).catch((error) => error);
assert(!(lyrics instanceof Error), `should retrieve song lyrics`, lyrics);
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
}
const n_token = new NToken(Constants.n_scramble_sc, Constants.original_ntoken).transform();
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
const transformed_url = new Signature(Constants.test_url, { sig_decipher_sc: Constants.sig_decipher_sc }).decipher();
assert(transformed_url == Constants.expected_url, `should decipher signature`, transformed_url);
if (failed_tests_count > 0)
throw new Error(`${failed_tests_count} tests have failed`);
}
function downloadVideo(id, youtube) {
return new Promise((resolve, reject) => {
let got_video_info = false;
const stream = youtube.download(id, { type: 'videoandaudio' });
stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
stream.on('end', () => Fs.existsSync(`./${id}.mp4`) && got_video_info && resolve() || reject());
stream.on('info', () => got_video_info = true);
stream.on('error', (err) => reject(err));
});
}
function assert(outcome, description, data) {
const pass_fail = outcome ? 'pass' : 'fail';
console.info(pass_fail, ':', description);
!outcome && (failed_tests_count += 1);
!outcome && console.error('Error: ', data);
return outcome;
}
performTests();

111
test/main.test.js Normal file
View File

@@ -0,0 +1,111 @@
'use strict';
const Fs = require('fs');
const Innertube = require('..');
const NToken = require('../lib/deciphers/NToken');
const Signature = require('../lib/deciphers/Signature');
const Constants = require('./constants');
describe('YouTube.js Tests', () => {
beforeAll(async () => {
this.session = await new Innertube();
});
describe('Search', () => {
it('Should search on YouTube', async () => {
const search = await this.session.search(Constants.VIDEOS[0].QUERY, { client: 'YOUTUBE' });
expect(search.videos.length).toBeLessThanOrEqual(20);
});
it('Should search on YouTube Music', async () => {
const search = await this.session.search(Constants.VIDEOS[1].QUERY, { client: 'YTMUSIC' });
expect(search.results.songs.length).toBeLessThanOrEqual(3);
});
it('Should retrieve YouTube search suggestions', async () => {
const suggestions = await this.session.getSearchSuggestions(Constants.VIDEOS[0].QUERY, { client: 'YOUTUBE' });
expect(suggestions.results.length).toBeLessThanOrEqual(10);
});
it('Should retrieve YouTube Music search suggestions', async () => {
const suggestions = await this.session.getSearchSuggestions(Constants.VIDEOS[1].QUERY, { client: 'YTMUSIC' });
expect(suggestions.results.length).toBeLessThanOrEqual(10);
});
});
describe('Comments', () => {
it('Should retrieve comments', async () => {
this.comments = await this.session.getComments(Constants.VIDEOS[1].ID);
expect(this.comments.items.length).toBeLessThanOrEqual(20);
});
it('Should retrieve comment thread continuation', async () => {
const next = await this.comments.getContinuation();
expect(next.items.length).toBeLessThanOrEqual(20);
});
it('Should retrieve comment replies', async () => {
const top_comment = this.comments.items[0];
const replies = await top_comment.getReplies();
expect(replies.items.length).toBeLessThanOrEqual(10);
});
});
describe('Playlists', () => {
it('Should retrieve playlist with YouTube', async () => {
const playlist = await this.session.getPlaylist('PLLw0AzOz95FU7w2juhPECP9NyGhbZmz_t', { client: 'YOUTUBE' });
expect(playlist.items.length).toBeLessThanOrEqual(100);
});
it('Should retrieve playlist with YouTube Music', async () => {
const playlist = await this.session.getPlaylist('PLLw0AzOz95FU7w2juhPECP9NyGhbZmz_t', { client: 'YTMUSIC' });
expect(playlist.items.length).toBeLessThanOrEqual(100);
});
});
describe('General', () => {
it('Should retrieve home feed', async () => {
const homefeed = await this.session.getHomeFeed();
expect(homefeed.videos.length).toBeLessThanOrEqual(30);
});
it('Should retrieve trending content', async () => {
const trending = await this.session.getTrending();
expect(trending.now.content[0].videos.length).toBeLessThanOrEqual(100);
});
it('Should retrieve video info', async () => {
const details = await this.session.getDetails(Constants.VIDEOS[0].ID);
expect(details.id).toBe(Constants.VIDEOS[0].ID);
});
it('Should download video', async () => {
const result = await download(Constants.VIDEOS[1].ID, this.session);
expect(result).toBeTruthy();
});
});
describe('Deciphers', () => {
it('Should decipher signature', () => {
const result = new Signature(Constants.DECIPHERS.SIG.ORIGINAL_URL, Constants.DECIPHERS.SIG.ALGORITHM).decipher();
expect(result).toEqual(Constants.DECIPHERS.SIG.DECIPHERED_URL);
});
it('Should decipher ntoken', () => {
const result = new NToken(Constants.DECIPHERS.N.ALGORITHM, Constants.DECIPHERS.N.ORIGINAL_TOKEN).transform();
expect(result).toEqual(Constants.DECIPHERS.N.DECIPHERED_TOKEN);
});
});
});
function download(id, session) {
let got_video_info = false;
return new Promise((resolve, reject) => {
const stream = session.download(id, { type: 'videoandaudio' });
stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
stream.on('end', () => resolve(Fs.existsSync(`./${id}.mp4`) && got_video_info));
stream.on('info', () => got_video_info = true);
stream.on('error', () => resolve(false));
});
}

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"allowJs": true,
"outDir": "./typings",
"lib": ["ESNext"],
"target": "ESNext",
"moduleResolution": "node"
},
"include": [
"./lib/**/*.js",
"./index.js"
],
"exclude": [
"node_modules",
"**/*.d.ts"
]
}

192
typings/index.d.ts vendored
View File

@@ -1,190 +1,2 @@
interface AuthInfo {
access_token: string;
refresh_token: string;
expires: Date;
}
interface AccountInfo {
name: string;
photo: Record<string, any>[];
country: string;
language: string;
}
interface SearchOptions {
client: 'YTMUSIC' | 'YOUTUBE';
period: 'any' | 'hour' | 'day' | 'week' | 'month' | 'year';
order: 'relevance' | 'rating' | 'age' | 'views';
duration: 'any' | 'short' | 'long';
}
interface YouTubeSearch {
query: string;
corrected_query: string;
estimated_results: number;
videos: any[];
getContinuation: () => Promise<object>;
}
interface YouTubeMusicSearch {
query: string;
corrected_query: string;
results: {
top_result?: any[];
songs?: any[];
albums?: any[];
videos?: any[];
community_playlists?: any[];
artists?: any[];
}
}
type SearchResults = YouTubeSearch | YouTubeMusicSearch;
type ClientOption = Pick<SearchOptions, 'client'>;
type SortBy = 'TOP_COMMENTS' | 'NEWEST_FIRST';
interface Suggestion {
text: string;
bold_text: string;
}
interface ApiStatus {
success: boolean;
status_code: number;
data: object;
message?: string;
}
interface Comments {
page_count: number,
comment_count: number;
items: any[];
getContinuation: () => Promise<object>;
}
interface Video {
title: string;
description: string;
thumbnail: object;
metadata: Record<any, any>;
like: () => Promise<ApiStatus>;
dislike: () => Promise<ApiStatus>;
removeLike: () => Promise<ApiStatus>;
subscribe: () => Promise<ApiStatus>;
unsubscribe: () => Promise<ApiStatus>;
comment: (text: string) => Promise<ApiStatus>;
getComments: () => Promise<Comments>;
getLivechat: () => any; // TODO type LiveChat
setNotificationPreferences: () => Promise<ApiStatus>;
}
interface Channel {
title: string;
description: string;
metadata: object;
content: object;
}
interface PlayList {
description: string;
items: any[];
title: string;
total_items: string | number;
duration?: string;
last_updated?: string;
views?: string;
year?: string;
}
interface History {
items: {
date: string;
videos: any[];
}[];
getContinuation: () => Promise<object>;
}
interface SubscriptionFeed {
items: {
date: string;
videos: any[];
}[];
getContinuation: () => Promise<object>;
}
interface HomeFeed {
videos: {
id: string;
title: string;
description: string;
channel: string;
metadata: Record<string, any>;
}[];
getContinuation: () => Promise<object>;
}
interface Trending {
now: {
content: {
title: string;
videos: [];
}[];
};
music: { getVideos: () => Promise<Array>; };
gaming: { getVideos: () => Promise<Array>; };
movies: { getVideos: () => Promise<Array>; };
}
interface Notifications {
items: {
title: string;
sent_time: string;
channel_name: string;
channel_thumbnail: Record<string, any>;
video_thumbnail: Record<string, any>;
video_url: string;
read: boolean;
notification_id: string;
}[];
getContinuation: () => Promise<object>;
}
interface StreamingData {
selected_format: Record<string, any>;
formats: any[];
}
interface StreamingOptions {
quality?: string;
type?: string;
format?: string;
}
interface Config {
gl?: string;
cookie?: string;
}
export default class Innertube {
constructor(auth_info?: Config)
public signIn(auth_info: AuthInfo): Promise<void>;
public signOut(): Promise<ApiStatus>;
public getAccountInfo(): Promise<AccountInfo>;
public search(query: string, options: SearchOptions): Promise<SearchResults>;
public getSearchSuggestions(query: string, options?: ClientOption): Promise<Suggestion>;
public getDetails(video_id: string): Promise<ApiStatus>;
public getChannel(id: string): Promise<Channel>;
public getLyrics(video_id: string): Promise<string>;
public getPlaylist(playlist_id: string, options?: ClientOption): Promise<PlayList>;
public getComments(video_id: string, sort_by?: SortBy): Promise<Comments>;
public getHistory(): Promise<History>;
public getHomeFeed(): Promise<HomeFeed>;
public getTrending(): Promise<Trending>;
public getSubscriptionsFeed(): Promise<SubscriptionFeed>;
public getNotifications(): Promise<Notifications>;
public getUnseenNotificationsCount(): Promise<number>;
public getStreamingData(id: string, options?: StreamingOptions): Promise<StreamingData>;
public download(id: string, options?: StreamingOptions): ReadableStream;
}
declare const _exports: typeof import("./lib/Innertube");
export = _exports;

330
typings/lib/Innertube.d.ts vendored Normal file
View File

@@ -0,0 +1,330 @@
export = Innertube;
/**
* Innertube instance.
* @namespace
*/
declare class Innertube {
/**
* @example
* ```js
* const Innertube = require('youtubei.js');
* const youtube = await new Innertube();
* ```
*
* @param {object} [config]
* @param {string} [config.gl]
* @param {string} [config.cookie]
* @param {boolean} [config.debug]
*
* @returns {Innertube}
* @constructor
*/
constructor(config?: {
gl?: string;
cookie?: string;
debug?: boolean;
});
config: {
gl?: string;
cookie?: string;
debug?: boolean;
};
key: any;
version: any;
context: any;
logged_in: boolean;
player_url: any;
sts: any;
/**
* @fires Innertube#auth - fired when signing in to an account.
* @fires Innertube#update-credentials - fired when the access token is no longer valid.
* @type {EventEmitter}
*/
ev: EventEmitter;
oauth: OAuth;
auth_apisid: any;
request: Request;
actions: Actions;
account: AccountManager;
playlist: PlaylistManager;
interact: InteractionManager;
/**
* Signs in to a google account.
*
* @param {object} auth_info
* @param {string} auth_info.access_token - Token used to sign in.
* @param {string} auth_info.refresh_token - Token used to get a new access token.
* @param {Date} auth_info.expires - Access token's expiration date, which is usually 24hrs-ish.
*
* @returns {Promise.<void>}
*/
signIn(auth_info?: {
access_token: string;
refresh_token: string;
expires: Date;
}): Promise<void>;
access_token: string;
refresh_token: string;
/**
* Signs out of an account.
* @returns {Promise.<{ success: boolean; status_code: number }>}
*/
signOut(): Promise<{
success: boolean;
status_code: number;
}>;
/**
* Searches a given query.
*
* @param {string} query - search query.
* @param {object} [options] - search options.
* @param {string} [options.client] - client used to perform the search, can be: `YTMUSIC` or `YOUTUBE`.
* @param {object} [options.filters] - search filters.
* @param {string} [options.filters.upload_date] - filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
* @param {string} [options.filters.type] - filter results by type, can be: any | video | channel | playlist | movie
* @param {string} [options.filters.duration] - filter videos by duration, can be: any | short | medium | long
* @param {string} [options.filters.sort_by] - filter video results by order, can be: relevance | rating | upload_date | view_count
*
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: object[] } |
* { results: { songs: object[]; videos: object[]; albums: object[]; community_playlists: object[] } }>}
*/
search(query: string, options?: {
client?: string;
filters?: {
upload_date?: string;
type?: string;
duration?: string;
sort_by?: string;
};
}): Promise<{
query: string;
corrected_query: string;
estimated_results: number;
videos: object[];
} | {
results: {
songs: object[];
videos: object[];
albums: object[];
community_playlists: object[];
};
}>;
/**
* Retrieves search suggestions for a given query.
*
* @param {string} query - the search query.
* @param {object} [options] - search options.
* @param {string} [options.client='YOUTUBE'] - client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
*
* @returns {Promise.<{ query: string; results: string[] }>}
*/
getSearchSuggestions(query: string, options?: {
client?: string;
}): Promise<{
query: string;
results: string[];
}>;
/**
* Retrieves video info.
* @param {string} video_id - the video id.
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>}
*/
getDetails(video_id: string): Promise<{
title: string;
description: string;
thumbnail: [];
metadata: object;
}>;
/**
* Retrieves comments for a given video.
*
* @param {string} video_id - the video id.
* @param {string} [sort_by] - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
* @return {Promise.<{ page_count: number; comment_count: number; items: object[]; }>}
*/
getComments(video_id: string, sort_by?: string): Promise<{
page_count: number;
comment_count: number;
items: object[];
}>;
/**
* Retrieves contents for a given channel. (WIP)
* @param {string} id - channel id
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>}
*/
getChannel(id: string): Promise<{
title: string;
description: string;
metadata: object;
content: object;
}>;
/**
* Retrieves watch history.
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
*/
getHistory(): Promise<{
items: {
date: string;
videos: object[];
}[];
}>;
/**
* Retrieves home feed (aka recommendations).
* @returns {Promise.<{ videos: { id: string; title: string; description: string; channel: string; metadata: object }[] }>}
*/
getHomeFeed(): Promise<{
videos: {
id: string;
title: string;
description: string;
channel: string;
metadata: object;
}[];
}>;
/**
* Retrieves trending content.
* @returns {Promise.<{ now: { content: { title: string; videos: object[]; }[] };
* music: { getVideos: Promise.<Array.<object>>; }; gaming: { getVideos: Promise.<Array.<object>>; };
* movies: { getVideos: Promise.<Array.<object>>; } }>}
*/
getTrending(): Promise<{
now: {
content: {
title: string;
videos: object[];
}[];
};
music: {
getVideos: Promise<Array<object>>;
};
gaming: {
getVideos: Promise<Array<object>>;
};
movies: {
getVideos: Promise<Array<object>>;
};
}>;
/**
* @todo finish this
* WIP
*/
getLibrary(): Promise<any>;
/**
* Retrieves subscriptions feed.
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
*/
getSubscriptionsFeed(): Promise<{
items: {
date: string;
videos: object[];
}[];
}>;
/**
* Retrieves notifications.
* @returns {Promise.<{ items: { title: string; sent_time: string; channel_name: string; channel_thumbnail: object; video_thumbnail: object; video_url: string; read: boolean; notification_id: string }[] }>}
*/
getNotifications(): Promise<{
items: {
title: string;
sent_time: string;
channel_name: string;
channel_thumbnail: object;
video_thumbnail: object;
video_url: string;
read: boolean;
notification_id: string;
}[];
}>;
/**
* Retrieves unseen notifications count.
* @returns {Promise.<number>}
*/
getUnseenNotificationsCount(): Promise<number>;
/**
* Retrieves lyrics for a given song if available.
*
* @param {string} video_id
* @returns {Promise.<string>}
*/
getLyrics(video_id: string): Promise<string>;
/**
* Retrieves the contents of a given playlist.
*
* @param {string} playlist_id - the id of the playlist.
* @param {object} options - `YOUTUBE` | `YTMUSIC`
* @param {string} options.client - client used to parse the playlist, can be: `YTMUSIC` | `YOUTUBE`
*
* @returns {Promise.<
* { title: string; description: string; total_items: string; last_updated: string; views: string; items: [] } |
* { title: string; description: string; total_items: number; duration: string; year: string; items: [] }>}
*/
getPlaylist(playlist_id: string, options?: {
client: string;
}): Promise<{
title: string;
description: string;
total_items: string;
last_updated: string;
views: string;
items: [];
} | {
title: string;
description: string;
total_items: number;
duration: string;
year: string;
items: [];
}>;
/**
* An alternative to {@link download}.
* Returns deciphered streaming data.
*
* @param {string} video_id - video id
* @param {object} options - download options.
* @param {string} options.quality - video quality; 360p, 720p, 1080p, etc...
* @param {string} options.type - download type, can be: video, audio or videoandaudio
* @param {string} options.format - file format
*
* @returns {Promise.<{ selected_format: object; formats: object[] }>}
*/
getStreamingData(video_id: string, options?: {
quality: string;
type: string;
format: string;
}): Promise<{
selected_format: object;
formats: object[];
}>;
/**
* Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}.
*
* @param {string} video_id - video id
* @param {object} options - download options.
* @param {string} [options.quality] - video quality; 360p, 720p, 1080p, etc...
* @param {string} [options.type] - download type, can be: video, audio or videoandaudio
* @param {string} [options.format] - file format
* @param {object} [options.range] - download range, indicates which bytes should be downloaded.
* @param {number} options.range.start - the beginning of the range.
* @param {number} options.range.end - the end of the range.
*
* @return {Stream.PassThrough}
*/
download(video_id: string, options?: {
quality?: string;
type?: string;
format?: string;
range?: {
start: number;
end: number;
};
}): Stream.PassThrough;
#private;
}
import EventEmitter = require("events");
import OAuth = require("./core/OAuth");
import Request = require("./utils/Request");
import Actions = require("./core/Actions");
import AccountManager = require("./core/AccountManager");
import PlaylistManager = require("./core/PlaylistManager");
import InteractionManager = require("./core/InteractionManager");
import Stream = require("stream");

192
typings/lib/core/AccountManager.d.ts vendored Normal file
View File

@@ -0,0 +1,192 @@
export = AccountManager;
/** @namespace */
declare class AccountManager {
/**
* @param {Actions} actions
* @constructor
*/
constructor(actions: Actions);
/** @namespace */
channel: {
/**
* Edits channel name.
*
* @param {string} new_name
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editName: (new_name: string) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Edits channel description.
*
* @param {string} new_description
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editDescription: (new_description: string) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Retrieves basic channel analytics.
* @borrows AccountManager#getAnalytics as getBasicAnalytics
*/
getBasicAnalytics: () => Promise<{
metrics: {
title: string;
subtitle: string;
metric_value: string;
comparison_indicator: object;
series_configuration: object;
}[];
top_content: {
views: string;
published: string;
thumbnails: object[];
duration: string;
is_short: boolean;
}[];
}>;
};
/** @namespace */
settings: {
notifications: {
/**
* Notify about activity from the channels you're subscribed to.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptions: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Recommended content notifications.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setRecommendedVideos: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Notify about activity on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setChannelActivity: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Notify about replies to your comments.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setCommentReplies: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Notify when others mention your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setMentions: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Notify when others share your content on their channels.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSharedContent: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
};
privacy: {
/**
* If set to true, your subscriptions won't be visible to others.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptionsPrivate: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* If set to true, saved playlists won't appear on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSavedPlaylistsPrivate: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
};
};
/**
* Retrieves channel info.
* @returns {Promise.<{ name: string; email: string; channel_id: string; subscriber_count: string; photo: object[]; }>}
*/
getInfo(): Promise<{
name: string;
email: string;
channel_id: string;
subscriber_count: string;
photo: object[];
}>;
/**
* Retrieves time watched statistics.
* @returns {Promise.<[{ title: string; time: string; }]>}
*/
getTimeWatched(): Promise<[{
title: string;
time: string;
}]>;
/**
* Retrieves basic channel analytics.
*
* @returns {Promise.<{ metrics: { title: string; subtitle: string; metric_value: string;
* comparison_indicator: object; series_configuration: object; }[]; top_content: { views: string;
* published: string; thumbnails: object[]; duration: string; is_short: boolean }[]; }>}
*/
getAnalytics(): Promise<{
metrics: {
title: string;
subtitle: string;
metric_value: string;
comparison_indicator: object;
series_configuration: object;
}[];
top_content: {
views: string;
published: string;
thumbnails: object[];
duration: string;
is_short: boolean;
}[];
}>;
#private;
}

331
typings/lib/core/Actions.d.ts vendored Normal file
View File

@@ -0,0 +1,331 @@
export = Actions;
/** namespace **/
declare class Actions {
/**
* @param {Innertube} session
* @constructor
*/
constructor(session: Innertube);
/**
* Covers `/browse` endpoint, mostly used to access
* YouTube's sections such as the home feed, etc
* and sometimes to retrieve continuations.
*
* @param {string} id - browseId or a continuation token
* @param {object} args - additional arguments
* @param {string} [args.params]
* @param {boolean} [args.is_ytm]
* @param {boolean} [args.is_ctoken]
* @param {string} [args.client]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object }>}
*/
browse(id: string, args?: {
params?: string;
is_ytm?: boolean;
is_ctoken?: boolean;
client?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints used to perform direct interactions
* on YouTube.
*
* @param {string} action
* @param {object} args
* @param {string} [args.video_id]
* @param {string} [args.channel_id]
* @param {string} [args.comment_id]
* @param {string} [args.comment_action]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
engage(action: string, args?: {
video_id?: string;
channel_id?: string;
comment_id?: string;
comment_action?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints related to account management.
*
* @param {string} action
* @param {object} args
* @param {string} [args.new_value]
* @param {string} [args.setting_item_id]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object }>}
*/
account(action: string, args?: {
new_value?: string;
setting_item_id?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Endpoint used for search.
*
* @param {object} args
* @param {string} [args.query]
* @param {object} [args.options]
* @param {string} [args.options.period]
* @param {string} [args.options.duration]
* @param {string} [args.options.order]
* @param {string} [args.client]
* @param {string} [args.ctoken]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
search(args?: {
query?: string;
options?: {
period?: string;
duration?: string;
order?: string;
};
client?: string;
ctoken?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Endpoint used fo Shorts' sound search.
*
* @param {object} args
* @param {string} args.query
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
searchSound(args?: {
query: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Channel management endpoints.
*
* @param {string} action
* @param {object} args
* @param {string} [args.new_name]
* @param {string} [args.new_description]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
channel(action: string, args?: {
new_name?: string;
new_description?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints used for playlist management.
*
* @param {string} action
* @param {object} args
* @param {string} [args.title]
* @param {string} [args.ids]
* @param {string} [args.playlist_id]
* @param {string} [args.action]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
playlist(action: string, args?: {
title?: string;
ids?: string;
playlist_id?: string;
action?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints used for notifications management.
*
* @param {string} action
* @param {object} args
* @param {string} [args.pref]
* @param {string} [args.channel_id]
* @param {string} [args.ctoken]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
notifications(action: string, args?: {
pref?: string;
channel_id?: string;
ctoken?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers livechat endpoints.
*
* @param {string} action
* @param {object} args
* @param {string} [args.text]
* @param {string} [args.video_id]
* @param {string} [args.channel_id]
* @param {string} [args.ctoken]
* @param {string} [args.params]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
livechat(action: string, args?: {
text?: string;
video_id?: string;
channel_id?: string;
ctoken?: string;
params?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Endpoint used to retrieve video thumbnails.
*
* @param {object} args
* @param {string} args.video_id
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
thumbnails(args?: {
video_id: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Place Autocomplete endpoint, found it in the APK but
* doesn't seem to be used anywhere on YouTube (maybe for ads?).
*
* Ex:
* ```js
* const places = await session.actions.geo('place_autocomplete', { input: 'San diego cafe' });
* console.info(places.data);
* ```
*
* @param {string} action
* @param {object} args
* @param {string} args.input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
geo(action: string, args?: {
input: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints used to report content.
*
* @param {string} action
* @param {object} args
* @param {object} [args.action]
* @param {string} [args.params]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
flag(action: string, args: {
action?: object;
params?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers specific YouTube Music endpoints.
*
* @param {string} action
* @param {string} args.input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
music(action: string, args: any): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Mostly used for pagination and specific operations.
*
* @param {object} args
* @param {string} [args.video_id]
* @param {string} [args.ctoken]
* @param {string} [args.client]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
next(args?: {
video_id?: string;
ctoken?: string;
client?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Used to retrieve video info.
*
* @param {string} id
* @param {string} [cpn]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
getVideoInfo(id: string, cpn?: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers search suggestion endpoints.
*
* @param {string} client
* @param {string} input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
getSearchSuggestions(client: string, query: any): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Endpoint used to retrieve user mention suggestions.
*
* @param {object} args
* @param {string} args.input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
getUserMentionSuggestions(args?: {
input: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
#private;
}

107
typings/lib/core/InteractionManager.d.ts vendored Normal file
View File

@@ -0,0 +1,107 @@
export = InteractionManager;
/** @namespace */
declare class InteractionManager {
/**
* @param {Actions} actions
* @constructor
*/
constructor(actions: Actions);
/**
* Likes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
like(video_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Dislikes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
dislike(video_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Removes a like/dislike.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
removeLike(video_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Subscribes to a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
subscribe(channel_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Unsubscribes from a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
unsubscribe(channel_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Posts a comment on a given video.
*
* @param {string} video_id
* @param {string} text
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
comment(video_id: string, text: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Translates a given text using YouTube's comment translate feature.
*
* @param {string} text
* @param {string} target_language - an ISO language code
* @param {object} [args] - optional arguments
* @param {string} [args.video_id]
* @param {string} [args.comment_id]
*
* @returns {Promise.<{ success: boolean; status_code: number; translated_content: string; data: object; }>}
*/
translate(text: string, target_language: string, args?: {
video_id?: string;
comment_id?: string;
}): Promise<{
success: boolean;
status_code: number;
translated_content: string;
data: object;
}>;
/**
* Changes notification preferences for a given channel.
* Only works with channels you are subscribed to.
*
* @param {string} channel_id
* @param {string} type - `PERSONALIZED` | `ALL` | `NONE`
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setNotificationPreferences(channel_id: string, type: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
#private;
}

23
typings/lib/core/Livechat.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
export = Livechat;
declare class Livechat extends EventEmitter {
constructor(session: any, token: any, channel_id: any, video_id: any);
ctoken: any;
session: any;
video_id: any;
channel_id: any;
message_queue: any[];
id_cache: any[];
poll_intervals_ms: number;
running: boolean;
metadata_ctoken: any;
livechat_poller: NodeJS.Timeout;
sendMessage(text: any): Promise<any>;
/**
* Blocks a user.
* @todo Implement this method
*/
blockUser(): Promise<void>;
stop(): void;
#private;
}
import EventEmitter = require("events");

47
typings/lib/core/OAuth.d.ts vendored Normal file
View File

@@ -0,0 +1,47 @@
export = OAuth;
/** @namespace */
declare class OAuth {
/**
* @param {EventEmitter} ev
* @constructor
*/
constructor(ev: EventEmitter);
/**
* Starts the auth flow in case no valid credentials are available.
* @returns {Promise.<void>}
*/
init(auth_info: any): Promise<void>;
client_id: string;
client_secret: string;
/**
* Refreshes the access token if necessary.
* @returns {Promise.<void>}
*/
checkTokenValidity(): Promise<void>;
/**
* Revokes access token (note that the refresh token will also be revoked).
* @returns {Promise.<void>}
*/
revokeAccessToken(): Promise<void>;
/**
* Returns the access token.
* @returns {string}
*/
getAccessToken(): string;
/**
* Returns the refresh token.
* @returns {string}
*/
getRefreshToken(): string;
/**
* Checks if the auth info format is valid.
* @returns {boolean} true | false
*/
isValidAuthInfo(): boolean;
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
shouldRefreshToken(): boolean;
#private;
}

37
typings/lib/core/Player.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
export = Player;
/** @namespace */
declare class Player {
/**
* Represents the YouTube Web player script.
* @param {string} id - the id of the player.
* @constructor
*/
constructor(id: string);
init(): Promise<Player>;
/**
* Returns the current player's url.
* @readonly
* @returns {string}
*/
readonly get url(): string;
/**
* Returns the signature timestamp.
* @readonly
* @returns {string}
*/
readonly get sts(): string;
/**
* Returns the n-token decipher algorithm.
* @readonly
* @returns {string}
*/
readonly get ntoken_decipher(): string;
/**
* Returns the signature decipher algorithm.
* @readonly
* @returns {string}
*/
readonly get signature_decipher(): string;
isCached(): boolean;
#private;
}

63
typings/lib/core/PlaylistManager.d.ts vendored Normal file
View File

@@ -0,0 +1,63 @@
export = PlaylistManager;
/** @namespace */
declare class PlaylistManager {
/**
* @param {Actions} actions
* @constructor
*/
constructor(actions: Actions);
/**
* Creates a playlist.
*
* @param {string} title
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
create(title: string, video_ids: Array<string>): Promise<{
success: boolean;
status_code: number;
playlist_id: string;
data: object;
}>;
/**
* Deletes a given playlist.
* @param {string} playlist_id
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
delete(playlist_id: string): Promise<{
success: boolean;
status_code: number;
playlist_id: string;
data: object;
}>;
/**
* Adds videos to a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
addVideos(playlist_id: string, video_ids: Array<string>): Promise<{
success: boolean;
status_code: number;
playlist_id: string;
data: object;
}>;
/**
* Removes videos from a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
removeVideos(playlist_id: string, video_ids: Array<string>): Promise<{
success: boolean;
status_code: number;
playlist_id: string;
data: object;
}>;
#private;
}

23
typings/lib/core/SessionBuilder.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
export = SessionBuilder;
/** @namespace */
declare class SessionBuilder {
/**
* @param {object} config
* @constructor
*/
constructor(config: object);
build(): Promise<SessionBuilder>;
/** @readonly */
readonly get key(): any;
/** @readonly */
readonly get context(): any;
/** @readonly */
readonly get api_version(): any;
/** @readonly */
readonly get client_version(): any;
/** @readonly */
readonly get client_name(): any;
/** @readonly */
readonly get player(): any;
#private;
}

12
typings/lib/deciphers/NToken.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
export = NToken;
declare class NToken {
constructor(raw_code: any, n: any);
n: any;
raw_code: any;
/**
* Solves throttling challange by transforming the n token.
* @returns {string}
*/
transform(): string;
#private;
}

12
typings/lib/deciphers/Signature.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
export = Signature;
declare class Signature {
constructor(url: any, sig_decipher_sc: any);
url: any;
sig_decipher_sc: any;
/**
* Deciphers signature.
* @returns {string}
*/
decipher(): string;
#private;
}

9
typings/lib/parser/index.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
export = Parser;
declare class Parser {
constructor(session: any, data: any, args?: {});
data: any;
session: any;
args: {};
parse(): any;
#private;
}

11
typings/lib/parser/youtube/index.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import VideoResultItem = require("./search/VideoResultItem");
import SearchSuggestionItem = require("./search/SearchSuggestionItem");
import PlaylistItem = require("./others/PlaylistItem");
import NotificationItem = require("./others/NotificationItem");
import VideoItem = require("./others/VideoItem");
import GridVideoItem = require("./others/GridVideoItem");
import GridPlaylistItem = require("./others/GridPlaylistItem");
import ChannelMetadata = require("./others/ChannelMetadata");
import ShelfRenderer = require("./others/ShelfRenderer");
import CommentThread = require("./others/CommentThread");
export { VideoResultItem, SearchSuggestionItem, PlaylistItem, NotificationItem, VideoItem, GridVideoItem, GridPlaylistItem, ChannelMetadata, ShelfRenderer, CommentThread };

View File

@@ -0,0 +1,15 @@
export = ChannelMetadata;
declare class ChannelMetadata {
static parse(data: any): {
title: any;
description: any;
metadata: {
url: any;
rss_urls: any;
vanity_channel_url: any;
external_id: any;
is_family_safe: any;
keywords: any;
};
};
}

View File

@@ -0,0 +1,23 @@
export = CommentThread;
declare class CommentThread {
static parseItem(item: any): {
text: any;
author: {
name: any;
thumbnails: any;
channel_id: any;
channel_url: string;
};
metadata: {
published: any;
is_reply: boolean;
is_liked: any;
is_disliked: any;
is_pinned: boolean;
is_channel_owner: any;
like_count: number;
reply_count: any;
id: any;
};
};
}

View File

@@ -0,0 +1,12 @@
export = GridPlaylistItem;
declare class GridPlaylistItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
metadata: {
thumbnail: any;
video_count: any;
};
};
}

View File

@@ -0,0 +1,25 @@
export = GridVideoItem;
declare class GridVideoItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
channel: {
id: any;
name: any;
url: string;
};
metadata: {
view_count: any;
short_view_count_text: {
simple_text: any;
accessibility_label: any;
};
thumbnail: any;
moving_thumbnail: any;
published: any;
badges: any;
owner_badges: any;
};
};
}

View File

@@ -0,0 +1,14 @@
export = NotificationItem;
declare class NotificationItem {
static parse(data: any): any;
static parseItem(item: any): {
title: any;
sent_time: any;
channel_name: any;
channel_thumbnail: any;
video_thumbnail: any;
video_url: string;
read: any;
notification_id: any;
};
}

View File

@@ -0,0 +1,15 @@
export = PlaylistItem;
declare class PlaylistItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
duration: {
seconds: number;
simple_text: any;
accessibility_label: any;
};
thumbnails: any;
};
}

View File

@@ -0,0 +1,9 @@
export = ShelfRenderer;
declare class ShelfRenderer {
static parse(data: any): {
title: any;
videos: any;
};
static getTitle(data: any): any;
static parseItems(data: any): any;
}

View File

@@ -0,0 +1,31 @@
export = VideoItem;
declare class VideoItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
description: any;
channel: {
id: any;
name: any;
url: string;
};
metadata: {
view_count: any;
short_view_count_text: {
simple_text: any;
accessibility_label: any;
};
thumbnail: any;
moving_thumbnail: any;
published: any;
duration: {
seconds: number;
simple_text: any;
accessibility_label: any;
};
badges: any;
owner_badges: any;
};
};
}

View File

@@ -0,0 +1,7 @@
export = SearchSuggestionItem;
declare class SearchSuggestionItem {
static parse(data: any): {
query: any;
results: any;
};
}

View File

@@ -0,0 +1,31 @@
export = VideoResultItem;
declare class VideoResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
url: string;
title: any;
description: any;
channel: {
id: any;
name: any;
url: string;
};
metadata: {
view_count: any;
short_view_count_text: {
simple_text: any;
accessibility_label: any;
};
thumbnails: any;
duration: {
seconds: number;
simple_text: any;
accessibility_label: any;
};
published: any;
badges: any;
owner_badges: any;
};
};
}

9
typings/lib/parser/ytmusic/index.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import SongResultItem = require("./search/SongResultItem");
import VideoResultItem = require("./search/VideoResultItem");
import AlbumResultItem = require("./search/AlbumResultItem");
import ArtistResultItem = require("./search/ArtistResultItem");
import PlaylistResultItem = require("./search/PlaylistResultItem");
import MusicSearchSuggestionItem = require("./search/MusicSearchSuggestionItem");
import TopResultItem = require("./search/TopResultItem");
import PlaylistItem = require("./others/PlaylistItem");
export { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, MusicSearchSuggestionItem, TopResultItem, PlaylistItem };

View File

@@ -0,0 +1,14 @@
export = PlaylistItem;
declare class PlaylistItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
duration: {
seconds: number;
simple_text: any;
};
thumbnails: any;
};
}

View File

@@ -0,0 +1,11 @@
export = AlbumResultItem;
declare class AlbumResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
year: any;
thumbnails: any;
};
}

View File

@@ -0,0 +1,10 @@
export = ArtistResultItem;
declare class ArtistResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
name: any;
subscribers: any;
thumbnails: any;
};
}

View File

@@ -0,0 +1,8 @@
export = MusicSearchSuggestionItem;
declare class MusicSearchSuggestionItem {
static parse(data: any): {
query: any;
results: any;
};
static parseItem(item: any): any;
}

View File

@@ -0,0 +1,11 @@
export = PlaylistResultItem;
declare class PlaylistResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
channel_id: any;
total_items: number;
};
}

View File

@@ -0,0 +1,12 @@
export = SongResultItem;
declare class SongResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
artist: any;
album: any;
duration: any;
thumbnails: any;
};
}

View File

@@ -0,0 +1,4 @@
export = TopResultItem;
declare class TopResultItem {
static parse(data: any): any;
}

View File

@@ -0,0 +1,12 @@
export = VideoResultItem;
declare class VideoResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
views: any;
duration: any;
thumbnails: any;
};
}

113
typings/lib/proto/index.d.ts vendored Normal file
View File

@@ -0,0 +1,113 @@
export = Proto;
declare class Proto {
/**
* Encodes visitor data.
*
* @param {string} id
* @param {number} timestamp
*
* @returns {string}
*/
static encodeVisitorData(id: string, timestamp: number): string;
/**
* Encodes basic channel analytics parameters.
*
* @param {string} channel_id
* @returns {string}
*/
static encodeChannelAnalyticsParams(channel_id: string): string;
/**
* Encodes search filters.
*
* @param {object} filters
* @param {string} [filters.upload_date] - any | last_hour | today | this_week | this_month | this_year
* @param {string} [filters.type] - any | video | channel | playlist | movie
* @param {string} [filters.duration] - any | short | medium | long
* @param {string} [filters.sort_by] - relevance | rating | upload_date | view_count
* @todo implement remaining filters.
*
* @returns {string}
*/
static encodeSearchFilter(filters: {
upload_date?: string;
type?: string;
duration?: string;
sort_by?: string;
}): string;
/**
* Encodes livechat message parameters.
*
* @param {string} channel_id
* @param {string} video_id
*
* @returns {string}
*/
static encodeMessageParams(channel_id: string, video_id: string): string;
/**
* Encodes comment section parameters.
*
* @param {string} video_id
* @param {object} options
* @param {string} options.type
* @param {string} options.sort_by
*
* @returns {string}
*/
static encodeCommentsSectionParams(video_id: string, options?: {
type: string;
sort_by: string;
}): string;
/**
* Encodes replies thread parameters.
*
* @param {string} video_id
* @param {string} comment_id
*
* @returns {string}
*/
static encodeCommentRepliesParams(video_id: string, comment_id: string): string;
/**
* Encodes comment parameters.
*
* @param {string} video_id
* @returns {string}
*/
static encodeCommentParams(video_id: string): string;
/**
* Encodes comment reply parameters.
*
* @param {string} comment_id
* @param {string} video_id
*
* @return {string}
*/
static encodeCommentReplyParams(comment_id: string, video_id: string): string;
/**
* Encodes comment action parameters.
*
* @param {string} type
* @param {string} comment_id
* @param {string} video_id
* @param {string} [text]
* @param {string} [target_language]
*
* @returns {string}
*/
static encodeCommentActionParams(type: string, args?: {}): string;
/**
* Encodes notification preference parameters.
*
* @param {string} channel_id
* @param {number} index
*
* @returns {string}
*/
static encodeNotificationPref(channel_id: string, index: number): string;
/**
* Encodes sound info parameters.
*
* @param {string} id
* @returns {string}
*/
static encodeSoundInfoParams(id: string): string;
}

106
typings/lib/proto/messages.d.ts vendored Normal file
View File

@@ -0,0 +1,106 @@
export namespace VisitorData {
const buffer: boolean;
const encodingLength: any;
const encode: any;
const decode: any;
}
export namespace ChannelAnalytics {
const buffer_1: boolean;
export { buffer_1 as buffer };
const encodingLength_1: any;
export { encodingLength_1 as encodingLength };
const encode_1: any;
export { encode_1 as encode };
const decode_1: any;
export { decode_1 as decode };
}
export namespace InnertubePayload {
const buffer_2: boolean;
export { buffer_2 as buffer };
const encodingLength_2: any;
export { encodingLength_2 as encodingLength };
const encode_2: any;
export { encode_2 as encode };
const decode_2: any;
export { decode_2 as decode };
}
export namespace SoundInfoParams {
const buffer_3: boolean;
export { buffer_3 as buffer };
const encodingLength_3: any;
export { encodingLength_3 as encodingLength };
const encode_3: any;
export { encode_3 as encode };
const decode_3: any;
export { decode_3 as decode };
}
export namespace NotificationPreferences {
const buffer_4: boolean;
export { buffer_4 as buffer };
const encodingLength_4: any;
export { encodingLength_4 as encodingLength };
const encode_4: any;
export { encode_4 as encode };
const decode_4: any;
export { decode_4 as decode };
}
export namespace LiveMessageParams {
const buffer_5: boolean;
export { buffer_5 as buffer };
const encodingLength_5: any;
export { encodingLength_5 as encodingLength };
const encode_5: any;
export { encode_5 as encode };
const decode_5: any;
export { decode_5 as decode };
}
export namespace GetCommentsSectionParams {
const buffer_6: boolean;
export { buffer_6 as buffer };
const encodingLength_6: any;
export { encodingLength_6 as encodingLength };
const encode_6: any;
export { encode_6 as encode };
const decode_6: any;
export { decode_6 as decode };
}
export namespace CreateCommentParams {
const buffer_7: boolean;
export { buffer_7 as buffer };
const encodingLength_7: any;
export { encodingLength_7 as encodingLength };
const encode_7: any;
export { encode_7 as encode };
const decode_7: any;
export { decode_7 as decode };
}
export namespace CreateCommentReplyParams {
const buffer_8: boolean;
export { buffer_8 as buffer };
const encodingLength_8: any;
export { encodingLength_8 as encodingLength };
const encode_8: any;
export { encode_8 as encode };
const decode_8: any;
export { decode_8 as decode };
}
export namespace PeformCommentActionParams {
const buffer_9: boolean;
export { buffer_9 as buffer };
const encodingLength_9: any;
export { encodingLength_9 as encodingLength };
const encode_9: any;
export { encode_9 as encode };
const decode_9: any;
export { decode_9 as decode };
}
export namespace SearchFilter {
const buffer_10: boolean;
export { buffer_10 as buffer };
const encodingLength_10: any;
export { encodingLength_10 as encodingLength };
const encode_10: any;
export { encode_10 as encode };
const decode_10: any;
export { decode_10 as decode };
}

94
typings/lib/utils/Constants.d.ts vendored Normal file
View File

@@ -0,0 +1,94 @@
export namespace URLS {
const YT_BASE: string;
const YT_BASE_API: string;
const YT_STUDIO_BASE_API: string;
const YT_SUGGESTIONS: string;
const YT_MUSIC: string;
const YT_MUSIC_BASE_API: string;
}
export namespace OAUTH {
const SCOPE: string;
const GRANT_TYPE: string;
const MODEL_NAME: string;
namespace HEADERS {
const headers: {
accept: string;
origin: string;
'user-agent': string;
'content-type': string;
referer: string;
'accept-language': string;
};
}
namespace REGEX {
const AUTH_SCRIPT: RegExp;
const CLIENT_IDENTITY: RegExp;
}
}
export namespace CLIENTS {
namespace WEB {
const NAME: string;
}
namespace YTMUSIC {
const NAME_1: string;
export { NAME_1 as NAME };
export const VERSION: string;
}
namespace ANDROID {
const NAME_2: string;
export { NAME_2 as NAME };
const VERSION_1: string;
export { VERSION_1 as VERSION };
}
}
export namespace STREAM_HEADERS {
const accept: string;
const connection: string;
const origin: string;
const referer: string;
const DNT: string;
}
export const INNERTUBE_HEADERS_BASE: {
accept: string;
'accept-encoding': string;
'content-type': string;
};
export const METADATA_KEYS: string[];
export const BLACKLISTED_KEYS: string[];
export namespace ACCOUNT_SETTINGS {
const SUBSCRIPTIONS: string;
const RECOMMENDED_VIDEOS: string;
const CHANNEL_ACTIVITY: string;
const COMMENT_REPLIES: string;
const USER_MENTION: string;
const SHARED_CONTENT: string;
const PLAYLISTS_PRIVACY: string;
const SUBSCRIPTIONS_PRIVACY: string;
}
export namespace BASE64_DIALECT {
const NORMAL: string[];
const REVERSE: string[];
}
export namespace SIG_REGEX {
const ACTIONS: RegExp;
const FUNCTIONS: RegExp;
}
export namespace NTOKEN_REGEX {
export const CALLS: RegExp;
export const PLACEHOLDERS: RegExp;
const FUNCTIONS_1: RegExp;
export { FUNCTIONS_1 as FUNCTIONS };
}
export namespace FUNCS {
const PUSH: string;
const REVERSE_1: string;
const REVERSE_2: string;
const SPLICE: string;
const SWAP0_1: string;
const SWAP0_2: string;
const ROTATE_1: string;
const ROTATE_2: string;
const BASE64_DIA: string;
const TRANSLATE_1: string;
const TRANSLATE_2: string;
}

12
typings/lib/utils/Request.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
export = Request;
/** @namespace */
declare class Request {
/**
* @param {Innertube} session
* @constructor
*/
constructor(session: Innertube);
session: Innertube;
instance: any;
#private;
}

95
typings/lib/utils/Utils.d.ts vendored Normal file
View File

@@ -0,0 +1,95 @@
/** @namespace */
export class InnertubeError extends Error {
constructor(message: any, info: any);
info: any;
date: Date;
version: any;
}
export class UnavailableContentError extends InnertubeError {
}
export class ParsingError extends InnertubeError {
}
export class DownloadError extends InnertubeError {
}
export class MissingParamError extends InnertubeError {
}
export class NoStreamingDataError extends InnertubeError {
}
/**
* Utility to help access deep properties of an object.
*
* @param {object} obj - the object.
* @param {string} key - key of the property being accessed.
* @param {string} target - anything that might be inside of the property.
* @param {number} depth - maximum number of nested objects to flatten.
* @param {boolean} safe - if set to true arrays will be preserved.
*
* @returns {object|Array.<any>}
*/
export function findNode(obj: object, key: string, target: string, depth: number, safe?: boolean): object | Array<any>;
/**
* Returns a random user agent.
*
* @param {string} type - mobile | desktop
* @returns {object}
*/
export function getRandomUserAgent(type: string): object;
/**
* Generates an authentication token from a cookies' sid.
*
* @param {string} sid - Sid extracted from cookies
* @returns {string}
*/
export function generateSidAuth(sid: string): string;
/**
* Generates a random string with a given length.
*
* @param {number} length
* @returns {string}
*/
export function generateRandomString(length: number): string;
/**
* Finds a string between two delimiters.
*
* @param {string} data - the data.
* @param {string} start_string - start string.
* @param {string} end_string - end string.
*
* @returns {string}
*/
export function getStringBetweenStrings(data: string, start_string: string, end_string: string): string;
/**
* Converts strings in camelCase to snake_case.
*
* @param {string} string The string in camelCase.
* @returns {string}
*/
export function camelToSnake(string: string): string;
/**
* Checks if a given client is valid.
*
* @param {string} client
* @returns {boolean}
*/
export function isValidClient(client: string): boolean;
/**
* Throws an error if given parameters are undefined.
*
* @param {object} params
* @returns
*/
export function throwIfMissing(params: object): void;
/**
* Converts time (h:m:s) to seconds.
*
* @param {string} time
* @returns {number} seconds
*/
export function timeToSeconds(time: string): number;
/**
* Turns the ntoken transform data into a valid json array
*
* @param {string} data
* @returns {string}
*/
export function refineNTokenData(data: string): string;