mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
205 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0565ec924 | ||
|
|
15437e3937 | ||
|
|
c7c0ac8b54 | ||
|
|
1e23cdb510 | ||
|
|
a85e9ef667 | ||
|
|
865b6870a1 | ||
|
|
7284425618 | ||
|
|
05f74fe004 | ||
|
|
864f10f2e9 | ||
|
|
369e1048d1 | ||
|
|
b1cf5d33b8 | ||
|
|
19008e126d | ||
|
|
c525163f28 | ||
|
|
155dc9bd15 | ||
|
|
5560ba3ce4 | ||
|
|
6aaf9c70b9 | ||
|
|
e0c7496e37 | ||
|
|
fa79e5cad2 | ||
|
|
98a2b49395 | ||
|
|
17978193d0 | ||
|
|
13f571a6dc | ||
|
|
9f3f8ad820 | ||
|
|
2ba7a5c64e | ||
|
|
d7d1c96d8c | ||
|
|
0219c075c7 | ||
|
|
759351c38e | ||
|
|
6312e97f95 | ||
|
|
c60babcf25 | ||
|
|
c48cfcd8a0 | ||
|
|
594202d61d | ||
|
|
7a5490452a | ||
|
|
b4bb44b797 | ||
|
|
43f3c3fbf8 | ||
|
|
b48ae0b8d3 | ||
|
|
8cf3e67f79 | ||
|
|
ffa243bc07 | ||
|
|
a08580eeee | ||
|
|
039ebb7c0c | ||
|
|
46a385aa06 | ||
|
|
f656ccd690 | ||
|
|
ddd276d99f | ||
|
|
5fbeaeabb6 | ||
|
|
18e62f6ff8 | ||
|
|
6235985871 | ||
|
|
4eef0ddab0 | ||
|
|
6127690b4c | ||
|
|
b6cfdb733c | ||
|
|
b565213f11 | ||
|
|
a5c9c9d863 | ||
|
|
cf95d82d3e | ||
|
|
00e0131672 | ||
|
|
2315306d9f | ||
|
|
1dfd4b6263 | ||
|
|
b0a861dec8 | ||
|
|
4943685e57 | ||
|
|
b773f5668c | ||
|
|
4fd7371cf3 | ||
|
|
16bb879689 | ||
|
|
a852cd22c8 | ||
|
|
90bb3e20c0 | ||
|
|
eab40c0034 | ||
|
|
19f7336a48 | ||
|
|
75895e5492 | ||
|
|
0cdfac1812 | ||
|
|
446966fb2d | ||
|
|
29897981f0 | ||
|
|
7e8a517de9 | ||
|
|
a8b9487b58 | ||
|
|
80a338e5ff | ||
|
|
e2ca022a47 | ||
|
|
2ebcd49f02 | ||
|
|
98a62c31da | ||
|
|
1bfe2676d8 | ||
|
|
4db0a0358f | ||
|
|
6bdccb89e5 | ||
|
|
bbfecdb015 | ||
|
|
f79d4b635d | ||
|
|
283c06e64f | ||
|
|
5c572dba66 | ||
|
|
aa943a46a8 | ||
|
|
d634892b01 | ||
|
|
2010714f50 | ||
|
|
c6c96fd223 | ||
|
|
db41fa40d2 | ||
|
|
02ece1ddda | ||
|
|
b175e02f6d | ||
|
|
d3394f846a | ||
|
|
07b73ab78d | ||
|
|
d743b5a088 | ||
|
|
bb206c044c | ||
|
|
d48065405d | ||
|
|
dbc8b62ba2 | ||
|
|
e32981728b | ||
|
|
7b33dcbb79 | ||
|
|
4c6bf49bbe | ||
|
|
4bbc2d50f4 | ||
|
|
440d80063d | ||
|
|
c49147523a | ||
|
|
e221c79448 | ||
|
|
291d04e703 | ||
|
|
12baec0b0d | ||
|
|
b793c61fd8 | ||
|
|
b9e15b5fbd | ||
|
|
d0c54f2b8b | ||
|
|
6ff984df66 | ||
|
|
4fa2e5c127 | ||
|
|
725f186bd9 | ||
|
|
07340931a0 | ||
|
|
46d62bf83f | ||
|
|
c28da62ec1 | ||
|
|
c7fc18b516 | ||
|
|
7230a2d927 | ||
|
|
924693349c | ||
|
|
1ab302319d | ||
|
|
bbc1d0135b | ||
|
|
9c1e34c9ab | ||
|
|
c5eea2b4ff | ||
|
|
60130f4d0f | ||
|
|
5090c572d5 | ||
|
|
c9c72d0f31 | ||
|
|
7635f49191 | ||
|
|
c932e65dad | ||
|
|
23717aab11 | ||
|
|
85df28a7fb | ||
|
|
9f4970b3ee | ||
|
|
82bbc715ff | ||
|
|
3ec111212c | ||
|
|
7ca4b2bb45 | ||
|
|
8d411f25c8 | ||
|
|
80fe969917 | ||
|
|
13c94fbb8a | ||
|
|
60ce869054 | ||
|
|
1268ac83a6 | ||
|
|
5e588d0db5 | ||
|
|
8b37bd99b1 | ||
|
|
08741de831 | ||
|
|
574a595a01 | ||
|
|
16928ee71b | ||
|
|
de6283080b | ||
|
|
23ab8bca4d | ||
|
|
068b86b410 | ||
|
|
0b001c0956 | ||
|
|
4c14662d42 | ||
|
|
f1a9d5d77b | ||
|
|
398cd8728d | ||
|
|
459c30528e | ||
|
|
6e1e96610c | ||
|
|
6d30aa3228 | ||
|
|
d33cb0b576 | ||
|
|
51af4c3ffe | ||
|
|
b577a79893 | ||
|
|
da0c5e5887 | ||
|
|
b47350894d | ||
|
|
c0387017e3 | ||
|
|
b286bc43df | ||
|
|
61028a2ab9 | ||
|
|
254588da81 | ||
|
|
ef3e54775c | ||
|
|
30cec36660 | ||
|
|
427a1bd396 | ||
|
|
cf4901fd3c | ||
|
|
2fd98a021f | ||
|
|
cd64e30b69 | ||
|
|
2b5027eb06 | ||
|
|
0c9f7135bf | ||
|
|
ce8a109398 | ||
|
|
6aaa3360e8 | ||
|
|
89c018c431 | ||
|
|
339a01f3a9 | ||
|
|
dd3f4c0009 | ||
|
|
7cd41e1d8a | ||
|
|
6ac8561af2 | ||
|
|
b4607d531f | ||
|
|
b3a1cdc1cd | ||
|
|
fd662df93d | ||
|
|
8a1f4b4e55 | ||
|
|
4ff83bdc3f | ||
|
|
c81e8e29ac | ||
|
|
d5f884ff9b | ||
|
|
5517c2f202 | ||
|
|
3493a82765 | ||
|
|
07f02d0dc1 | ||
|
|
b2afa86744 | ||
|
|
a1caa60750 | ||
|
|
e1dd718832 | ||
|
|
222bf1e61f | ||
|
|
3b48de20dd | ||
|
|
348d901935 | ||
|
|
94b12002ff | ||
|
|
2720e8f251 | ||
|
|
a8a1ec2182 | ||
|
|
ee0d1bef40 | ||
|
|
5cad39ee44 | ||
|
|
e8ca248919 | ||
|
|
44d09026b5 | ||
|
|
ff044f4216 | ||
|
|
8153e6178c | ||
|
|
ee3f1b4638 | ||
|
|
86c8a7e0d2 | ||
|
|
b375ae2f06 | ||
|
|
2ff4b2ea95 | ||
|
|
599ab69107 | ||
|
|
c6c6dc24bd | ||
|
|
fa2e0724c6 | ||
|
|
6af689ada6 |
7
.eslintignore
Normal file
7
.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.github
|
||||
test/
|
||||
cache/
|
||||
lib/proto/messages.js
|
||||
coverage/
|
||||
node_modules/
|
||||
45
.eslintrc.yml
Normal file
45
.eslintrc.yml
Normal 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
2
.github/FUNDING.yml
vendored
@@ -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' ]
|
||||
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Use this template for requesting new features
|
||||
title: "[FEATURE NAME]"
|
||||
labels: enhancement
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Please describe the behavior you are expecting
|
||||
|
||||
## Current Behavior
|
||||
|
||||
What is the current behavior?
|
||||
|
||||
## Sample Code
|
||||
|
||||
If applicable, provide a sample code snippet that demonstrates the gist of the feature you're proposing. This can be either from a usage standpoint, or an implementation standpoint.
|
||||
38
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Issue Report
|
||||
about: Use this template to report a problem
|
||||
title: "[VERSION] [PROBLEM SUMMARY]"
|
||||
labels: bug
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Please describe the behavior you are expecting
|
||||
|
||||
## Current Behavior
|
||||
|
||||
What is the current behavior?
|
||||
|
||||
## Failure Information (for bugs)
|
||||
|
||||
Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
Please provide detailed steps for reproducing the issue.
|
||||
|
||||
1. step 1
|
||||
2. step 2
|
||||
3. you get it...
|
||||
|
||||
### Failure Logs
|
||||
|
||||
Please include any relevant log snippets or files here.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I am running the latest version
|
||||
- [ ] I checked the documentation and found no answer
|
||||
- [ ] I checked to make sure that this issue has not already been filed
|
||||
- [ ] I have provided sufficient information
|
||||
15
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal file
15
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Question
|
||||
about: Use this template to ask a question about the project
|
||||
title: "[QUESTION SUMMARY]"
|
||||
labels: question
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Question
|
||||
|
||||
State your question
|
||||
|
||||
## Sample Code
|
||||
|
||||
Please include relevant code snippets or files that provide context for your question.
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
27
.github/pull_request_template.md
vendored
Normal file
27
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Pull Request Template
|
||||
|
||||
## Description
|
||||
|
||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] I have checked my code and corrected any misspellings
|
||||
17
.github/workflows/lint.yml
vendored
Normal file
17
.github/workflows/lint.yml
vendored
Normal 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
|
||||
7
.github/workflows/node.js.yml
vendored
7
.github/workflows/node.js.yml
vendored
@@ -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:
|
||||
@@ -16,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [ 12.x, 14.x, 15.x]
|
||||
node-version: [ 14.x, 15.x, 16.x ]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
@@ -26,4 +23,4 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
- run: npm run test
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -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
52
.gitignore
vendored
Normal 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.*
|
||||
2
LICENSE
2
LICENSE
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
@@ -1,92 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
const creds_path = './yt_oauth_creds.json';
|
||||
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
|
||||
youtube.on('auth', (data) => {
|
||||
if (data.status === 'AUTHORIZATION_PENDING') {
|
||||
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
|
||||
} else if (data.status === 'SUCCESS') {
|
||||
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token, expires: data.expires }));
|
||||
console.info('Successfully signed-in, enjoy!');
|
||||
}
|
||||
});
|
||||
|
||||
youtube.on('update-credentials', (data) => {
|
||||
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token, expires: data.expires }));
|
||||
console.info('Credentials updated!', data);
|
||||
});
|
||||
|
||||
await youtube.signIn(creds);
|
||||
|
||||
const search = await youtube.search('Looking for life on Mars - documentary');
|
||||
console.info('Search results:', search);
|
||||
|
||||
const video = await youtube.getDetails(search.videos[0].id).catch((error) => error);
|
||||
console.info('Video details:', video);
|
||||
|
||||
if (youtube.logged_in) {
|
||||
const myNotifications = await youtube.getNotifications();
|
||||
console.info('My notifications:', myNotifications);
|
||||
|
||||
const like = await video.like();
|
||||
if (like.success) {
|
||||
console.info('Video marked as liked!');
|
||||
}
|
||||
|
||||
const dislike = await video.dislike();
|
||||
if (dislike.success) {
|
||||
console.info('Video marked as disliked!');
|
||||
}
|
||||
|
||||
const removeDislikeOrLike = await video.removeLike();
|
||||
if (removeDislikeOrLike.success) {
|
||||
console.info('Removed the dislike/like!')
|
||||
}
|
||||
|
||||
const myComment = await video.comment('Haha, nice!');
|
||||
if (myComment.success) {
|
||||
console.info('Comment successfully posted!')
|
||||
}
|
||||
|
||||
const subscribe = await video.subscribe();
|
||||
if (subscribe.success) {
|
||||
console.info('Just subscribed to', video.metadata.channel_name + '!');
|
||||
}
|
||||
|
||||
const unsubscribe = await video.unsubscribe();
|
||||
if (unsubscribe.success) {
|
||||
console.info('Just unsubscribed from', video.metadata.channel_name + ' :(');
|
||||
}
|
||||
}
|
||||
|
||||
// Downloading videos:
|
||||
const stream = youtube.download(search.videos[0].id, {
|
||||
format: 'mp4', // Optional, ignored when type is set to audio and defaults to mp4, and I recommend to leave it as it is
|
||||
quality: '360p', // if a video doesn't have a specific quality it'll fall back to 360p, this is ignored when type is set to audio
|
||||
type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
|
||||
});
|
||||
|
||||
stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
|
||||
|
||||
stream.on('start', () => {
|
||||
console.info('[DOWNLOADER]', 'Starting download now!');
|
||||
});
|
||||
|
||||
stream.on('info', (info) => {
|
||||
console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
console.info('[DOWNLOADER]', 'Done!');
|
||||
});
|
||||
|
||||
stream.on('error', (err) => console.error('[ERROR]', err));
|
||||
}
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
const creds_path = './yt_oauth_creds.json';
|
||||
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
|
||||
youtube.ev.on('auth', (data) => {
|
||||
if (data.status === 'AUTHORIZATION_PENDING') {
|
||||
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
|
||||
} else if (data.status === 'SUCCESS') {
|
||||
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Successfully signed-in, enjoy!');
|
||||
}
|
||||
});
|
||||
|
||||
youtube.ev.on('update-credentials', (data) => {
|
||||
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Credentials updated!', data);
|
||||
});
|
||||
|
||||
await youtube.signIn(creds);
|
||||
|
||||
const search = await youtube.search('Looking for life on Mars - documentary');
|
||||
console.info('Search results:', search);
|
||||
|
||||
const video = await youtube.getDetails(search.videos[0].id);
|
||||
console.info('Video details:', video);
|
||||
|
||||
if (youtube.logged_in) {
|
||||
const myNotifications = await youtube.getNotifications();
|
||||
console.info('My notifications:', myNotifications);
|
||||
|
||||
const like = await video.like();
|
||||
if (like.success) {
|
||||
console.info('Video marked as liked!');
|
||||
}
|
||||
|
||||
const dislike = await video.dislike();
|
||||
if (dislike.success) {
|
||||
console.info('Video marked as disliked!');
|
||||
}
|
||||
|
||||
const removeDislikeOrLike = await video.removeLike();
|
||||
if (removeDislikeOrLike.success) {
|
||||
console.info('Removed the dislike/like!')
|
||||
}
|
||||
|
||||
const myComment = await video.comment('Haha, nice!');
|
||||
if (myComment.success) {
|
||||
console.info('Comment successfully posted!')
|
||||
}
|
||||
|
||||
const subscribe = await video.subscribe();
|
||||
if (subscribe.success) {
|
||||
console.info('Just subscribed to', video.metadata.channel_name + '!');
|
||||
}
|
||||
|
||||
const unsubscribe = await video.unsubscribe();
|
||||
if (unsubscribe.success) {
|
||||
console.info('Just unsubscribed from', video.metadata.channel_name + ' :(');
|
||||
}
|
||||
}
|
||||
|
||||
// Downloading videos:
|
||||
const stream = youtube.download(search.videos[0].id, {
|
||||
format: 'mp4', // Optional, ignored when type is set to audio and defaults to mp4, and I recommend to leave it as it is
|
||||
quality: '360p', // if a video doesn't have a specific quality it'll fall back to 360p, this is ignored when type is set to audio
|
||||
type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
|
||||
});
|
||||
|
||||
stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
|
||||
|
||||
stream.on('start', () => {
|
||||
console.info('[DOWNLOADER]', 'Starting download now!');
|
||||
});
|
||||
|
||||
stream.on('info', (info) => {
|
||||
console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
console.info('[DOWNLOADER]', 'Done!');
|
||||
});
|
||||
|
||||
stream.on('error', (err) => console.error('[ERROR]', err));
|
||||
}
|
||||
|
||||
start();
|
||||
7
jest.config.js
Normal file
7
jest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
roots: [ '<rootDir>/test' ],
|
||||
testMatch: [ '**/*.test.js' ],
|
||||
testTimeout: 10000
|
||||
};
|
||||
210
lib/Actions.js
210
lib/Actions.js
@@ -1,210 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Uuid = require('uuid');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
async function engage(session, engagement_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed-in');
|
||||
|
||||
let data;
|
||||
switch (engagement_type) {
|
||||
case 'like/like':
|
||||
case 'like/dislike':
|
||||
case 'like/removelike':
|
||||
data = {
|
||||
context: session.context,
|
||||
target: {
|
||||
videoId: args.video_id
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'subscription/subscribe':
|
||||
case 'subscription/unsubscribe':
|
||||
data = {
|
||||
context: session.context,
|
||||
channelIds: [args.channel_id]
|
||||
};
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data = {
|
||||
context: session.context,
|
||||
commentText: args.text,
|
||||
createCommentParams: Utils.generateCommentParams(args.video_id)
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status
|
||||
};
|
||||
}
|
||||
|
||||
async function browse(session, action_type) {
|
||||
if (!session.logged_in) throw new Error('You are not signed-in');
|
||||
|
||||
let data;
|
||||
switch (action_type) { // TODO: Handle more actions
|
||||
case 'subscriptions_feed':
|
||||
data = {
|
||||
context: session.context,
|
||||
browseId: 'FEsubscriptions'
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function search(session, args = {}) {
|
||||
if (!args.query) throw new Error('No query was provided');
|
||||
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify({
|
||||
context: session.context,
|
||||
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
|
||||
query: args.query
|
||||
}), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function notifications(session, action_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed-in');
|
||||
|
||||
let data;
|
||||
|
||||
switch (action_type) {
|
||||
case 'modify_channel_preference':
|
||||
let pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
|
||||
data = {
|
||||
context: session.context,
|
||||
params: Utils.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()])
|
||||
};
|
||||
break;
|
||||
case 'get_notification_menu':
|
||||
data = {
|
||||
context: session.context,
|
||||
notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
|
||||
};
|
||||
break;
|
||||
case 'get_unseen_count':
|
||||
data = {
|
||||
context: session.context
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function livechat(session, action_type, args = {}) {
|
||||
let data;
|
||||
switch (action_type) {
|
||||
case 'live_chat/send_message':
|
||||
data = {
|
||||
context: session.context,
|
||||
params: Utils.generateMessageParams(args.channel_id, args.video_id),
|
||||
clientMessageId: `ytjs-${Uuid.v4()}`,
|
||||
richMessage: {
|
||||
textSegments: [{ text: args.text }]
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'live_chat/get_item_context_menu':
|
||||
data = {
|
||||
context: session.context
|
||||
};
|
||||
break;
|
||||
case 'live_chat/moderate':
|
||||
data = {
|
||||
context: session.context,
|
||||
params: args.cmd_params
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function getVideoInfo(session, args = {}) {
|
||||
let response;
|
||||
|
||||
!args.is_desktop && (response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) ||
|
||||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
|
||||
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getContinuation(session, info = {}) {
|
||||
let data = { context: session.context };
|
||||
info.continuation_token && (data.continuation = info.continuation_token);
|
||||
|
||||
if (info.video_id) {
|
||||
data.videoId = info.video_id;
|
||||
data.racyCheckOk = true;
|
||||
data.contentCheckOk = false;
|
||||
data.autonavState = 'STATE_NONE';
|
||||
data.playbackContext = {
|
||||
vis: 0,
|
||||
lactMilliseconds: '-1'
|
||||
};
|
||||
data.captionsRequested = false;
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { engage, browse, search, notifications, livechat, getVideoInfo, getContinuation };
|
||||
196
lib/Constants.js
196
lib/Constants.js
@@ -1,196 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
|
||||
module.exports = {
|
||||
URLS: {
|
||||
YT_BASE_URL: 'https://www.youtube.com',
|
||||
YT_MOBILE_URL: 'https://m.youtube.com',
|
||||
YT_WATCH_PAGE: 'https://m.youtube.com/watch'
|
||||
},
|
||||
OAUTH: {
|
||||
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
|
||||
GRANT_TYPE: 'http://oauth.net/grant_type/device/1.0',
|
||||
MODEL_NAME: 'ytlr::',
|
||||
HEADERS: {
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'origin': 'https://www.youtube.com',
|
||||
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
|
||||
'content-type': 'application/json',
|
||||
'referer': `https://www.youtube.com/tv`,
|
||||
'accept-language': 'en-US'
|
||||
}
|
||||
}
|
||||
},
|
||||
DEFAULT_HEADERS: (session) => {
|
||||
return {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Referer': 'https://www.google.com/',
|
||||
'Accept': 'text/html',
|
||||
'Accept-Language': 'en-US,en',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Upgrade-Insecure-Requests': 1
|
||||
}
|
||||
};
|
||||
},
|
||||
STREAM_HEADERS: {
|
||||
'Accept': '*/*',
|
||||
'User-Agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Connection': 'keep-alive',
|
||||
'Origin': 'https://www.youtube.com',
|
||||
'Referer': 'https://www.youtube.com',
|
||||
'DNT': '?1'
|
||||
},
|
||||
INNERTUBE_REQOPTS: (info) => {
|
||||
info.desktop === undefined && (info.desktop = true);
|
||||
let req_opts = {
|
||||
params: info.params || {},
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'user-agent': Utils.getRandomUserAgent(info.desktop ? 'desktop' : 'mobile').userAgent,
|
||||
'content-type': 'application/json',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
'x-goog-authuser': 0,
|
||||
'x-goog-visitor-id': info.session.context.client.visitorData || '',
|
||||
'x-youtube-client-name': info.desktop ? 1 : 2,
|
||||
'x-youtube-client-version': info.session.context.client.clientVersion,
|
||||
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
|
||||
'x-origin': info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com',
|
||||
'origin': info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com',
|
||||
}
|
||||
};
|
||||
|
||||
info.id && (req_opts.headers.referer = (info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com') + '/watch?v=' + info.id);
|
||||
|
||||
if (info.session.logged_in && info.desktop) {
|
||||
req_opts.headers.Cookie = info.session.cookie;
|
||||
req_opts.headers.authorization = info.session.cookie.length < 1 ? `Bearer ${info.session.access_token}` : info.session.auth_apisid;
|
||||
}
|
||||
|
||||
return req_opts;
|
||||
},
|
||||
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
|
||||
};
|
||||
},
|
||||
BASE64_DIALECT: {
|
||||
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
|
||||
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
|
||||
},
|
||||
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 h=f|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/,
|
||||
FUNCS: {
|
||||
PUSH: 'd.push(e)',
|
||||
REVERSE_1: 'd.reverse()',
|
||||
REVERSE_2: 'function(d){for(var',
|
||||
SPLICE: 'd.length;d.splice(e,1)',
|
||||
SWAP0_1: 'd[0])[0])',
|
||||
SWAP0_2: 'f=d[0];d[0]',
|
||||
ROTATE_1: 'reverse().forEach',
|
||||
ROTATE_2: 'unshift(d.pop())',
|
||||
BASE64_DIA: 'function(){for(var',
|
||||
TRANSLATE_1: 'function(d,e){for(var f',
|
||||
TRANSLATE_2: 'function(d,e,f){var h=f'
|
||||
},
|
||||
// Helper functions, felt like Utils.js wasn't the right place for them:
|
||||
formatNTransformData: (data) => {
|
||||
return data
|
||||
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
|
||||
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
|
||||
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)')
|
||||
.replace(/,b,/g, ',"b",').replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
|
||||
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",').replace(/""/g, '')
|
||||
.replace(/length]\)}"/g, 'length])}');
|
||||
},
|
||||
formatVideoData: (data, context, is_desktop) => {
|
||||
let video_details = {};
|
||||
let metadata = {};
|
||||
|
||||
if (is_desktop) {
|
||||
metadata.embed = data.microformat.playerMicroformatRenderer.embed;
|
||||
metadata.view_count = parseInt(data.videoDetails.viewCount);
|
||||
metadata.average_rating = data.videoDetails.averageRating;
|
||||
metadata.length_seconds = data.microformat.playerMicroformatRenderer.lengthSeconds;
|
||||
metadata.channel_id = data.videoDetails.channelId;
|
||||
metadata.channel_url = data.microformat.playerMicroformatRenderer.ownerProfileUrl;
|
||||
metadata.external_channel_id = data.microformat.playerMicroformatRenderer.externalChannelId;
|
||||
metadata.is_live_content = data.videoDetails.isLiveContent;
|
||||
metadata.is_family_safe = data.microformat.playerMicroformatRenderer.isFamilySafe;
|
||||
metadata.is_unlisted = data.microformat.playerMicroformatRenderer.isUnlisted;
|
||||
metadata.is_private = data.videoDetails.isPrivate;
|
||||
metadata.has_ypc_metadata = data.microformat.playerMicroformatRenderer.hasYpcMetadata;
|
||||
metadata.category = data.microformat.playerMicroformatRenderer.category;
|
||||
metadata.channel_name = data.microformat.playerMicroformatRenderer.ownerChannelName;
|
||||
metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A';
|
||||
metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A';
|
||||
metadata.keywords = data.videoDetails.keywords || [];
|
||||
metadata.available_qualities = [...new Set(data.streamingData.adaptiveFormats.filter(v => v.qualityLabel).map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))];
|
||||
|
||||
video_details.id = data.videoDetails.videoId;
|
||||
video_details.title = data.videoDetails.title;
|
||||
video_details.description = data.videoDetails.shortDescription;
|
||||
video_details.thumbnail = data.videoDetails.thumbnail.thumbnails.slice(-1)[0];
|
||||
video_details.metadata = metadata;
|
||||
} else {
|
||||
const is_dislike_available = data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility && true || false;
|
||||
|
||||
metadata.embed = data[2].playerResponse.microformat.playerMicroformatRenderer.embed;
|
||||
metadata.likes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
|
||||
metadata.dislikes = is_dislike_available && parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0;
|
||||
metadata.view_count = parseInt(data[2].playerResponse.videoDetails.viewCount);
|
||||
metadata.average_rating = data[2].playerResponse.videoDetails.averageRating;
|
||||
metadata.length_seconds = data[2].playerResponse.microformat.playerMicroformatRenderer.lengthSeconds;
|
||||
metadata.channel_id = data[2].playerResponse.videoDetails.channelId;
|
||||
metadata.channel_url = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerProfileUrl;
|
||||
metadata.external_channel_id = data[2].playerResponse.microformat.playerMicroformatRenderer.externalChannelId;
|
||||
metadata.is_live_content = data[2].playerResponse.videoDetails.isLiveContent;
|
||||
metadata.is_family_safe = data[2].playerResponse.microformat.playerMicroformatRenderer.isFamilySafe;
|
||||
metadata.is_unlisted = data[2].playerResponse.microformat.playerMicroformatRenderer.isUnlisted;
|
||||
metadata.is_private = data[2].playerResponse.videoDetails.isPrivate;
|
||||
metadata.has_ypc_metadata = data[2].playerResponse.microformat.playerMicroformatRenderer.hasYpcMetadata;
|
||||
metadata.category = data[2].playerResponse.microformat.playerMicroformatRenderer.category;
|
||||
metadata.channel_name = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerChannelName;
|
||||
metadata.publish_date = data[2].playerResponse.microformat.playerMicroformatRenderer.publishDate;
|
||||
metadata.upload_date = data[2].playerResponse.microformat.playerMicroformatRenderer.uploadDate;
|
||||
metadata.keywords = data[2].playerResponse.videoDetails.keywords;
|
||||
metadata.available_qualities = [...new Set(data[2].playerResponse.streamingData.adaptiveFormats.filter(v => v.qualityLabel).map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))];
|
||||
|
||||
video_details.id = data[2].playerResponse.videoDetails.videoId;
|
||||
video_details.title = data[2].playerResponse.videoDetails.title;
|
||||
video_details.description = data[2].playerResponse.videoDetails.shortDescription;
|
||||
video_details.thumbnail = data[2].playerResponse.videoDetails.thumbnail.thumbnails.slice(-1)[0];
|
||||
|
||||
// Placeholders for functions
|
||||
video_details.like = () => {};
|
||||
video_details.dislike = () => {};
|
||||
video_details.removeLike = () => {};
|
||||
video_details.subscribe = () => {};
|
||||
video_details.unsubscribe = () => {};
|
||||
video_details.comment = () => {};
|
||||
video_details.getComments = () => {};
|
||||
video_details.setNotificationPref = () => {};
|
||||
video_details.getLivechat = () => {};
|
||||
|
||||
// Additional metadata
|
||||
video_details.metadata = metadata;
|
||||
}
|
||||
return video_details;
|
||||
}
|
||||
};
|
||||
875
lib/Innertube.js
875
lib/Innertube.js
@@ -2,372 +2,540 @@
|
||||
|
||||
const Axios = require('axios');
|
||||
const Stream = require('stream');
|
||||
const OAuth = require('./OAuth');
|
||||
const Utils = require('./Utils');
|
||||
const Player = require('./Player');
|
||||
const NToken = require('./NToken');
|
||||
const Actions = require('./Actions');
|
||||
const Livechat = require('./Livechat');
|
||||
const Constants = require('./Constants');
|
||||
const SigDecipher = require('./Sig');
|
||||
const EventEmitter = require('events');
|
||||
const TimeToSeconds = require('time-to-seconds');
|
||||
const Parser = require('./parser');
|
||||
const CancelToken = Axios.CancelToken;
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const OAuth = require('./core/OAuth');
|
||||
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');
|
||||
const Constants = require('./utils/Constants');
|
||||
|
||||
const Proto = require('./proto');
|
||||
const NToken = require('./deciphers/NToken');
|
||||
const Signature = require('./deciphers/Signature');
|
||||
|
||||
/**
|
||||
* Innertube instance.
|
||||
* @namespace
|
||||
*/
|
||||
class Innertube {
|
||||
constructor(cookie) {
|
||||
this.cookie = cookie || '';
|
||||
this.retry_count = 0;
|
||||
return this.init();
|
||||
#player;
|
||||
|
||||
/**
|
||||
* @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 || {};
|
||||
return this.#init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
const response = await Axios.get(Constants.URLS.YT_BASE_URL, Constants.DEFAULT_HEADERS(this)).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not extract Innertube data: ${response.message}`);
|
||||
|
||||
try {
|
||||
const innertube_data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`);
|
||||
if (innertube_data.INNERTUBE_CONTEXT) {
|
||||
this.context = innertube_data.INNERTUBE_CONTEXT;
|
||||
this.key = innertube_data.INNERTUBE_API_KEY;
|
||||
this.id_token = innertube_data.ID_TOKEN;
|
||||
this.session_token = innertube_data.XSRF_TOKEN;
|
||||
this.player_url = innertube_data.PLAYER_JS_URL;
|
||||
this.logged_in = innertube_data.LOGGED_IN;
|
||||
this.sts = innertube_data.STS;
|
||||
this.context.client.hl = 'en';
|
||||
this.context.client.gl = 'US';
|
||||
|
||||
this.player = new Player(this);
|
||||
await this.player.init();
|
||||
|
||||
if (this.logged_in && this.cookie.length > 1) {
|
||||
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
|
||||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
|
||||
}
|
||||
|
||||
this.ev = new EventEmitter();
|
||||
} else {
|
||||
this.retry_count += 1;
|
||||
if (this.retry_count >= 10) throw new Error('Could not retrieve Innertube data');
|
||||
return this.init();
|
||||
}
|
||||
} catch (err) {
|
||||
this.retry_count += 1;
|
||||
if (this.retry_count >= 10) throw new Error('Could not retrieve Innertube data');
|
||||
return this.init();
|
||||
|
||||
async #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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {}) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const oauth = new OAuth(auth_info);
|
||||
if (auth_info.access_token) {
|
||||
const is_valid = await oauth.isTokenValid(auth_info.expires);
|
||||
return new Promise(async (resolve) => {
|
||||
this.oauth.init(auth_info);
|
||||
|
||||
if (!is_valid) {
|
||||
const new_tokens = await oauth.refreshAccessToken(auth_info.refresh_token);
|
||||
auth_info.refresh_token = new_tokens.credentials.refresh_token;
|
||||
auth_info.access_token = new_tokens.credentials.access_token;
|
||||
|
||||
this.ev.emit('update-credentials', {
|
||||
credentials: new_tokens.credentials,
|
||||
status: new_tokens.status
|
||||
});
|
||||
}
|
||||
|
||||
this.access_token = auth_info.access_token;
|
||||
this.refresh_token = auth_info.refresh_token;
|
||||
this.logged_in = true;
|
||||
|
||||
resolve();
|
||||
} else {
|
||||
oauth.on('auth', (data) => {
|
||||
if (data.status === 'SUCCESS') {
|
||||
this.access_token = data.credentials.access_token;
|
||||
this.refresh_token = data.credentials.refresh_token;
|
||||
this.logged_in = true;
|
||||
|
||||
this.ev.emit('auth', {
|
||||
credentials: data.credentials,
|
||||
status: data.status
|
||||
});
|
||||
|
||||
resolve();
|
||||
} else {
|
||||
this.ev.emit('auth', data);
|
||||
}
|
||||
});
|
||||
if (this.oauth.isValidAuthInfo()) {
|
||||
await this.oauth.checkTokenValidity();
|
||||
this.#updateCredentials();
|
||||
return resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async search(query, options = { period: 'any', order: 'relevance', duration: 'any' }) {
|
||||
const response = await Actions.search(this, { query, options });
|
||||
if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`);
|
||||
|
||||
const content = response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents;
|
||||
const search = {};
|
||||
|
||||
search.search_metadata = {};
|
||||
search.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query;
|
||||
search.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query;
|
||||
search.search_metadata.estimated_results = parseInt(response.data.estimatedResults);
|
||||
search.videos = content.map((data) => {
|
||||
if (!data.videoRenderer) return;
|
||||
const video = data.videoRenderer;
|
||||
return {
|
||||
title: video.title.runs[0].text,
|
||||
description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
|
||||
author: video.ownerText.runs[0].text,
|
||||
id: video.videoId,
|
||||
url: `https://youtu.be/${video.videoId}`,
|
||||
channel_url: `${Constants.URLS.YT_BASE_URL}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`,
|
||||
metadata: {
|
||||
view_count: video.viewCountText && video.viewCountText.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: video.shortViewCountText && video.shortViewCountText.simpleText || 'N/A',
|
||||
accessibility_label: video.shortViewCountText && (video.shortViewCountText.accessibility && video.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
|
||||
},
|
||||
thumbnails: video.thumbnail.thumbnails,
|
||||
duration: {
|
||||
seconds: TimeToSeconds(video.lengthText && video.lengthText.simpleText || '0'),
|
||||
simple_text: video.lengthText && video.lengthText.simpleText || 'N/A',
|
||||
accessibility_label: video.lengthText && video.lengthText.accessibility.accessibilityData.label || 'N/A'
|
||||
},
|
||||
published: video.publishedTimeText && video.publishedTimeText.simpleText || 'N/A',
|
||||
badges: video.badges && video.badges.map((item) => item.metadataBadgeRenderer.label) || 'N/A',
|
||||
owner_badges: video.ownerBadges && video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) || 'N/A '
|
||||
this.ev.on('auth', (data) => {
|
||||
if (data.status === 'SUCCESS') {
|
||||
this.#updateCredentials();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
}).filter((video_block) => video_block !== undefined);
|
||||
return search;
|
||||
}
|
||||
|
||||
async getDetails(id) {
|
||||
if (!id) throw new Error('You must provide a video id');
|
||||
|
||||
const data = await Actions.getVideoInfo(this, { id, is_desktop: false });
|
||||
const video_data = Constants.formatVideoData(data, this, false);
|
||||
|
||||
if (video_data.metadata.is_live_content) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id: id });
|
||||
if (!data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) return;
|
||||
video_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, video_data.metadata.channel_id, id);
|
||||
} else {
|
||||
video_data.getLivechat = () => {};
|
||||
}
|
||||
|
||||
video_data.like = () => Actions.engage(this, 'like/like', { video_id: id });
|
||||
video_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id });
|
||||
video_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id });
|
||||
video_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
|
||||
video_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
|
||||
video_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text });
|
||||
video_data.getComments = () => this.getComments(id);
|
||||
video_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: video_data.metadata.channel_id, pref: pref || 'NONE' });
|
||||
|
||||
return video_data;
|
||||
}
|
||||
|
||||
async getComments(video_id, token) {
|
||||
let comment_section_token;
|
||||
|
||||
if (!token) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id });
|
||||
const item_section_renderer = data_continuation.data.contents.twoColumnWatchNextResults.results.results.contents.find((item) => item.itemSectionRenderer);
|
||||
comment_section_token = item_section_renderer.itemSectionRenderer.contents[0].continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
}
|
||||
|
||||
const response = await Actions.getContinuation(this, { continuation_token: comment_section_token || token });
|
||||
if (!response.success) throw new Error('Could not fetch comments section');
|
||||
|
||||
const comments_section = { comments: [] };
|
||||
!token && (comments_section.comment_count = response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems && response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems[0].commentsHeaderRenderer.countText.runs[0].text || 'N/A');
|
||||
|
||||
let continuation_token;
|
||||
!token && (continuation_token = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token) ||
|
||||
(continuation_token = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token);
|
||||
|
||||
comments_section.getContinuation = () => this.getComments(video_id, continuation_token);
|
||||
|
||||
let contents;
|
||||
!token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) ||
|
||||
(contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems);
|
||||
|
||||
contents.forEach((thread) => {
|
||||
if (!thread.commentThreadRenderer) return;
|
||||
const comment = {
|
||||
text: thread.commentThreadRenderer.comment.commentRenderer.contentText.runs.map((t) => t.text).join(' '),
|
||||
author: {
|
||||
name: thread.commentThreadRenderer.comment.commentRenderer.authorText.simpleText,
|
||||
thumbnail: thread.commentThreadRenderer.comment.commentRenderer.authorThumbnail.thumbnails,
|
||||
channel_id: thread.commentThreadRenderer.comment.commentRenderer.authorEndpoint.browseEndpoint.browseId
|
||||
},
|
||||
metadata: {
|
||||
published: thread.commentThreadRenderer.comment.commentRenderer.publishedTimeText.runs[0].text,
|
||||
is_liked: thread.commentThreadRenderer.comment.commentRenderer.isLiked,
|
||||
is_channel_owner: thread.commentThreadRenderer.comment.commentRenderer.authorIsChannelOwner,
|
||||
like_count: thread.commentThreadRenderer.comment.commentRenderer.voteCount && thread.commentThreadRenderer.comment.commentRenderer.voteCount.simpleText || 'N/A',
|
||||
reply_count: thread.commentThreadRenderer.comment.commentRenderer.replyCount || 0,
|
||||
id: thread.commentThreadRenderer.comment.commentRenderer.commentId,
|
||||
}
|
||||
};
|
||||
comments_section.comments.push(comment);
|
||||
});
|
||||
|
||||
return comments_section;
|
||||
}
|
||||
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await Actions.browse(this, 'subscriptions_feed');
|
||||
if (!response.success) throw new Error('Could not fetch subscriptions feed');
|
||||
|
||||
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
const subscriptions_feed = {};
|
||||
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
|
||||
const section_contents = section.itemSectionRenderer.contents[0];
|
||||
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
|
||||
|
||||
const key = section_contents.shelfRenderer.title.runs[0].text;
|
||||
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')] = [];
|
||||
|
||||
section_items.forEach((item) => {
|
||||
const content = {
|
||||
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
id: item.gridVideoRenderer.videoId,
|
||||
channel: item.gridVideoRenderer.shortBylineText && item.gridVideoRenderer.shortBylineText.runs[0].text || 'N/A',
|
||||
metadata: {
|
||||
view_count: item.gridVideoRenderer.viewCountText && item.gridVideoRenderer.viewCountText.simpleText || 'N/A',
|
||||
thumbnail: item.gridVideoRenderer.thumbnail && item.gridVideoRenderer.thumbnail.thumbnails || [],
|
||||
published: item.gridVideoRenderer.publishedTimeText && item.gridVideoRenderer.publishedTimeText.simpleText || 'N/A',
|
||||
badges: item.gridVideoRenderer.badges && item.gridVideoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
|
||||
owner_badges: item.gridVideoRenderer.ownerBadges && item.gridVideoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
|
||||
}
|
||||
};
|
||||
|
||||
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')].push(content);
|
||||
});
|
||||
});
|
||||
|
||||
return subscriptions_feed;
|
||||
}
|
||||
|
||||
#updateCredentials() {
|
||||
this.access_token = this.oauth.getAccessToken();
|
||||
this.refresh_token = this.oauth.getRefreshToken();
|
||||
this.logged_in = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
response.success && (this.logged_in = false);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] } }>}
|
||||
*/
|
||||
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,
|
||||
data_type: 'SEARCH'
|
||||
}).parse();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] }>}
|
||||
*/
|
||||
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, {
|
||||
client: options.client,
|
||||
data_type: 'SEARCH_SUGGESTIONS'
|
||||
}).parse();
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves video info.
|
||||
* @param {string} video_id - the video id.
|
||||
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>}
|
||||
*/
|
||||
async getDetails(video_id) {
|
||||
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',
|
||||
data_type: 'VIDEO_INFO'
|
||||
}).parse();
|
||||
|
||||
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, 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 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[]; }>}
|
||||
*/
|
||||
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 this.actions.next({ ctoken: payload });
|
||||
const comments = new Parser(this, response.data, {
|
||||
video_id,
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'COMMENTS'
|
||||
}).parse();
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves contents for a given channel. (WIP)
|
||||
* @param {string} id - channel id
|
||||
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>}
|
||||
*/
|
||||
async getChannel(id) {
|
||||
Utils.throwIfMissing({ id });
|
||||
|
||||
const response = await this.actions.browse(id);
|
||||
|
||||
const channel_info = new Parser(this, response.data, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'CHANNEL'
|
||||
}).parse();
|
||||
|
||||
return channel_info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves watch history.
|
||||
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
|
||||
*/
|
||||
async getHistory() {
|
||||
const response = await this.actions.browse('FEhistory');
|
||||
|
||||
const history = new Parser(this, response, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'HISTORY'
|
||||
}).parse();
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves home feed (aka recommendations).
|
||||
* @returns {Promise.<{ videos: { id: string; title: string; description: string; channel: string; metadata: object }[] }>}
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await this.actions.browse('FEwhat_to_watch');
|
||||
|
||||
const homefeed = new Parser(this, response, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'HOMEFEED'
|
||||
}).parse();
|
||||
|
||||
return homefeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>>; } }>}
|
||||
*/
|
||||
async getTrending() {
|
||||
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 subscriptions feed.
|
||||
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await this.actions.browse('FEsubscriptions');
|
||||
|
||||
const subsfeed = new Parser(this, response, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'SUBSFEED'
|
||||
}).parse();
|
||||
|
||||
return subsfeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Error('Could not fetch notifications');
|
||||
const response = await this.actions.notifications('get_notification_menu');
|
||||
|
||||
const notifications = new Parser(this, response.data, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'NOTIFICATIONS'
|
||||
}).parse();
|
||||
|
||||
const contents = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
|
||||
if (!contents.multiPageMenuNotificationSectionRenderer) return { error: 'You don\'t have any notification.' };
|
||||
return contents.multiPageMenuNotificationSectionRenderer.items.map((notification) => {
|
||||
if (!notification.notificationRenderer) return;
|
||||
notification = notification.notificationRenderer;
|
||||
return {
|
||||
title: notification.shortMessage.simpleText,
|
||||
sent_time: notification.sentTimeText.simpleText,
|
||||
channel_name: notification.contextualMenu.menuRenderer.items[1].menuServiceItemRenderer.text.runs[1].text,
|
||||
channel_thumbnail: notification.thumbnail.thumbnails[0],
|
||||
video_thumbnail: notification.videoThumbnail.thumbnails[0],
|
||||
video_url: `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}`,
|
||||
read: notification.read,
|
||||
notification_id: notification.notificationId,
|
||||
};
|
||||
}).filter((notification_block) => notification_block);
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves unseen notifications count.
|
||||
* @returns {Promise.<number>}
|
||||
*/
|
||||
async getUnseenNotificationsCount() {
|
||||
const response = await Actions.notifications(this, 'get_unseen_count');
|
||||
if (!response.success) throw new Error('Could not fetch unseen notifications count');
|
||||
const response = await this.actions.notifications('get_unseen_count');
|
||||
return response.data.unseenCount;
|
||||
}
|
||||
|
||||
download(id, options = {}) {
|
||||
if (!id) throw new Error('Missing video id');
|
||||
/**
|
||||
* Retrieves lyrics for a given song if available.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {Promise.<string>}
|
||||
*/
|
||||
async getLyrics(video_id) {
|
||||
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 = Utils.findNode(response.data, 'contents', 'runs', 6, false);
|
||||
return lyrics.runs[0].text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: [] }>}
|
||||
*/
|
||||
async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) {
|
||||
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,
|
||||
data_type: 'PLAYLIST'
|
||||
}).parse();
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to process and filter formats.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} video_data
|
||||
*
|
||||
* @returns {object.<{ selected_format: {}; formats: [] }>}
|
||||
*/
|
||||
#chooseFormat(options, video_data) {
|
||||
let formats = [];
|
||||
|
||||
formats = formats
|
||||
.concat(video_data.streamingData.formats || [])
|
||||
.concat(video_data.streamingData.adaptiveFormats || []);
|
||||
|
||||
formats.forEach((format) => {
|
||||
format.url = format.url || format.signatureCipher || format.cipher;
|
||||
|
||||
if (format.signatureCipher || format.cipher) {
|
||||
format.url = new Signature(format.url, this.#player.signature_decipher).decipher();
|
||||
}
|
||||
|
||||
const url_components = new URL(format.url);
|
||||
url_components.searchParams.set('cver', this.context.client.clientVersion);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
|
||||
if (url_components.searchParams.get('n')) {
|
||||
url_components.searchParams.set('n', new NToken(this.#player.ntoken_decipher, url_components.searchParams.get('n')).transform());
|
||||
}
|
||||
|
||||
format.url = url_components.toString();
|
||||
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
|
||||
format.has_video = !!format.qualityLabel;
|
||||
|
||||
delete format.cipher;
|
||||
delete format.signatureCipher;
|
||||
});
|
||||
|
||||
let format;
|
||||
let bitrates;
|
||||
let filtered_formats;
|
||||
|
||||
filtered_formats = ({
|
||||
'video': formats.filter((format) => format.has_video && !format.has_audio),
|
||||
'audio': formats.filter((format) => format.has_audio && !format.has_video),
|
||||
'videoandaudio': formats.filter((format) => format.has_video && format.has_audio)
|
||||
})[options.type] || formats.filter((format) => format.has_video && format.has_audio);
|
||||
|
||||
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')));
|
||||
|
||||
!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];
|
||||
|
||||
return { selected_format: format, formats };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] }>}
|
||||
*/
|
||||
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 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.', { video_id, options });
|
||||
|
||||
return streaming_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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, is_desktop: true }).then(async (video_data) => {
|
||||
let formats = [];
|
||||
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)
|
||||
return stream.emit('error', { message: 'Streaming data not available.', error_type: 'NO_STREAMING_DATA', playability_status: video_data.playabilityStatus.status });
|
||||
|
||||
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) return stream.emit('error', { message: 'Streaming data not available.', error_type: 'NO_STREAMING_DATA', playability_status: video_data.playabilityStatus.status });
|
||||
const { selected_format: format, formats } = this.#chooseFormat(options, video_data);
|
||||
|
||||
formats = formats.concat(video_data.streamingData.formats || []).concat(video_data.streamingData.adaptiveFormats || []);
|
||||
formats.forEach((format) => {
|
||||
format.url = format.url || format.signatureCipher || format.cipher;
|
||||
|
||||
if (format.signatureCipher || format.cipher) {
|
||||
format.url = new SigDecipher(format.url, this.context.client.clientVersion, this.player).decipher();
|
||||
} else {
|
||||
const url_components = new URL(format.url);
|
||||
url_components.searchParams.set('cver', this.context.client.clientVersion);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
format.url = url_components.toString();
|
||||
}
|
||||
|
||||
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
|
||||
format.has_video = !!format.qualityLabel;
|
||||
|
||||
delete format.cipher;
|
||||
delete format.signatureCipher;
|
||||
});
|
||||
|
||||
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
|
||||
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
|
||||
|
||||
const video_details = Constants.formatVideoData(video_data, this, true);
|
||||
|
||||
let url;
|
||||
let bitrates;
|
||||
let filtered_streams;
|
||||
|
||||
switch (options.type) {
|
||||
case 'video':
|
||||
filtered_streams = formats.filter((format) => format.has_video && !format.has_audio);
|
||||
break;
|
||||
case 'audio':
|
||||
filtered_streams = formats.filter((format) => format.has_audio && !format.has_video);
|
||||
break;
|
||||
case 'videoandaudio':
|
||||
filtered_streams = formats.filter((format) => format.has_video && format.has_audio);
|
||||
break;
|
||||
default:
|
||||
filtered_streams = formats.filter((format) => format.has_video && format.has_audio);
|
||||
break;
|
||||
}
|
||||
|
||||
if (options.type != 'videoandaudio') {
|
||||
let streams;
|
||||
|
||||
options.type != 'audio' && (streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
|
||||
(streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4')));
|
||||
|
||||
streams == undefined || streams.length == 0 && (streams = filtered_streams.filter((format) => format.quality == 'medium'));
|
||||
|
||||
bitrates = streams.map((format) => format.bitrate);
|
||||
url = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
|
||||
}
|
||||
|
||||
const selected_format = options.type == 'videoandaudio' ? filtered_streams[0] : url;
|
||||
if (!selected_format) {
|
||||
if (!format)
|
||||
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
|
||||
} else {
|
||||
stream.emit('info', { video_details, selected_format, formats });
|
||||
}
|
||||
|
||||
const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse();
|
||||
stream.emit('info', { video_details, selected_format: format, formats });
|
||||
|
||||
if (options.type == 'videoandaudio' && !options.range) {
|
||||
const response = await Axios.get(selected_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
|
||||
@@ -386,11 +554,23 @@ class Innertube {
|
||||
downloaded_size += chunk.length;
|
||||
let size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / response.headers['content-length']) * 100);
|
||||
stream.emit('progress', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-length'] } });
|
||||
|
||||
stream.emit('progress', {
|
||||
size,
|
||||
percentage,
|
||||
chunk_size: chunk.length,
|
||||
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
|
||||
raw_data: {
|
||||
chunk_size: chunk.length,
|
||||
downloaded: downloaded_size,
|
||||
size: response.headers['content-length']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
cancelled && stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
cancelled &&
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
});
|
||||
|
||||
@@ -404,12 +584,12 @@ class Innertube {
|
||||
let must_end = false;
|
||||
|
||||
stream.emit('start');
|
||||
|
||||
|
||||
const downloadChunk = async () => {
|
||||
(chunk_end >= selected_format.contentLength || options.range) && (must_end = true);
|
||||
options.range && (selected_format.contentLength = options.range.end);
|
||||
|
||||
const response = await Axios.get(`${selected_format.url}&range=${chunk_start}-${chunk_end || ''}`, {
|
||||
(chunk_end >= format.contentLength || options.range) && (must_end = true);
|
||||
options.range && (format.contentLength = options.range.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
|
||||
@@ -422,17 +602,27 @@ class Innertube {
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded_size += chunk.length;
|
||||
let size = (selected_format.contentLength / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / selected_format.contentLength) * 100);
|
||||
stream.emit('progress', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-length'] } });
|
||||
|
||||
let size = (format.contentLength / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / format.contentLength) * 100);
|
||||
|
||||
stream.emit('progress', {
|
||||
size,
|
||||
percentage,
|
||||
chunk_size: chunk.length,
|
||||
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
|
||||
raw_data: {
|
||||
chunk_size: chunk.length,
|
||||
downloaded: downloaded_size,
|
||||
size: response.headers['content-length']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
if (cancelled) {
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' });
|
||||
} else {
|
||||
cancelled &&
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on('end', () => {
|
||||
@@ -445,6 +635,7 @@ class Innertube {
|
||||
|
||||
response.data.pipe(stream, { end: must_end });
|
||||
};
|
||||
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
|
||||
181
lib/OAuth.js
181
lib/OAuth.js
@@ -1,181 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
const EventEmitter = require('events');
|
||||
const Uuid = require('uuid');
|
||||
|
||||
class OAuth extends EventEmitter {
|
||||
constructor(auth_info) {
|
||||
super();
|
||||
this.refresh_interval = 5;
|
||||
|
||||
this.oauth_code_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/device/code`;
|
||||
this.oauth_token_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/token`;
|
||||
this.guide_url = `${Constants.URLS.YT_BASE_URL}/youtubei/v1/guide`;
|
||||
|
||||
this.model_name = Constants.OAUTH.MODEL_NAME;
|
||||
this.grant_type = Constants.OAUTH.GRANT_TYPE;
|
||||
this.scope = Constants.OAUTH.SCOPE;
|
||||
|
||||
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
|
||||
this.identity_regex = /var .+?=\"(?<id>.+?)\",.+?=\"(?<secret>.+?)\"/;
|
||||
|
||||
if (auth_info.access_token) return;
|
||||
this.requestAuthCode();
|
||||
}
|
||||
|
||||
async requestAuthCode() {
|
||||
const identity = await this.getClientIdentity();
|
||||
|
||||
this.client_id = identity.id;
|
||||
this.client_secret = identity.secret;
|
||||
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
scope: this.scope,
|
||||
device_id: Uuid.v4(),
|
||||
model_name: this.model_name
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get auth code.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('auth', {
|
||||
code: response.data.user_code,
|
||||
status: 'AUTHORIZATION_PENDING',
|
||||
expires_in: response.data.expires_in,
|
||||
verification_url: response.data.verification_url
|
||||
});
|
||||
|
||||
this.refresh_interval = response.data.interval;
|
||||
|
||||
// Keeps requesting at a specific rate until the authorization is granted or denied.
|
||||
this.waitForAuth(response.data.device_code);
|
||||
}
|
||||
|
||||
waitForAuth(device_code) {
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
client_secret: this.client_secret,
|
||||
code: device_code,
|
||||
grant_type: this.grant_type
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get authentication token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
if (response.data.error) {
|
||||
switch (response.data.error) {
|
||||
case 'slow_down':
|
||||
case 'authorization_pending':
|
||||
this.waitForAuth(device_code);
|
||||
break;
|
||||
case 'access_denied':
|
||||
this.emit('auth', {
|
||||
error: 'Access was denied.',
|
||||
status: 'ACCESS_DENIED'
|
||||
});
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.emit('auth', {
|
||||
error: 'The device code has expired, requesting a new one.',
|
||||
status: 'DEVICE_CODE_EXPIRED'
|
||||
});
|
||||
this.requestAuthCode();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
this.emit('auth', {
|
||||
credentials: {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
expires: expiration_date,
|
||||
},
|
||||
token_type: response.data.token_type,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
}, 1000 * this.refresh_interval);
|
||||
}
|
||||
|
||||
async refreshAccessToken(refresh_token) {
|
||||
const identity = await this.getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: identity.id,
|
||||
client_secret: identity.secret,
|
||||
refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
this.emit('auth', {
|
||||
error: 'Could not refresh access token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
access_token: this.auth_info.access_token,
|
||||
refresh_token: this.auth_info.refresh_token,
|
||||
expires: this.auth_info.expires
|
||||
},
|
||||
status: 'FAILED'
|
||||
};
|
||||
}
|
||||
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
refresh_token: refresh_token,
|
||||
access_token: response.data.access_token,
|
||||
expires: expiration_date
|
||||
},
|
||||
token_type: response.data.token_type,
|
||||
status: 'SUCCESS'
|
||||
};
|
||||
}
|
||||
|
||||
async isTokenValid(expiration_date) {
|
||||
const timestamp = new Date(expiration_date).getTime();
|
||||
const is_valid = new Date().getTime() < timestamp;
|
||||
return is_valid;
|
||||
}
|
||||
|
||||
async getClientIdentity() {
|
||||
// The first request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
|
||||
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
|
||||
|
||||
// Here we get the script and extract the necessary data to proceed with the auth flow.
|
||||
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
|
||||
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
|
||||
|
||||
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
|
||||
|
||||
const identity_function = Utils.getStringBetweenStrings(response.data, 'setQuery("");', '{useGaiaSandbox:');
|
||||
const client_identity = identity_function.replace(/\n/g, '').match(this.identity_regex);
|
||||
|
||||
return client_identity.groups;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth;
|
||||
@@ -1,46 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
class Player {
|
||||
constructor(innertube_session) {
|
||||
this.session = innertube_session;
|
||||
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
|
||||
this.tmp_cache_dir = __dirname.slice(0, -3) + 'cache';
|
||||
}
|
||||
|
||||
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.getSigDecipherCode(player_data);
|
||||
this.getNEncoder(player_data);
|
||||
} else {
|
||||
const response = await Axios.get(`${Constants.URLS.YT_BASE_URL}${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);
|
||||
|
||||
try {
|
||||
// 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.getSigDecipherCode(response.data);
|
||||
this.getNEncoder(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
getSigDecipherCode(data) {
|
||||
const manipulation_algorithm_code = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const manipulation_sequence_code = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
this.sig_decipher_sc = manipulation_algorithm_code + manipulation_sequence_code;
|
||||
}
|
||||
|
||||
getNEncoder(data) {
|
||||
this.ntoken_sc = `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Player;
|
||||
77
lib/Sig.js
77
lib/Sig.js
@@ -1,77 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const NToken = require('./NToken');
|
||||
const QueryString = require('querystring');
|
||||
|
||||
class SigDecipher {
|
||||
constructor(url, cver, player) {
|
||||
this.url = url;
|
||||
this.cver = cver;
|
||||
this.player = player;
|
||||
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
|
||||
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
|
||||
}
|
||||
|
||||
decipher() {
|
||||
const args = QueryString.parse(this.url);
|
||||
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]) {
|
||||
case functions[0]:
|
||||
reverse(signature, actions[2]);
|
||||
break;
|
||||
case functions[1]:
|
||||
splice(signature, actions[2]);
|
||||
break;
|
||||
case functions[2]:
|
||||
swap(signature, actions[2]);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
const url_components = new URL(args.url);
|
||||
args.sp !== undefined ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
|
||||
url_components.searchParams.set('cver', this.cver);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
return url_components.toString();
|
||||
}
|
||||
|
||||
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];
|
||||
} else if (func[0].includes('splice')) {
|
||||
func_name[1] = func[1];
|
||||
} else {
|
||||
func_name[2] = func[1];
|
||||
}
|
||||
}
|
||||
|
||||
return func_name;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SigDecipher;
|
||||
105
lib/Utils.js
105
lib/Utils.js
@@ -1,105 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Proto = require('protons');
|
||||
const Crypto = require('crypto');
|
||||
const UserAgent = require('user-agents');
|
||||
|
||||
function getRandomUserAgent(type) {
|
||||
switch (type) {
|
||||
case 'mobile':
|
||||
return new UserAgent(/Android/).data;
|
||||
case 'desktop':
|
||||
return new UserAgent({ deviceCategory: 'desktop' }).data;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
function generateSidAuth(sid) {
|
||||
const youtube = 'https://www.youtube.com';
|
||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||
const input = [timestamp, sid, youtube].join(' ');
|
||||
|
||||
let hash = Crypto.createHash('sha1');
|
||||
let data = hash.update(input, 'utf-8');
|
||||
let gen_hash = data.digest('hex');
|
||||
|
||||
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
|
||||
}
|
||||
|
||||
function getStringBetweenStrings(data, start_string, end_string) {
|
||||
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, "s");
|
||||
const match = data.match(regex);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
function escapeStringRegexp(string) {
|
||||
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
|
||||
}
|
||||
|
||||
function encodeNotificationPref(channel_id, index) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.NotificationPreferences.encode({
|
||||
channel_id,
|
||||
pref_id: {
|
||||
index
|
||||
},
|
||||
number_0: 0,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
function generateMessageParams(channel_id, video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.LiveMessageParams.encode({
|
||||
params: {
|
||||
ids: {
|
||||
channel_id,
|
||||
video_id
|
||||
}
|
||||
},
|
||||
number_0: 1,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
|
||||
}
|
||||
|
||||
function generateCommentParams(video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.CreateCommentParams.encode({
|
||||
video_id,
|
||||
params: {
|
||||
index: 0
|
||||
},
|
||||
number: 7
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
function encodeFilter(period, duration, order) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
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 search_filter_buff = youtube_proto.SearchFilter.encode({
|
||||
number: orders[order],
|
||||
filter: {
|
||||
param_0: periods[period],
|
||||
param_1: (period == 'hour' && order == 'relevance') ? null : 1,
|
||||
param_2: durations[duration]
|
||||
}
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
|
||||
}
|
||||
|
||||
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, generateMessageParams, generateCommentParams, encodeNotificationPref, encodeFilter };
|
||||
251
lib/core/AccountManager.js
Normal file
251
lib/core/AccountManager.js
Normal 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;
|
||||
587
lib/core/Actions.js
Normal file
587
lib/core/Actions.js
Normal file
@@ -0,0 +1,587 @@
|
||||
'use strict';
|
||||
|
||||
const Uuid = require('uuid');
|
||||
const Proto = require('../proto');
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
/** namespace **/
|
||||
class Actions {
|
||||
#session;
|
||||
#request;
|
||||
|
||||
/**
|
||||
* @param {Innertube} session
|
||||
* @constructor
|
||||
*/
|
||||
constructor(session) {
|
||||
this.#session = session;
|
||||
this.#request = session.request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }>}
|
||||
*/
|
||||
async browse(id, args = {}) {
|
||||
if (this.#needsLogin(id) && !this.#session.logged_in)
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
|
||||
args.params &&
|
||||
(data.params = args.params);
|
||||
|
||||
args.is_ctoken &&
|
||||
(data.continuation = id) ||
|
||||
(data.browseId = id);
|
||||
|
||||
args.client &&
|
||||
(data.client = args.client);
|
||||
|
||||
const response = await this.#request.post('/browse', data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }>}
|
||||
*/
|
||||
async engage(action, args = {}) {
|
||||
if (!this.#session.logged_in && !args.hasOwnProperty('text'))
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
|
||||
switch (action) {
|
||||
case 'like/like':
|
||||
case 'like/dislike':
|
||||
case 'like/removelike':
|
||||
data.target = {
|
||||
videoId: args.video_id
|
||||
}
|
||||
break;
|
||||
case 'subscription/subscribe':
|
||||
case 'subscription/unsubscribe':
|
||||
data.channelIds = [args.channel_id];
|
||||
data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data.commentText = args.text;
|
||||
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
|
||||
break;
|
||||
case 'comment/create_comment_reply':
|
||||
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
|
||||
data.commentText = args.text;
|
||||
break;
|
||||
case 'comment/perform_comment_action':
|
||||
const target_action = ({
|
||||
like: () => Proto.encodeCommentActionParams(5, args),
|
||||
dislike: () => Proto.encodeCommentActionParams(4, args),
|
||||
translate: () => Proto.encodeCommentActionParams(22, args)
|
||||
})[args.comment_action]();
|
||||
|
||||
data.actions = [ target_action ];
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#request.post(`/${action}`, data);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }>}
|
||||
*/
|
||||
async account(action, args = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = { client: args.client };
|
||||
|
||||
switch (action) {
|
||||
case 'account/set_setting':
|
||||
data.newValue = { boolValue: args.new_value };
|
||||
data.settingItemId = args.setting_item_id;
|
||||
break;
|
||||
case 'account/accounts_list':
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#request.post(`/${action}`, data);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }>}
|
||||
*/
|
||||
async search(args = {}) {
|
||||
const data = {};
|
||||
|
||||
args.query &&
|
||||
(data.query = args.query);
|
||||
|
||||
args.ctoken &&
|
||||
(data.continuation = args.ctoken);
|
||||
|
||||
args.client == 'YOUTUBE' &&
|
||||
(data.params = Proto.encodeSearchFilter(args.options.filters));
|
||||
|
||||
args.client &&
|
||||
(data.client = args.client);
|
||||
|
||||
const response = await this.#request.post('/search', data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Endpoint used fo Shorts' sound search.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.query
|
||||
*
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
|
||||
*/
|
||||
async searchSound(args = {}) {
|
||||
const data = {
|
||||
query: args.query,
|
||||
client: 'ANDROID',
|
||||
};
|
||||
|
||||
const response = await this.#request.post('/sfv/search', data);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }>}
|
||||
*/
|
||||
async channel(action, args = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = { client: args.client || 'ANDROID' };
|
||||
|
||||
switch (action) {
|
||||
case 'channel/edit_name':
|
||||
data.givenName = args.new_name;
|
||||
break;
|
||||
case 'channel/edit_description':
|
||||
data.description = args.new_description;
|
||||
break;
|
||||
case 'channel/get_profile_editor':
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#request.post(`/${action}`, data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }>}
|
||||
*/
|
||||
async playlist(action, args = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
|
||||
switch (action) {
|
||||
case 'playlist/create':
|
||||
data.title = args.title;
|
||||
data.videoIds = args.ids;
|
||||
break;
|
||||
case 'playlist/delete':
|
||||
data.playlistId = args.playlist_id;
|
||||
break;
|
||||
case 'browse/edit_playlist':
|
||||
data.playlistId = args.playlist_id;
|
||||
data.actions = args.ids.map((id) => ({
|
||||
'ACTION_ADD_VIDEO': {
|
||||
action: args.action,
|
||||
addedVideoId: id
|
||||
},
|
||||
'ACTION_REMOVE_VIDEO': {
|
||||
action: args.action,
|
||||
setVideoId: id
|
||||
}
|
||||
})[args.action]);
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#request.post(`/${action}`, data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }>}
|
||||
*/
|
||||
async notifications(action, args = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
|
||||
switch (action) {
|
||||
case 'modify_channel_preference':
|
||||
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
|
||||
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
|
||||
break;
|
||||
case 'get_notification_menu':
|
||||
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
|
||||
args.ctoken && (data.ctoken = args.ctoken);
|
||||
break;
|
||||
case 'get_unseen_count':
|
||||
// doesn't require any parameter
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#request.post(`/notification/${action}`, data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }>}
|
||||
*/
|
||||
async livechat(action, args = {}) {
|
||||
const data = {};
|
||||
|
||||
switch (action) {
|
||||
case 'live_chat/get_live_chat':
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
case 'live_chat/send_message':
|
||||
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
|
||||
data.clientMessageId = Uuid.v4();
|
||||
data.richMessage = {
|
||||
textSegments: [{ text: args.text }]
|
||||
}
|
||||
break;
|
||||
case 'live_chat/get_item_context_menu':
|
||||
// note: this is currently broken due to a recent refactor
|
||||
break;
|
||||
case 'live_chat/moderate':
|
||||
data.params = args.params;
|
||||
break;
|
||||
case 'updated_metadata':
|
||||
data.videoId = args.video_id;
|
||||
args.ctoken && (data.continuation = args.ctoken);
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#request.post(`/${action}`, data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used to retrieve video thumbnails.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.video_id
|
||||
*
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
|
||||
*/
|
||||
async thumbnails(args = {}) {
|
||||
const data = {
|
||||
client: 'ANDROID',
|
||||
videoId: args.video_id
|
||||
};
|
||||
|
||||
const response = await this.#request.post('/thumbnails', data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }>}
|
||||
*/
|
||||
async geo(action, args = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = {
|
||||
input: args.input,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#request.post(`/geo/${action}`, data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }>}
|
||||
*/
|
||||
async flag(action, args) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
|
||||
switch (action) {
|
||||
case 'flag/flag':
|
||||
data.action = args.action;
|
||||
break;
|
||||
case 'flag/get_form':
|
||||
data.params = args.params;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#request.post(`/${action}`, data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers specific YouTube Music endpoints.
|
||||
*
|
||||
* @param {string} action
|
||||
* @param {string} args.input
|
||||
*
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
|
||||
*/
|
||||
async music(action, args) {
|
||||
const data = {
|
||||
input: args.input || '',
|
||||
client: 'YTMUSIC'
|
||||
};
|
||||
|
||||
const response = await this.#request.post(`/music/${action}`, data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; }>}
|
||||
*/
|
||||
async next(args = {}) {
|
||||
const data = {};
|
||||
|
||||
args.ctoken &&
|
||||
(data.continuation = args.ctoken);
|
||||
|
||||
args.video_id &&
|
||||
(data.videoId = args.video_id);
|
||||
|
||||
args.client &&
|
||||
(data.client == args.client);
|
||||
|
||||
const response = await this.#request.post('/next', data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to retrieve video info.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} [cpn]
|
||||
*
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
|
||||
*/
|
||||
async getVideoInfo(id, cpn) {
|
||||
const data = {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
vis: 0,
|
||||
splay: false,
|
||||
referer: 'https://www.youtube.com',
|
||||
currentUrl: '/watch?v=' + id,
|
||||
autonavState: 'STATE_OFF',
|
||||
signatureTimestamp: this.#session.sts,
|
||||
autoCaptionsDefaultOn: false,
|
||||
html5Preference: 'HTML5_PREF_WANTS',
|
||||
lactMilliseconds: '-1'
|
||||
}
|
||||
},
|
||||
videoId: id
|
||||
};
|
||||
|
||||
cpn && (data.cpn = cpn);
|
||||
|
||||
const response = await this.#request.post('/player', data);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers search suggestion endpoints.
|
||||
*
|
||||
* @param {string} client
|
||||
* @param {string} input
|
||||
*
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
|
||||
*/
|
||||
async getSearchSuggestions(client, query) {
|
||||
if (!['YOUTUBE', 'YTMUSIC'].includes(client))
|
||||
throw new Utils.InnertubeError('Invalid client', client);
|
||||
|
||||
const response = await ({
|
||||
YOUTUBE: () => this.#request({
|
||||
url: 'search',
|
||||
baseURL: Constants.URLS.YT_SUGGESTIONS,
|
||||
params: {
|
||||
q: query,
|
||||
ds: 'yt',
|
||||
client: 'youtube',
|
||||
xssi: 't',
|
||||
oe: 'UTF',
|
||||
gl: this.#session.context.client.gl,
|
||||
hl: this.#session.context.client.hl
|
||||
}
|
||||
}),
|
||||
YTMUSIC: () => this.music('get_search_suggestions', { input: query })
|
||||
}[client])();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used to retrieve user mention suggestions.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.input
|
||||
*
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
|
||||
*/
|
||||
async getUserMentionSuggestions(args = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = {
|
||||
input: args.input,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#request.post('get_user_mention_suggestions', data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
#needsLogin(id) {
|
||||
return [
|
||||
'FElibrary', 'FEhistory', 'FEsubscriptions',
|
||||
'SPaccount_notifications', 'SPaccount_privacy',
|
||||
'SPtime_watched'
|
||||
].includes(id);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Actions;
|
||||
134
lib/core/InteractionManager.js
Normal file
134
lib/core/InteractionManager.js
Normal 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;
|
||||
@@ -1,13 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Actions = require('./Actions');
|
||||
const Constants = require('./Constants');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class Livechat extends EventEmitter {
|
||||
constructor(session, token, channel_id, video_id) {
|
||||
super(session);
|
||||
|
||||
if (!token)
|
||||
throw new Error('Could not retrieve livechat data');
|
||||
|
||||
this.ctoken = token;
|
||||
this.session = session;
|
||||
this.video_id = video_id;
|
||||
@@ -19,10 +20,53 @@ class Livechat extends EventEmitter {
|
||||
this.poll_intervals_ms = 1000;
|
||||
this.running = true;
|
||||
|
||||
this.poll();
|
||||
this.#poll();
|
||||
}
|
||||
|
||||
enqueueActionGroup(group) {
|
||||
async #poll() {
|
||||
if (!this.running) return;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const continuation_contents = livechat.data.continuationContents;
|
||||
const action_group = continuation_contents.liveChatContinuation.actions;
|
||||
this.#enqueueActionGroup(action_group);
|
||||
|
||||
this.message_queue.forEach((message) => {
|
||||
if (this.id_cache.includes(message.id)) return;
|
||||
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
|
||||
this.id_cache.push(message.id);
|
||||
});
|
||||
|
||||
this.message_queue = [];
|
||||
|
||||
const data = { video_id: this.video_id };
|
||||
if (this.metadata_ctoken) data.ctoken = this.metadata_ctoken;
|
||||
|
||||
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}.` });
|
||||
}
|
||||
|
||||
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
|
||||
|
||||
const metadata = updated_metadata.data.actions;
|
||||
this.emit('update-metadata', {
|
||||
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
view_count: {
|
||||
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
|
||||
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
|
||||
}
|
||||
});
|
||||
|
||||
this.livechat_poller = setTimeout(async () => await this.#poll(), this.poll_intervals_ms);
|
||||
}
|
||||
|
||||
#enqueueActionGroup(group) {
|
||||
group.forEach((action) => {
|
||||
if (!action.addChatItemAction) return; //TODO: handle different action types
|
||||
const message_content = action.addChatItemAction.item.liveChatTextMessageRenderer;
|
||||
@@ -43,59 +87,17 @@ class Livechat extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
async poll() {
|
||||
if (!this.running) return;
|
||||
|
||||
let data;
|
||||
|
||||
data = { context: this.session.context, continuation: this.ctoken };
|
||||
const livechat = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/live_chat/get_live_chat${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session: this.session, data, desktop: true }));
|
||||
if (livechat instanceof Error) throw new Error(`Error polling livechat: ${livechat.message}`);
|
||||
|
||||
const continuation_contents = livechat.data.continuationContents;
|
||||
const action_group = continuation_contents.liveChatContinuation.actions;
|
||||
this.enqueueActionGroup(action_group);
|
||||
|
||||
// Why don't we just emit the message directly? Well, enqueueing the messages is necessary so they are not emitted in a “messy” way, funny enough that's exactly how YouTube does it in its live chat js script.
|
||||
this.message_queue.forEach((message, index) => {
|
||||
if (this.id_cache.includes(message.id)) return;
|
||||
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
|
||||
this.id_cache.push(message.id);
|
||||
});
|
||||
|
||||
this.message_queue = [];
|
||||
|
||||
data = { context: this.session.context, videoId: this.video_id };
|
||||
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
|
||||
|
||||
const updated_metadata = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/updated_metadata${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session: this.session, data, desktop: true }));
|
||||
if (updated_metadata instanceof Error) throw new Error(`Error polling updated metadata: ${updated_metadata.message}`);
|
||||
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
|
||||
|
||||
const metadata = updated_metadata.data.actions;
|
||||
this.emit('update-metadata', {
|
||||
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
dislikes: metadata[2].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
view_count: {
|
||||
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
|
||||
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
|
||||
}
|
||||
});
|
||||
|
||||
this.livechat_poller = setTimeout(async () => await this.poll(), this.poll_intervals_ms);
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -104,7 +106,7 @@ class Livechat extends EventEmitter {
|
||||
return {
|
||||
success: true,
|
||||
status_code: message.status_code,
|
||||
deleteMessage,
|
||||
deleteMessage: deleteMessage,
|
||||
message_data: {
|
||||
text: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message.runs.map((item) => item.text).join(' '),
|
||||
author: {
|
||||
@@ -118,8 +120,11 @@ class Livechat extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
async blockUser(msg_params) {
|
||||
/* TODO: Implement this */
|
||||
/**
|
||||
* Blocks a user.
|
||||
* @todo Implement this method
|
||||
*/
|
||||
async blockUser() {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
244
lib/core/OAuth.js
Normal file
244
lib/core/OAuth.js
Normal file
@@ -0,0 +1,244 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Constants = require('../utils/Constants');
|
||||
const Uuid = require('uuid');
|
||||
|
||||
/** @namespace */
|
||||
class OAuth {
|
||||
#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 = {};
|
||||
#polling_interval = 5;
|
||||
#ev = null;
|
||||
|
||||
/**
|
||||
* @param {EventEmitter} ev
|
||||
* @constructor
|
||||
*/
|
||||
constructor(ev) {
|
||||
this.#ev = ev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the auth flow in case no valid credentials are available.
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async init(auth_info) {
|
||||
this.#auth_info = auth_info;
|
||||
if (!auth_info.access_token) {
|
||||
this.#requestUserCode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the OAuth server for a user code
|
||||
* and verification URL.
|
||||
*
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async #requestUserCode() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
this.client_id = identity.id;
|
||||
this.client_secret = identity.secret;
|
||||
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
scope: Constants.OAUTH.SCOPE,
|
||||
device_id: Uuid.v4(),
|
||||
model_name: Constants.OAUTH.MODEL_NAME
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.#oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not obtain user code.', status: 'FAILED' });
|
||||
|
||||
this.#ev.emit('auth', {
|
||||
code: response.data.user_code,
|
||||
status: 'AUTHORIZATION_PENDING',
|
||||
expires_in: response.data.expires_in,
|
||||
verification_url: response.data.verification_url
|
||||
});
|
||||
|
||||
this.#polling_interval = response.data.interval;
|
||||
|
||||
this.#waitForAuth(response.data.device_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for sign-in authorization.
|
||||
*
|
||||
* @param {string} device_code - Client's device code.
|
||||
* @returns
|
||||
*/
|
||||
#waitForAuth(device_code) {
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
client_secret: this.client_secret,
|
||||
code: device_code,
|
||||
grant_type: Constants.OAUTH.GRANT_TYPE
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not get authentication token.', status: 'FAILED' });
|
||||
|
||||
if (response.data.error) {
|
||||
switch (response.data.error) {
|
||||
case 'slow_down':
|
||||
case 'authorization_pending':
|
||||
this.#waitForAuth(device_code);
|
||||
break;
|
||||
case 'access_denied':
|
||||
this.#ev.emit('auth', {
|
||||
error: 'Access was denied.',
|
||||
status: 'ACCESS_DENIED'
|
||||
});
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.#ev.emit('auth', {
|
||||
error: 'The user code has expired, requesting a new one.',
|
||||
status: 'DEVICE_CODE_EXPIRED'
|
||||
});
|
||||
this.#requestUserCode();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
const credentials = {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
expires: expiration_date,
|
||||
};
|
||||
|
||||
this.#auth_info = credentials;
|
||||
|
||||
this.#ev.emit('auth', {
|
||||
credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
}, 1000 * this.#polling_interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the access token if necessary.
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async checkTokenValidity() {
|
||||
if (this.shouldRefreshToken()) {
|
||||
await this.#refreshAccessToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new access token using a refresh token.
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async #refreshAccessToken() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: identity.id,
|
||||
client_secret: identity.secret,
|
||||
refresh_token: this.#auth_info.refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
|
||||
if (response instanceof Error)
|
||||
return this.#ev.emit('update-credentials', {
|
||||
error: 'Could not refresh access token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
const credentials = {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token || this.#auth_info.refresh_token,
|
||||
expires: expiration_date,
|
||||
};
|
||||
|
||||
this.#auth_info = credentials;
|
||||
|
||||
this.#ev.emit('update-credentials', {
|
||||
credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes access token (note that the refresh token will also be revoked).
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async revokeAccessToken() {
|
||||
const response = await Axios.post(`${this.#oauth_revoke_url}?token=${this.getAccessToken()}`, Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
return {
|
||||
success: !(response instanceof Error),
|
||||
status_code: response.status || 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets client identity data.
|
||||
* @returns {Promise.<{ id: string; secret: string }>}
|
||||
*/
|
||||
async #getClientIdentity() {
|
||||
// This request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
|
||||
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
|
||||
|
||||
// Here we download the script and extract the necessary data to proceed with the auth flow.
|
||||
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).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 format is valid.
|
||||
* @returns {boolean} true | false
|
||||
*/
|
||||
isValidAuthInfo() {
|
||||
return this.#auth_info.hasOwnProperty('access_token')
|
||||
&& this.#auth_info.hasOwnProperty('refresh_token')
|
||||
&& this.#auth_info.hasOwnProperty('expires');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks access token validity.
|
||||
* @returns {boolean} true | false
|
||||
*/
|
||||
shouldRefreshToken() {
|
||||
const timestamp = new Date(this.#auth_info.expires).getTime();
|
||||
return new Date().getTime() > timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth;
|
||||
127
lib/core/Player.js
Normal file
127
lib/core/Player.js
Normal file
@@ -0,0 +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 {
|
||||
#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 (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(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 {
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
115
lib/core/PlaylistManager.js
Normal 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
146
lib/core/SessionBuilder.js
Normal 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;
|
||||
@@ -1,109 +1,140 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
class NToken {
|
||||
constructor(raw_code) {
|
||||
constructor(raw_code, n) {
|
||||
this.n = n;
|
||||
this.raw_code = raw_code;
|
||||
this.null_placeholder_regex = /c\[(.*?)\]=c/g;
|
||||
this.transformation_calls_regex = /c\[(.*?)\]\((.+?)\)/g;
|
||||
}
|
||||
|
||||
transform(n) {
|
||||
let n_token = n.split('');
|
||||
/**
|
||||
* Solves throttling challange by transforming the n token.
|
||||
* @returns {string}
|
||||
*/
|
||||
transform() {
|
||||
let n_token = this.n.split('');
|
||||
|
||||
try {
|
||||
let transformations = this.getTransformationData();
|
||||
let transformations = this.#getTransformationData();
|
||||
transformations = transformations.map((el) => {
|
||||
if (el != null && typeof el != 'number') {
|
||||
const is_reverse_base64 = el.includes('case 65:');
|
||||
(({ // Identifies the transformation functions and emulates them accordingly.
|
||||
[Constants.FUNCS.PUSH]: () => el = (arr, i) => this.push(arr, i),
|
||||
[Constants.FUNCS.SPLICE]: () => el = (arr, i) => this.splice(arr, i),
|
||||
[Constants.FUNCS.SWAP0_1]: () => el = (arr, i) => this.swap0(arr, i),
|
||||
[Constants.FUNCS.SWAP0_2]: () => el = (arr, i) => this.swap0(arr, i),
|
||||
[Constants.FUNCS.ROTATE_1]: () => el = (arr, i) => this.rotate(arr, i),
|
||||
[Constants.FUNCS.ROTATE_2]: () => el = (arr, i) => this.rotate(arr, i),
|
||||
[Constants.FUNCS.REVERSE_1]: () => el = (arr) => this.reverse(arr),
|
||||
[Constants.FUNCS.REVERSE_2]: () => el = (arr) => this.reverse(arr),
|
||||
[Constants.FUNCS.BASE64_DIA]: () => el = () => this.getBase64Dia(is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_1]: () => el = (arr, token) => this.translate1(arr, token, is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_2]: () => el = (arr, token, base64_dic) => this.translate2(arr, token, base64_dic)
|
||||
})[this.getFunc(el)] || (() => el === 'b' && (el = n_token)))();
|
||||
(({ // Identifies the transformation functions
|
||||
[Constants.FUNCS.PUSH]: () => el = (arr, i) => this.#push(arr, i),
|
||||
[Constants.FUNCS.SPLICE]: () => el = (arr, i) => this.#splice(arr, i),
|
||||
[Constants.FUNCS.SWAP0_1]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.SWAP0_2]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.ROTATE_1]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.ROTATE_2]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.REVERSE_1]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.REVERSE_2]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.BASE64_DIA]: () => el = () => this.#getBase64Dia(is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_1]: () => el = (arr, token) => this.#translate1(arr, token, is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_2]: () => el = (arr, token, base64_dic) => this.#translate2(arr, token, base64_dic)
|
||||
})[this.#getFunc(el)] || (() => el === 'b' && (el = n_token)))();
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
// Fills the null placeholders with a copy of the transformations array.
|
||||
let null_placeholder_positions = [...this.raw_code.matchAll(this.null_placeholder_regex)].map((item) => parseInt(item[1]));
|
||||
null_placeholder_positions.forEach((pos) => transformations[pos] = transformations);
|
||||
// Fills all placeholders with the transformations array
|
||||
const placeholder_indexes = [...this.raw_code.matchAll(Constants.NTOKEN_REGEX.PLACEHOLDERS)].map((item) => parseInt(item[1]));
|
||||
placeholder_indexes.forEach((i) => transformations[i] = transformations);
|
||||
|
||||
// Parses and emulates calls to functions of the transformations array.
|
||||
let transformation_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch').matchAll(this.transformation_calls_regex)].map((params) => ({ index: params[1], params: params[2] }));
|
||||
transformation_calls.forEach((data) => {
|
||||
// Parses and emulates calls to the functions of the transformations array
|
||||
const function_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch')
|
||||
.matchAll(Constants.NTOKEN_REGEX.CALLS)].map((params) => ({ index: params[1], params: params[2] }));
|
||||
|
||||
function_calls.forEach((data) => {
|
||||
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
|
||||
const base64_dia = (param_index[2] && transformations[param_index[2]]());
|
||||
transformations[data.index](transformations[param_index[0]], transformations[param_index[1]], base64_dia);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Could not transform n-token, download may be throttled:', err);
|
||||
return n;
|
||||
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);
|
||||
#getFunc(el) {
|
||||
return el.match(Constants.NTOKEN_REGEX.FUNCTIONS);
|
||||
}
|
||||
|
||||
getTransformationData() {
|
||||
/**
|
||||
* Takes the n-transform data, refines it, and then returns a readable json array.
|
||||
* @returns {Array}
|
||||
*/
|
||||
#getTransformationData() {
|
||||
const data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
|
||||
return JSON.parse(Constants.formatNTransformData(data));
|
||||
return JSON.parse(Utils.refineNTokenData(data));
|
||||
}
|
||||
|
||||
translate1(arr, token, is_reverse_base64) {
|
||||
/**
|
||||
* Gets a base64 alphabet and uses it as a lookup table to modify n.
|
||||
* @returns
|
||||
*/
|
||||
#translate1(arr, token, is_reverse_base64) {
|
||||
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
|
||||
arr.forEach(function(char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
|
||||
}, token.split(''));
|
||||
}
|
||||
|
||||
translate2(arr, token, characters) {
|
||||
#translate2(arr, token, characters) {
|
||||
let chars_length = characters.length;
|
||||
arr.forEach(function(char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + index + chars_length--) % characters.length]);
|
||||
}, token.split(''));
|
||||
}
|
||||
|
||||
getBase64Dia(is_reverse_base64) {
|
||||
/**
|
||||
* Returns the requested base64 dialect, currently this is only used by 'translate2'.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
#getBase64Dia(is_reverse_base64) {
|
||||
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
|
||||
return characters;
|
||||
}
|
||||
|
||||
swap0(arr, index) {
|
||||
const old_value = arr[0];
|
||||
/**
|
||||
* Swaps the first element with the one at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#swap0(arr, index) {
|
||||
const old_elem = arr[0];
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr[0] = arr[index];
|
||||
arr[index] = old_value;
|
||||
arr[index] = old_elem;
|
||||
}
|
||||
|
||||
rotate(arr, index) {
|
||||
/**
|
||||
* Rotates elements of the array.
|
||||
* @returns
|
||||
*/
|
||||
#rotate(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(-index).reverse().forEach((el) => arr.unshift(el));
|
||||
}
|
||||
|
||||
splice(arr, index) {
|
||||
/**
|
||||
* Deletes one element at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#splice(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(index, 1);
|
||||
}
|
||||
|
||||
reverse(arr) {
|
||||
#reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
|
||||
push(arr, item) {
|
||||
#push(arr, item) {
|
||||
arr.push(item);
|
||||
}
|
||||
}
|
||||
91
lib/deciphers/Signature.js
Normal file
91
lib/deciphers/Signature.js
Normal file
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
const Constants = require('../utils/Constants');
|
||||
const QueryString = require('querystring');
|
||||
|
||||
class Signature {
|
||||
constructor(url, sig_decipher_sc) {
|
||||
this.url = url;
|
||||
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();
|
||||
|
||||
/**
|
||||
* 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]:
|
||||
this.#reverse(signature);
|
||||
break;
|
||||
case functions[1]:
|
||||
this.#splice(signature, action.param);
|
||||
break;
|
||||
case functions[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(''));
|
||||
|
||||
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 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')) {
|
||||
functions[1] = func[1];
|
||||
} else {
|
||||
functions[2] = func[1];
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Signature;
|
||||
589
lib/parser/index.js
Normal file
589
lib/parser/index.js
Normal file
@@ -0,0 +1,589 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
const YTDataItems = require('./youtube');
|
||||
const YTMusicDataItems = require('./ytmusic');
|
||||
const Proto = require('../proto');
|
||||
|
||||
class Parser {
|
||||
constructor(session, data, args = {}) {
|
||||
this.data = data;
|
||||
this.session = session;
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
parse() {
|
||||
const client = this.args.client;
|
||||
const data_type = this.args.data_type
|
||||
|
||||
let processed_data;
|
||||
|
||||
switch (client) {
|
||||
case 'YOUTUBE':
|
||||
processed_data = ({
|
||||
SEARCH: () => this.#processSearch(),
|
||||
CHANNEL: () => this.#processChannel(),
|
||||
PLAYLIST: () => this.#processPlaylist(),
|
||||
SUBSFEED: () => this.#processSubscriptionFeed(),
|
||||
HOMEFEED: () => this.#processHomeFeed(),
|
||||
LIBRARY: () => this.#processLibrary(), //WIP
|
||||
TRENDING: () => this.#processTrending(),
|
||||
HISTORY: () => this.#processHistory(),
|
||||
COMMENTS: () => this.#processComments(),
|
||||
VIDEO_INFO: () => this.#processVideoInfo(),
|
||||
NOTIFICATIONS: () => this.#processNotifications(),
|
||||
SEARCH_SUGGESTIONS: () => this.#processSearchSuggestions(),
|
||||
})[data_type]()
|
||||
break;
|
||||
case 'YTMUSIC':
|
||||
processed_data = ({
|
||||
SEARCH: () => this.#processMusicSearch(),
|
||||
PLAYLIST: () => this.#processMusicPlaylist(),
|
||||
SEARCH_SUGGESTIONS: () => this.#processMusicSearchSuggestions(),
|
||||
})[data_type]();
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid client');
|
||||
}
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processSearch() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'contents', 5);
|
||||
|
||||
const processed_data = {};
|
||||
|
||||
const parseItems = (contents) => {
|
||||
const content = contents[0].itemSectionRenderer.contents;
|
||||
|
||||
processed_data.query = content[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
|
||||
processed_data.corrected_query = content[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || 'N/A';
|
||||
processed_data.estimated_results = parseInt(this.data.estimatedResults);
|
||||
|
||||
processed_data.videos = YTDataItems.VideoResultItem.parse(content);
|
||||
|
||||
processed_data.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await this.session.actions.search({ ctoken });
|
||||
|
||||
const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
|
||||
return parseItems(continuation_items);
|
||||
};
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
#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;
|
||||
|
||||
const processed_data = {
|
||||
query: '',
|
||||
corrected_query: '',
|
||||
results: {}
|
||||
};
|
||||
|
||||
processed_data.query = this.args.query;
|
||||
processed_data.corrected_query = did_you_mean_renderer?.correctedQuery.runs.map((run) => run.text).join('') || 'N/A';
|
||||
|
||||
contents.forEach((content) => {
|
||||
const section = content?.musicShelfRenderer;
|
||||
if (section) {
|
||||
const section_title = section.title.runs[0].text;
|
||||
|
||||
const section_items = ({
|
||||
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents),
|
||||
['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents),
|
||||
['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents),
|
||||
['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
|
||||
['Community playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
|
||||
['Artists']: () => YTMusicDataItems.ArtistResultItem.parse(section.contents),
|
||||
['Albums']: () => YTMusicDataItems.AlbumResultItem.parse(section.contents)
|
||||
}[section_title] || (() => {}))();
|
||||
|
||||
processed_data.results[section_title.replace(/ /g, '_').toLowerCase()] = section_items;
|
||||
}
|
||||
});
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processSearchSuggestions() {
|
||||
return YTDataItems.SearchSuggestionItem.parse(JSON.parse(this.data.replace(')]}\'', '')));
|
||||
}
|
||||
|
||||
#processMusicSearchSuggestions() {
|
||||
const contents = this.data.contents[0].searchSuggestionsSectionRenderer.contents;
|
||||
return YTMusicDataItems.MusicSearchSuggestionItem.parse(contents);
|
||||
}
|
||||
|
||||
#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 || 'N/A',
|
||||
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1]?.text || 'N/A',
|
||||
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
|
||||
}
|
||||
|
||||
const list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
|
||||
const items = YTDataItems.PlaylistItem.parse(list.contents);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#processMusicPlaylist() {
|
||||
const details = this.data.header.musicDetailHeaderRenderer;
|
||||
|
||||
const metadata = {
|
||||
title: details?.title?.runs[0].text,
|
||||
description: details?.description?.runs?.map((run) => run.text).join('') || 'N/A',
|
||||
total_items: parseInt(details?.secondSubtitle?.runs[0].text.match(/\d+/g)),
|
||||
duration: details?.secondSubtitle?.runs[2].text,
|
||||
year: details?.subtitle?.runs[4].text
|
||||
};
|
||||
|
||||
const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
|
||||
|
||||
const items = YTMusicDataItems.PlaylistItem.parse(playlist_content);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
|
||||
*/
|
||||
#processVideoInfo() {
|
||||
const playability_status = this.data.playabilityStatus;
|
||||
|
||||
if (playability_status.status == 'ERROR')
|
||||
throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
|
||||
|
||||
const details = this.data.videoDetails;
|
||||
const microformat = this.data.microformat.playerMicroformatRenderer;
|
||||
const streaming_data = this.data.streamingData;
|
||||
|
||||
const mf_raw_data = Object.entries(microformat);
|
||||
const dt_raw_data = Object.entries(details);
|
||||
|
||||
const processed_data = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
thumbnail: [],
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
// Extracts most of the metadata
|
||||
mf_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
|
||||
key == 'owner_profile_url' && (processed_data.metadata.channel_url = entry[1]) ||
|
||||
key == 'owner_channel_name' && (processed_data.metadata.channel_name = entry[1]) ||
|
||||
(processed_data.metadata[key] = entry[1]);
|
||||
} else {
|
||||
processed_data[key] = entry[1];
|
||||
}
|
||||
});
|
||||
|
||||
// Extracts extra details
|
||||
dt_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
|
||||
(processed_data.metadata[key] = entry[1]);
|
||||
} else {
|
||||
key == 'short_description' && (processed_data.description = entry[1]) ||
|
||||
key == 'thumbnail' && (processed_data.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
|
||||
key == 'video_id' && (processed_data.id = entry[1]) ||
|
||||
(processed_data[key] = entry[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Data continuation is only required for getDetails()
|
||||
if (this.data.continuation) {
|
||||
const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
|
||||
|
||||
const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
|
||||
|
||||
const like_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
|
||||
|
||||
const dislike_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
|
||||
|
||||
const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
|
||||
?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
|
||||
|
||||
// These will always be false if logged out.
|
||||
processed_data.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
|
||||
processed_data.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
|
||||
processed_data.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
|
||||
|
||||
processed_data.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
|
||||
processed_data.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
|
||||
.state.buttonRenderer.icon.iconType || 'N/A';
|
||||
|
||||
// Simpler version of publish_date
|
||||
processed_data.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
|
||||
|
||||
// Only parse like count if it's enabled
|
||||
if (processed_data.metadata.allow_ratings) {
|
||||
processed_data.metadata.likes = {
|
||||
count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
|
||||
short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
|
||||
};
|
||||
}
|
||||
|
||||
processed_data.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
|
||||
}
|
||||
|
||||
streaming_data && streaming_data.adaptiveFormats &&
|
||||
(processed_data.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
|
||||
.map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]) ||
|
||||
(processed_data.metadata.available_qualities = []);
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processComments() {
|
||||
if (!this.data.onResponseReceivedEndpoints)
|
||||
throw new Utils.UnavailableContentError('Comments section not available', this.args);
|
||||
|
||||
const header = Utils.findNode(this.data, 'onResponseReceivedEndpoints', 'commentsHeaderRenderer', 5, false);
|
||||
const comment_count = parseInt(header.commentsHeaderRenderer.countText.runs[0].text.replace(/,/g, ''));
|
||||
const page_count = parseInt(comment_count / 20);
|
||||
|
||||
const parseComments = (data) => {
|
||||
const items = Utils.findNode(data, 'onResponseReceivedEndpoints', 'commentRenderer', 4, false);
|
||||
|
||||
const response = {
|
||||
page_count,
|
||||
comment_count,
|
||||
items: []
|
||||
};
|
||||
|
||||
response.items = items.map((item) => {
|
||||
const comment = YTDataItems.CommentThread.parseItem(item);
|
||||
if (comment) {
|
||||
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 this.session.actions.flag('flag/get_form', { params: payload.params });
|
||||
|
||||
const action = Utils.findNode(form, 'actions', 'flagAction', 13, false);
|
||||
const flag = await this.session.actions.flag('flag/flag', { action: action.flagAction });
|
||||
|
||||
return flag;
|
||||
};
|
||||
|
||||
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 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 this.session.actions.next({ ctoken: payload.token });
|
||||
|
||||
return parseComments(next.data);
|
||||
};
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return parseComments(this.data);
|
||||
}
|
||||
|
||||
#processHomeFeed() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
|
||||
|
||||
const parseItems = (contents) => {
|
||||
const videos = YTDataItems.VideoItem.parse(contents);
|
||||
|
||||
const getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await this.session.actions.browse(ctoken, { is_ctoken: true });
|
||||
|
||||
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
|
||||
}
|
||||
|
||||
return { videos, getContinuation };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const subsfeed = { items: [] };
|
||||
|
||||
const parseItems = (contents) => {
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
|
||||
const section_contents = section.itemSectionRenderer.contents[0];
|
||||
const section_title = section_contents.shelfRenderer.title.runs[0].text;
|
||||
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
|
||||
|
||||
const items = YTDataItems.GridVideoItem.parse(section_items);
|
||||
|
||||
subsfeed.items.push({
|
||||
date: section_title,
|
||||
videos: items
|
||||
});
|
||||
});
|
||||
|
||||
subsfeed.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await this.session.actions.browse(ctoken, { is_ctoken: true });
|
||||
|
||||
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false);
|
||||
subsfeed.items = [];
|
||||
|
||||
return parseItems(ccontents);
|
||||
}
|
||||
|
||||
return subsfeed;
|
||||
};
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
#processChannel() {
|
||||
const tabs = this.data.contents.twoColumnBrowseResultsRenderer.tabs;
|
||||
const metadata = this.data.metadata;
|
||||
|
||||
const home_tab = tabs.find((tab) => tab.tabRenderer.title == 'Home');
|
||||
const home_contents = home_tab.tabRenderer.content.sectionListRenderer.contents;
|
||||
const home_shelves = [];
|
||||
|
||||
home_contents.forEach((content) => {
|
||||
if (content.itemSectionRenderer) {
|
||||
const contents = content.itemSectionRenderer.contents[0];
|
||||
const list = contents?.shelfRenderer?.content.horizontalListRenderer;
|
||||
if (!list) return; // Ignores featured channels (for now only videos & playlists are supported)
|
||||
|
||||
const shelf = {
|
||||
title: contents.shelfRenderer.title.runs[0].text,
|
||||
content: []
|
||||
};
|
||||
|
||||
shelf.content = list.items.map((item) => {
|
||||
if (item.gridVideoRenderer) {
|
||||
return YTDataItems.GridVideoItem.parseItem(item);
|
||||
} else if (item.gridPlaylistRenderer) {
|
||||
return YTDataItems.GridPlaylistItem.parseItem(item);
|
||||
}
|
||||
});
|
||||
|
||||
home_shelves.push(shelf);
|
||||
}
|
||||
});
|
||||
|
||||
const ch_info = YTDataItems.ChannelMetadata.parse(metadata);
|
||||
|
||||
return {
|
||||
...ch_info,
|
||||
content: {
|
||||
// Home page of the channel, always available in the first request.
|
||||
home_page: home_shelves,
|
||||
|
||||
// TODO: Implement these (note: they require additional requests)
|
||||
getVideos: () => {},
|
||||
getPlaylists: () => {},
|
||||
getCommunity: () => {},
|
||||
getChannels: () => {},
|
||||
getAbout: () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#processNotifications() {
|
||||
const contents = this.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
|
||||
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications');
|
||||
|
||||
const parseItems = (items) => {
|
||||
const parsed_items = YTDataItems.NotificationItem.parse(items);
|
||||
|
||||
const getContinuation = async () => {
|
||||
const citem = items.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken;
|
||||
|
||||
const response = await this.session.actions.notifications('get_notification_menu', { ctoken });
|
||||
|
||||
return parseItems(response.data.actions[0].appendContinuationItemsAction.continuationItems);
|
||||
}
|
||||
|
||||
return { items: parsed_items, getContinuation };
|
||||
}
|
||||
|
||||
return parseItems(contents.multiPageMenuNotificationSectionRenderer.items);
|
||||
}
|
||||
|
||||
#processTrending() {
|
||||
const tabs = Utils.findNode(this.data, 'contents', 'tabRenderer', 4, false);
|
||||
const categories = {};
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
const tab_renderer = tab.tabRenderer;
|
||||
const tab_content = tab_renderer?.content;
|
||||
const category_title = tab_renderer.title.toLowerCase();
|
||||
|
||||
categories[category_title] = {};
|
||||
|
||||
if (tab_content) { // The “Now” category is always available
|
||||
const contents = tab_content.sectionListRenderer.contents;
|
||||
|
||||
categories[category_title].content = contents.map((content) => {
|
||||
const shelf = content.itemSectionRenderer.contents[0].shelfRenderer;
|
||||
const parsed_shelf = YTDataItems.ShelfRenderer.parse(shelf);
|
||||
return parsed_shelf;
|
||||
});
|
||||
} 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 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);
|
||||
|
||||
const contents = tab.tabRenderer.content.sectionListRenderer.contents;
|
||||
const items = Utils.findNode(contents, 'itemSectionRenderer', 'items', 8, false);
|
||||
|
||||
return YTDataItems.VideoItem.parse(items);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
#processHistory() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
|
||||
|
||||
const history = { items: [] };
|
||||
|
||||
const parseItems = (contents) => {
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
|
||||
const header = section.itemSectionRenderer.header.itemSectionHeaderRenderer.title;
|
||||
const section_title = header?.simpleText || header?.runs.map((run) => run.text).join('');
|
||||
const contents = section.itemSectionRenderer.contents;
|
||||
|
||||
const section_items = YTDataItems.VideoItem.parse(contents);
|
||||
|
||||
history.items.push({
|
||||
date: section_title,
|
||||
videos: section_items
|
||||
});
|
||||
});
|
||||
|
||||
history.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await this.session.actions.browse(ctoken, { is_ctoken: true });
|
||||
|
||||
history.items = [];
|
||||
|
||||
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Parser;
|
||||
14
lib/parser/youtube/index.js
Normal file
14
lib/parser/youtube/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const VideoResultItem = require('./search/VideoResultItem');
|
||||
const SearchSuggestionItem = require('./search/SearchSuggestionItem');
|
||||
const PlaylistItem = require('./others/PlaylistItem');
|
||||
const NotificationItem = require('./others/NotificationItem');
|
||||
const VideoItem = require('./others/VideoItem');
|
||||
const GridVideoItem = require('./others/GridVideoItem');
|
||||
const GridPlaylistItem = require('./others/GridPlaylistItem');
|
||||
const ChannelMetadata = require('./others/ChannelMetadata');
|
||||
const ShelfRenderer = require('./others/ShelfRenderer');
|
||||
const CommentThread = require('./others/CommentThread');
|
||||
|
||||
module.exports = { VideoResultItem, SearchSuggestionItem, PlaylistItem, NotificationItem, VideoItem, GridVideoItem, GridPlaylistItem, ChannelMetadata, ShelfRenderer, CommentThread };
|
||||
20
lib/parser/youtube/others/ChannelMetadata.js
Normal file
20
lib/parser/youtube/others/ChannelMetadata.js
Normal file
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
class ChannelMetadata {
|
||||
static parse(data) {
|
||||
return {
|
||||
title: data.channelMetadataRenderer.title,
|
||||
description: data.channelMetadataRenderer.description,
|
||||
metadata: {
|
||||
url: data.channelMetadataRenderer?.channelUrl,
|
||||
rss_urls: data.channelMetadataRenderer?.rssUrl,
|
||||
vanity_channel_url: data.channelMetadataRenderer?.vanityChannelUrl,
|
||||
external_id: data.channelMetadataRenderer?.externalId,
|
||||
is_family_safe: data.channelMetadataRenderer?.isFamilySafe,
|
||||
keywords: data.channelMetadataRenderer?.keywords
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChannelMetadata;
|
||||
36
lib/parser/youtube/others/CommentThread.js
Normal file
36
lib/parser/youtube/others/CommentThread.js
Normal file
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
const Constants = require('../../../utils/Constants');
|
||||
|
||||
class CommentThread {
|
||||
static parseItem(item) {
|
||||
if (item.commentThreadRenderer || item.commentRenderer) {
|
||||
const comment = item?.commentThreadRenderer?.comment || item;
|
||||
|
||||
const like_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.likeButton;
|
||||
const dislike_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.dislikeButton;
|
||||
|
||||
return {
|
||||
text: comment.commentRenderer.contentText.runs.map((run) => run.text).join(''),
|
||||
author: {
|
||||
name: comment.commentRenderer.authorText.simpleText,
|
||||
thumbnails: comment.commentRenderer.authorThumbnail.thumbnails,
|
||||
channel_id: comment.commentRenderer.authorEndpoint.browseEndpoint.browseId,
|
||||
channel_url: Constants.URLS.YT_BASE + comment.commentRenderer.authorEndpoint.browseEndpoint.canonicalBaseUrl
|
||||
},
|
||||
metadata: {
|
||||
published: comment.commentRenderer.publishedTimeText.runs[0].text,
|
||||
is_reply: !!item.commentRenderer,
|
||||
is_liked: like_btn.toggleButtonRenderer.isToggled,
|
||||
is_disliked: dislike_btn.toggleButtonRenderer.isToggled,
|
||||
is_pinned: comment.commentRenderer.pinnedCommentBadge && true || false,
|
||||
is_channel_owner: comment.commentRenderer.authorIsChannelOwner,
|
||||
like_count: parseInt(like_btn?.toggleButtonRenderer?.accessibilityData?.accessibilityData.label.replace(/\D/g, '')),
|
||||
reply_count: comment.commentRenderer.replyCount || 0,
|
||||
id: comment.commentRenderer.commentId,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = CommentThread;
|
||||
20
lib/parser/youtube/others/GridPlaylistItem.js
Normal file
20
lib/parser/youtube/others/GridPlaylistItem.js
Normal file
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
class GridPlaylistItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
return {
|
||||
id: item?.gridPlaylistRenderer.playlistId,
|
||||
title: item?.gridPlaylistRenderer.title?.runs?.map((run) => run.text).join(''),
|
||||
metadata: {
|
||||
thumbnail: item?.gridPlaylistRenderer.thumbnail?.thumbnails?.slice(-1)[0] || {},
|
||||
video_count: item?.gridPlaylistRenderer.videoCountShortText?.simpleText || 'N/A',
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GridPlaylistItem;
|
||||
35
lib/parser/youtube/others/GridVideoItem.js
Normal file
35
lib/parser/youtube/others/GridVideoItem.js
Normal file
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
const Constants = require('../../../utils/Constants');
|
||||
|
||||
class GridVideoItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
return {
|
||||
id: item.gridVideoRenderer.videoId,
|
||||
title: item?.gridVideoRenderer?.title?.runs?.map((run) => run.text).join(' '),
|
||||
channel: {
|
||||
id: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
|
||||
name: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
|
||||
url: `${Constants.URLS.YT_BASE}${item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
|
||||
},
|
||||
metadata: {
|
||||
view_count: item?.gridVideoRenderer?.viewCountText?.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: item?.gridVideoRenderer?.shortViewCountText?.simpleText || 'N/A',
|
||||
accessibility_label: item?.gridVideoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
|
||||
},
|
||||
thumbnail: item?.gridVideoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || [],
|
||||
moving_thumbnail: item?.gridVideoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
|
||||
published: item?.gridVideoRenderer?.publishedTimeText?.simpleText || 'N/A',
|
||||
badges: item?.gridVideoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: item?.gridVideoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GridVideoItem;
|
||||
25
lib/parser/youtube/others/NotificationItem.js
Normal file
25
lib/parser/youtube/others/NotificationItem.js
Normal file
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
class NotificationItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
if (item.notificationRenderer) {
|
||||
const notification = item.notificationRenderer;
|
||||
return {
|
||||
title: notification?.shortMessage?.simpleText,
|
||||
sent_time: notification?.sentTimeText?.simpleText,
|
||||
channel_name: notification?.contextualMenu?.menuRenderer?.items[1]?.menuServiceItemRenderer?.text?.runs[1]?.text || 'N/A',
|
||||
channel_thumbnail: notification?.thumbnail?.thumbnails[0],
|
||||
video_thumbnail: notification?.videoThumbnail?.thumbnails[0],
|
||||
video_url: notification.navigationEndpoint.watchEndpoint && `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}` || 'N/A',
|
||||
read: notification.read,
|
||||
notification_id: notification.notificationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NotificationItem;
|
||||
26
lib/parser/youtube/others/PlaylistItem.js
Normal file
26
lib/parser/youtube/others/PlaylistItem.js
Normal file
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
|
||||
class PlaylistItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
if (item.playlistVideoRenderer)
|
||||
return {
|
||||
id: item?.playlistVideoRenderer?.videoId,
|
||||
title: item?.playlistVideoRenderer?.title?.runs[0]?.text,
|
||||
author: item?.playlistVideoRenderer?.shortBylineText?.runs[0]?.text,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(item?.playlistVideoRenderer?.lengthText?.simpleText || '0'),
|
||||
simple_text: item?.playlistVideoRenderer?.lengthText?.simpleText || 'N/A',
|
||||
accessibility_label: item?.playlistVideoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
|
||||
},
|
||||
thumbnails: item?.playlistVideoRenderer?.thumbnail?.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistItem;
|
||||
41
lib/parser/youtube/others/ShelfRenderer.js
Normal file
41
lib/parser/youtube/others/ShelfRenderer.js
Normal file
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
const VideoItem = require('./VideoItem');
|
||||
const GridVideoItem = require('./GridVideoItem');
|
||||
|
||||
class ShelfRenderer {
|
||||
static parse(data) {
|
||||
return {
|
||||
title: this.getTitle(data.title),
|
||||
videos: this.parseItems(data.content)
|
||||
}
|
||||
}
|
||||
|
||||
static getTitle(data) {
|
||||
if ('runs' in (data || {})) {
|
||||
return data.runs.map((run) => run.text).join('');
|
||||
} else if ('simpleText' in (data || {})) {
|
||||
return data.simpleText;
|
||||
} else {
|
||||
return 'Others';
|
||||
}
|
||||
}
|
||||
|
||||
static parseItems(data) {
|
||||
let items;
|
||||
|
||||
if ('expandedShelfContentsRenderer' in data) {
|
||||
items = data.expandedShelfContentsRenderer.items;
|
||||
} else if ('horizontalListRenderer' in data) {
|
||||
items = data.horizontalListRenderer.items;
|
||||
}
|
||||
|
||||
const videos = ('gridVideoRenderer' in items[0])
|
||||
&& GridVideoItem.parse(items)
|
||||
|| VideoItem.parse(items);
|
||||
|
||||
return videos;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ShelfRenderer;
|
||||
46
lib/parser/youtube/others/VideoItem.js
Normal file
46
lib/parser/youtube/others/VideoItem.js
Normal file
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
const Constants = require('../../../utils/Constants');
|
||||
|
||||
class VideoItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
item = (item.richItemRenderer && item.richItemRenderer.content.videoRenderer)
|
||||
&& item.richItemRenderer.content
|
||||
|| item;
|
||||
|
||||
if (item.videoRenderer) return {
|
||||
id: item.videoRenderer.videoId,
|
||||
title: item.videoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
description: item?.videoRenderer?.descriptionSnippet?.runs[0]?.text || 'N/A',
|
||||
channel: {
|
||||
id: item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
|
||||
name: item?.videoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
|
||||
url: `${Constants.URLS.YT_BASE}${item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
|
||||
},
|
||||
metadata: {
|
||||
view_count: item?.videoRenderer?.viewCountText?.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: item?.videoRenderer?.shortViewCountText?.simpleText || 'N/A',
|
||||
accessibility_label: item?.videoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
|
||||
},
|
||||
thumbnail: item?.videoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || {},
|
||||
moving_thumbnail: item?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
|
||||
published: item?.videoRenderer?.publishedTimeText?.simpleText || 'N/A',
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(item?.videoRenderer?.lengthText?.simpleText || '0'),
|
||||
simple_text: item?.videoRenderer?.lengthText?.simpleText || 'N/A',
|
||||
accessibility_label: item?.videoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
|
||||
},
|
||||
badges: item?.videoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: item?.videoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VideoItem;
|
||||
12
lib/parser/youtube/search/SearchSuggestionItem.js
Normal file
12
lib/parser/youtube/search/SearchSuggestionItem.js
Normal file
@@ -0,0 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
class SearchSuggestionItem {
|
||||
static parse(data) {
|
||||
return {
|
||||
query: data[0],
|
||||
results: data[1].map((res) => res[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SearchSuggestionItem;
|
||||
43
lib/parser/youtube/search/VideoResultItem.js
Normal file
43
lib/parser/youtube/search/VideoResultItem.js
Normal file
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
const Constants = require('../../../utils/Constants');
|
||||
|
||||
class VideoResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const renderer = item.videoRenderer || item.compactVideoRenderer;
|
||||
if (renderer) return {
|
||||
id: renderer.videoId,
|
||||
url: `https://youtu.be/${renderer.videoId}`,
|
||||
title: renderer.title.runs[0].text,
|
||||
description: renderer?.detailedMetadataSnippets && renderer?.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
|
||||
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}`
|
||||
},
|
||||
metadata: {
|
||||
view_count: renderer?.viewCountText?.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: renderer?.shortViewCountText?.simpleText || 'N/A',
|
||||
accessibility_label: renderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
|
||||
},
|
||||
thumbnails: renderer?.thumbnail.thumbnails,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(renderer?.lengthText?.simpleText || '0'),
|
||||
simple_text: renderer?.lengthText?.simpleText || 'N/A',
|
||||
accessibility_label: renderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
|
||||
},
|
||||
published: renderer?.publishedTimeText?.simpleText || 'N/A',
|
||||
badges: renderer?.badges?.map((item) => item.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: renderer?.ownerBadges?.map((item) => item.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VideoResultItem;
|
||||
12
lib/parser/ytmusic/index.js
Normal file
12
lib/parser/ytmusic/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
const SongResultItem = require('./search/SongResultItem');
|
||||
const VideoResultItem = require('./search/VideoResultItem');
|
||||
const AlbumResultItem = require('./search/AlbumResultItem');
|
||||
const ArtistResultItem = require('./search/ArtistResultItem');
|
||||
const PlaylistResultItem = require('./search/PlaylistResultItem');
|
||||
const MusicSearchSuggestionItem = require('./search/MusicSearchSuggestionItem');
|
||||
const TopResultItem = require('./search/TopResultItem');
|
||||
const PlaylistItem = require('./others/PlaylistItem');
|
||||
|
||||
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, MusicSearchSuggestionItem, TopResultItem, PlaylistItem };
|
||||
28
lib/parser/ytmusic/others/PlaylistItem.js
Normal file
28
lib/parser/ytmusic/others/PlaylistItem.js
Normal file
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
|
||||
class PlaylistItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item.id);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const item_renderer = item.musicResponsiveListItemRenderer;
|
||||
const fixed_columns = item_renderer.fixedColumns;
|
||||
const flex_columns = item_renderer.flexColumns;
|
||||
|
||||
return {
|
||||
id: item_renderer.playlistItemData && item_renderer.playlistItemData.videoId,
|
||||
title: flex_columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
author: flex_columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text || '0'),
|
||||
simple_text: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
|
||||
},
|
||||
thumbnails: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistItem;
|
||||
21
lib/parser/ytmusic/search/AlbumResultItem.js
Normal file
21
lib/parser/ytmusic/search/AlbumResultItem.js
Normal file
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
class AlbumResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
return {
|
||||
id: list_item.navigationEndpoint.browseEndpoint.browseId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
year: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
|
||||
.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
|
||||
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AlbumResultItem;
|
||||
19
lib/parser/ytmusic/search/ArtistResultItem.js
Normal file
19
lib/parser/ytmusic/search/ArtistResultItem.js
Normal file
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
class ArtistResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
return {
|
||||
id: list_item.navigationEndpoint.browseEndpoint.browseId,
|
||||
name: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
subscribers: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ArtistResultItem;
|
||||
22
lib/parser/ytmusic/search/MusicSearchSuggestionItem.js
Normal file
22
lib/parser/ytmusic/search/MusicSearchSuggestionItem.js
Normal file
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
class MusicSearchSuggestionItem {
|
||||
static parse(data) {
|
||||
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) {
|
||||
let suggestion;
|
||||
|
||||
item.historySuggestionRenderer &&
|
||||
(suggestion = item.historySuggestionRenderer.suggestion) ||
|
||||
(suggestion = item.searchSuggestionRenderer.suggestion);
|
||||
|
||||
return suggestion;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MusicSearchSuggestionItem;
|
||||
23
lib/parser/ytmusic/search/PlaylistResultItem.js
Normal file
23
lib/parser/ytmusic/search/PlaylistResultItem.js
Normal file
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
class PlaylistResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
const watch_playlist_endpoint = list_item?.overlay?.musicItemThumbnailOverlayRenderer
|
||||
?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchPlaylistEndpoint;
|
||||
|
||||
return {
|
||||
id: watch_playlist_endpoint?.playlistId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
channel_id: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.navigationEndpoint?.browseEndpoint.browseId || '0',
|
||||
total_items: parseInt(list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text.match(/\d+/g)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistResultItem;
|
||||
22
lib/parser/ytmusic/search/SongResultItem.js
Normal file
22
lib/parser/ytmusic/search/SongResultItem.js
Normal file
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
class SongResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
if (list_item.playlistItemData) return {
|
||||
id: list_item.playlistItemData.videoId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
artist: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
album: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
|
||||
duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
|
||||
.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
|
||||
thumbnails: list_item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SongResultItem;
|
||||
33
lib/parser/ytmusic/search/TopResultItem.js
Normal file
33
lib/parser/ytmusic/search/TopResultItem.js
Normal file
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
const SongResultItem = require('./SongResultItem');
|
||||
const VideoResultItem = require('./VideoResultItem');
|
||||
const AlbumResultItem = require('./AlbumResultItem');
|
||||
const ArtistResultItem = require('./ArtistResultItem');
|
||||
const PlaylistResultItem = require('./PlaylistResultItem');
|
||||
|
||||
class TopResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
|
||||
const runs = list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs;
|
||||
const type = runs[0].text.toLowerCase();
|
||||
|
||||
const parsed_item = ({
|
||||
playlist: () => PlaylistResultItem.parseItem(item),
|
||||
song: () => SongResultItem.parseItem(item),
|
||||
video: () => VideoResultItem.parseItem(item),
|
||||
artist: () => ArtistResultItem.parseItem(item),
|
||||
album: () => AlbumResultItem.parseItem(item),
|
||||
single: () => AlbumResultItem.parseItem(item)
|
||||
}[type] || (() => {}))();
|
||||
|
||||
parsed_item && (parsed_item.type = type);
|
||||
|
||||
return parsed_item;
|
||||
}).filter((item) => item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TopResultItem;
|
||||
22
lib/parser/ytmusic/search/VideoResultItem.js
Normal file
22
lib/parser/ytmusic/search/VideoResultItem.js
Normal file
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
class VideoResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
if (list_item.playlistItemData) return {
|
||||
id: list_item.playlistItemData.videoId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
views: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
|
||||
duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
|
||||
.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
|
||||
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VideoResultItem;
|
||||
287
lib/proto/index.js
Normal file
287
lib/proto/index.js
Normal file
@@ -0,0 +1,287 @@
|
||||
'use strict';
|
||||
|
||||
const messages = require('./messages');
|
||||
|
||||
class Proto {
|
||||
/**
|
||||
* 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 } },
|
||||
number_0: 1, number_1: 4
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
const buf = messages.GetCommentsSectionParams.encode({
|
||||
ctx: { video_id },
|
||||
unk_param: 6,
|
||||
params: {
|
||||
opts: {
|
||||
video_id,
|
||||
sort_by: sort_menu[options.sort_by || 'TOP_COMMENTS'],
|
||||
type: options.type || 2
|
||||
},
|
||||
target: 'comments-section'
|
||||
}
|
||||
});
|
||||
|
||||
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 },
|
||||
unk_param: 6,
|
||||
params: {
|
||||
replies_opts: {
|
||||
video_id, comment_id,
|
||||
unkopts: { unk_param: 0 },
|
||||
unk_param_1: 1, unk_param_2: 10,
|
||||
channel_id: ' ' // Seems like this can be omitted
|
||||
},
|
||||
target: `comment-replies-item-${comment_id}`
|
||||
}
|
||||
});
|
||||
|
||||
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 },
|
||||
number: 7
|
||||
});
|
||||
|
||||
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,
|
||||
params: { unk_num: 0 },
|
||||
unk_num: 7
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
number_0: 0, number_1: 4
|
||||
});
|
||||
|
||||
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;
|
||||
2767
lib/proto/messages.js
Normal file
2767
lib/proto/messages.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,67 @@
|
||||
syntax = "proto2";
|
||||
package proto;
|
||||
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;
|
||||
int32 number_0 = 3;
|
||||
int32 number_1 = 4;
|
||||
|
||||
optional int32 number_0 = 3;
|
||||
optional int32 number_1 = 4;
|
||||
}
|
||||
|
||||
message LiveMessageParams {
|
||||
@@ -20,8 +73,51 @@ message LiveMessageParams {
|
||||
Ids ids = 5;
|
||||
}
|
||||
Params params = 1;
|
||||
int32 number_0 = 2;
|
||||
int32 number_1 = 3;
|
||||
|
||||
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 {
|
||||
@@ -33,12 +129,51 @@ message CreateCommentParams {
|
||||
int32 number = 10;
|
||||
}
|
||||
|
||||
message SearchFilter {
|
||||
int32 number = 1;
|
||||
message Filter {
|
||||
int32 param_0 = 1;
|
||||
int32 param_1 = 2;
|
||||
int32 param_2 = 3;
|
||||
message CreateCommentReplyParams {
|
||||
string video_id = 2;
|
||||
string comment_id = 4;
|
||||
|
||||
message UnknownParams {
|
||||
int32 unk_num = 1;
|
||||
}
|
||||
Filter filter = 2;
|
||||
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;
|
||||
}
|
||||
108
lib/utils/Constants.js
Normal file
108
lib/utils/Constants.js
Normal file
@@ -0,0 +1,108 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
URLS: {
|
||||
YT_BASE: 'https://www.youtube.com',
|
||||
YT_BASE_API: 'https://www.youtube.com/youtubei/',
|
||||
YT_STUDIO_BASE_API: 'https://studio.youtube.com/youtubei/',
|
||||
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/',
|
||||
YT_MUSIC: 'https://music.youtube.com',
|
||||
YT_MUSIC_BASE_API: 'https://music.youtube.com/youtubei/'
|
||||
},
|
||||
OAUTH: {
|
||||
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
|
||||
GRANT_TYPE: 'http://oauth.net/grant_type/device/1.0',
|
||||
MODEL_NAME: 'ytlr::',
|
||||
HEADERS: {
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'origin': 'https://www.youtube.com',
|
||||
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
|
||||
'content-type': 'application/json',
|
||||
'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>.+?)"},/
|
||||
}
|
||||
},
|
||||
CLIENTS: {
|
||||
WEB: {
|
||||
NAME: 'WEB'
|
||||
},
|
||||
YTMUSIC: {
|
||||
NAME: 'WEB_REMIX',
|
||||
VERSION: '1.20211213.00.00'
|
||||
},
|
||||
ANDROID: {
|
||||
NAME: 'ANDROID',
|
||||
VERSION: '17.17.32'
|
||||
}
|
||||
},
|
||||
STREAM_HEADERS: {
|
||||
'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',
|
||||
},
|
||||
METADATA_KEYS: [
|
||||
'embed', 'view_count', 'average_rating', 'allow_ratings',
|
||||
'length_seconds', 'channel_id', 'channel_url',
|
||||
'external_channel_id', 'is_live_content', 'is_family_safe',
|
||||
'is_unlisted', 'is_private', 'has_ypc_metadata',
|
||||
'category', 'owner_channel_name', 'publish_date',
|
||||
'upload_date', 'keywords', 'available_countries',
|
||||
'owner_profile_url'
|
||||
],
|
||||
BLACKLISTED_KEYS: [
|
||||
'is_owner_viewing', 'is_unplugged_corpus',
|
||||
'is_crawlable', 'author'
|
||||
],
|
||||
ACCOUNT_SETTINGS: {
|
||||
// Notifications
|
||||
SUBSCRIPTIONS: 'NOTIFICATION_SUBSCRIPTION_NOTIFICATIONS',
|
||||
RECOMMENDED_VIDEOS: 'NOTIFICATION_RECOMMENDATION_WEB_CONTROL',
|
||||
CHANNEL_ACTIVITY: 'NOTIFICATION_COMMENT_WEB_CONTROL',
|
||||
COMMENT_REPLIES: 'NOTIFICATION_COMMENT_REPLY_OTHER_WEB_CONTROL',
|
||||
USER_MENTION: 'NOTIFICATION_USER_MENTION_WEB_CONTROL',
|
||||
SHARED_CONTENT: 'NOTIFICATION_RETUBING_WEB_CONTROL',
|
||||
|
||||
// Privacy
|
||||
PLAYLISTS_PRIVACY: 'PRIVACY_DISCOVERABLE_SAVED_PLAYLISTS',
|
||||
SUBSCRIPTIONS_PRIVACY: 'PRIVACY_DISCOVERABLE_SUBSCRIPTIONS'
|
||||
},
|
||||
BASE64_DIALECT: {
|
||||
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: {
|
||||
PUSH: 'd.push(e)',
|
||||
REVERSE_1: 'd.reverse()',
|
||||
REVERSE_2: 'function(d){for(var',
|
||||
SPLICE: 'd.length;d.splice(e,1)',
|
||||
SWAP0_1: 'd[0])[0])',
|
||||
SWAP0_2: 'f=d[0];d[0]',
|
||||
ROTATE_1: 'reverse().forEach',
|
||||
ROTATE_2: 'unshift(d.pop())',
|
||||
BASE64_DIA: 'function(){for(var',
|
||||
TRANSLATE_1: 'function(d,e){for(var f',
|
||||
TRANSLATE_2: 'function(d,e,f){var'
|
||||
}
|
||||
};
|
||||
122
lib/utils/Request.js
Normal file
122
lib/utils/Request.js
Normal file
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
/** @namespace */
|
||||
class Request {
|
||||
/**
|
||||
* @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, prettyPrint: false },
|
||||
validateStatus: () => true,
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
this.#setupInterceptor();
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
#setupInterceptor() {
|
||||
this.instance.interceptors.request.use((config) => {
|
||||
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-youtube-client-version'] = this.session.context.client.clientVersion;
|
||||
|
||||
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;
|
||||
|
||||
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) => {
|
||||
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;
|
||||
180
lib/utils/Utils.js
Normal file
180
lib/utils/Utils.js
Normal file
@@ -0,0 +1,180 @@
|
||||
'use strict';
|
||||
|
||||
const Crypto = require('crypto');
|
||||
const UserAgent = require('user-agents');
|
||||
const Flatten = require('flat');
|
||||
|
||||
/** @namespace */
|
||||
class InnertubeError extends Error {
|
||||
constructor (message, info) {
|
||||
super(message);
|
||||
|
||||
info && (this.info = info);
|
||||
|
||||
this.date = new Date();
|
||||
this.version = require('../../package.json').version;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @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, null, 4).slice(0, 300)}..` });
|
||||
return flat_obj[result];
|
||||
}
|
||||
|
||||
function escapeStringRegexp(string) {
|
||||
return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
function getStringBetweenStrings(data, start_string, end_string) {
|
||||
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, 's');
|
||||
const match = data.match(regex);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random user agent.
|
||||
*
|
||||
* @param {string} type - mobile | desktop
|
||||
* @returns {object}
|
||||
*/
|
||||
function getRandomUserAgent(type) {
|
||||
switch (type) {
|
||||
case 'mobile':
|
||||
return new UserAgent(/Android/).data;
|
||||
case 'desktop':
|
||||
return new UserAgent({ deviceCategory: 'desktop' }).data;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an authentication token from a cookies' sid.
|
||||
*
|
||||
* @param {string} sid - Sid extracted from cookies
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateSidAuth(sid) {
|
||||
const youtube = 'https://www.youtube.com';
|
||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||
const input = [timestamp, sid, youtube].join(' ');
|
||||
|
||||
let hash = Crypto.createHash('sha1');
|
||||
let data = hash.update(input, 'utf-8');
|
||||
let gen_hash = data.digest('hex');
|
||||
|
||||
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.
|
||||
*
|
||||
* @param {string} time
|
||||
* @returns {number} seconds
|
||||
*/
|
||||
function timeToSeconds(time) {
|
||||
let params = time.split(':');
|
||||
return parseInt(({
|
||||
3: +params[0] * 3600 + +params[1] * 60 + +params[2],
|
||||
2: +params[0] * 60 + +params[1],
|
||||
1: +params[0]
|
||||
})[params.length]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts strings in camelCase to snake_case.
|
||||
*
|
||||
* @param {string} string The string in camelCase.
|
||||
* @returns {string}
|
||||
*/
|
||||
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
|
||||
*
|
||||
* @param {string} data
|
||||
* @returns {string}
|
||||
*/
|
||||
function refineNTokenData(data) {
|
||||
return data
|
||||
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
|
||||
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
|
||||
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)').replace(/,b,/g, ',"b",')
|
||||
.replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
|
||||
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",')
|
||||
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
|
||||
}
|
||||
|
||||
const errors = { InnertubeError, UnavailableContentError, ParsingError, DownloadError, MissingParamError, NoStreamingDataError };
|
||||
const functions = { findNode, getRandomUserAgent, generateSidAuth, generateRandomString, getStringBetweenStrings, camelToSnake, isValidClient, throwIfMissing, timeToSeconds, refineNTokenData };
|
||||
|
||||
module.exports = { ...functions, ...errors };
|
||||
7071
package-lock.json
generated
7071
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
79
package.json
79
package.json
@@ -1,46 +1,67 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.2.8",
|
||||
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos 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",
|
||||
"scripts": {
|
||||
"test": "node test"
|
||||
},
|
||||
"author": "LuanRT",
|
||||
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
|
||||
"funding": "https://ko-fi.com/luanrt",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"lint": "eslint ./",
|
||||
"lint:fix": "eslint --fix ./",
|
||||
"build:types": "npx tsc"
|
||||
},
|
||||
"types": "./typings/index.d.ts",
|
||||
"directories": {
|
||||
"example": "examples",
|
||||
"lib": "lib"
|
||||
"test": "./test",
|
||||
"typings": "./typings",
|
||||
"examples": "./examples",
|
||||
"lib": "./lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"protons": "^2.0.3",
|
||||
"time-to-seconds": "^1.1.5",
|
||||
"flat": "^5.0.2",
|
||||
"protocol-buffers-encodings": "^1.1.1",
|
||||
"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"
|
||||
},
|
||||
"keywords": [
|
||||
"youtube",
|
||||
"youtube-dl",
|
||||
"innertube",
|
||||
"innertubeapi",
|
||||
"livechat",
|
||||
"api",
|
||||
"search",
|
||||
"like",
|
||||
"dislike",
|
||||
"comment",
|
||||
"automation",
|
||||
"downloader",
|
||||
"comments-section",
|
||||
"youtube-downloader"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/LuanRT/YouTube.js/issues"
|
||||
},
|
||||
"homepage": "https://github.com/LuanRT/YouTube.js#readme"
|
||||
}
|
||||
"homepage": "https://github.com/LuanRT/YouTube.js#readme",
|
||||
"keywords": [
|
||||
"yt",
|
||||
"dl",
|
||||
"ytdl",
|
||||
"youtube",
|
||||
"youtubedl",
|
||||
"youtube-dl",
|
||||
"youtube-downloader",
|
||||
"innertube",
|
||||
"innertubeapi",
|
||||
"unofficial",
|
||||
"downloader",
|
||||
"livechat",
|
||||
"ytmusic",
|
||||
"dislike",
|
||||
"search",
|
||||
"comment",
|
||||
"music",
|
||||
"like",
|
||||
"api"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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=omhIaB28Jepv6Q&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&cver=2.20211101.01.00',
|
||||
original_ntoken: 'PqjqqJjdB9K821VIisj',
|
||||
expected_ntoken: 'AxwyS-osUl1WhMUd1',
|
||||
client_version: '2.20211101.01.00',
|
||||
test_video_id: 'FT_nzxtgXEw',
|
||||
test_video_id_1: 'YE7VzlLtp-4',
|
||||
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("");`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
const NToken = require('../lib/NToken');
|
||||
const SigDecipher = require('../lib/Sig');
|
||||
const Constants = require('./constants');
|
||||
|
||||
let failed_tests = 0;
|
||||
|
||||
async function performTests() {
|
||||
const youtube = await new Innertube().catch((error) => error);
|
||||
assert(youtube instanceof Error ? false : true, `should retrieve Innertube configuration data`, youtube);
|
||||
|
||||
if (!(youtube instanceof Error)) {
|
||||
const search = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
|
||||
assert((search instanceof Error ? false : true) && search.videos.length >= 1, `should search videos`, search);
|
||||
|
||||
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
|
||||
assert(details instanceof Error ? false : true, `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 ? false : true, `should retrieve comments for ${Constants.test_video_id}`, comments);
|
||||
|
||||
const video = await downloadVideo(Constants.test_video_id_1, youtube).catch((error) => error);
|
||||
assert(video instanceof Error ? false : true, `should download video (${Constants.test_video_id_1})`, video);
|
||||
}
|
||||
|
||||
const n_token = new NToken(Constants.n_scramble_sc).transform(Constants.original_ntoken);
|
||||
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
|
||||
|
||||
const transformed_url = new SigDecipher(Constants.test_url, Constants.client_version, { sig_decipher_sc: Constants.sig_decipher_sc, ntoken_sc: Constants.n_scramble_sc }).decipher();
|
||||
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url);
|
||||
|
||||
if (failed_tests > 0)
|
||||
throw new Error('Some 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';
|
||||
!outcome && (failed_tests += 1);
|
||||
console.info(pass_fail, ':', description, !outcome && `\nError: ${data}` || '');
|
||||
return outcome;
|
||||
};
|
||||
|
||||
performTests();
|
||||
111
test/main.test.js
Normal file
111
test/main.test.js
Normal 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
19
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
2
typings/index.d.ts
vendored
Normal file
2
typings/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _exports: typeof import("./lib/Innertube");
|
||||
export = _exports;
|
||||
330
typings/lib/Innertube.d.ts
vendored
Normal file
330
typings/lib/Innertube.d.ts
vendored
Normal 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
192
typings/lib/core/AccountManager.d.ts
vendored
Normal 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
331
typings/lib/core/Actions.d.ts
vendored
Normal 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
107
typings/lib/core/InteractionManager.d.ts
vendored
Normal 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
23
typings/lib/core/Livechat.d.ts
vendored
Normal 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
47
typings/lib/core/OAuth.d.ts
vendored
Normal 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
37
typings/lib/core/Player.d.ts
vendored
Normal 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
63
typings/lib/core/PlaylistManager.d.ts
vendored
Normal 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
23
typings/lib/core/SessionBuilder.d.ts
vendored
Normal 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
12
typings/lib/deciphers/NToken.d.ts
vendored
Normal 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
12
typings/lib/deciphers/Signature.d.ts
vendored
Normal 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
9
typings/lib/parser/index.d.ts
vendored
Normal 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
11
typings/lib/parser/youtube/index.d.ts
vendored
Normal 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 };
|
||||
15
typings/lib/parser/youtube/others/ChannelMetadata.d.ts
vendored
Normal file
15
typings/lib/parser/youtube/others/ChannelMetadata.d.ts
vendored
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
23
typings/lib/parser/youtube/others/CommentThread.d.ts
vendored
Normal file
23
typings/lib/parser/youtube/others/CommentThread.d.ts
vendored
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
12
typings/lib/parser/youtube/others/GridPlaylistItem.d.ts
vendored
Normal file
12
typings/lib/parser/youtube/others/GridPlaylistItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
25
typings/lib/parser/youtube/others/GridVideoItem.d.ts
vendored
Normal file
25
typings/lib/parser/youtube/others/GridVideoItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
14
typings/lib/parser/youtube/others/NotificationItem.d.ts
vendored
Normal file
14
typings/lib/parser/youtube/others/NotificationItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
15
typings/lib/parser/youtube/others/PlaylistItem.d.ts
vendored
Normal file
15
typings/lib/parser/youtube/others/PlaylistItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
9
typings/lib/parser/youtube/others/ShelfRenderer.d.ts
vendored
Normal file
9
typings/lib/parser/youtube/others/ShelfRenderer.d.ts
vendored
Normal 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;
|
||||
}
|
||||
31
typings/lib/parser/youtube/others/VideoItem.d.ts
vendored
Normal file
31
typings/lib/parser/youtube/others/VideoItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
7
typings/lib/parser/youtube/search/SearchSuggestionItem.d.ts
vendored
Normal file
7
typings/lib/parser/youtube/search/SearchSuggestionItem.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export = SearchSuggestionItem;
|
||||
declare class SearchSuggestionItem {
|
||||
static parse(data: any): {
|
||||
query: any;
|
||||
results: any;
|
||||
};
|
||||
}
|
||||
31
typings/lib/parser/youtube/search/VideoResultItem.d.ts
vendored
Normal file
31
typings/lib/parser/youtube/search/VideoResultItem.d.ts
vendored
Normal 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
9
typings/lib/parser/ytmusic/index.d.ts
vendored
Normal 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 };
|
||||
14
typings/lib/parser/ytmusic/others/PlaylistItem.d.ts
vendored
Normal file
14
typings/lib/parser/ytmusic/others/PlaylistItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
11
typings/lib/parser/ytmusic/search/AlbumResultItem.d.ts
vendored
Normal file
11
typings/lib/parser/ytmusic/search/AlbumResultItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
10
typings/lib/parser/ytmusic/search/ArtistResultItem.d.ts
vendored
Normal file
10
typings/lib/parser/ytmusic/search/ArtistResultItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
8
typings/lib/parser/ytmusic/search/MusicSearchSuggestionItem.d.ts
vendored
Normal file
8
typings/lib/parser/ytmusic/search/MusicSearchSuggestionItem.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export = MusicSearchSuggestionItem;
|
||||
declare class MusicSearchSuggestionItem {
|
||||
static parse(data: any): {
|
||||
query: any;
|
||||
results: any;
|
||||
};
|
||||
static parseItem(item: any): any;
|
||||
}
|
||||
11
typings/lib/parser/ytmusic/search/PlaylistResultItem.d.ts
vendored
Normal file
11
typings/lib/parser/ytmusic/search/PlaylistResultItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
12
typings/lib/parser/ytmusic/search/SongResultItem.d.ts
vendored
Normal file
12
typings/lib/parser/ytmusic/search/SongResultItem.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
4
typings/lib/parser/ytmusic/search/TopResultItem.d.ts
vendored
Normal file
4
typings/lib/parser/ytmusic/search/TopResultItem.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export = TopResultItem;
|
||||
declare class TopResultItem {
|
||||
static parse(data: any): any;
|
||||
}
|
||||
12
typings/lib/parser/ytmusic/search/VideoResultItem.d.ts
vendored
Normal file
12
typings/lib/parser/ytmusic/search/VideoResultItem.d.ts
vendored
Normal 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
113
typings/lib/proto/index.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user