mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 17:42:18 +00:00
Compare commits
461 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c2cdc1599 | ||
|
|
010704929f | ||
|
|
d4a938771b | ||
|
|
5ecfb08772 | ||
|
|
2029aec90d | ||
|
|
d589365ea2 | ||
|
|
45f33d8c04 | ||
|
|
92117eaaa0 | ||
|
|
39725374e3 | ||
|
|
213d78b1ab | ||
|
|
28f53a698d | ||
|
|
776a156f65 | ||
|
|
4a9bd32fd7 | ||
|
|
3170659880 | ||
|
|
e6f1f078a8 | ||
|
|
900f557202 | ||
|
|
7ca2a0c3e4 | ||
|
|
f95283b236 | ||
|
|
f6a7bcc44a | ||
|
|
c444843799 | ||
|
|
5fe91d6642 | ||
|
|
bff65f8889 | ||
|
|
dac5eb712d | ||
|
|
2068dfb73e | ||
|
|
3e84775fd3 | ||
|
|
89fa3b27a8 | ||
|
|
ab7201f0cc | ||
|
|
b21eb9f33d | ||
|
|
0751793380 | ||
|
|
5c91c2af4a | ||
|
|
47cad4c6e1 | ||
|
|
4fb9dff0f2 | ||
|
|
81dd5d3288 | ||
|
|
c7f42220db | ||
|
|
5204b29e81 | ||
|
|
cbaa838cee | ||
|
|
379e63d2f6 | ||
|
|
e86a0daf45 | ||
|
|
7fbc37f9d1 | ||
|
|
2e710dc9f7 | ||
|
|
fed3512461 | ||
|
|
6dd03e1658 | ||
|
|
2073aa910a | ||
|
|
f7b7bbd47a | ||
|
|
04d55d04c7 | ||
|
|
6082b4a52e | ||
|
|
3980f97b8f | ||
|
|
59f4cfb4db | ||
|
|
254f77944f | ||
|
|
586bb5f139 | ||
|
|
562e6a20f0 | ||
|
|
b7cacc34f3 | ||
|
|
8f07e49512 | ||
|
|
abd8a82cd0 | ||
|
|
7ffd0fc25e | ||
|
|
b50408fc1c | ||
|
|
9618f38fe1 | ||
|
|
e7efec2cf4 | ||
|
|
82d5d1e3e1 | ||
|
|
9c503f4fa8 | ||
|
|
4dd977e375 | ||
|
|
e4f2a00c84 | ||
|
|
fcd3044982 | ||
|
|
14578ac96a | ||
|
|
5c83e999df | ||
|
|
4e67240ff9 | ||
|
|
f938c34ee8 | ||
|
|
bd487f8bef | ||
|
|
48a5d4e7c3 | ||
|
|
37ae55a7c3 | ||
|
|
923232de07 | ||
|
|
a1c3ef8fbb | ||
|
|
5c9c231cc2 | ||
|
|
572e16c541 | ||
|
|
ed2cbf8a13 | ||
|
|
4261915fd4 | ||
|
|
f74ed5a1cf | ||
|
|
5ae15be63d | ||
|
|
a32aa8c633 | ||
|
|
4806fc6c11 | ||
|
|
95ed60207a | ||
|
|
b50e2001aa | ||
|
|
b60930a0c1 | ||
|
|
c66eb1fecf | ||
|
|
6a5a579e39 | ||
|
|
ff4ab1680e | ||
|
|
9007b65237 | ||
|
|
e02139532b | ||
|
|
db7f6209b2 | ||
|
|
312c636ec4 | ||
|
|
4c0de199e8 | ||
|
|
9ab528ec82 | ||
|
|
24ffb01aef | ||
|
|
eaac38c919 | ||
|
|
e627887fe0 | ||
|
|
beaa28f4c6 | ||
|
|
a45273fec4 | ||
|
|
bc97e07ac6 | ||
|
|
f35b4c2c8c | ||
|
|
c934325648 | ||
|
|
cd27acd25b | ||
|
|
83b42d2585 | ||
|
|
e54c0c4bf1 | ||
|
|
8e372d5c67 | ||
|
|
987f50604a | ||
|
|
69702085c6 | ||
|
|
d2959b3a55 | ||
|
|
68df321858 | ||
|
|
f4bc8508d0 | ||
|
|
e216124bb0 | ||
|
|
6d98abbd53 | ||
|
|
fba3fc9714 | ||
|
|
f94ea6cf91 | ||
|
|
86fb33ed03 | ||
|
|
bff4210349 | ||
|
|
91de6e5c0e | ||
|
|
c26972c42a | ||
|
|
8bc2aaa358 | ||
|
|
2e5f076fd7 | ||
|
|
0412fa05ff | ||
|
|
10c15bfb9f | ||
|
|
4862c35cee | ||
|
|
2eed1726d5 | ||
|
|
8b69587787 | ||
|
|
ed7be2a675 | ||
|
|
361fb4a9f1 | ||
|
|
1c3ea2acd3 | ||
|
|
859c4585d9 | ||
|
|
751f2b90fd | ||
|
|
90be877d28 | ||
|
|
052632314b | ||
|
|
22a38c0762 | ||
|
|
f7614634b6 | ||
|
|
bf1510b235 | ||
|
|
815e54b854 | ||
|
|
f7666051f6 | ||
|
|
494ee8776a | ||
|
|
87ed3960ff | ||
|
|
eb3cca1e2e | ||
|
|
9971ffe021 | ||
|
|
7949b3df66 | ||
|
|
aa385142e4 | ||
|
|
6c8a916f0f | ||
|
|
31d27b1bca | ||
|
|
cb37c6a17b | ||
|
|
1ff3e1a440 | ||
|
|
46fe18b763 | ||
|
|
0dda97e0b0 | ||
|
|
e370116092 | ||
|
|
3bc53a8c12 | ||
|
|
74e1a5e068 | ||
|
|
0fa5a859ae | ||
|
|
02a111250a | ||
|
|
c1886f9a83 | ||
|
|
5f4cbdb904 | ||
|
|
d91695a9ec | ||
|
|
137464ca66 | ||
|
|
6997982cf2 | ||
|
|
18cbc8c038 | ||
|
|
30ff087587 | ||
|
|
1a034733f6 | ||
|
|
c477b824c0 | ||
|
|
7e5c3648c1 | ||
|
|
bdd98a3b9b | ||
|
|
06750aaa74 | ||
|
|
708c5f7394 | ||
|
|
a9cdbf7010 | ||
|
|
b50d1ef67d | ||
|
|
555d257459 | ||
|
|
2aef67876e | ||
|
|
ae2557d15c | ||
|
|
8c688efb4a | ||
|
|
cffa868c6e | ||
|
|
f267fcd8be | ||
|
|
23c22a93c4 | ||
|
|
1ca20836bf | ||
|
|
5f058e69ae | ||
|
|
3500e92632 | ||
|
|
3f57c2fa5c | ||
|
|
7528ebdb60 | ||
|
|
5e3846259f | ||
|
|
222dfce6bb | ||
|
|
83cbfd631b | ||
|
|
4f9427d752 | ||
|
|
07c1b3e0e5 | ||
|
|
89548ad48a | ||
|
|
519be72445 | ||
|
|
e434bb2632 | ||
|
|
a11e5962c6 | ||
|
|
77b39c79ee | ||
|
|
7c530d30ee | ||
|
|
1e07a184ff | ||
|
|
5de7b24dc5 | ||
|
|
01fd1ee72a | ||
|
|
84b4f1efd1 | ||
|
|
046103a4d8 | ||
|
|
beb4733e84 | ||
|
|
66b026bf49 | ||
|
|
26734194ab | ||
|
|
38a83c3c2a | ||
|
|
b1f19f16ac | ||
|
|
891d889408 | ||
|
|
d4adb9eb6b | ||
|
|
3b0498b68b | ||
|
|
154a5d2868 | ||
|
|
7c0abfccd7 | ||
|
|
8f50c668aa | ||
|
|
4f7ec07c3f | ||
|
|
ab3d5ab16c | ||
|
|
dd21f8c75a | ||
|
|
3a7e58d2b9 | ||
|
|
75ea09dde8 | ||
|
|
95e0294eab | ||
|
|
22ae6c93ee | ||
|
|
257bd475a0 | ||
|
|
f66f0bd656 | ||
|
|
05de3ec97a | ||
|
|
a0566969ba | ||
|
|
a9cad49333 | ||
|
|
096bf362c9 | ||
|
|
ec9c0979f5 | ||
|
|
342d1d95e9 | ||
|
|
dbfc569602 | ||
|
|
c16a967987 | ||
|
|
a07375eb20 | ||
|
|
ce9d9c56b4 | ||
|
|
f50ce1a06b | ||
|
|
3b6ccfa3d8 | ||
|
|
878488d1b3 | ||
|
|
3c94c9da4b | ||
|
|
0b301de6a1 | ||
|
|
c9135e66d3 | ||
|
|
e82c843928 | ||
|
|
be71d7c937 | ||
|
|
470d8d9406 | ||
|
|
2c5907f80f | ||
|
|
ade5feb31c | ||
|
|
13ebf0a039 | ||
|
|
cb8fafe94b | ||
|
|
bd35faa597 | ||
|
|
a8b507ee65 | ||
|
|
e7eacd9742 | ||
|
|
1c72a41675 | ||
|
|
62a68b207c | ||
|
|
1d9587e8c1 | ||
|
|
a90e5e0d07 | ||
|
|
955c8010a6 | ||
|
|
b2269deb79 | ||
|
|
573c8643aa | ||
|
|
e21542c227 | ||
|
|
9d912e5938 | ||
|
|
7ca0607004 | ||
|
|
20d84265b5 | ||
|
|
b13bf6e992 | ||
|
|
3d3436472f | ||
|
|
1a2fc3abd7 | ||
|
|
8ef4b42d44 | ||
|
|
b71f03caf2 | ||
|
|
dae7d6e40c | ||
|
|
2cee59024c | ||
|
|
ffd7d79308 | ||
|
|
9b005d62d6 | ||
|
|
a8e7e644ec | ||
|
|
ad1d3dbf91 | ||
|
|
3df3261488 | ||
|
|
1b1ce41c00 | ||
|
|
b82b720e4b | ||
|
|
4784dfa563 | ||
|
|
3e4d41bf06 | ||
|
|
9f1c31d7a0 | ||
|
|
9cb4530299 | ||
|
|
cb9a0c5410 | ||
|
|
427db5bbc2 | ||
|
|
2b29244b41 | ||
|
|
f9754f5ac6 | ||
|
|
b2253df802 | ||
|
|
f3517708ff | ||
|
|
0d35fe0ca5 | ||
|
|
3e3dc351bb | ||
|
|
197bb759cd | ||
|
|
c76b24b3f4 | ||
|
|
574b67a1f7 | ||
|
|
9b2738f128 | ||
|
|
95f1d4077f | ||
|
|
a511608f18 | ||
|
|
cf8a33c79f | ||
|
|
cfc1a183e0 | ||
|
|
95033e723e | ||
|
|
2cc7b8bcd6 | ||
|
|
2d774e26aa | ||
|
|
214aa147ce | ||
|
|
ce53ac1843 | ||
|
|
0ad26f28d9 | ||
|
|
4c7b8a3403 | ||
|
|
33a6e740d7 | ||
|
|
0b1840a62c | ||
|
|
f4e0f30e6e | ||
|
|
200632f374 | ||
|
|
f933cb45bc | ||
|
|
a0e6cef00f | ||
|
|
a0bfe16427 | ||
|
|
9d352b58eb | ||
|
|
6b6c80ddf1 | ||
|
|
58a6c84121 | ||
|
|
63b1261b7c | ||
|
|
d2eff3bfb8 | ||
|
|
b668ba8cfb | ||
|
|
0b88575614 | ||
|
|
bed0ff4154 | ||
|
|
27a50a2a7e | ||
|
|
d4f2d704bb | ||
|
|
97f181b212 | ||
|
|
251ed74bba | ||
|
|
1cdf701c84 | ||
|
|
bf12740333 | ||
|
|
0d77b59945 | ||
|
|
6e30309f56 | ||
|
|
e37cf62732 | ||
|
|
567fdbaf52 | ||
|
|
0a22319d9e | ||
|
|
eb72c2f6ef | ||
|
|
2ccbe2ce62 | ||
|
|
a69e43bf3a | ||
|
|
b2900f48a7 | ||
|
|
d612590530 | ||
|
|
e82e23dfbb | ||
|
|
f62c66db39 | ||
|
|
de61782f1a | ||
|
|
ceefbed98c | ||
|
|
315d89f84a | ||
|
|
2ea3602b61 | ||
|
|
b7df3d6df4 | ||
|
|
2acb7da019 | ||
|
|
0b991800a5 | ||
|
|
50ef71284d | ||
|
|
d6c5a9b971 | ||
|
|
0fc29f0bbf | ||
|
|
2bbefefbb7 | ||
|
|
13ad3774c9 | ||
|
|
8051a7dee6 | ||
|
|
2842b1d917 | ||
|
|
870b2811d9 | ||
|
|
1aedbd3ea6 | ||
|
|
e8af2a603d | ||
|
|
8e37efa575 | ||
|
|
5a362a0bd5 | ||
|
|
89ee68b084 | ||
|
|
dca61c3a22 | ||
|
|
56e6e23453 | ||
|
|
00fa514b03 | ||
|
|
d36389c865 | ||
|
|
55ca986888 | ||
|
|
b04df7e119 | ||
|
|
d8d92866d1 | ||
|
|
b4b0731589 | ||
|
|
d69d701869 | ||
|
|
cd4d28c951 | ||
|
|
22b9c174bb | ||
|
|
b704c8e78c | ||
|
|
bbfeb99f55 | ||
|
|
f2adeeeab4 | ||
|
|
3756e63996 | ||
|
|
a27807b6c1 | ||
|
|
5cfb969e33 | ||
|
|
1163125f5c | ||
|
|
9ac5043309 | ||
|
|
6a4b4f3359 | ||
|
|
2b3642ba63 | ||
|
|
fb2e237284 | ||
|
|
6f3deaf16a | ||
|
|
d4382e81c3 | ||
|
|
89956cab46 | ||
|
|
ac9341c769 | ||
|
|
cac762569a | ||
|
|
9978ebf085 | ||
|
|
b036e2fcdc | ||
|
|
e37f42f41b | ||
|
|
883a023624 | ||
|
|
506834b253 | ||
|
|
87e7ef77eb | ||
|
|
27fdd8268a | ||
|
|
d4ea87b8b0 | ||
|
|
ec87eea20d | ||
|
|
e43ad202f4 | ||
|
|
104c36b450 | ||
|
|
f5d61d70f2 | ||
|
|
c76f5f478d | ||
|
|
49d1432b5a | ||
|
|
be157ef016 | ||
|
|
9f703203b6 | ||
|
|
516eeeff45 | ||
|
|
6caa679df6 | ||
|
|
2a87f42b32 | ||
|
|
f7c1e0f249 | ||
|
|
fe4c5433cf | ||
|
|
0e5e0c0fab | ||
|
|
f0fd6146c7 | ||
|
|
43061970c6 | ||
|
|
746023d9bb | ||
|
|
3102479dd9 | ||
|
|
c7a13c948c | ||
|
|
ec875ba321 | ||
|
|
db77bba802 | ||
|
|
5ea0a0ebf8 | ||
|
|
0130229236 | ||
|
|
da517fe6d1 | ||
|
|
95ff1e6c5e | ||
|
|
0f8adfd9b8 | ||
|
|
b514765354 | ||
|
|
3cbcd71a3a | ||
|
|
4c00f15f55 | ||
|
|
ea1d206b26 | ||
|
|
aa334aacbd | ||
|
|
1eda93ee08 | ||
|
|
fe0ac0a961 | ||
|
|
8740deb1f2 | ||
|
|
d71b762df5 | ||
|
|
dc14d3785f | ||
|
|
088f909515 | ||
|
|
2a78d77aa3 | ||
|
|
1b2862c00f | ||
|
|
477c030084 | ||
|
|
19d579df13 | ||
|
|
5313c57783 | ||
|
|
190f7681be | ||
|
|
6e027bcc85 | ||
|
|
6b531dd0ea | ||
|
|
92f24076db | ||
|
|
a9eba7ca62 | ||
|
|
2f56c15ecc | ||
|
|
95e0479745 | ||
|
|
556c7cd6e8 | ||
|
|
a4a88419ef | ||
|
|
aefecd061e | ||
|
|
7485726f1e | ||
|
|
9e703abe3a | ||
|
|
affbe84284 | ||
|
|
fcbdae3e34 | ||
|
|
059c858021 | ||
|
|
4ecd3360e0 | ||
|
|
08e9527931 | ||
|
|
a9f03a1523 | ||
|
|
c8980c7985 | ||
|
|
2e5688f235 | ||
|
|
dcf2b720a0 | ||
|
|
a90f5eb853 | ||
|
|
c6482e07b9 | ||
|
|
2de77c8f2c | ||
|
|
2aaa209906 | ||
|
|
ab028ba1ec | ||
|
|
f2f48af1bc | ||
|
|
3a7da21fd1 | ||
|
|
89794d65da | ||
|
|
91847ae3cc | ||
|
|
eb44b71939 | ||
|
|
88ebb5e2ae | ||
|
|
b237b6af4e | ||
|
|
9e618cc576 | ||
|
|
daf95cfe87 | ||
|
|
bc03c91df9 | ||
|
|
e00be25bf4 |
@@ -5,4 +5,5 @@ cache/
|
||||
src/proto/youtube.ts
|
||||
coverage/
|
||||
node_modules/
|
||||
dist/
|
||||
dist/
|
||||
src/proto/generated/
|
||||
@@ -8,10 +8,13 @@ extends: [ eslint:recommended, 'plugin:@typescript-eslint/recommended' ]
|
||||
parser: '@typescript-eslint/parser'
|
||||
parserOptions:
|
||||
ecmaVersion: latest
|
||||
project:
|
||||
- tsconfig.json
|
||||
overrides:
|
||||
-
|
||||
files:
|
||||
- '**/*.js'
|
||||
- '**/*.mjs'
|
||||
rules:
|
||||
'tsdoc/syntax': 'off'
|
||||
rules:
|
||||
@@ -30,6 +33,8 @@ rules:
|
||||
'@typescript-eslint/ban-types': 'off'
|
||||
'tsdoc/syntax': 'warn'
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
'@typescript-eslint/consistent-type-imports': 'error'
|
||||
'@typescript-eslint/consistent-type-exports': 'error'
|
||||
|
||||
no-template-curly-in-string: error
|
||||
no-unreachable-loop: error
|
||||
@@ -75,6 +80,7 @@ rules:
|
||||
prefer-template: error
|
||||
|
||||
keyword-spacing: ["error", { "before": true } ]
|
||||
object-curly-spacing: ["warn", "always"]
|
||||
array-bracket-spacing: ["error", "always"]
|
||||
arrow-parens: ["error", "always"]
|
||||
comma-dangle: ["error", "never"]
|
||||
|
||||
8
.github/labeler_config.yml
vendored
8
.github/labeler_config.yml
vendored
@@ -2,13 +2,7 @@ version: 1
|
||||
labels:
|
||||
- label: "breaking-change"
|
||||
title: "^refactor!:.*"
|
||||
|
||||
- label: "enhancement"
|
||||
title: "^feat:.*"
|
||||
|
||||
- label: "bug"
|
||||
title: "^fix:.*"
|
||||
|
||||
|
||||
- label: "github"
|
||||
files:
|
||||
- ".github/.*"
|
||||
|
||||
33
.github/pull_request_template.md
vendored
33
.github/pull_request_template.md
vendored
@@ -1,27 +1,6 @@
|
||||
# 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
|
||||
<!-- Thank you for submitting a Pull Request! Please:
|
||||
* Read our contributing guidelines: https://github.com/LuanRT/YouTube.js/blob/main/CONTRIBUTING.md
|
||||
* Add "Fixes #<issue_number>" to the PR description if you are fixing an issue.
|
||||
* Ensure that the code is up-to-date with the `main` branch.
|
||||
* Include a description of the proposed changes and how to test them.
|
||||
-->
|
||||
20
.github/release.yml
vendored
20
.github/release.yml
vendored
@@ -1,20 +0,0 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
authors:
|
||||
- octocat
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- Semver-Major
|
||||
- breaking-change
|
||||
- title: New Features
|
||||
labels:
|
||||
- Semver-Minor
|
||||
- enhancement
|
||||
- title: Bug Fixes
|
||||
- bug
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@@ -5,9 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: srvaroa/labeler@master
|
||||
with:
|
||||
|
||||
25
.github/workflows/lint.yml
vendored
25
.github/workflows/lint.yml
vendored
@@ -1,17 +1,18 @@
|
||||
name: Lint
|
||||
name: lint
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
name: Lint
|
||||
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
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
26
.github/workflows/node.js.yml
vendored
26
.github/workflows/node.js.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [ 16.x, 18.x ]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm install
|
||||
- run: npm run test
|
||||
62
.github/workflows/release-please.yml
vendored
Normal file
62
.github/workflows/release-please.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: release-please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: google-github-actions/release-please-action@v3
|
||||
id: release
|
||||
with:
|
||||
release-type: node
|
||||
package-name: youtubei.js
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: "16.x"
|
||||
- name: Build for Deno
|
||||
run: |
|
||||
npm ci
|
||||
npm run build:deno
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- name: Move Deno files
|
||||
run: |
|
||||
mkdir build
|
||||
mv deno build/deno
|
||||
cp deno.ts build/deno.ts
|
||||
cp {LICENSE,README.md} build
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- name: Push to the Deno branch
|
||||
uses: s0/git-publish-subdir-action@develop
|
||||
env:
|
||||
REPO: self
|
||||
BRANCH: deno
|
||||
FOLDER: ./build
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SKIP_EMPTY_COMMITS: true
|
||||
MESSAGE: "chore: ${{ steps.release.outputs.tag_name }} release"
|
||||
TAG: ${{ steps.release.outputs.tag_name }}-deno
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- name: Remove Deno folder
|
||||
run: rm -rf build
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "16.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- name: Publish package to npmjs
|
||||
run: |
|
||||
npm ci
|
||||
npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
5
.github/workflows/stale.yml
vendored
5
.github/workflows/stale.yml
vendored
@@ -11,9 +11,8 @@ jobs:
|
||||
- uses: actions/stale@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.'
|
||||
stale-issue-message: 'This issue has been automatically marked as stale because it has not had 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'
|
||||
any-of-issue-labels: 'needs-more-info,cannot-reproduce,question,help-wanted'
|
||||
days-before-stale: 60
|
||||
days-before-close: 4
|
||||
18
.github/workflows/test.yml
vendored
Normal file
18
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm ci
|
||||
- run: npm run test
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -66,6 +66,12 @@ tmp/
|
||||
dist/
|
||||
bundle/*.js.*
|
||||
bundle/*.js
|
||||
bundle/*.cjs
|
||||
bundle/*.cjs.*
|
||||
deno/
|
||||
|
||||
# VSCode files
|
||||
.vscode/
|
||||
|
||||
# MacOS
|
||||
.DS_Store
|
||||
|
||||
597
CHANGELOG.md
Normal file
597
CHANGELOG.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# Changelog
|
||||
|
||||
## [9.2.1](https://github.com/LuanRT/YouTube.js/compare/v9.2.0...v9.2.1) (2024-04-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **toDash:** Add missing transfer characteristics for h264 streams ([#631](https://github.com/LuanRT/YouTube.js/issues/631)) ([0107049](https://github.com/LuanRT/YouTube.js/commit/010704929fa4b737f68a5a1f10bf0b166cfbf905))
|
||||
|
||||
## [9.2.0](https://github.com/LuanRT/YouTube.js/compare/v9.1.0...v9.2.0) (2024-03-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add support of cloudflare workers ([#596](https://github.com/LuanRT/YouTube.js/issues/596)) ([2029aec](https://github.com/LuanRT/YouTube.js/commit/2029aec90de3c0fdb022094d7b704a2feed4133b))
|
||||
* **parser:** Support CommentView nodes ([#614](https://github.com/LuanRT/YouTube.js/issues/614)) ([900f557](https://github.com/LuanRT/YouTube.js/commit/900f5572021d348e7012909f2080e52eac06adae))
|
||||
* **parser:** Support LockupView and it's child nodes ([#609](https://github.com/LuanRT/YouTube.js/issues/609)) ([7ca2a0c](https://github.com/LuanRT/YouTube.js/commit/7ca2a0c3e43ebd4b9443e69b7432f302b09e9c7a))
|
||||
* **Text:** Support formatting and emojis in `fromAttributed` ([#615](https://github.com/LuanRT/YouTube.js/issues/615)) ([e6f1f07](https://github.com/LuanRT/YouTube.js/commit/e6f1f078a828f8ea5db1fe7aec9f677bc53694e3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Cache:** handle the value read from the db correctly according to its type ([#620](https://github.com/LuanRT/YouTube.js/issues/620)) ([3170659](https://github.com/LuanRT/YouTube.js/commit/317065988007c860bf6173b0ac3c3d685ed81d20))
|
||||
* **PlayerEndpoint:** Workaround for "The following content is not available on this app" (Android) ([#624](https://github.com/LuanRT/YouTube.js/issues/624)) ([d589365](https://github.com/LuanRT/YouTube.js/commit/d589365ea27f540ff36e33a65362c932cd28c274))
|
||||
|
||||
## [9.1.0](https://github.com/LuanRT/YouTube.js/compare/v9.0.2...v9.1.0) (2024-02-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Format:** Support caption tracks in adaptive formats ([#598](https://github.com/LuanRT/YouTube.js/issues/598)) ([bff65f8](https://github.com/LuanRT/YouTube.js/commit/bff65f8889c32813ec05bd187f3a4386fc6127c0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Playlist:** `items` getter failing if a playlist contains Shorts ([89fa3b2](https://github.com/LuanRT/YouTube.js/commit/89fa3b27a839d98aaf8bd70dd75220ee309c2bea))
|
||||
* **Session:** Don't try to extract api version from service worker ([2068dfb](https://github.com/LuanRT/YouTube.js/commit/2068dfb73eefc0e40157421d4e5b4096c0c8429c))
|
||||
|
||||
## [9.0.2](https://github.com/LuanRT/YouTube.js/compare/v9.0.1...v9.0.2) (2024-01-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **VideoInfo:** Fix error because of typo in getWatchNextContinuation ([#590](https://github.com/LuanRT/YouTube.js/issues/590)) ([b21eb9f](https://github.com/LuanRT/YouTube.js/commit/b21eb9f33d956e130bac98712384125ae04ace47))
|
||||
|
||||
## [9.0.1](https://github.com/LuanRT/YouTube.js/compare/v9.0.0...v9.0.1) (2024-01-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** Circular imports causing issues with webpack ([81dd5d3](https://github.com/LuanRT/YouTube.js/commit/81dd5d3288771132e7fb904b620e58277f639ccc))
|
||||
|
||||
## [9.0.0](https://github.com/LuanRT/YouTube.js/compare/v8.2.0...v9.0.0) (2024-01-25)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **toDash:** Add support for generating manifests for Post Live DVR videos ([#580](https://github.com/LuanRT/YouTube.js/issues/580))
|
||||
|
||||
### Features
|
||||
|
||||
* **Channel:** Support getting about with PageHeader ([#581](https://github.com/LuanRT/YouTube.js/issues/581)) ([2e710dc](https://github.com/LuanRT/YouTube.js/commit/2e710dc9f7e206627f189df19be17009b270bc8b))
|
||||
* **Channel:** Support PageHeader being used on user channels ([#577](https://github.com/LuanRT/YouTube.js/issues/577)) ([6082b4a](https://github.com/LuanRT/YouTube.js/commit/6082b4a52ee07a622735e6e9128a0531a5ae3bfb))
|
||||
* **Format:** Add `max_dvr_duration_sec` and `target_duration_dec` ([#570](https://github.com/LuanRT/YouTube.js/issues/570)) ([586bb5f](https://github.com/LuanRT/YouTube.js/commit/586bb5f1398d68bfabfb9449f527e53c398c3767))
|
||||
* **parser:** Add `ImageBannerView` ([#583](https://github.com/LuanRT/YouTube.js/issues/583)) ([2073aa9](https://github.com/LuanRT/YouTube.js/commit/2073aa910a0e441a8ec1a9ba0434051ec0e2e6a9))
|
||||
* **toDash:** Add support for generating manifests for Post Live DVR videos ([#580](https://github.com/LuanRT/YouTube.js/issues/580)) ([6dd03e1](https://github.com/LuanRT/YouTube.js/commit/6dd03e1658036c2fba0696de81033b5e16abb379))
|
||||
* **VideoDetails:** Add `is_live_dvr_enabled`, `is_low_latency_live_stream` and `live_chunk_readahead` ([#569](https://github.com/LuanRT/YouTube.js/issues/569)) ([254f779](https://github.com/LuanRT/YouTube.js/commit/254f77944fcd398cc19cb62b82b0fdfbe6ed70ed))
|
||||
* **VideoInfo:** Add live stream `end_timestamp` ([#571](https://github.com/LuanRT/YouTube.js/issues/571)) ([562e6a2](https://github.com/LuanRT/YouTube.js/commit/562e6a20f06ef5302af427861355215630d91edc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **DecoratedAvatarView:** Fix parsing and optional properties ([#584](https://github.com/LuanRT/YouTube.js/issues/584)) ([fed3512](https://github.com/LuanRT/YouTube.js/commit/fed3512461277b7fc41e26c770e2bd3d4a7d5eb5))
|
||||
* **PlayerCaptionTracklist:** Fix `captions_tracks[].kind` type ([#586](https://github.com/LuanRT/YouTube.js/issues/586)) ([7fbc37f](https://github.com/LuanRT/YouTube.js/commit/7fbc37f9d1c109e448085d5736326dce82ca2c9a))
|
||||
* **proto:** Fix visitor data base64url decoding ([#576](https://github.com/LuanRT/YouTube.js/issues/576)) ([3980f97](https://github.com/LuanRT/YouTube.js/commit/3980f97b8fca05f95cda1ab347db9402c55b8b3c))
|
||||
* **toDash:** Add missing transfer characteristics for h264 streams ([#573](https://github.com/LuanRT/YouTube.js/issues/573)) ([59f4cfb](https://github.com/LuanRT/YouTube.js/commit/59f4cfb4db6184d47f0a6634832055e9ce71f644))
|
||||
|
||||
## [8.2.0](https://github.com/LuanRT/YouTube.js/compare/v8.1.0...v8.2.0) (2024-01-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **OAuth:** Allow passing custom client identity ([#566](https://github.com/LuanRT/YouTube.js/issues/566)) ([7ffd0fc](https://github.com/LuanRT/YouTube.js/commit/7ffd0fc25edef99a938e7986b1c74af05b8f954e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Parser:** Add `SortFilterHeader` ([#563](https://github.com/LuanRT/YouTube.js/issues/563)) ([8f07e49](https://github.com/LuanRT/YouTube.js/commit/8f07e49512c59eb72debc80a9d9623ca62330858))
|
||||
|
||||
## [8.1.0](https://github.com/LuanRT/YouTube.js/compare/v8.0.0...v8.1.0) (2023-12-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **generator:** add support for arrays ([#556](https://github.com/LuanRT/YouTube.js/issues/556)) ([e4f2a00](https://github.com/LuanRT/YouTube.js/commit/e4f2a00c843fe453cc7904f79e35597cc6e2e619))
|
||||
* **generator:** Add support for generating view models ([#550](https://github.com/LuanRT/YouTube.js/issues/550)) ([f938c34](https://github.com/LuanRT/YouTube.js/commit/f938c34ee81186774096b3d24d06250211ce2851))
|
||||
* **MediaInfo:** Parse player config ([5c9c231](https://github.com/LuanRT/YouTube.js/commit/5c9c231cc2f17c49da03daa8262043b985320e9a))
|
||||
* **parser:** Support new like and dislike nodes ([#557](https://github.com/LuanRT/YouTube.js/issues/557)) ([fcd3044](https://github.com/LuanRT/YouTube.js/commit/fcd30449821763e9b5b57718dd02eff15d964d2b))
|
||||
* **Thumbnail:** Support `sources` in `Thumbnail.fromResponse` ([#552](https://github.com/LuanRT/YouTube.js/issues/552)) ([48a5d4e](https://github.com/LuanRT/YouTube.js/commit/48a5d4e7c37b76f8980f9b68e8815aef7a6d91ab))
|
||||
* **YouTube:** Add FEchannels feed ([#560](https://github.com/LuanRT/YouTube.js/issues/560)) ([14578ac](https://github.com/LuanRT/YouTube.js/commit/14578ac96af4b8bee652cce87d043173de964113))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Format:** Extract correct audio language from captions ([#553](https://github.com/LuanRT/YouTube.js/issues/553)) ([5c83e99](https://github.com/LuanRT/YouTube.js/commit/5c83e999dfa00386d18369f42aa9aa10123ba578))
|
||||
* **generator:** Output Parser.parseItem() calls with one valid type, without the array ([#551](https://github.com/LuanRT/YouTube.js/issues/551)) ([bd487f8](https://github.com/LuanRT/YouTube.js/commit/bd487f8befe7f62022c61ff3aae7f487104e81eb))
|
||||
* **VideoInfo:** Restore `like`, `dislike` & `removeRating` methods ([9c503f4](https://github.com/LuanRT/YouTube.js/commit/9c503f4fa8a750558cedbeca974faf36e304147e))
|
||||
|
||||
## [8.0.0](https://github.com/LuanRT/YouTube.js/compare/v7.0.0...v8.0.0) (2023-12-01)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **Library:** Add support for the new layout and remove profile & stats info
|
||||
* **Channel:** YouTube removed the "Channels" tab on channels, so this pull request removes the `getChannels()` method and `has_channels` getter from the `YT.Channel` class, as they are no longer useful. The featured channels are now shown on the channel home tab. To get them you can use the `channels` getter on the home tab of the channel. Please note that some channel owners might not have added that section to their home page yet, so you won't be able to get the featured channels for those channels. The home tab is the default tab that is returned when you call `InnerTube#getChannel()`, you can also access that tab by calling `getHome()` on a `YT.Channel` object.
|
||||
|
||||
### Features
|
||||
|
||||
* add `FeedNudge` ([#533](https://github.com/LuanRT/YouTube.js/issues/533)) ([e021395](https://github.com/LuanRT/YouTube.js/commit/e02139532b2c07aaf72dd1bd8610f63b6780001d))
|
||||
* add `VideoAttributeView` ([#531](https://github.com/LuanRT/YouTube.js/issues/531)) ([ff4ab16](https://github.com/LuanRT/YouTube.js/commit/ff4ab1680e110fc32e09d09215fd3e05dbde2c85))
|
||||
* Add Shorts endpoint ([#512](https://github.com/LuanRT/YouTube.js/issues/512)) ([a32aa8c](https://github.com/LuanRT/YouTube.js/commit/a32aa8c633b6f3c3bb0695ad1878cbb313867346))
|
||||
* **Channel:** Support new about popup ([#537](https://github.com/LuanRT/YouTube.js/issues/537)) ([c66eb1f](https://github.com/LuanRT/YouTube.js/commit/c66eb1fecf0e66d9eca841be0ca56b39ad4466eb))
|
||||
* **parser:** Add `ChannelOwnerEmptyState` ([#541](https://github.com/LuanRT/YouTube.js/issues/541)) ([b60930a](https://github.com/LuanRT/YouTube.js/commit/b60930a0c1ce419dddb753846c84d4e46ddf04e1))
|
||||
* **Parser:** Add `ClipSection` ([#532](https://github.com/LuanRT/YouTube.js/issues/532)) ([9007b65](https://github.com/LuanRT/YouTube.js/commit/9007b652375e1ca3c3844bdf091fe3670f98dc2c))
|
||||
* **toDash:** Add `contentType` to audio and video adaption sets ([#539](https://github.com/LuanRT/YouTube.js/issues/539)) ([4806fc6](https://github.com/LuanRT/YouTube.js/commit/4806fc6c112cb3cf0584f7d253f3c4aeaffa9927))
|
||||
* Use `overrides` instead of `--legacy-peer-deps` ([#529](https://github.com/LuanRT/YouTube.js/issues/529)) ([db7f620](https://github.com/LuanRT/YouTube.js/commit/db7f6209b2329bf18b8b35aababfdb9b750c3b0f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Channel:** Remove `getChannels()` and `has_channels`, as YouTube removed the tab ([#542](https://github.com/LuanRT/YouTube.js/issues/542)) ([6a5a579](https://github.com/LuanRT/YouTube.js/commit/6a5a579e3947109af0e7c2a318aef40edb8484f8))
|
||||
* **Library:** Add support for the new layout and remove profile & stats info ([4261915](https://github.com/LuanRT/YouTube.js/commit/4261915fd4aa84f7619a45d678910be0ae30e13e))
|
||||
* **StructuredDescriptionContent:** Add `ReelShelf` to list of possible nodes ([f74ed5a](https://github.com/LuanRT/YouTube.js/commit/f74ed5a1cf352a7b57fa84b9373f9ed9ba1911fc))
|
||||
* **VideoAttributeView:** Fix `image` and `overflow_menu_on_tap` props ([5ae15be](https://github.com/LuanRT/YouTube.js/commit/5ae15be63dee2a2393a1aa2a308ca5378140760a))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* Use named Parser import, to allow bundlers to create direct function references ([#535](https://github.com/LuanRT/YouTube.js/issues/535)) ([95ed602](https://github.com/LuanRT/YouTube.js/commit/95ed60207a1219f4891f28d2b2b90cf816f11831))
|
||||
|
||||
## [7.0.0](https://github.com/LuanRT/YouTube.js/compare/v6.4.1...v7.0.0) (2023-10-28)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **music#getSearchSuggestions:** Return array of `SearchSuggestionsSection` instead of a single node
|
||||
|
||||
### Features
|
||||
|
||||
* **Kids:** Add `blockChannel` command to easily block channels ([#503](https://github.com/LuanRT/YouTube.js/issues/503)) ([9ab528e](https://github.com/LuanRT/YouTube.js/commit/9ab528ec823dcd527a97150009eed632c6d3eb6a))
|
||||
* **music#getSearchSuggestions:** Return array of `SearchSuggestionsSection` instead of a single node ([beaa28f](https://github.com/LuanRT/YouTube.js/commit/beaa28f4c68de8366caa84ce5a026bf9e12e1b9d))
|
||||
* **parser:** Add `PlayerOverflow` and `PlayerControlsOverlay` ([a45273f](https://github.com/LuanRT/YouTube.js/commit/a45273fec498df87eecd364ffb708c9f787793d5))
|
||||
* **UpdateViewerShipAction:** Add `original_view_count` and `unlabeled_view_count_value` ([#527](https://github.com/LuanRT/YouTube.js/issues/527)) ([bc97e07](https://github.com/LuanRT/YouTube.js/commit/bc97e07ac6d1cdc45194e214c6001cf92190e1d5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** Inline package.json import to avoid runtime erros ([#509](https://github.com/LuanRT/YouTube.js/issues/509)) ([4c0de19](https://github.com/LuanRT/YouTube.js/commit/4c0de199e85dd5cc8b3719920b24dec9613acaab))
|
||||
|
||||
## [6.4.1](https://github.com/LuanRT/YouTube.js/compare/v6.4.0...v6.4.1) (2023-10-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Feed:** Do not throw when multiple continuations are present ([8e372d5](https://github.com/LuanRT/YouTube.js/commit/8e372d5c67f148be288bb0485f2c70ec43fbecd0))
|
||||
* **Playlist:** Throw a more helpful error when parsing empty responses ([987f506](https://github.com/LuanRT/YouTube.js/commit/987f50604a0163f9a07091ce787995c6f6fddb75))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* Cache deciphered n-params by info response ([#505](https://github.com/LuanRT/YouTube.js/issues/505)) ([d2959b3](https://github.com/LuanRT/YouTube.js/commit/d2959b3a55a5081295da4754627913933bbaf1e7))
|
||||
* **generator:** Remove duplicate checks in `isMiscType` ([#506](https://github.com/LuanRT/YouTube.js/issues/506)) ([68df321](https://github.com/LuanRT/YouTube.js/commit/68df3218580db10c9a0932c93ff2ce487526ff1e))
|
||||
|
||||
## [6.4.0](https://github.com/LuanRT/YouTube.js/compare/v6.3.0...v6.4.0) (2023-09-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for retrieving transcripts ([#500](https://github.com/LuanRT/YouTube.js/issues/500)) ([f94ea6c](https://github.com/LuanRT/YouTube.js/commit/f94ea6cf917f63f30dd66514b22a4cf43b948f07))
|
||||
* **PlaylistManager:** add .setName() and .setDescription() functions for editing playlists ([#498](https://github.com/LuanRT/YouTube.js/issues/498)) ([86fb33e](https://github.com/LuanRT/YouTube.js/commit/86fb33ed03a127d9fd4caa695ca97642bffe61bd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **BackstagePost:** `vote_button` type mismatch ([fba3fc9](https://github.com/LuanRT/YouTube.js/commit/fba3fc971454d66d80d4920fbd60889a221de381))
|
||||
|
||||
## [6.3.0](https://github.com/LuanRT/YouTube.js/compare/v6.2.0...v6.3.0) (2023-08-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ChannelMetadata:** Add `music_artist_name` ([#497](https://github.com/LuanRT/YouTube.js/issues/497)) ([91de6e5](https://github.com/LuanRT/YouTube.js/commit/91de6e5c0e5b27e6d12ce5db2f500c5ff78b9830))
|
||||
* **Session:** Add on_behalf_of_user session option. ([#494](https://github.com/LuanRT/YouTube.js/issues/494)) ([8bc2aaa](https://github.com/LuanRT/YouTube.js/commit/8bc2aaa3587fcf79f69eedbc2bf422a4c6fa7eb1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **CompactMovie:** Add missing import and remove unnecessary console.log ([#496](https://github.com/LuanRT/YouTube.js/issues/496)) ([c26972c](https://github.com/LuanRT/YouTube.js/commit/c26972c42a6368822ac254c00f1bbee5a1542486))
|
||||
|
||||
## [6.2.0](https://github.com/LuanRT/YouTube.js/compare/v6.1.0...v6.2.0) (2023-08-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Session:** Add fallback for session data retrieval ([#490](https://github.com/LuanRT/YouTube.js/issues/490)) ([10c15bf](https://github.com/LuanRT/YouTube.js/commit/10c15bfb9f131a2acea2f26ff3328993d8d8f4aa))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Format:** Fix `is_original` always being `true` ([#492](https://github.com/LuanRT/YouTube.js/issues/492)) ([0412fa0](https://github.com/LuanRT/YouTube.js/commit/0412fa05ff1f00960b398c2f18d5ce39ce0cb864))
|
||||
|
||||
## [6.1.0](https://github.com/LuanRT/YouTube.js/compare/v6.0.2...v6.1.0) (2023-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **parser:** Add `AlertWithButton` ([#486](https://github.com/LuanRT/YouTube.js/issues/486)) ([8b69587](https://github.com/LuanRT/YouTube.js/commit/8b6958778721ba274283f641779fb60bc6f42cd2))
|
||||
* **parser:** Add `ChannelHeaderLinksView` ([#484](https://github.com/LuanRT/YouTube.js/issues/484)) ([ed7be2a](https://github.com/LuanRT/YouTube.js/commit/ed7be2a675cf1ec663e743e90db6260c97546739))
|
||||
* **parser:** Add `CompactMovie` ([#487](https://github.com/LuanRT/YouTube.js/issues/487)) ([2eed172](https://github.com/LuanRT/YouTube.js/commit/2eed1726d5bde7648af09273cc14ab4a315cb23e))
|
||||
|
||||
## [6.0.2](https://github.com/LuanRT/YouTube.js/compare/v6.0.1...v6.0.2) (2023-08-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid set ids in dash manifest ([#480](https://github.com/LuanRT/YouTube.js/issues/480)) ([1c3ea2a](https://github.com/LuanRT/YouTube.js/commit/1c3ea2acd38652c6b40a0817a7836c672a776c4e))
|
||||
|
||||
## [6.0.1](https://github.com/LuanRT/YouTube.js/compare/v6.0.0...v6.0.1) (2023-08-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **SearchSubMenu:** Groups not being parsed due to a typo ([90be877](https://github.com/LuanRT/YouTube.js/commit/90be877d28e0ef013056eaeaa4f2765c91addd61))
|
||||
|
||||
## [6.0.0](https://github.com/LuanRT/YouTube.js/compare/v5.8.0...v6.0.0) (2023-08-18)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* replace unnecessary classes with pure functions ([#468](https://github.com/LuanRT/YouTube.js/issues/468))
|
||||
|
||||
### Features
|
||||
|
||||
* **MusicResponsiveListItem:** Detect non music tracks properly ([815e54b](https://github.com/LuanRT/YouTube.js/commit/815e54b854fcda3f5423231c8495ce1fb69d8237))
|
||||
* **parser:** add `MusicMultiRowListItem` ([494ee87](https://github.com/LuanRT/YouTube.js/commit/494ee8776af0839d3ee2cca3d2fd836680cfdb9e))
|
||||
* **Session:** Add `IOS` to `ClientType` enum ([22a38c0](https://github.com/LuanRT/YouTube.js/commit/22a38c0762499de74f0aeb3ef01332f893518b08))
|
||||
* **VideoInfo:** support iOS client ([#467](https://github.com/LuanRT/YouTube.js/issues/467)) ([46fe18b](https://github.com/LuanRT/YouTube.js/commit/46fe18b763e0c943b24ea10fdf25456ab9ade709))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Format:** Extracting audio language from captions ([#470](https://github.com/LuanRT/YouTube.js/issues/470)) ([31d27b1](https://github.com/LuanRT/YouTube.js/commit/31d27b1bca489ee0053d2783f1a956609845a901))
|
||||
* **parser:** Allow any property in the `RawResponse` interface ([3bc53a8](https://github.com/LuanRT/YouTube.js/commit/3bc53a8c12e65b22f19a3e337641196b692a94db))
|
||||
* **parser:** Logger logging `classdata` as `[Object object]` ([bf1510b](https://github.com/LuanRT/YouTube.js/commit/bf1510b235e3ee7d13d51f092babd1105c3d6b9f))
|
||||
* **Playlist:** Only try extracting the subtitle for the first page ([#465](https://github.com/LuanRT/YouTube.js/issues/465)) ([e370116](https://github.com/LuanRT/YouTube.js/commit/e3701160928e9e959b88ca215c6b0a44c70ca6e6))
|
||||
* **toDash:** Format grouping into AdaptationSets ([#462](https://github.com/LuanRT/YouTube.js/issues/462)) ([1ff3e1a](https://github.com/LuanRT/YouTube.js/commit/1ff3e1a440389e71055d4b201c29021ca5b39254))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* Cleanup some unnecessary uses of `YTNode#key` and `Maybe` ([#463](https://github.com/LuanRT/YouTube.js/issues/463)) ([0dda97e](https://github.com/LuanRT/YouTube.js/commit/0dda97e0b03171de52d7f11a5abf78911e74cead))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* replace unnecessary classes with pure functions ([#468](https://github.com/LuanRT/YouTube.js/issues/468)) ([87ed396](https://github.com/LuanRT/YouTube.js/commit/87ed3960ffa1c738b6f3b5acaf423647db4d367e))
|
||||
|
||||
## [5.8.0](https://github.com/LuanRT/YouTube.js/compare/v5.7.1...v5.8.0) (2023-07-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **YouTube Playlist:** Add subtitle and fix author optionality ([#458](https://github.com/LuanRT/YouTube.js/issues/458)) ([0fa5a85](https://github.com/LuanRT/YouTube.js/commit/0fa5a859ae15a35266297079e3e34fd9f3a5ebf4))
|
||||
|
||||
## [5.7.1](https://github.com/LuanRT/YouTube.js/compare/v5.7.0...v5.7.1) (2023-07-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **SearchHeader:** remove console.log ([d91695a](https://github.com/LuanRT/YouTube.js/commit/d91695a9ec6c55445cbeedba4ace4ac1e0a72eee))
|
||||
|
||||
## [5.7.0](https://github.com/LuanRT/YouTube.js/compare/v5.6.0...v5.7.0) (2023-07-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **parser:** Add `PageHeader` ([#450](https://github.com/LuanRT/YouTube.js/issues/450)) ([18cbc8c](https://github.com/LuanRT/YouTube.js/commit/18cbc8c038ddddffa1ba1519e56a8054b2996e42))
|
||||
* **parser:** Add `SearchHeader` ([6997982](https://github.com/LuanRT/YouTube.js/commit/6997982cf2db87edf4929e9a77e2690e7b630d3d)), closes [#452](https://github.com/LuanRT/YouTube.js/issues/452)
|
||||
|
||||
## [5.6.0](https://github.com/LuanRT/YouTube.js/compare/v5.5.0...v5.6.0) (2023-07-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **parser:** Add `IncludingResultsFor` ([#447](https://github.com/LuanRT/YouTube.js/issues/447)) ([c477b82](https://github.com/LuanRT/YouTube.js/commit/c477b824c084552169062f72cde8890e77b31f59))
|
||||
* **toDash:** Add option to include thumbnails in the manifest ([#446](https://github.com/LuanRT/YouTube.js/issues/446)) ([1a03473](https://github.com/LuanRT/YouTube.js/commit/1a034733f6bb641e2d97df12de81ae3516c1f703))
|
||||
|
||||
## [5.5.0](https://github.com/LuanRT/YouTube.js/compare/v5.4.0...v5.5.0) (2023-07-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Format:** Populate audio language from captions when available ([#445](https://github.com/LuanRT/YouTube.js/issues/445)) ([bdd98a3](https://github.com/LuanRT/YouTube.js/commit/bdd98a3b9be39c11942043a300a6ebce9a15efc6))
|
||||
* **parser:** Add `CommentsSimplebox` parser ([#442](https://github.com/LuanRT/YouTube.js/issues/442)) ([555d257](https://github.com/LuanRT/YouTube.js/commit/555d257459b76d7c0158e9c6b189a75a82b10faf))
|
||||
* **parser:** Add `HashtagTile` ([#440](https://github.com/LuanRT/YouTube.js/issues/440)) ([ae2557d](https://github.com/LuanRT/YouTube.js/commit/ae2557d15c9df09bb92e0dc6191670d72b36631a))
|
||||
* **parser:** add `MacroMarkersList` ([#444](https://github.com/LuanRT/YouTube.js/issues/444)) ([708c5f7](https://github.com/LuanRT/YouTube.js/commit/708c5f7394b4ea140836b9483848cb61b97ea1af))
|
||||
* **parser:** Add `ShowMiniplayerCommand` ([#443](https://github.com/LuanRT/YouTube.js/issues/443)) ([a9cdbf7](https://github.com/LuanRT/YouTube.js/commit/a9cdbf7010e7b9b9cfde5db645d51bdad51006c5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **package:** Bump Jinter to fix bad export order ([#439](https://github.com/LuanRT/YouTube.js/issues/439)) ([2aef678](https://github.com/LuanRT/YouTube.js/commit/2aef67876ec19118b37d3cecd429ccf8239989e0))
|
||||
* **StructuredDescriptionContent:** `items` can also be a `HorizontalCardList` ([b50d1ef](https://github.com/LuanRT/YouTube.js/commit/b50d1ef67d81276864818de10c61b5a7980cbc1a))
|
||||
|
||||
## [5.4.0](https://github.com/LuanRT/YouTube.js/compare/v5.3.0...v5.4.0) (2023-07-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Channel:** Add `getPodcasts()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
|
||||
* **Channel:** Add `getReleases()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
|
||||
* **parser:** Add `Quiz` ([#437](https://github.com/LuanRT/YouTube.js/issues/437)) ([cffa868](https://github.com/LuanRT/YouTube.js/commit/cffa868c6eeb579047653fac65da8e913fb3c621))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Playlist:** Parse `PlaylistCustomThumbnail` for `thumbnail_renderer` ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
|
||||
|
||||
## [5.3.0](https://github.com/LuanRT/YouTube.js/compare/v5.2.1...v5.3.0) (2023-07-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **toDash:** Add color information ([#430](https://github.com/LuanRT/YouTube.js/issues/430)) ([3500e92](https://github.com/LuanRT/YouTube.js/commit/3500e926327d560b1db036bfe503c276b91922ac))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **Format:** Cleanup the xtags parsing ([#434](https://github.com/LuanRT/YouTube.js/issues/434)) ([1ca2083](https://github.com/LuanRT/YouTube.js/commit/1ca20836bf343c78461fab7ad3b71db2b96e65c3))
|
||||
* **toDash:** Hoist duplicates from Representation to AdaptationSet ([#431](https://github.com/LuanRT/YouTube.js/issues/431)) ([5f058e6](https://github.com/LuanRT/YouTube.js/commit/5f058e69ae8594491133f7f96287bea4137f7822))
|
||||
|
||||
## [5.2.1](https://github.com/LuanRT/YouTube.js/compare/v5.2.0...v5.2.1) (2023-07-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incorrect node parser implementations ([#428](https://github.com/LuanRT/YouTube.js/issues/428)) ([222dfce](https://github.com/LuanRT/YouTube.js/commit/222dfce6bbd13b2cd80ae11540cbc0edd9053fc5))
|
||||
|
||||
## [5.2.0](https://github.com/LuanRT/YouTube.js/compare/v5.1.0...v5.2.0) (2023-06-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **VideoDetails:** Add is_post_live_dvr property ([#411](https://github.com/LuanRT/YouTube.js/issues/411)) ([a11e596](https://github.com/LuanRT/YouTube.js/commit/a11e5962c6eb73b14623a9de1e6c8c2534146b1e))
|
||||
* **ytmusic:** Add support for YouTube Music mood filters ([#404](https://github.com/LuanRT/YouTube.js/issues/404)) ([77b39c7](https://github.com/LuanRT/YouTube.js/commit/77b39c79ee0768eb203b7d47ea81286d470c21f2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **OAuth:** client identity matching ([#421](https://github.com/LuanRT/YouTube.js/issues/421)) ([07c1b3e](https://github.com/LuanRT/YouTube.js/commit/07c1b3e0e57cb1fa42e4772775bfd1437bbc731f))
|
||||
* **PlayerEndpoint:** Use different player params ([#419](https://github.com/LuanRT/YouTube.js/issues/419)) ([519be72](https://github.com/LuanRT/YouTube.js/commit/519be72445b7ff392b396e16bcb1dc05c7df8976))
|
||||
* **Playlist:** Add thumbnail_renderer on Playlist when response includes it ([#424](https://github.com/LuanRT/YouTube.js/issues/424)) ([4f9427d](https://github.com/LuanRT/YouTube.js/commit/4f9427d752e89faec8dd1c4fd7a9607dca998c7a))
|
||||
* **VideoInfo.ts:** reimplement `get music_tracks` ([#409](https://github.com/LuanRT/YouTube.js/issues/409)) ([e434bb2](https://github.com/LuanRT/YouTube.js/commit/e434bb2632fe2b20aab6f1e707a93ca76f9d5c91))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **Search:** Speed up results parsing ([#408](https://github.com/LuanRT/YouTube.js/issues/408)) ([1e07a18](https://github.com/LuanRT/YouTube.js/commit/1e07a184ffaff508ad5ba869cb5e7dc9f095f744))
|
||||
* **toDash:** Speed up format filtering ([#405](https://github.com/LuanRT/YouTube.js/issues/405)) ([5de7b24](https://github.com/LuanRT/YouTube.js/commit/5de7b24dc55fca3eb8fccc6fa30d3c2cd60b8184))
|
||||
|
||||
## [5.1.0](https://github.com/LuanRT/YouTube.js/compare/v5.0.4...v5.1.0) (2023-05-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ReelItem:** Add accessibility label ([#401](https://github.com/LuanRT/YouTube.js/issues/401)) ([046103a](https://github.com/LuanRT/YouTube.js/commit/046103a4d8af09fafefab6e9f971184eeca75c2e))
|
||||
* **toDash:** Add audio track labels to the manifest when available ([#402](https://github.com/LuanRT/YouTube.js/issues/402)) ([84b4f1e](https://github.com/LuanRT/YouTube.js/commit/84b4f1efd111321e4f3e5a87844790c4ec9b0b52))
|
||||
|
||||
## [5.0.4](https://github.com/LuanRT/YouTube.js/compare/v5.0.3...v5.0.4) (2023-05-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **bundles:** Use ESM tslib build for the browser bundles ([#397](https://github.com/LuanRT/YouTube.js/issues/397)) ([2673419](https://github.com/LuanRT/YouTube.js/commit/26734194ab0bc5a9f57e1c509d7646ce8903d0c6))
|
||||
* **Utils:** Circular dependency introduced in 38a83c3c2aa814150d1d9b8ed99fca915c1d67fe ([#400](https://github.com/LuanRT/YouTube.js/issues/400)) ([66b026b](https://github.com/LuanRT/YouTube.js/commit/66b026bf493d71a39e12825938fe54dc63aefd16))
|
||||
* **Utils:** Use instanceof in deepCompare instead of the constructor name ([#398](https://github.com/LuanRT/YouTube.js/issues/398)) ([38a83c3](https://github.com/LuanRT/YouTube.js/commit/38a83c3c2aa814150d1d9b8ed99fca915c1d67fe))
|
||||
|
||||
## [5.0.3](https://github.com/LuanRT/YouTube.js/compare/v5.0.2...v5.0.3) (2023-05-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Video:** typo causing node parsing to fail ([3b0498b](https://github.com/LuanRT/YouTube.js/commit/3b0498b68b5378e63283e792bd45571c0b919e0b))
|
||||
|
||||
## [5.0.2](https://github.com/LuanRT/YouTube.js/compare/v5.0.1...v5.0.2) (2023-04-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **VideoInfo:** Use microformat view_count when videoDetails view_count is NaN ([#393](https://github.com/LuanRT/YouTube.js/issues/393)) ([7c0abfc](https://github.com/LuanRT/YouTube.js/commit/7c0abfccd78a6c291d898f898d73a4f16170e2a9))
|
||||
|
||||
## [5.0.1](https://github.com/LuanRT/YouTube.js/compare/v5.0.0...v5.0.1) (2023-04-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** slow downloads due to visitor data ([#391](https://github.com/LuanRT/YouTube.js/issues/391)) ([4f7ec07](https://github.com/LuanRT/YouTube.js/commit/4f7ec07c3f689219b07e8291877c23b6fbf45fb1))
|
||||
|
||||
## [5.0.0](https://github.com/LuanRT/YouTube.js/compare/v4.3.0...v5.0.0) (2023-04-29)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* overhaul core classes and remove redundant code ([#388](https://github.com/LuanRT/YouTube.js/issues/388))
|
||||
|
||||
### Features
|
||||
|
||||
* **NavigationEndpoint:** parse `content` prop ([dd21f8c](https://github.com/LuanRT/YouTube.js/commit/dd21f8c75ae1d76180faab4f0ef9ee40920966e3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **android:** workaround streaming URLs returning 403 ([#390](https://github.com/LuanRT/YouTube.js/issues/390)) ([75ea09d](https://github.com/LuanRT/YouTube.js/commit/75ea09dde86b1bdf13b197d6e02701899300a371))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* overhaul core classes and remove redundant code ([#388](https://github.com/LuanRT/YouTube.js/issues/388)) ([95e0294](https://github.com/LuanRT/YouTube.js/commit/95e0294eabfdb20bbee2a4bfb751fd101402c5d6))
|
||||
|
||||
## [4.3.0](https://github.com/LuanRT/YouTube.js/compare/v4.2.0...v4.3.0) (2023-04-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **GridVideo:** add `upcoming`, `upcoming_text`, `is_reminder_set` and `buttons` ([05de3ec](https://github.com/LuanRT/YouTube.js/commit/05de3ec97a1fea92543b5e5f84933b86a07ab830)), closes [#380](https://github.com/LuanRT/YouTube.js/issues/380)
|
||||
* **MusicResponsiveListItem:** make flex/fixed cols public ([#382](https://github.com/LuanRT/YouTube.js/issues/382)) ([096bf36](https://github.com/LuanRT/YouTube.js/commit/096bf362c9bd46a510ecb0d01623c70841e26e26))
|
||||
* **ToggleMenuServiceItem:** parse default nav endpoint ([a056696](https://github.com/LuanRT/YouTube.js/commit/a0566969ba436f31ca3722d09442a0c6302235d7))
|
||||
* **ytmusic:** add taste builder nodes ([#383](https://github.com/LuanRT/YouTube.js/issues/383)) ([a9cad49](https://github.com/LuanRT/YouTube.js/commit/a9cad49333aa85c98bbb96e5f2d5b57d9beeb0c7))
|
||||
|
||||
## [4.2.0](https://github.com/LuanRT/YouTube.js/compare/v4.1.1...v4.2.0) (2023-04-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Enable importHelpers in tsconfig to reduce output size ([#378](https://github.com/LuanRT/YouTube.js/issues/378)) ([0b301de](https://github.com/LuanRT/YouTube.js/commit/0b301de6a1e1352a64881c1751a84360922a77cd))
|
||||
* **parser:** ignore PrimetimePromo node ([ce9d9c5](https://github.com/LuanRT/YouTube.js/commit/ce9d9c56b4f45c0139d74edc95c295ecfd1ee4b1))
|
||||
* **PlaylistVideo:** Extract video_info and accessibility_label texts ([#376](https://github.com/LuanRT/YouTube.js/issues/376)) ([c9135e6](https://github.com/LuanRT/YouTube.js/commit/c9135e66d3c9c72b8d063eedcf3cc2123800946d))
|
||||
|
||||
## [4.1.1](https://github.com/LuanRT/YouTube.js/compare/v4.1.0...v4.1.1) (2023-03-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **PlayerCaptionsTracklist:** parse props only if they exist in the node ([470d8d9](https://github.com/LuanRT/YouTube.js/commit/470d8d94063f0159cd005c9eb15fd1a4a175bea0)), closes [#372](https://github.com/LuanRT/YouTube.js/issues/372)
|
||||
* **Search:** Return search results even if there are ads ([#373](https://github.com/LuanRT/YouTube.js/issues/373)) ([2c5907f](https://github.com/LuanRT/YouTube.js/commit/2c5907f80fd76452afe87d1722fe35a4f45a22e0))
|
||||
|
||||
## [4.1.0](https://github.com/LuanRT/YouTube.js/compare/v4.0.1...v4.1.0) (2023-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Session:** allow setting a custom visitor data token ([#371](https://github.com/LuanRT/YouTube.js/issues/371)) ([13ebf0a](https://github.com/LuanRT/YouTube.js/commit/13ebf0a03973e7ba7b65e9f72c4333927e4254f6))
|
||||
* **ShowingResultsFor:** parse all props ([1d9587e](https://github.com/LuanRT/YouTube.js/commit/1d9587e8c1ee0b11bb0e444c3d1e98162e9e1059))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **http:** android tv http client missing `clientName` ([#370](https://github.com/LuanRT/YouTube.js/issues/370)) ([cb8fafe](https://github.com/LuanRT/YouTube.js/commit/cb8fafe94b8ab330ae58211a892923321d65d890))
|
||||
* **node:** Electron apps crashing ([#367](https://github.com/LuanRT/YouTube.js/issues/367)) ([e7eacd9](https://github.com/LuanRT/YouTube.js/commit/e7eacd974211c90e7fbddfbf8019388cda3dfa5a))
|
||||
* **parser:** Make Video.is_live work on channel pages ([#368](https://github.com/LuanRT/YouTube.js/issues/368)) ([bd35faa](https://github.com/LuanRT/YouTube.js/commit/bd35faa5978f0b822e98d019523be1303374ddc0))
|
||||
* **toDash:** Generate unique Representation ids ([#366](https://github.com/LuanRT/YouTube.js/issues/366)) ([a8b507e](https://github.com/LuanRT/YouTube.js/commit/a8b507ee65ccd8edc9aea2aef8a908fa272bb23c))
|
||||
* **Utils:** Properly parse timestamps with thousands separators ([#363](https://github.com/LuanRT/YouTube.js/issues/363)) ([1c72a41](https://github.com/LuanRT/YouTube.js/commit/1c72a41675e47f711bd61ebb898bca5527406a79))
|
||||
|
||||
## [4.0.1](https://github.com/LuanRT/YouTube.js/compare/v4.0.0...v4.0.1) (2023-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Channel:** type mismatch in `subscribe_button` prop ([573c864](https://github.com/LuanRT/YouTube.js/commit/573c8643aae16ec7b6be5b333619a5d8c91ca5c1))
|
||||
|
||||
## [4.0.0](https://github.com/LuanRT/YouTube.js/compare/v3.3.0...v4.0.0) (2023-03-15)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **Parser:** general refactoring of parsers ([#344](https://github.com/LuanRT/YouTube.js/issues/344))
|
||||
* The `toDash` functions are now asynchronous, they now return a `Promise<string>` instead of a `string`, as we need to fetch the first sequence of the OTF format streams while building the manifest.
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for OTF format streams ([3e4d41b](https://github.com/LuanRT/YouTube.js/commit/3e4d41bf06ba16232979977c705444f2032bcde6))
|
||||
* **parser:** add `GridMix` ([#356](https://github.com/LuanRT/YouTube.js/issues/356)) ([a8e7e64](https://github.com/LuanRT/YouTube.js/commit/a8e7e644ec6df3b3c98a313f0321da27b4ca456e))
|
||||
* **parser:** add `GridShow` and `ShowCustomThumbnail` ([8ef4b42](https://github.com/LuanRT/YouTube.js/commit/8ef4b42d444c4fbe5cd65a55c0e0e7aa31738755)), closes [#459](https://github.com/LuanRT/YouTube.js/issues/459)
|
||||
* **parser:** add `MusicCardShelf` ([#358](https://github.com/LuanRT/YouTube.js/issues/358)) ([9b005d6](https://github.com/LuanRT/YouTube.js/commit/9b005d62d6590a2ddf6848dabfa33fce36e8df9c))
|
||||
* **parser:** Add `play_all_button` to `Shelf` ([#345](https://github.com/LuanRT/YouTube.js/issues/345)) ([427db5b](https://github.com/LuanRT/YouTube.js/commit/427db5bbc2bf3e8ec60371d504c2ab1cdae6e918))
|
||||
* **parser:** add `view_playlist` to `Playlist` ([#348](https://github.com/LuanRT/YouTube.js/issues/348)) ([9cb4530](https://github.com/LuanRT/YouTube.js/commit/9cb45302997771d909487b1ecba6f38655abef48))
|
||||
* **parser:** add InfoPanelContent and InfoPanelContainer nodes ([4784dfa](https://github.com/LuanRT/YouTube.js/commit/4784dfa563a4dbeaee31811824d5aa37a67f5557)), closes [#326](https://github.com/LuanRT/YouTube.js/issues/326)
|
||||
* **Parser:** just-in-time YTNode generation ([#310](https://github.com/LuanRT/YouTube.js/issues/310)) ([2cee590](https://github.com/LuanRT/YouTube.js/commit/2cee59024c730c34aa06052849ed6fb3f862ef33))
|
||||
* **yt:** add support for movie items and trailers ([#349](https://github.com/LuanRT/YouTube.js/issues/349)) ([9f1c31d](https://github.com/LuanRT/YouTube.js/commit/9f1c31d7a09532e80a187b14acceff31c22579bf))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **Parser:** general refactoring of parsers ([#344](https://github.com/LuanRT/YouTube.js/issues/344)) ([b13bf6e](https://github.com/LuanRT/YouTube.js/commit/b13bf6e9926c19a1939e0f4b69cbd53d1af0f7c8))
|
||||
|
||||
## [3.3.0](https://github.com/LuanRT/YouTube.js/compare/v3.2.0...v3.3.0) (2023-03-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **parser:** add `ConversationBar` node ([b2253df](https://github.com/LuanRT/YouTube.js/commit/b2253df8022217dc486155d8cacbf22db04dd9c2))
|
||||
* **VideoInfo:** support get by endpoint + more info ([#342](https://github.com/LuanRT/YouTube.js/issues/342)) ([0d35fe0](https://github.com/LuanRT/YouTube.js/commit/0d35fe0ca5e87a877b76cbb6cf3c92843eac5a99))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **MultiMarkersPlayerBar:** avoid observing undefined objects ([f351770](https://github.com/LuanRT/YouTube.js/commit/f3517708ff34093a544c09d6f5f1ec806130d5cc))
|
||||
* **SharedPost:** import `Menu` node directly (oops) ([3e3dc35](https://github.com/LuanRT/YouTube.js/commit/3e3dc351bb44faec87616d9b922924d14a95f29f))
|
||||
* **ytmusic:** use static visitor id to avoid empty API responses ([f9754f5](https://github.com/LuanRT/YouTube.js/commit/f9754f5ac61d0f11b025f37f93783f971560268b)), closes [#279](https://github.com/LuanRT/YouTube.js/issues/279)
|
||||
|
||||
## [3.2.0](https://github.com/LuanRT/YouTube.js/compare/v3.1.1...v3.2.0) (2023-03-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for descriptive audio tracks ([#338](https://github.com/LuanRT/YouTube.js/issues/338)) ([574b67a](https://github.com/LuanRT/YouTube.js/commit/574b67a1f707a32378586dd2fe7b2f36f4ab6ddb))
|
||||
* export `FormatUtils`' types ([2d774e2](https://github.com/LuanRT/YouTube.js/commit/2d774e26aae79f3d1b115e0e85c148ae80985529))
|
||||
* **parser:** add `banner` to `PlaylistHeader` ([#337](https://github.com/LuanRT/YouTube.js/issues/337)) ([95033e7](https://github.com/LuanRT/YouTube.js/commit/95033e723ef912706e4d176de6b2760f017184e1))
|
||||
* **parser:** SharedPost ([#332](https://github.com/LuanRT/YouTube.js/issues/332)) ([ce53ac1](https://github.com/LuanRT/YouTube.js/commit/ce53ac18435cbcb20d6d4c4ab52fd156091e7592))
|
||||
* **VideoInfo:** add `game_info` and `category` ([#333](https://github.com/LuanRT/YouTube.js/issues/333)) ([214aa14](https://github.com/LuanRT/YouTube.js/commit/214aa147ce6306e37a6bf860a7bed5635db4797e))
|
||||
* **YouTube/Search:** add `SearchSubMenu` node ([#340](https://github.com/LuanRT/YouTube.js/issues/340)) ([a511608](https://github.com/LuanRT/YouTube.js/commit/a511608f18b37b0d9f2c7958ed5128330fabcfa0))
|
||||
* **yt:** add `getGuide()` ([#335](https://github.com/LuanRT/YouTube.js/issues/335)) ([2cc7b8b](https://github.com/LuanRT/YouTube.js/commit/2cc7b8bcd6938c7fb3af4f854a1d78b86d153873))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **SegmentedLikeDislikeButton:** like/dislike buttons can also be a simple `Button` ([9b2738f](https://github.com/LuanRT/YouTube.js/commit/9b2738f1285b278c3e83541857651be9a6248288))
|
||||
* **YouTube:** fix warnings when retrieving members-only content ([#341](https://github.com/LuanRT/YouTube.js/issues/341)) ([95f1d40](https://github.com/LuanRT/YouTube.js/commit/95f1d4077ff3775f36967dca786139a09e2830a2))
|
||||
* **ytmusic:** export search filters type ([cf8a33c](https://github.com/LuanRT/YouTube.js/commit/cf8a33c79f5432136b865d535fd0ecedc2393382))
|
||||
|
||||
## [3.1.1](https://github.com/LuanRT/YouTube.js/compare/v3.1.0...v3.1.1) (2023-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Channel:** getting community continuations ([#329](https://github.com/LuanRT/YouTube.js/issues/329)) ([4c7b8a3](https://github.com/LuanRT/YouTube.js/commit/4c7b8a34030effa26c4ea186d3e9509128aec31c))
|
||||
|
||||
## [3.1.0](https://github.com/LuanRT/YouTube.js/compare/v3.0.0...v3.1.0) (2023-02-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add upcoming and live info to playlist videos ([#317](https://github.com/LuanRT/YouTube.js/issues/317)) ([a0bfe16](https://github.com/LuanRT/YouTube.js/commit/a0bfe164279ec27b0c49c6b0c32222c1a92df5c3))
|
||||
* **VideoSecondaryInfo:** add support for attributed descriptions ([#325](https://github.com/LuanRT/YouTube.js/issues/325)) ([f933cb4](https://github.com/LuanRT/YouTube.js/commit/f933cb45bcb92c07b3bc063d63869a51cbff4eb0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **parser:** export YTNodes individually so they can be used as types ([200632f](https://github.com/LuanRT/YouTube.js/commit/200632f374d5e0e105b600d579a2665a6fb36e38)), closes [#321](https://github.com/LuanRT/YouTube.js/issues/321)
|
||||
* **PlayerMicroformat:** Make the embed field optional ([#320](https://github.com/LuanRT/YouTube.js/issues/320)) ([a0e6cef](https://github.com/LuanRT/YouTube.js/commit/a0e6cef00fb9e3f52593cec22704f7ddc1f7553e))
|
||||
* send correct UA for Android requests ([f4e0f30](https://github.com/LuanRT/YouTube.js/commit/f4e0f30e6e94b347b28d67d9a86284ea2d23ee15)), closes [#322](https://github.com/LuanRT/YouTube.js/issues/322)
|
||||
|
||||
## [3.0.0](https://github.com/LuanRT/YouTube.js/compare/v2.9.0...v3.0.0) (2023-02-17)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* cleanup platform support ([#306](https://github.com/LuanRT/YouTube.js/issues/306))
|
||||
|
||||
### Features
|
||||
|
||||
* add parser support for MultiImage community posts ([#298](https://github.com/LuanRT/YouTube.js/issues/298)) ([de61782](https://github.com/LuanRT/YouTube.js/commit/de61782f1a673cbe66ae9b410341e39b7501ba84))
|
||||
* add support for hashtag feeds ([#312](https://github.com/LuanRT/YouTube.js/issues/312)) ([bf12740](https://github.com/LuanRT/YouTube.js/commit/bf12740333a82c26fe84e7c702c2fbb8859814fc))
|
||||
* add support for YouTube Kids ([#291](https://github.com/LuanRT/YouTube.js/issues/291)) ([2bbefef](https://github.com/LuanRT/YouTube.js/commit/2bbefefbb7cb061f3e7b686158b7568c32f0da5d))
|
||||
* allow checking whether a channel has optional tabs ([#296](https://github.com/LuanRT/YouTube.js/issues/296)) ([ceefbed](https://github.com/LuanRT/YouTube.js/commit/ceefbed98c70bb936e2d2df58c02834842acfdfc))
|
||||
* **Channel:** Add getters for all optional tabs ([#303](https://github.com/LuanRT/YouTube.js/issues/303)) ([b2900f4](https://github.com/LuanRT/YouTube.js/commit/b2900f48a7aa4c22635e1819ba9f636e81964f2c))
|
||||
* **Channel:** add support for sorting the playlist tab ([#295](https://github.com/LuanRT/YouTube.js/issues/295)) ([50ef712](https://github.com/LuanRT/YouTube.js/commit/50ef71284db41e5f94bb511892651d22a1d363a0))
|
||||
* extract channel error alert ([0b99180](https://github.com/LuanRT/YouTube.js/commit/0b991800a5c67f0e702251982b52eb8531f36f19))
|
||||
* **FormatUtils:** support multiple audio tracks in the DASH manifest ([#308](https://github.com/LuanRT/YouTube.js/issues/308)) ([a69e43b](https://github.com/LuanRT/YouTube.js/commit/a69e43bf3ae02f2428c4aa86f647e3e5e0db5ba6))
|
||||
* improve support for dubbed content ([#293](https://github.com/LuanRT/YouTube.js/issues/293)) ([d6c5a9b](https://github.com/LuanRT/YouTube.js/commit/d6c5a9b971444d0cd746aaf5310d3389793680ea))
|
||||
* parse isLive in CompactVideo ([#294](https://github.com/LuanRT/YouTube.js/issues/294)) ([2acb7da](https://github.com/LuanRT/YouTube.js/commit/2acb7da0198bfeca6ff911cf95cf06a220fccaa5))
|
||||
* **parser:** add `ChannelAgeGate` node ([1cdf701](https://github.com/LuanRT/YouTube.js/commit/1cdf701c8403db6b681a26ecb1df2daa51add454))
|
||||
* **parser:** Text#toHTML ([#300](https://github.com/LuanRT/YouTube.js/issues/300)) ([e82e23d](https://github.com/LuanRT/YouTube.js/commit/e82e23dfbb24dff3ddf45754c7319d783990e254))
|
||||
* **ytkids:** add `getChannel()` ([#292](https://github.com/LuanRT/YouTube.js/issues/292)) ([0fc29f0](https://github.com/LuanRT/YouTube.js/commit/0fc29f0bbf965215146a6ae192494c74e6cefcbb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* assign MetadataBadge's label ([#311](https://github.com/LuanRT/YouTube.js/issues/311)) ([e37cf62](https://github.com/LuanRT/YouTube.js/commit/e37cf627322f688fcef18d41345f77cbccd58829))
|
||||
* **ChannelAboutFullMetadata:** fix error when there are no primary links ([#299](https://github.com/LuanRT/YouTube.js/issues/299)) ([f62c66d](https://github.com/LuanRT/YouTube.js/commit/f62c66db396ba7d2f93007414101112b49d8375f))
|
||||
* **TopicChannelDetails:** avatar and subtitle parsing ([#302](https://github.com/LuanRT/YouTube.js/issues/302)) ([d612590](https://github.com/LuanRT/YouTube.js/commit/d612590530f5fe590fee969810b1dd44c37f0457))
|
||||
* **VideoInfo:** Gracefully handle missing watch next continuation ([#288](https://github.com/LuanRT/YouTube.js/issues/288)) ([13ad377](https://github.com/LuanRT/YouTube.js/commit/13ad3774c9783ed2a9f286aeee88110bd43b3a73))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* cleanup platform support ([#306](https://github.com/LuanRT/YouTube.js/issues/306)) ([2ccbe2c](https://github.com/LuanRT/YouTube.js/commit/2ccbe2ce6260ace3bfac8b4b391e583fbcc4e286))
|
||||
20
COLLABORATORS.md
Normal file
20
COLLABORATORS.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Collaborators
|
||||
|
||||
This page lists the collaborators who have contributed to the development and success of the project.
|
||||
|
||||
## [LuanRT](https://github.com/LuanRT)
|
||||
[](https://github.com/sponsors/LuanRT)
|
||||
|
||||
Owner and maintainer.
|
||||
|
||||
## [Wykerd](https://github.com/wykerd/)
|
||||
Initial parser implementation, several bug fixes, major refactorings and general maintenance.
|
||||
|
||||
## [MasterOfBob777](https://github.com/MasterOfBob777)
|
||||
Bug fixes and TypeScript support.
|
||||
|
||||
## [patrickkfkan](https://github.com/patrickkfkan)
|
||||
Major refactorings, improved YouTube Music support, and bug fixes.
|
||||
|
||||
## [Absidue](https://github.com/absidue)
|
||||
Several bug fixes, new features & improved MPD support.
|
||||
127
CONTRIBUTING.md
127
CONTRIBUTING.md
@@ -1,103 +1,66 @@
|
||||
# Contributing to YouTube.js
|
||||
Welcome to YouTube.js! We're thrilled to have you interested in contributing to our project. As a community-driven project, we believe in the power of collaboration and look forward to working with you. To get started, please follow our guidelines:
|
||||
|
||||
Thank you for taking the time to contribute!
|
||||
The following is a set of guidelines for contributing to YouTube.js.
|
||||
___
|
||||
* [Issues](#issues)
|
||||
* [Create a new issue](#issue-1)
|
||||
* [Solve an issue](#issue-2)
|
||||
|
||||
* [Make changes](#changes)
|
||||
* [Commit your updates](#changes-1)
|
||||
* [Create a PR](#changes-2)
|
||||
* [Run tests](#test)
|
||||
* [Lint your code](#lint)
|
||||
* [Build for node](#build-1)
|
||||
* [Bundle for browsers](#build-2)
|
||||
* [Compile proto file](#build-3)
|
||||
* [Build parser map](#build-4)
|
||||
|
||||
## Issues
|
||||
|
||||
<a id="issue-1"></a>
|
||||
#### Create a new issue
|
||||
If you find a problem, search if an issue already exists. If a related issue doesn't exist, you can open a new issue using a relevant issue form.
|
||||
### Creating a new issue
|
||||
Before creating a new issue, we recommend searching for similar or related issues to avoid duplication efforts. However, if you can't find one, you're more than welcome to create a new issue using a relevant issue form. Please make sure to describe the issue as clearly and concisely as possible.
|
||||
|
||||
<a id="issue-2"></a>
|
||||
#### Solve an issue
|
||||
Scan through the existing issues to find one that interests you. You can narrow down the search using labels as filters. If you find an issue to work on, you are welcome to open a PR with a fix.
|
||||
### Solving an issue
|
||||
If you want to lend a hand by solving an issue, it's always good to browse existing issues to find one that grabs your attention. You can narrow down the search using tags as filters. If you find an issue you'd like to help with, please feel free to open a Pull Request with a fix. We appreciate documentation updates and grammar fixes too!
|
||||
|
||||
<a id="changes"></a>
|
||||
## Make changes
|
||||
## Making Changes
|
||||
|
||||
1. Fork the repository
|
||||
2. Install or update to **Node.js v16**
|
||||
3. Create a working branch and start with your changes!
|
||||
1. Fork the repository on GitHub.
|
||||
2. Ensure that you have the latest Node.js v16 version installed.
|
||||
3. Create a working branch and start making your changes and improvements!
|
||||
|
||||
<a id="changes-1"></a>
|
||||
#### Commit your updates
|
||||
### Committing updates
|
||||
When you're done with the changes, make sure to commit them. Don't forget to write a clear, descriptive commit message. We recommend following the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
||||
|
||||
Commit the changes once you're happy with them.
|
||||
### Creating a Pull Request
|
||||
Once you're happy with your updates, create a pull request on GitHub. This is the most efficient way to get your contribution reviewed and eventually merged into our codebase.
|
||||
|
||||
<a id="changes-2"></a>
|
||||
#### Pull Request
|
||||
- Use the pull request template to fill in the necessary details.
|
||||
- If you're solving an issue, link the pull request to that issue.
|
||||
- Enable the checkbox to allow maintainers to edit the branch and update it for merging.
|
||||
- Changes may be required before we can merge your changes, and we'll let you know what needs to be done.
|
||||
|
||||
When you think the code is ready for review a pull request should be created on Github. Owners of the repository will watch out for new PR‘s and review them in regular intervals.
|
||||
### Testing, Linting, and Building
|
||||
We have some automated processes set up for testing, linting, and building. Please run the following commands to test, lint, and build your code before submitting it:
|
||||
|
||||
- Fill the template.
|
||||
- Link the PR to an issue, if you are solving one.
|
||||
- Enable the checkbox to allow maintainer edits so the branch can be updated for a merge.
|
||||
- Changes may be requested before a PR can be merged.
|
||||
- As you update your PR and apply changes, mark each conversation as resolved.
|
||||
|
||||
<a id="test"></a>
|
||||
#### Test
|
||||
|
||||
```bash
|
||||
Testing:
|
||||
```sh
|
||||
npm run test
|
||||
```
|
||||
|
||||
<a id="lint"></a>
|
||||
#### Lint
|
||||
|
||||
```bash
|
||||
Linting:
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Or
|
||||
Building:
|
||||
```sh
|
||||
# Build all
|
||||
npm run build
|
||||
|
||||
```bash
|
||||
npm run lint:fix
|
||||
```
|
||||
# Protobuf
|
||||
npm run build:proto
|
||||
|
||||
<a id="build-1"></a>
|
||||
#### Build for Node
|
||||
|
||||
```bash
|
||||
npm run build:node
|
||||
```
|
||||
|
||||
<a id="build-2"></a>
|
||||
#### Build for browsers
|
||||
|
||||
```bash
|
||||
npm run build:browser
|
||||
```
|
||||
Or:
|
||||
```bash
|
||||
npm run build:browser:prod
|
||||
```
|
||||
|
||||
<a id="build-3"></a>
|
||||
#### Compile proto file
|
||||
|
||||
```bash
|
||||
// TODO
|
||||
```
|
||||
|
||||
<a id="build-4"></a>
|
||||
#### Build parser map
|
||||
|
||||
```bash
|
||||
# Parser map
|
||||
npm run build:parser-map
|
||||
|
||||
# Deno
|
||||
npm run build:deno
|
||||
|
||||
# ES Module
|
||||
npm run build:esm
|
||||
|
||||
# Node
|
||||
npm run bundle:node
|
||||
|
||||
# Browser
|
||||
npm run bundle:browser
|
||||
npm run bundle:browser:prod
|
||||
```
|
||||
|
||||
We appreciate your efforts and contributions to YouTube.js! Together, we can make this project even better.
|
||||
560
README.md
560
README.md
@@ -1,115 +1,82 @@
|
||||
<!-- Hi there, fellow coder :) -->
|
||||
|
||||
<!-- BADGE LINKS -->
|
||||
[npm]: https://www.npmjs.com/package/youtubei.js
|
||||
[versions]: https://www.npmjs.com/package/youtubei.js?activeTab=versions
|
||||
[codefactor]: https://www.codefactor.io/repository/github/luanrt/youtube.js
|
||||
[actions]: https://github.com/LuanRT/YouTube.js/actions
|
||||
[say-thanks]: https://saythanks.io/to/LuanRT
|
||||
[github-sponsors]:https://github.com/sponsors/LuanRT
|
||||
[collaborators]: https://github.com/LuanRT/YouTube.js/blob/main/COLLABORATORS.md
|
||||
|
||||
<!-- OTHER LINKS -->
|
||||
[project]: https://github.com/LuanRT/YouTube.js
|
||||
[twitter]: https://twitter.com/lrt_nooneknows
|
||||
[nodejs]: https://nodejs.org
|
||||
[twitter]: https://twitter.com/thesciencephile
|
||||
[discord]: https://discord.gg/syDu7Yks54
|
||||
|
||||
<!-- INTRODUCTION -->
|
||||
<h1 align=center>
|
||||
YouTube.js
|
||||
</h1>
|
||||
<h1 align=center>YouTube.js</h1>
|
||||
|
||||
<p align=center>
|
||||
<i>
|
||||
A full-featured wrapper around the InnerTube API, which is what YouTube itself uses.
|
||||
</i>
|
||||
</p>
|
||||
<p align=center>A full-featured wrapper around the InnerTube API</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/LuanRT/YouTube.js/issues">
|
||||
Report Bug
|
||||
</a>
|
||||
·
|
||||
<a href="https://github.com/LuanRT/YouTube.js/issues">
|
||||
Request Feature
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- BADGES -->
|
||||
<div align="center">
|
||||
|
||||
[][actions]
|
||||
[][versions]
|
||||
[][actions]
|
||||
[][versions]
|
||||
[][codefactor]
|
||||
[][npm]
|
||||
[][say-thanks]
|
||||
|
||||
[][npm]
|
||||
[][discord]
|
||||
<br>
|
||||
[][collaborators]
|
||||
|
||||
</div>
|
||||
|
||||
<!-- SPONSORS -->
|
||||
<div align="center">
|
||||
<p>
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://serpapi.com" target="_blank">
|
||||
<img width="80" alt="SerpApi" src="https://luanrt.github.io/assets/img/serpapi.svg" />
|
||||
<br>
|
||||
<sub>
|
||||
API to get search engine results with ease.
|
||||
</sub>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<br>
|
||||
<hr>
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a><sub>Special thanks to:<sub></a>
|
||||
</p>
|
||||
|
||||
<table align="center">
|
||||
<body>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://serpapi.com/">
|
||||
<img width="80" alt="SerpApi" src="https://luanrt.is-a.dev/assets/img/serpapi.svg" />
|
||||
<br>
|
||||
<b>
|
||||
<sub>
|
||||
Scrape Google and other search engines from a fast, easy and complete API.
|
||||
</sub>
|
||||
</b>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</body>
|
||||
</table>
|
||||
|
||||
___
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
<details>
|
||||
<summary>Table of Contents</summary>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#about">About</a>
|
||||
</li>
|
||||
<li>
|
||||
## Table of Contents
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#description">Description</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#getting-started">Getting Started</a>
|
||||
<ul>
|
||||
<li><a href="#prerequisites">Prerequisites</a></li>
|
||||
<li><a href="#installation">Installation</a></li>
|
||||
<li><a href="#prerequisites">Prerequisites</a></li>
|
||||
<li><a href="#installation">Installation</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#usage">Usage</a>
|
||||
<ul>
|
||||
<li><a href="#browser-usage">Browser Usage</a></li>
|
||||
<li><a href="#caching">Caching</a></li>
|
||||
<li><a href="#api">API</a></li>
|
||||
<li><a href="#browser-usage">Browser Usage</a></li>
|
||||
<li><a href="#caching">Caching</a></li>
|
||||
<li><a href="#api">API</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#implementing-custom-functionality">Implementing custom functionality </a></li>
|
||||
<li><a href="#contributing">Contributing</a></li>
|
||||
<li><a href="#contributors">Contributors</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
<li><a href="#disclaimer">Disclaimer</a></li>
|
||||
<li><a href="#license">License</a></li>
|
||||
</ol>
|
||||
</details>
|
||||
</li>
|
||||
<li><a href="#extending-the-library">Extending the library</a></li>
|
||||
<li><a href="#contributing">Contributing</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
<li><a href="#disclaimer">Disclaimer</a></li>
|
||||
<li><a href="#license">License</a></li>
|
||||
</ol>
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
## About
|
||||
## Description
|
||||
|
||||
InnerTube is an API used across all YouTube clients, it was created to simplify[^1] the internal structure of the platform in a way that updates, tweaks, and experiments can be easily made. This library handles all the low-level communication with InnerTube, providing a simple, fast, and efficient way to interact with YouTube programmatically.
|
||||
InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform [^1]. This library manages all low-level communication with InnerTube, providing a simple and efficient way to interact with YouTube programmatically. Its design aims to closely emulate an actual client, including the parsing of API responses.
|
||||
|
||||
If you have any questions or need help, feel free to contact us on our chat server [here](https://discord.gg/syDu7Yks54).
|
||||
If you have any questions or need help, feel free to reach out to us on our [Discord server][discord] or open an issue [here](https://github.com/LuanRT/YouTube.js/issues).
|
||||
|
||||
<!-- GETTING STARTED -->
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -117,7 +84,7 @@ YouTube.js runs on Node.js, Deno, and modern browsers.
|
||||
|
||||
It requires a runtime with the following features:
|
||||
- [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
|
||||
- On Node we use [undici]()'s fetch implementation which requires Node.js 16.8+. You may provide your fetch implementation if you need to use an older version. See [providing your own fetch implementation](#custom-fetch) for more information.
|
||||
- On Node, we use [undici](https://github.com/nodejs/undici)'s fetch implementation, which requires Node.js 16.8+. If you need to use an older version, you may provide your own fetch implementation. See [providing your own fetch implementation](#custom-fetch) for more information.
|
||||
- The `Response` object returned by fetch must thus be spec compliant and return a `ReadableStream` object if you want to use the `VideoInfo#download` method. (Implementations like `node-fetch` returns a non-standard `Readable` object.)
|
||||
- [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) and [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) are required.
|
||||
|
||||
@@ -134,25 +101,54 @@ yarn add youtubei.js@latest
|
||||
npm install github:LuanRT/YouTube.js
|
||||
```
|
||||
|
||||
**TODO:** Deno install instructions (esm.sh possibly?)
|
||||
When using Deno, you can import YouTube.js directly from deno.land:
|
||||
```ts
|
||||
import { Innertube } from 'https://deno.land/x/youtubei/deno.ts';
|
||||
```
|
||||
|
||||
<!-- USAGE -->
|
||||
## Usage
|
||||
Create an InnerTube instance:
|
||||
```ts
|
||||
// const { Innertube } = require('youtubei.js');
|
||||
import { Innertube } from 'youtubei.js';
|
||||
const youtube = await Innertube.create();
|
||||
const youtube = await Innertube.create(/* options */);
|
||||
```
|
||||
|
||||
## Browser Usage
|
||||
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in [`examples/browser/proxy/deno.ts`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/proxy/deno.ts).
|
||||
### Initialization Options
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
You may provide your own fetch implementation to be used by YouTube.js. Which we will use here to modify and send the requests through our proxy. See [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/web) for a simple example using [Vite](https://vitejs.dev/).
|
||||
| Option | Type | Description | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| `lang` | `string` | Language. | `en` |
|
||||
| `location` | `string` | Geolocation. | `US` |
|
||||
| `account_index` | `number` | The account index to use. This is useful if you have multiple accounts logged in. **NOTE:** Only works if you are signed in with cookies. | `0` |
|
||||
| `visitor_data` | `string` | Setting this to a valid and persistent visitor data string will allow YouTube to give this session tailored content even when not logged in. A good way to get a valid one is by either grabbing it from a browser or calling InnerTube's `/visitor_id` endpoint. | `undefined` |
|
||||
| `retrieve_player` | `boolean` | Specifies whether to retrieve the JS player. Disabling this will make session creation faster. **NOTE:** Deciphering formats is not possible without the JS player. | `true` |
|
||||
| `enable_safety_mode` | `boolean` | Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content. | `false` |
|
||||
| `generate_session_locally` | `boolean` | Specifies whether to generate the session data locally or retrieve it from YouTube. This can be useful if you need more performance. | `false` |
|
||||
| `device_category` | `DeviceCategory` | Platform to use for the session. | `DESKTOP` |
|
||||
| `client_type` | `ClientType` | InnerTube client type. | `WEB` |
|
||||
| `timezone` | `string` | The time zone. | `*` |
|
||||
| `cache` | `ICache` | Used to cache the deciphering functions from the JS player. | `undefined` |
|
||||
| `cookie` | `string` | YouTube cookies. | `undefined` |
|
||||
| `fetch` | `FetchFunction` | Fetch function to use. | `fetch` |
|
||||
|
||||
</details>
|
||||
|
||||
## Browser Usage
|
||||
To use YouTube.js in the browser, you must proxy requests through your own server. You can see our simple reference implementation in Deno at [`examples/browser/proxy/deno.ts`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/proxy/deno.ts).
|
||||
|
||||
You may provide your own fetch implementation to be used by YouTube.js, which we will use to modify and send the requests through a proxy. See [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/web) for a simple example using [Vite](https://vitejs.dev/).
|
||||
|
||||
```ts
|
||||
// Pre-bundled version for the web
|
||||
import { Innertube } from 'youtubei.js/bundle/browser';
|
||||
// Multiple exports are available for the web.
|
||||
// Unbundled ESM version
|
||||
import { Innertube } from 'youtubei.js/web';
|
||||
// Bundled ESM version
|
||||
// import { Innertube } from 'youtubei.js/web.bundle';
|
||||
// Production Bundled ESM version
|
||||
// import { Innertube } from 'youtubei.js/web.bundle.min';
|
||||
await Innertube.create({
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
// Modify the request
|
||||
@@ -170,18 +166,18 @@ YouTube.js supports streaming of videos in the browser by converting YouTube's s
|
||||
The example below uses [`dash.js`](https://github.com/Dash-Industry-Forum/dash.js) to play the video.
|
||||
|
||||
```ts
|
||||
import { Innertube } from 'youtubei.js';
|
||||
import { Innertube } from 'youtubei.js/web';
|
||||
import dashjs from 'dashjs';
|
||||
|
||||
const youtube = await Innertube.create({ /* setup - see above */ });
|
||||
|
||||
// get the video info
|
||||
// Get the video info
|
||||
const videoInfo = await youtube.getInfo('videoId');
|
||||
|
||||
// now convert to a dash manifest
|
||||
// again - to be able to stream the video in the browser - we must proxy the requests through our own server
|
||||
// to do this, we provide a method to transform the URLs before writing them to the manifest
|
||||
const manifest = videoInfo.toDash(url => {
|
||||
const manifest = await videoInfo.toDash(url => {
|
||||
// modify the url
|
||||
// and return it
|
||||
return url;
|
||||
@@ -195,7 +191,8 @@ const player = dashjs.MediaPlayer().create();
|
||||
player.initialize(videoElement, uri, true);
|
||||
```
|
||||
|
||||
Our browser example in [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/blob/main/examples/browser/web) provides a fully working example.
|
||||
A fully working example can be found in [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/blob/main/examples/browser/web).
|
||||
|
||||
<a name="custom-fetch"></a>
|
||||
|
||||
## Providing your own fetch implementation
|
||||
@@ -216,23 +213,25 @@ const yt = await Innertube.create({
|
||||
<a name="caching"></a>
|
||||
|
||||
## Caching
|
||||
To improve performance, you may wish to cache the transformed player instance which we use to decode the streaming urls.
|
||||
Caching the transformed player instance can greatly improve the performance. Our `UniversalCache` implementation uses different caching methods depending on the environment.
|
||||
|
||||
Our cache uses the `node:fs` module in Node-like environments, `Deno.writeFile` in Deno, and `indexedDB` in browsers.
|
||||
In Node.js, we use the `node:fs` module, `Deno.writeFile()` in Deno, and `indexedDB` in browsers.
|
||||
|
||||
By default, the cache stores data in the operating system's temporary directory (or `indexedDB` in browsers). You can make this cache persistent by specifying the path to the cache directory, which will be created if it doesn't exist.
|
||||
|
||||
```ts
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
// By default, cache stores files in the OS temp directory (or indexedDB in browsers).
|
||||
// Create a cache that stores files in the OS temp directory (or indexedDB in browsers) by default.
|
||||
const yt = await Innertube.create({
|
||||
cache: new UniversalCache()
|
||||
cache: new UniversalCache(false)
|
||||
});
|
||||
|
||||
// You may wish to make the cache persistent (on Node and Deno)
|
||||
// You may want to create a persistent cache instead (on Node and Deno).
|
||||
const yt = await Innertube.create({
|
||||
cache: new UniversalCache(
|
||||
// Enables persistent caching
|
||||
true,
|
||||
// Path to the cache directory will create the directory if it doesn't exist
|
||||
// Path to the cache directory. The directory will be created if it doesn't exist
|
||||
'./.cache'
|
||||
)
|
||||
});
|
||||
@@ -243,7 +242,7 @@ const yt = await Innertube.create({
|
||||
* `Innertube`
|
||||
|
||||
<details>
|
||||
<summary>objects</summary>
|
||||
<summary>Properties</summary>
|
||||
<p>
|
||||
|
||||
* [.session](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/session.md)
|
||||
@@ -252,21 +251,23 @@ const yt = await Innertube.create({
|
||||
* [.playlist](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/playlist.md)
|
||||
* [.music](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/music.md)
|
||||
* [.studio](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/studio.md)
|
||||
* [.kids](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/kids.md)
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>methods</summary>
|
||||
<summary>Methods</summary>
|
||||
<p>
|
||||
|
||||
* [.getInfo(video_id, client?)](#getinfo)
|
||||
* [.getInfo(target, client?)](#getinfo)
|
||||
* [.getBasicInfo(video_id, client?)](#getbasicinfo)
|
||||
* [.search(query, filters?)](#search)
|
||||
* [.getSearchSuggestions(query)](#getsearchsuggestions)
|
||||
* [.getComments(video_id, sort_by?)](#getcomments)
|
||||
* [.getHomeFeed()](#gethomefeed)
|
||||
* [.getGuide()](#getguide)
|
||||
* [.getLibrary()](#getlibrary)
|
||||
* [.getHistory()](#gethistory)
|
||||
* [.getTrending()](#gettrending)
|
||||
@@ -275,24 +276,26 @@ const yt = await Innertube.create({
|
||||
* [.getNotifications()](#getnotifications)
|
||||
* [.getUnseenNotificationsCount()](#getunseennotificationscount)
|
||||
* [.getPlaylist(id)](#getplaylist)
|
||||
* [.getHashtag(hashtag)](#gethashtag)
|
||||
* [.getStreamingData(video_id, options)](#getstreamingdata)
|
||||
* [.download(video_id, options?)](#download)
|
||||
* [.resolveURL(url)](#resolveurl)
|
||||
* [.call(endpoint, args?)](#call)
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getinfo"></a>
|
||||
### getInfo(video_id, client?)
|
||||
### `getInfo(target, client?)`
|
||||
|
||||
Retrieves video info, including playback data and even layout elements such as menus, buttons, etc — all nicely parsed.
|
||||
Retrieves video info.
|
||||
|
||||
**Returns**: `Promise.<VideoInfo>`
|
||||
**Returns**: `Promise<VideoInfo>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | The id of the video |
|
||||
| client? | `InnerTubeClient` | `WEB`, `ANDROID` or `YTMUSIC` |
|
||||
| target | `string` \| `NavigationEndpoint` | If `string`, the id of the video. If `NavigationEndpoint`, the endpoint of watchable elements such as `Video`, `Mix` and `Playlist`. To clarify, valid endpoints have payloads containing at least `videoId` and optionally `playlistId`, `params` and `index`. |
|
||||
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC`, `YTMUSIC_ANDROID` or `TV_EMBEDDED` |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
@@ -304,21 +307,27 @@ Retrieves video info, including playback data and even layout elements such as m
|
||||
- `<info>#dislike()`
|
||||
- Dislikes the video.
|
||||
|
||||
- `<info>#removeLike()`
|
||||
- `<info>#removeRating()`
|
||||
- Removes like/dislike.
|
||||
|
||||
- `<info>#getLiveChat()`
|
||||
- Returns a LiveChat instance.
|
||||
|
||||
- `<info>#getTrailerInfo()`
|
||||
- Returns trailer info in a new `VideoInfo` instance, or `null` if none. Typically available for non-purchased movies or films.
|
||||
|
||||
- `<info>#chooseFormat(options)`
|
||||
- Used to choose streaming data formats.
|
||||
|
||||
- `<info>#toDash(url_transformer)`
|
||||
- `<info>#toDash(url_transformer?, format_filter?)`
|
||||
- Converts streaming data to an MPEG-DASH manifest.
|
||||
|
||||
- `<info>#download(options)`
|
||||
- Downloads the video. See [download](#download).
|
||||
|
||||
- `<info>#getTranscript()`
|
||||
- Retrieves the video's transcript.
|
||||
|
||||
- `<info>#filters`
|
||||
- Returns filters that can be applied to the watch next feed.
|
||||
|
||||
@@ -331,6 +340,12 @@ Retrieves video info, including playback data and even layout elements such as m
|
||||
- `<info>#addToWatchHistory()`
|
||||
- Adds the video to the watch history.
|
||||
|
||||
- `<info>#autoplay_video_endpoint`
|
||||
- Returns the endpoint of the video for Autoplay.
|
||||
|
||||
- `<info>#has_trailer`
|
||||
- Checks if trailer is available.
|
||||
|
||||
- `<info>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
@@ -338,29 +353,46 @@ Retrieves video info, including playback data and even layout elements such as m
|
||||
</details>
|
||||
|
||||
<a name="getbasicinfo"></a>
|
||||
### getBasicInfo(video_id, client?)
|
||||
### `getBasicInfo(video_id, client?)`
|
||||
|
||||
Suitable for cases where you only need basic video metadata. Also, it is faster than [`getInfo()`](#getinfo).
|
||||
|
||||
**Returns**: `Promise.<VideoInfo>`
|
||||
**Returns**: `Promise<VideoInfo>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | The id of the video |
|
||||
| client? | `InnerTubeClient` | `WEB`, `ANDROID` or `YTMUSIC` |
|
||||
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC_ANDROID`, `YTMUSIC`, `TV_EMBEDDED` |
|
||||
|
||||
<a name="search"></a>
|
||||
### search(query, filters?)
|
||||
### `search(query, filters?)`
|
||||
|
||||
Searches the given query on YouTube.
|
||||
|
||||
**Returns**: `Promise.<Search>`
|
||||
**Returns**: `Promise<Search>`
|
||||
|
||||
> **Note**
|
||||
> `Search` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| query | `string` | The search query |
|
||||
| filters? | `SearchFilters` | Search filters |
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Search Filters</summary>
|
||||
|
||||
| Filter | Type | Value | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| upload_date | `string` | `all` \| `hour` \| `today` \| `week` \| `month` \| `year` | Filter by upload date |
|
||||
| type | `string` | `all` \| `video` \| `channel` \| `playlist` \| `movie` | Filter by type |
|
||||
| duration | `string` | `all` \| `short` \| `medium` \| `long` | Filter by duration |
|
||||
| sort_by | `string` | `relevance` \| `rating` \| `upload_date` \| `view_count` | Sort by |
|
||||
| features | `string[]` | `hd` \| `subtitles` \| `creative_commons` \| `3d` \| `live` \| `purchased` \| `4k` \| `360` \| `location` \| `hdr` \| `vr180` | Filter by features |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
@@ -378,20 +410,20 @@ Searches the given query on YouTube.
|
||||
</details>
|
||||
|
||||
<a name="getsearchsuggestions"></a>
|
||||
### getSearchSuggestions(query)
|
||||
### `getSearchSuggestions(query)`
|
||||
Retrieves search suggestions for given query.
|
||||
|
||||
**Returns**: `Promise.<string[]>`
|
||||
**Returns**: `Promise<string[]>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| query | `string` | The search query |
|
||||
|
||||
<a name="getcomments"></a>
|
||||
### getComments(video_id, sort_by?)
|
||||
### `getComments(video_id, sort_by?)`
|
||||
Retrieves comments for given video.
|
||||
|
||||
**Returns**: `Promise.<Comments>`
|
||||
**Returns**: `Promise<Comments>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -401,16 +433,53 @@ Retrieves comments for given video.
|
||||
See [`./examples/comments`](https://github.com/LuanRT/YouTube.js/blob/main/examples/comments) for examples.
|
||||
|
||||
<a name="gethomefeed"></a>
|
||||
### getHomeFeed()
|
||||
### `getHomeFeed()`
|
||||
Retrieves YouTube's home feed.
|
||||
|
||||
**Returns**: `Promise.<FilterableFeed>`
|
||||
**Returns**: `Promise<HomeFeed>`
|
||||
|
||||
> **Note**
|
||||
> `HomeFeed` extends the [`FilterableFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/filterable-feed.md) class.
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<home_feed>#videos`
|
||||
- Returns all videos in the home feed.
|
||||
|
||||
- `<home_feed>#posts`
|
||||
- Returns all posts in the home feed.
|
||||
|
||||
- `<home_feed>#shelves`
|
||||
- Returns all shelves in the home feed.
|
||||
|
||||
- `<home_feed>#filters`
|
||||
- Returns available filters.
|
||||
|
||||
- `<home_feed>#applyFilter(name | ChipCloudChip)`
|
||||
- Applies given filter and returns a new HomeFeed instance.
|
||||
|
||||
- `<home_feed>#getContinuation()`
|
||||
- Retrieves feed continuation.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getguide"></a>
|
||||
### `getGuide()`
|
||||
Retrieves YouTube's content guide.
|
||||
|
||||
**Returns**: `Promise<Guide>`
|
||||
|
||||
<a name="getlibrary"></a>
|
||||
### getLibrary()
|
||||
### `getLibrary()`
|
||||
Retrieves the account's library.
|
||||
|
||||
**Returns**: `Promise.<Library>`
|
||||
**Returns**: `Promise<Library>`
|
||||
|
||||
> **Note**
|
||||
> `Library` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
@@ -419,19 +488,20 @@ Retrieves the account's library.
|
||||
- `<library>#history`
|
||||
- `<library>#watch_later`
|
||||
- `<library>#liked_videos`
|
||||
- `<library>#playlists`
|
||||
- `<library>#playlists_section`
|
||||
- `<library>#clips`
|
||||
- `<library>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="gethistory"></a>
|
||||
### getHistory()
|
||||
### `getHistory()`
|
||||
Retrieves watch history.
|
||||
|
||||
**Returns**: `Promise.<History>`
|
||||
**Returns**: `Promise<History>`
|
||||
|
||||
> **Note**
|
||||
> `History` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
@@ -444,22 +514,25 @@ Retrieves watch history.
|
||||
</details>
|
||||
|
||||
<a name="gettrending"></a>
|
||||
### getTrending()
|
||||
### `getTrending()`
|
||||
Retrieves trending content.
|
||||
|
||||
**Returns**: `Promise.<TabbedFeed>`
|
||||
**Returns**: `Promise<TabbedFeed<IBrowseResponse>>`
|
||||
|
||||
<a name="getsubscriptionsfeed"></a>
|
||||
### getSubscriptionsFeed()
|
||||
Retrieves subscriptions feed.
|
||||
### `getSubscriptionsFeed()`
|
||||
Retrieves the subscriptions feed.
|
||||
|
||||
**Returns**: `Promise.<Feed>`
|
||||
**Returns**: `Promise<Feed<IBrowseResponse>>`
|
||||
|
||||
<a name="getchannel"></a>
|
||||
### getChannel(id)
|
||||
### `getChannel(id)`
|
||||
Retrieves contents for a given channel.
|
||||
|
||||
**Returns**: `Promise.<Channel>`
|
||||
**Returns**: `Promise<Channel>`
|
||||
|
||||
> **Note**
|
||||
> `Channel` extends the [`TabbedFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/tabbed-feed.md) class.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -470,11 +543,24 @@ Retrieves contents for a given channel.
|
||||
<p>
|
||||
|
||||
- `<channel>#getVideos()`
|
||||
- `<channel>#getShorts()`
|
||||
- `<channel>#getLiveStreams()`
|
||||
- `<channel>#getReleases()`
|
||||
- `<channel>#getPodcasts()`
|
||||
- `<channel>#getPlaylists()`
|
||||
- `<channel>#getHome()`
|
||||
- `<channel>#getCommunity()`
|
||||
- `<channel>#getChannels()`
|
||||
- `<channel>#getAbout()`
|
||||
- `<channel>#search(query)`
|
||||
- `<channel>#applyFilter(filter)`
|
||||
- `<channel>#applyContentTypeFilter(content_type_filter)`
|
||||
- `<channel>#applySort(sort)`
|
||||
- `<channel>#getContinuation()`
|
||||
- `<channel>#filters`
|
||||
- `<channel>#content_type_filters`
|
||||
- `<channel>#sort_filters`
|
||||
- `<channel>#page`
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -482,10 +568,10 @@ Retrieves contents for a given channel.
|
||||
See [`./examples/channel`](https://github.com/LuanRT/YouTube.js/blob/main/examples/channel) for examples.
|
||||
|
||||
<a name="getnotifications"></a>
|
||||
### getNotifications()
|
||||
### `getNotifications()`
|
||||
Retrieves notifications.
|
||||
|
||||
**Returns**: `Promise.<NotificationsMenu>`
|
||||
**Returns**: `Promise<NotificationsMenu>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getter</summary>
|
||||
@@ -498,16 +584,19 @@ Retrieves notifications.
|
||||
</details>
|
||||
|
||||
<a name="getunseennotificationscount"></a>
|
||||
### getUnseenNotificationsCount()
|
||||
### `getUnseenNotificationsCount()`
|
||||
Retrieves unseen notifications count.
|
||||
|
||||
**Returns**: `Promise.<number>`
|
||||
**Returns**: `Promise<number>`
|
||||
|
||||
<a name="getplaylist"></a>
|
||||
### getPlaylist(id)
|
||||
### `getPlaylist(id)`
|
||||
Retrieves playlist contents.
|
||||
|
||||
**Returns**: `Promise.<Playlist>`
|
||||
**Returns**: `Promise<Playlist>`
|
||||
|
||||
> **Note**
|
||||
> `Playlist` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -523,11 +612,51 @@ Retrieves playlist contents.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="gethashtag"></a>
|
||||
### `getHashtag(hashtag)`
|
||||
Retrieves a given hashtag's page.
|
||||
|
||||
**Returns**: `Promise<HashtagFeed>`
|
||||
|
||||
> **Note**
|
||||
> `HashtagFeed` extends the [`FilterableFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/filterable-feed.md) class.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| hashtag | `string` | The hashtag |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getter</summary>
|
||||
<p>
|
||||
|
||||
- `<hashtag>#applyFilter(filter)`
|
||||
- Applies given filter and returns a new `HashtagFeed` instance.
|
||||
- `<hashtag>#getContinuation()`
|
||||
- Retrieves next batch of contents.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getstreamingdata"></a>
|
||||
### getStreamingData(video_id, options)
|
||||
### `getStreamingData(video_id, options)`
|
||||
Returns deciphered streaming data.
|
||||
|
||||
**Returns**: `Promise.<object>`
|
||||
> **Note**
|
||||
> This method will be deprecated in the future. We recommend retrieving streaming data from a `VideoInfo` or `TrackInfo` object instead if you want to select formats manually. Please refer to the following example:
|
||||
|
||||
```ts
|
||||
const info = await yt.getBasicInfo('somevideoid');
|
||||
|
||||
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||
console.info('Playback url:', url);
|
||||
|
||||
// or:
|
||||
const format = info.chooseFormat({ type: 'audio', quality: 'best' });
|
||||
const url = format?.decipher(yt.session.player);
|
||||
console.info('Playback url:', url);
|
||||
```
|
||||
|
||||
**Returns**: `Promise<object>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -535,10 +664,10 @@ Returns deciphered streaming data.
|
||||
| options | `FormatOptions` | Format options |
|
||||
|
||||
<a name="download"></a>
|
||||
### download(video_id, options?)
|
||||
### `download(video_id, options?)`
|
||||
Downloads a given video.
|
||||
|
||||
**Returns**: `Promise.<ReadableStream<Uint8Array>>`
|
||||
**Returns**: `Promise<ReadableStream<Uint8Array>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -547,92 +676,97 @@ Downloads a given video.
|
||||
|
||||
See [`./examples/download`](https://github.com/LuanRT/YouTube.js/blob/main/examples/download) for examples.
|
||||
|
||||
<a name="resolveurl"></a>
|
||||
### `resolveURL(url)`
|
||||
Resolves a given url.
|
||||
|
||||
**Returns**: `Promise<NavigationEndpoint>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| url | `string` | Url to resolve |
|
||||
|
||||
<a name="call"></a>
|
||||
### call(endpoint, args?)
|
||||
### `call(endpoint, args?)`
|
||||
Utility to call navigation endpoints.
|
||||
|
||||
**Returns**: `Promise.<ActionsResponse | ParsedResponse>`
|
||||
**Returns**: `Promise<T extends IParsedResponse | IParsedResponse | ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| endpoint | `NavigationEndpoint` | The target endpoint |
|
||||
| args? | `object` | Additional payload arguments |
|
||||
|
||||
## Extending the library
|
||||
|
||||
## Implementing custom functionality
|
||||
|
||||
Something cool about YouTube.js is that it is completely modular and easy to tinker with. Almost all methods, classes, and utilities used internally are exposed and can be used to implement your own extensions without having to modify the library's source code.
|
||||
|
||||
For example, you may want to call an endpoint directly, that can be achieved with the `Actions` class:
|
||||
YouTube.js is modular and easy to extend. Most of the methods, classes, and utilities used internally are exposed and can be used to implement your own extensions without having to modify the library's source code.
|
||||
|
||||
For example, let's say we want to implement a method to retrieve video info. We can do that by using an instance of the `Actions` class:
|
||||
```ts
|
||||
// ...
|
||||
import { Innertube } from 'youtubei.js';
|
||||
|
||||
const payload = {
|
||||
videoId: 'jLTOuvBTLxA',
|
||||
client: 'YTMUSIC', // InnerTube client, can be ANDROID, YTMUSIC, WEB
|
||||
parse: true // tells YouTube.js to parse the response, this is not sent to InnerTube.
|
||||
};
|
||||
(async () => {
|
||||
const yt = await Innertube.create();
|
||||
|
||||
const response = await yt.actions.execute('/player', payload);
|
||||
async function getVideoInfo(videoId: string) {
|
||||
const videoInfo = await yt.actions.execute('/player', {
|
||||
// You can add any additional payloads here, and they'll merge with the default payload sent to InnerTube.
|
||||
videoId,
|
||||
client: 'YTMUSIC', // InnerTube client options: ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB, or TV_EMBEDDED.
|
||||
parse: true // tells YouTube.js to parse the response (not sent to InnerTube).
|
||||
});
|
||||
|
||||
console.info(response);
|
||||
return videoInfo;
|
||||
}
|
||||
|
||||
const videoInfo = await getVideoInfo('jLTOuvBTLxA');
|
||||
console.info(videoInfo);
|
||||
})();
|
||||
```
|
||||
|
||||
Or maybe there's an interesting `NavigationEndpoint` in a parsed response and we want to call it to see what happens:
|
||||
|
||||
Alternatively, suppose we locate a `NavigationEndpoint` in a parsed response and want to see what happens when we call it:
|
||||
```ts
|
||||
// ...
|
||||
const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
|
||||
const albums = artist.sections[1].as(MusicCarouselShelf);
|
||||
import { Innertube, YTNodes } from 'youtubei.js';
|
||||
|
||||
// Say we have a button and want to “click” it
|
||||
const button = albums.as(MusicCarouselShelf).header?.more_content;
|
||||
|
||||
if (button) {
|
||||
// To do that, we can call its navigation endpoint:
|
||||
const page = await button.endpoint.call(yt.actions, 'YTMUSIC', true);
|
||||
console.info(page);
|
||||
}
|
||||
(async () => {
|
||||
const yt = await Innertube.create();
|
||||
|
||||
const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
|
||||
const albums = artist.sections[1].as(YTNodes.MusicCarouselShelf);
|
||||
|
||||
// Let's imagine that we wish to click on the “More” button:
|
||||
const button = albums.as(YTNodes.MusicCarouselShelf).header?.more_content;
|
||||
|
||||
if (button) {
|
||||
// Having ensured that it exists, we can then call its navigation endpoint using the following code:
|
||||
const page = await button.endpoint.call(yt.actions, { parse: true });
|
||||
console.info(page);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
### Parser
|
||||
|
||||
If you're working on an extension for the library or just want to have nicely typed and sanitized InnerTube responses for a project then have a look at our powerful parser!
|
||||
|
||||
<details>
|
||||
<summary>Example:</summary>
|
||||
<p>
|
||||
YouTube.js' parser enables you to parse InnerTube responses and convert their nodes into strongly-typed objects that are simple to manipulate. Additionally, it provides numerous utility methods that make working with InnerTube a breeze.
|
||||
|
||||
Here's an example of its usage:
|
||||
```ts
|
||||
// See ./examples/parser
|
||||
|
||||
import { Parser } from 'youtubei.js';
|
||||
|
||||
import SectionList from 'youtubei.js/dist/src/parser/classes/SectionList';
|
||||
import SingleColumnBrowseResults from 'youtubei.js/dist/src/parser/classes/SingleColumnBrowseResults';
|
||||
|
||||
import MusicVisualHeader from 'youtubei.js/dist/src/parser/classes/MusicVisualHeader';
|
||||
import MusicImmersiveHeader from 'youtubei.js/dist/src/parser/classes/MusicImmersiveHeader';
|
||||
import MusicCarouselShelf from 'youtubei.js/dist/src/parser/classes/MusicCarouselShelf';
|
||||
import MusicDescriptionShelf from 'youtubei.js/dist/src/parser/classes/MusicDescriptionShelf';
|
||||
import MusicShelf from 'youtubei.js/dist/src/parser/classes/MusicShelf';
|
||||
|
||||
import { Parser, YTNodes } from 'youtubei.js';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Artist page response from YouTube Music
|
||||
// YouTube Music's artist page response
|
||||
const data = readFileSync('./artist.json').toString();
|
||||
|
||||
const page = Parser.parseResponse(JSON.parse(data));
|
||||
|
||||
const header = page.header.item().as(MusicImmersiveHeader, MusicVisualHeader);
|
||||
const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);
|
||||
|
||||
console.info('Header:', header);
|
||||
|
||||
// The parser encapsulates all arrays in a proxy object.
|
||||
// A proxy intercepts access to the actual data, allowing
|
||||
// the parser to add type safety and many utility methods
|
||||
// that make working with InnerTube much easier.
|
||||
const tab = page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: false });
|
||||
// The parser uses a proxy object to add type safety and utility methods for working with InnerTube's data arrays:
|
||||
const tab = page.contents?.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
|
||||
|
||||
if (!tab)
|
||||
throw new Error('Target tab not found');
|
||||
@@ -640,47 +774,37 @@ if (!tab)
|
||||
if (!tab.content)
|
||||
throw new Error('Target tab appears to be empty');
|
||||
|
||||
const sections = tab.content?.as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf, MusicShelf);
|
||||
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
|
||||
|
||||
console.info('Sections:', sections);
|
||||
```
|
||||
|
||||
</p>
|
||||
</details>
|
||||
Documentation for the parser can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/src/parser).
|
||||
|
||||
Detailed documentation can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/src/parser).
|
||||
|
||||
<!-- CONTRIBUTING -->
|
||||
## Contributing
|
||||
Contributions, issues, and feature requests are welcome.
|
||||
Feel free to check the [issues page](https://github.com/LuanRT/YouTube.js/issues) and our [guidelines](https://github.com/LuanRT/YouTube.js/blob/main/CONTRIBUTING.md) if you want to contribute.
|
||||
We welcome all contributions, issues and feature requests, whether small or large. If you want to contribute, feel free to check out our [issues page](https://github.com/LuanRT/YouTube.js/issues) and our [guidelines](https://github.com/LuanRT/YouTube.js/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<!-- CONTRIBUTORS -->
|
||||
## Contributors
|
||||
We are immensely grateful to all the wonderful people who have contributed to this project. A special shoutout to all our contributors! 🎉
|
||||
<a href="https://github.com/LuanRT/YouTube.js/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=LuanRT/YouTube.js" />
|
||||
</a>
|
||||
|
||||
<!-- CONTACT -->
|
||||
## Contact
|
||||
|
||||
LuanRT - [@lrt_nooneknows][twitter] - luan.lrt4@gmail.com
|
||||
LuanRT - [@thesciencephile][twitter] - luanrt@thatsciencephile.com
|
||||
|
||||
Project Link: [https://github.com/LuanRT/YouTube.js][project]
|
||||
|
||||
## Disclaimer
|
||||
This project is not affiliated with, endorsed, or sponsored by YouTube or any of its affiliates or subsidiaries.
|
||||
All trademarks, logos, and brand names are the property of their respective owners and are used only to directly describe the services being provided, as such, any usage of trademarks to refer to such services is considered nominative use.
|
||||
This project is not affiliated with, endorsed, or sponsored by YouTube or any of its affiliates or subsidiaries. All trademarks, logos, and brand names used in this project are the property of their respective owners and are used solely to describe the services provided.
|
||||
|
||||
Should you have any questions or concerns please contact me directly via email.
|
||||
As such, any usage of trademarks to refer to such services is considered nominative use. If you have any questions or concerns, please contact me directly via email.
|
||||
|
||||
<!-- Footnotes -->
|
||||
[^1]: https://gizmodo.com/how-project-innertube-helped-pull-youtube-out-of-the-gu-1704946491
|
||||
|
||||
<!-- LICENSE -->
|
||||
## License
|
||||
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
|
||||
|
||||
<p align=" right">
|
||||
(<a href="#top">back to top</a>)
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// Deno and browser runtimes
|
||||
|
||||
import Innertube from './src/Innertube';
|
||||
|
||||
export * from './src/utils';
|
||||
export { YTNodes } from './src/parser/map';
|
||||
export { default as Parser } from './src/parser';
|
||||
export { default as Innertube } from './src/Innertube';
|
||||
export default Innertube;
|
||||
2
bundle/browser.d.ts
vendored
2
bundle/browser.d.ts
vendored
@@ -1 +1 @@
|
||||
export * from '../dist/browser';
|
||||
export * from '../dist/src/platform/lib.js';
|
||||
1
bundle/node.d.cts
Normal file
1
bundle/node.d.cts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../dist/src/platform/lib.js';
|
||||
3
deno.ts
Normal file
3
deno.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './deno/src/platform/deno.ts';
|
||||
import Innertube from './deno/src/platform/deno.ts';
|
||||
export default Innertube;
|
||||
@@ -46,7 +46,7 @@ Retrieves account information.
|
||||
<p>
|
||||
|
||||
- `<accountinfo>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -63,7 +63,7 @@ Retrieves time watched statistics.
|
||||
<p>
|
||||
|
||||
- `<timewatched>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -91,6 +91,9 @@ Retrieves YouTube settings.
|
||||
- `<settings>#sidebar_items`
|
||||
- Returns options available in the sidebar menu.
|
||||
|
||||
- `<settings>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -106,7 +109,7 @@ Retrieves basic channel analytics.
|
||||
<p>
|
||||
|
||||
- `<analytics>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
115
docs/API/feed.md
Normal file
115
docs/API/feed.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Feed
|
||||
|
||||
Represents a YouTube feed. This class provides a set of utility methods for parsing and interacting with feeds.
|
||||
|
||||
## API
|
||||
|
||||
* Feed
|
||||
* [.videos](#videos)
|
||||
* [.posts](#posts)
|
||||
* [.channels](#channels)
|
||||
* [.playlists](#playlists)
|
||||
* [.shelves](#shelves)
|
||||
* [.memo](#memo)
|
||||
* [.page_contents](#page_contents)
|
||||
* [.secondary_contents](#secondary_contents)
|
||||
* [.page](#page)
|
||||
* [.has_continuation](#has_continuation)
|
||||
* [.getContinuationData()](#getcontinuationdata)
|
||||
* [.getContinuation()](#getcontinuation)
|
||||
* [.getShelf(title)](#getshelf)
|
||||
|
||||
<a name="videos"></a>
|
||||
### videos
|
||||
|
||||
Returns all videos in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>`
|
||||
|
||||
<a name="posts"></a>
|
||||
### posts
|
||||
|
||||
Returns all posts in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Post | BackstagePost>`
|
||||
|
||||
<a name="channels"></a>
|
||||
### channels
|
||||
|
||||
Returns all channels in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Channel | GridChannel>`
|
||||
|
||||
<a name="playlists"></a>
|
||||
### playlists
|
||||
|
||||
Returns all playlists in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Playlist | GridPlaylist>`
|
||||
|
||||
<a name="shelves"></a>
|
||||
### shelves
|
||||
|
||||
Returns all shelves in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Shelf | RichShelf | ReelShelf>`
|
||||
|
||||
<a name="memo"></a>
|
||||
### memo
|
||||
|
||||
Returns the memoized feed contents.
|
||||
|
||||
**Returns:** `Memo`
|
||||
|
||||
<a name="page_contents"></a>
|
||||
### page_contents
|
||||
|
||||
Returns the page contents.
|
||||
|
||||
**Returns:** `SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand`
|
||||
|
||||
<a name="secondary_contents"></a>
|
||||
### secondary_contents
|
||||
|
||||
Returns the secondary contents node.
|
||||
|
||||
**Returns:** `SuperParsedResult<YTNode> | undefined `
|
||||
|
||||
<a name="page"></a>
|
||||
### page
|
||||
|
||||
Returns the original InnerTube response, parsed and sanitized.
|
||||
|
||||
**Returns:** `T extends IParsedResponse = IParsedResponse`
|
||||
|
||||
<a name="has_continuation"></a>
|
||||
### has_continuation
|
||||
|
||||
Returns whether the feed has a continuation.
|
||||
|
||||
**Returns:** `boolean`
|
||||
|
||||
<a name="getcontinuationdata"></a>
|
||||
### getContinuationData()
|
||||
|
||||
Returns the continuation data.
|
||||
|
||||
**Returns:** `Promise<T | undefined>`
|
||||
|
||||
<a name="getcontinuation"></a>
|
||||
### getContinuation()
|
||||
|
||||
Retrieves the feed's continuation.
|
||||
|
||||
**Returns:** `Promise<Feed<T>>`
|
||||
|
||||
<a name="getshelf"></a>
|
||||
### getShelf(title)
|
||||
|
||||
Gets a shelf by its title.
|
||||
|
||||
**Returns:** `Shelf | RichShelf | ReelShelf | undefined`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| title | `string` | The title of the shelf to get |
|
||||
38
docs/API/filterable-feed.md
Normal file
38
docs/API/filterable-feed.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# FilterableFeed
|
||||
|
||||
Represents a feed that can be filtered.
|
||||
|
||||
> **Note**
|
||||
> This class extends the [Feed](feed.md) class.
|
||||
|
||||
## API
|
||||
|
||||
* FilterableFeed
|
||||
* [.filter_chips](#filter_chips)
|
||||
* [.filters](#filters)
|
||||
* [.getFilteredFeed(filter: string | ChipCloudChip)](#getfilteredfeed)
|
||||
|
||||
<a name="filter_chips"></a>
|
||||
### filter_chips
|
||||
|
||||
Returns the feed's filter chips.
|
||||
|
||||
**Returns:** `ObservedArray<ChipCloudChip>`
|
||||
|
||||
<a name="filters"></a>
|
||||
### filters
|
||||
|
||||
Returns the feed's filter chips as an array of strings.
|
||||
|
||||
**Returns:** `string[]`
|
||||
|
||||
<a name="getfilteredfeed"></a>
|
||||
### getFilteredFeed(filter: string | ChipCloudChip)
|
||||
|
||||
Returns a new [Feed](feed.md) with the given filter applied.
|
||||
|
||||
**Returns:** `Promise<Feed<T>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| filter | `string` \| `ChipCloudChip` | The filter to apply |
|
||||
@@ -7,19 +7,19 @@ Handles direct interactions.
|
||||
* InteractionManager
|
||||
* [.like(video_id)](#like)
|
||||
* [.dislike(video_id)](#dislike)
|
||||
* [.removeLike(video_id)](#removelike)
|
||||
* [.removeRating(video_id)](#removerating)
|
||||
* [.subscribe(video_id)](#subscribe)
|
||||
* [.unsubscribe(video_id)](#unsubscribe)
|
||||
* [.comment(video_id, text)](#comment)
|
||||
* [.translate(text, target_language, args?)](#translate)
|
||||
* [.setNotificationPreferences(channel_id, type)](#setnotificationpreferences)
|
||||
* [.setNotificationPreferences(channel_id, type)](#setnotificationpreferences)
|
||||
|
||||
<a name="like"></a>
|
||||
### like(video_id)
|
||||
|
||||
Likes given video.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -30,18 +30,18 @@ Likes given video.
|
||||
|
||||
Dislikes given video.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="removelike"></a>
|
||||
### removeLike(video_id)
|
||||
<a name="removerating"></a>
|
||||
### removeRating(video_id)
|
||||
|
||||
Remover like/dislike.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -52,7 +52,7 @@ Remover like/dislike.
|
||||
|
||||
Subscribes to given channel.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -63,7 +63,7 @@ Subscribes to given channel.
|
||||
|
||||
Unsubscribes from given channel.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -74,7 +74,7 @@ Unsubscribes from given channel.
|
||||
|
||||
Posts a comment on given video.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -86,7 +86,7 @@ Posts a comment on given video.
|
||||
|
||||
Translates given text using YouTube's comment translation feature.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -100,9 +100,9 @@ Translates given text using YouTube's comment translation feature.
|
||||
Changes notification preferences for a given channel.
|
||||
Only works with channels you are subscribed to.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | Channel id |
|
||||
| type | `string` | `PERSONALIZED`, `ALL` or `NONE` |
|
||||
| type | `string` | `PERSONALIZED`, `ALL` or `NONE` |
|
||||
|
||||
127
docs/API/kids.md
Normal file
127
docs/API/kids.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# YouTube Kids
|
||||
|
||||
YouTube Kids is a modified version of the YouTube app, with a simplified interface and curated content. This class allows you to interact with its API.
|
||||
|
||||
## API
|
||||
|
||||
* Kids
|
||||
* [.search(query)](#search)
|
||||
* [.getInfo(video_id)](#getinfo)
|
||||
* [.getChannel(channel_id)](#getchannel)
|
||||
* [.getHomeFeed()](#gethomefeed)
|
||||
* [.blockChannel(channel_id)](#blockchannel)
|
||||
|
||||
<a name="search"></a>
|
||||
### search(query)
|
||||
|
||||
Searches the given query on YouTube Kids.
|
||||
|
||||
**Returns:** `Promise.<Search>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| query | `string` | The query to search |
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<search>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getinfo"></a>
|
||||
### getInfo(video_id)
|
||||
|
||||
Retrieves video info.
|
||||
|
||||
**Returns:** `Promise.<VideoInfo>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | The video id |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<info>#toDash(url_transformer?, format_filter?)`
|
||||
- Generates a DASH manifest from the streaming data.
|
||||
|
||||
- `<info>#chooseFormat(options)`
|
||||
- Selects the format that best matches the given options. This method is used internally by `#download`.
|
||||
|
||||
- `<info>#download(options?)`
|
||||
- Downloads the video.
|
||||
|
||||
- `<info>#addToWatchHistory()`
|
||||
- Adds the video to the watch history.
|
||||
|
||||
- `<info>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getchannel"></a>
|
||||
### getChannel(channel_id)
|
||||
|
||||
Retrieves channel info.
|
||||
|
||||
**Returns:** `Promise.<Channel>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | The channel id |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<channel>#getContinuation()`
|
||||
- Retrieves next batch of videos.
|
||||
|
||||
- `<channel>#has_continuation`
|
||||
- Returns whether there are more videos to retrieve.
|
||||
|
||||
- `<channel>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="gethomefeed"></a>
|
||||
### getHomeFeed()
|
||||
|
||||
Retrieves the home feed.
|
||||
|
||||
**Returns:** `Promise.<HomeFeed>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<feed>#selectCategoryTab(tab: string | KidsCategoryTab)`
|
||||
- Selects the given category tab.
|
||||
|
||||
- `<feed>#categories`
|
||||
- Returns available categories.
|
||||
|
||||
- `<feed>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</details>
|
||||
|
||||
<a name="blockChannel"></a>
|
||||
### blockChannel(channel_id)
|
||||
|
||||
Retrieves the list of supervised accounts that the signed-in user has access to and blocks the given channel for each of them.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse[]>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | Channel id |
|
||||
@@ -1,11 +1,11 @@
|
||||
# Music
|
||||
# YouTube Music
|
||||
|
||||
YouTube Music class.
|
||||
YouTube Music is a music streaming service developed by YouTube, a subsidiary of Google. It provides a tailored interface for the service oriented towards music streaming, with a greater emphasis on browsing and discovery compared to its main service. This class allows you to interact with its API.
|
||||
|
||||
## API
|
||||
|
||||
* Music
|
||||
* [.getInfo(video_id)](#getinfo)
|
||||
* [.getInfo(target)](#getinfo)
|
||||
* [.search(query, filters?)](#search)
|
||||
* [.getHomeFeed()](#gethomefeed)
|
||||
* [.getExplore()](#getexplore)
|
||||
@@ -14,13 +14,13 @@ YouTube Music class.
|
||||
* [.getAlbum(album_id)](#getalbum)
|
||||
* [.getPlaylist(playlist_id)](#getplaylist)
|
||||
* [.getLyrics(video_id)](#getlyrics)
|
||||
* [.getUpNext(video_id)](#getupnext)
|
||||
* [.getUpNext(video_id, automix?)](#getupnext)
|
||||
* [.getRelated(video_id)](#getrelated)
|
||||
* [.getRecap()](#getrecap)
|
||||
* [.getSearchSuggestions(query)](#getsearchsuggestions)
|
||||
|
||||
<a name="getinfo"></a>
|
||||
### getInfo(video_id)
|
||||
### getInfo(target)
|
||||
|
||||
Retrieves track info.
|
||||
|
||||
@@ -28,7 +28,44 @@ Retrieves track info.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| target | `string` or `MusicTwoRowItem` | video id or list item |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<info>#getTab(title)`
|
||||
- Retrieves contents of the given tab.
|
||||
|
||||
- `<info>#getUpNext(automix?)`
|
||||
- Retrieves up next.
|
||||
|
||||
- `<info>#getRelated()`
|
||||
- Retrieves related content.
|
||||
|
||||
- `<info>#getLyrics()`
|
||||
- Retrieves song lyrics.
|
||||
|
||||
- `<info>#available_tabs`
|
||||
- Returns available tabs.
|
||||
|
||||
- `<info>#toDash(url_transformer?, format_filter?)`
|
||||
- Generates a DASH manifest from the streaming data.
|
||||
|
||||
- `<info>#chooseFormat(options)`
|
||||
- Selects the format that best matches the given options. This method is used internally by `#download`.
|
||||
|
||||
- `<info>#download(options?)`
|
||||
- Downloads the track.
|
||||
|
||||
- `<info>#addToWatchHistory()`
|
||||
- Adds the song to the watch history.
|
||||
|
||||
- `<info>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="search"></a>
|
||||
### search(query, filters?)
|
||||
@@ -40,7 +77,16 @@ Searches on YouTube Music.
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| query | `string` | Search query |
|
||||
| filters? | `object` | Search filters |
|
||||
| filters? | `MusicSearchFilters` | Search filters |
|
||||
|
||||
<details>
|
||||
<summary>Search Filters</summary>
|
||||
|
||||
| Filter | Type | Value | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| type | `string` | `all`, `song`, `video`, `album`, `playlist`, `artist` | Search type |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
@@ -77,7 +123,7 @@ Searches on YouTube Music.
|
||||
- Returns songs shelf.
|
||||
|
||||
- `<search>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -102,6 +148,9 @@ Retrieves home feed.
|
||||
- `<homefeed>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
- `<homefeed>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -117,7 +166,7 @@ Retrieves “Explore” feed.
|
||||
<p>
|
||||
|
||||
- `<explore>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -127,9 +176,35 @@ Retrieves “Explore” feed.
|
||||
|
||||
Retrieves library.
|
||||
|
||||
**Returns:** `Promise.<Library>`
|
||||
**Returns:** `Library`
|
||||
|
||||
<!-- TODO: document Library's methods and getters. -->
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<library>#applyFilter(filter)`
|
||||
- Applies given filter to the library.
|
||||
|
||||
- `<library>#applySort(sort_by)`
|
||||
- Applies given sort option to the library items.
|
||||
|
||||
- `<library>#getContinuation()`
|
||||
- Retrieves continuation of the library items.
|
||||
|
||||
- `<library>#has_continuation`
|
||||
- Checks if continuation is available.
|
||||
|
||||
- `<library>#filters`
|
||||
- Returns available filters.
|
||||
|
||||
- `<library>#sort_options`
|
||||
- Returns available sort options.
|
||||
|
||||
- `<library>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getartist"></a>
|
||||
### getArtist(artist_id)
|
||||
@@ -147,7 +222,7 @@ Retrieves artist's info & content.
|
||||
<p>
|
||||
|
||||
- `<artist>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -168,7 +243,7 @@ Retrieves given album.
|
||||
<p>
|
||||
|
||||
- `<album>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -201,7 +276,7 @@ Retrieves given playlist.
|
||||
- Checks if continuation is available.
|
||||
|
||||
- `<playlist>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
@@ -211,14 +286,14 @@ Retrieves given playlist.
|
||||
|
||||
Retrieves song lyrics.
|
||||
|
||||
**Returns:** `Promise.<{ text: string; footer: object; }>`
|
||||
**Returns:** `Promise.<MusicDescriptionShelf | undefined>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="getupnext"></a>
|
||||
### getUpNext(video_id)
|
||||
### getUpNext(video_id, automix?)
|
||||
|
||||
Retrieves up next content.
|
||||
|
||||
@@ -227,6 +302,7 @@ Retrieves up next content.
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| automix? | `boolean` | if automix should be fetched |
|
||||
|
||||
<a name="getrelated"></a>
|
||||
### getRelated(video_id)
|
||||
@@ -254,7 +330,7 @@ Retrieves your YouTube Music recap.
|
||||
- Retrieves recap playlist.
|
||||
|
||||
- `<recap>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -10,13 +10,15 @@ Playlist management class.
|
||||
* [.addVideos(playlist_id, video_ids)](#addvideos)
|
||||
* [.removeVideos(playlist_id, video_ids)](#removevideos)
|
||||
* [.moveVideo(playlist_id, moved_video_id, predecessor_video_id)](#movevideo)
|
||||
* [.setName(playlist_id, name)](#setname)
|
||||
* [.setDescription(playlist_id, description)](#setdescription)
|
||||
|
||||
<a name="create"></a>
|
||||
### create(title, video_ids)
|
||||
|
||||
Creates a playlist.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -28,7 +30,7 @@ Creates a playlist.
|
||||
|
||||
Deletes given playlist.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -69,4 +71,29 @@ Moves a video to a new position within a given playlist.
|
||||
| --- | --- | --- |
|
||||
| playlist_id | `string` | Playlist id |
|
||||
| moved_video_id | `string` | the video to be moved |
|
||||
| predecessor_video_id | `string` | the video present in the target position |
|
||||
| predecessor_video_id | `string` | the video present in the target position |
|
||||
|
||||
<a name="setname"></a>
|
||||
### setName(playlist_id, name)
|
||||
|
||||
Sets the name / title for the given playlist.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| playlist_id | `string` | Playlist id |
|
||||
| name | `string` | Name / title |
|
||||
|
||||
|
||||
<a name="setdescription"></a>
|
||||
### setDescription(playlist_id, description)
|
||||
|
||||
Sets the description for the given playlist.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| playlist_id | `string` | Playlist id |
|
||||
| description | `string` | Description |
|
||||
|
||||
@@ -41,7 +41,7 @@ InnerTube API key.
|
||||
**Returns:** `string`
|
||||
|
||||
<a name="api_version"></a>
|
||||
### key
|
||||
### api_version
|
||||
|
||||
InnerTube API version.
|
||||
|
||||
@@ -80,4 +80,4 @@ Player script object.
|
||||
|
||||
Client language.
|
||||
|
||||
**Returns:** `string`
|
||||
**Returns:** `string`
|
||||
|
||||
@@ -6,6 +6,7 @@ YouTube Studio class (WIP).
|
||||
|
||||
* Studio
|
||||
* [.setThumbnail(video_id, buffer)](#setthumbnail)
|
||||
* [.updateVideoMetadata(video_id, metadata)](#updatemetadata)
|
||||
* [.upload(file, metadata)](#upload)
|
||||
|
||||
<a name="setthumbnail"></a>
|
||||
@@ -13,21 +14,33 @@ YouTube Studio class (WIP).
|
||||
|
||||
Uploads a custom thumbnail and sets it for a video.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| buffer | `Uint8Array` | Thumbnail buffer |
|
||||
|
||||
<a name="updatemetadata"></a>
|
||||
### updateVideoMetadata(video_id, metadata)
|
||||
|
||||
Updates given video's metadata.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| metadata | `VideoMetadata` | Video metadata |
|
||||
|
||||
<a name="upload"></a>
|
||||
### upload(file, metadata)
|
||||
|
||||
Uploads a video to YouTube.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| file | `BodyInit` | Video file |
|
||||
| metadata | `VideoMetadata` | Video metadata |
|
||||
| metadata | `UploadedVideoMetadata` | Video metadata |
|
||||
62
docs/API/tabbed-feed.md
Normal file
62
docs/API/tabbed-feed.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# TabbedFeed
|
||||
|
||||
Represents a feed with tabs.
|
||||
|
||||
> **Note**
|
||||
> This class extends the [Feed](feed.md) class.
|
||||
|
||||
## API
|
||||
|
||||
* TabbedFeed
|
||||
* [.tabs](#tabs)
|
||||
* [.getTabByName(title: string)](#gettabbyname)
|
||||
* [.getTabByURL(url: string)](#gettabbyurl)
|
||||
* [.hasTabWithURL(url: string)](#hastabwithurl)
|
||||
* [.title](#title)
|
||||
|
||||
<a name="tabs"></a>
|
||||
### tabs
|
||||
|
||||
Returns the feed's tabs as an array of strings.
|
||||
|
||||
**Returns:** `string[]`
|
||||
|
||||
<a name="gettabbyname"></a>
|
||||
### getTabByName(title: string)
|
||||
|
||||
Fetches a tab by its title.
|
||||
|
||||
**Returns:** `Promise<TabbedFeed<T>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| title | `string` | The title of the tab to get |
|
||||
|
||||
<a name="gettabbyurl"></a>
|
||||
### getTabByURL(url: string)
|
||||
|
||||
Fetches a tab by its URL.
|
||||
|
||||
**Returns:** `Promise<TabbedFeed<T>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| url | `string` | The URL of the tab to get |
|
||||
|
||||
<a name="hastabwithurl"></a>
|
||||
### hasTabWithURL(url: string)
|
||||
|
||||
Returns whether the feed has a tab with the given URL.
|
||||
|
||||
**Returns:** `boolean`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| url | `string` | The URL to check |
|
||||
|
||||
<a name="title"></a>
|
||||
### title
|
||||
|
||||
Returns the currently selected tab's title.
|
||||
|
||||
**Returns:** `string | undefined`
|
||||
60
docs/updating-the-parser.md
Normal file
60
docs/updating-the-parser.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Updating the Parser
|
||||
|
||||
YouTube is constantly changing, so it is not uncommon to see YouTube crawlers/scrapers breaking every now and then.
|
||||
|
||||
Our parser, on the other hand, was written so that it behaves similarly to an official client, parsing and mapping renderers (also known as YTNodes) dynamically without hard-coding their path in the response. This way, whenever a new renderer pops up (e.g., when YouTube adds a new feature or makes a minor UI change), the library will print a warning similar to this:
|
||||
|
||||
```
|
||||
SomeRenderer not found!
|
||||
This is a bug, want to help us fix it? Follow the instructions at https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md or report it at https://github.com/LuanRT/YouTube.js/issues!
|
||||
Introspected and JIT generated this class in the meantime:
|
||||
class SomeRenderer extends YTNode {
|
||||
static type = 'SomeRenderer';
|
||||
|
||||
// ...
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This warning **does not** throw an error. The parser itself will continue working normally, even if a parsing error occurs in an existing renderer parser.
|
||||
|
||||
## Adding a New Renderer Parser
|
||||
|
||||
Thanks to the modularity of the parser, a renderer can be implemented by simply adding a new file anywhere in the [classes directory](../src/parser/classes)!
|
||||
|
||||
For example, suppose we have found a new renderer named `verticalListRenderer`. In that case, to let the parser know it exists at compile-time, we would have to create a file with the following structure:
|
||||
|
||||
> `../classes/VerticalList.ts`
|
||||
|
||||
```ts
|
||||
import { Parser, RawNode } from '../index.js';
|
||||
import { YTNode } from '../helpers.js';
|
||||
|
||||
export default class VerticalList extends YTNode {
|
||||
static type = 'VerticalList';
|
||||
|
||||
header;
|
||||
contents;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
// parse the data here, ex;
|
||||
this.header = Parser.parseItem(data.header);
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You may use the parser's generated class for the new renderer as a starting point for your own implementation.
|
||||
|
||||
Then update the parser map:
|
||||
|
||||
```bash
|
||||
npm run build:parser-map
|
||||
```
|
||||
|
||||
And that's it!
|
||||
@@ -1,8 +1,11 @@
|
||||
# Authentication via OAuth
|
||||
# OAuth2
|
||||
|
||||
## Usage
|
||||
## Custom OAuth2 Credentials
|
||||
Just like the official Data API, YouTube.js supports using your own OAuth2 credentials. A working example can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/examples/auth/custom-oauth2-creds).
|
||||
|
||||
Before using any methods which require authentication, you have to authenticate the session:
|
||||
## YouTube TV OAuth2
|
||||
|
||||
The library supports signing in using YouTube TV's client id. This is the recommended way to sign in as it doesn't require you to create your own OAuth2 credentials.
|
||||
|
||||
```js
|
||||
// 'auth-pending' is fired with the info needed to sign in via OAuth.
|
||||
@@ -25,9 +28,11 @@ yt.session.on('update-credentials', ({ credentials }) => {
|
||||
await yt.session.signIn(/* credentials */);
|
||||
```
|
||||
|
||||
### Cache Credentials
|
||||
A working example can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/examples/auth/yttv-oauth2.js).
|
||||
|
||||
If you don't wish to sign in every time you start the session, you can cache the credentials. Note that this SHOULD NOT be used in production, save your credentials in a database/file instead and pass them to `Session#signIn(creds?)` when signing in.
|
||||
## Cache Credentials
|
||||
|
||||
If you don't want to start the sign in flow every time you initialize the session, you can cache the credentials. Note that this SHOULD NOT be used in production, save your credentials in a database/file instead and pass them to `Session#signIn(creds?)` when signing in.
|
||||
|
||||
```js
|
||||
// If you use this, the next call to signIn won't fire 'auth-pending' instead just 'auth'
|
||||
@@ -36,9 +41,9 @@ await yt.session.oauth.cacheCredentials();
|
||||
|
||||
**Note:** When using cached credentials, you are still required to make a call to `Session#signIn()`.
|
||||
|
||||
### Sign Out
|
||||
## Sign Out
|
||||
|
||||
The sign out method may be used to sign out of the current session. This should also remove the cached credentials.
|
||||
The sign out method may be used to sign out of the current session. This removes and revokes the credentials.
|
||||
|
||||
```js
|
||||
await yt.session.signOut();
|
||||
@@ -47,3 +52,14 @@ await yt.session.signOut();
|
||||
// and only want to delete the cached credentials, use:
|
||||
await yt.session.oauth.removeCache();
|
||||
```
|
||||
|
||||
# Cookies
|
||||
|
||||
> **Note**
|
||||
> This is not as reliable as OAuth2 as cookies expire and can be completely revoked at any time.
|
||||
|
||||
```js
|
||||
const yt = await Innertube.create({
|
||||
cookie: '...'
|
||||
});
|
||||
```
|
||||
141
examples/auth/custom-oauth2-creds/index.ts
Normal file
141
examples/auth/custom-oauth2-creds/index.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import express from 'express';
|
||||
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
|
||||
const app = express();
|
||||
|
||||
let innertube: Innertube | undefined;
|
||||
let oAuth2Client: OAuth2Client | undefined;
|
||||
|
||||
/**
|
||||
* To get your own client id and secret, visit https://console.developers.google.com/, create a new project,
|
||||
* and create an OAuth 2.0 Client ID (Web application) under the Credentials tab.
|
||||
*
|
||||
* Don't forget to add http://localhost:3000/login as an authorized redirect URI.
|
||||
*/
|
||||
const clientId = 'YOUR_OAUTH2_CLIENT_ID';
|
||||
const clientSecret = 'YOUR_OAUTH2_CLIENT_SECRET';
|
||||
const redirectUri = 'http://localhost:3000/login';
|
||||
|
||||
const port = 3000;
|
||||
|
||||
let authorizationUrl: string | undefined;
|
||||
|
||||
app.use(express.static('public'))
|
||||
app.use(express.urlencoded({ extended: true, limit: '3mb' }))
|
||||
|
||||
const cache = new UniversalCache(true);
|
||||
|
||||
console.info("Cache dir:", cache.cache_dir);
|
||||
|
||||
app.get('/', async (_req, res) => {
|
||||
if (!innertube) {
|
||||
console.info('Creating innertube instance.');
|
||||
innertube = await Innertube.create({ cache });
|
||||
|
||||
innertube.session.on("update-credentials", async (_credentials) => {
|
||||
console.info('Credentials updated.');
|
||||
await innertube?.session.oauth.cacheCredentials();
|
||||
});
|
||||
}
|
||||
|
||||
if (await cache.get('youtubei_oauth_credentials')) {
|
||||
await innertube.session.signIn();
|
||||
}
|
||||
|
||||
if (innertube.session.logged_in) {
|
||||
console.info('Innertube instance is logged in.');
|
||||
|
||||
const userInfo = await innertube.account.getInfo();
|
||||
const library = await innertube.getLibrary();
|
||||
|
||||
const html = `
|
||||
<p>Hello ${userInfo.contents?.contents.first().account_name.text}! You have ${userInfo.contents?.contents.first().account_byline.text} on your YouTube channel.</p>
|
||||
<p>Email: ${userInfo.contents?.contents.first().endpoint.payload.directSigninUserProfile.email}</p>
|
||||
<p>Obfuscated Gaia ID: ${userInfo.contents?.contents.first().endpoint.payload.directSigninIdentity.effectiveObfuscatedGaiaId}</p>
|
||||
<p>Channel URL: <a href="https://www.youtube.com/channel/${userInfo.footers?.endpoint.payload.browseId}">https://www.youtube.com/channel/${userInfo.footers?.endpoint.payload.browseId}</a></p>
|
||||
<p>Profile Picture:</p>
|
||||
<img src="${userInfo.contents?.contents.first().account_photo[0].url}" />
|
||||
<p>Recently watched videos:</p>
|
||||
<ul>
|
||||
${library.videos.map((video) => `<li><a href="${video.as(YTNodes.GridVideo).endpoint.toURL()}">${video.title.toString()}</a> by ${video.as(YTNodes.GridVideo).author.name.toString()} - ${video.as(YTNodes.GridVideo).duration?.text}</li>`).join('')}
|
||||
</ul>
|
||||
<button onclick="window.location.href = '/logout'">Logout</button>
|
||||
`;
|
||||
|
||||
return res.send(html);
|
||||
}
|
||||
|
||||
if (!oAuth2Client) {
|
||||
console.info('Creating OAuth2 client.');
|
||||
|
||||
oAuth2Client = new OAuth2Client(
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri
|
||||
);
|
||||
|
||||
authorizationUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: [
|
||||
"http://gdata.youtube.com",
|
||||
"https://www.googleapis.com/auth/youtube-paid-content"
|
||||
],
|
||||
include_granted_scopes: true,
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
console.info('Redirecting to authorization URL...');
|
||||
|
||||
res.redirect(authorizationUrl);
|
||||
} else if (authorizationUrl) {
|
||||
console.info('OAuth2 client already exists. Redirecting to authorization URL...');
|
||||
res.redirect(authorizationUrl);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/login', async (req, res) => {
|
||||
const { code } = req.query;
|
||||
|
||||
if (!code) {
|
||||
return res.send('No code provided.');
|
||||
}
|
||||
|
||||
if (!oAuth2Client || !innertube) {
|
||||
return res.send('OAuth2 client or innertube instance is not initialized.');
|
||||
}
|
||||
|
||||
const { tokens } = await oAuth2Client.getToken(code as string);
|
||||
|
||||
if (tokens.access_token && tokens.refresh_token && tokens.expiry_date) {
|
||||
await innertube.session.signIn({
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires: new Date(tokens.expiry_date),
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
});
|
||||
|
||||
await innertube.session.oauth.cacheCredentials();
|
||||
|
||||
console.log('Logged in successfully. Redirecting to home page...');
|
||||
|
||||
res.redirect('/');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/logout', async (_req, res) => {
|
||||
if (!innertube) {
|
||||
return res.send('Innertube instance is not initialized.');
|
||||
}
|
||||
|
||||
await innertube.session.signOut();
|
||||
|
||||
console.log('Logged out successfully. Redirecting to home page...');
|
||||
|
||||
res.redirect('/');
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
});
|
||||
20
examples/auth/custom-oauth2-creds/package.json
Normal file
20
examples/auth/custom-oauth2-creds/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "yt-oauth-example",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"google-auth-library": "^9.4.1",
|
||||
"youtubei.js": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21"
|
||||
}
|
||||
}
|
||||
109
examples/auth/custom-oauth2-creds/tsconfig.json
Normal file
109
examples/auth/custom-oauth2-creds/tsconfig.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
const { Innertube, UniversalCache } = require('youtubei.js');
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({
|
||||
// required if you wish to use OAuth#cacheCredentials
|
||||
cache: new UniversalCache()
|
||||
cache: new UniversalCache(false)
|
||||
});
|
||||
|
||||
// 'auth-pending' is fired with the info needed to sign in via OAuth.
|
||||
@@ -17,8 +17,9 @@ const { Innertube, UniversalCache } = require('youtubei.js');
|
||||
});
|
||||
|
||||
// 'update-credentials' is fired when the access token expires, if you do not save the updated credentials any subsequent request will fail
|
||||
yt.session.on('update-credentials', ({ credentials }) => {
|
||||
yt.session.on('update-credentials', async ({ credentials }) => {
|
||||
console.log('Credentials updated:', credentials);
|
||||
await yt.session.oauth.cacheCredentials();
|
||||
});
|
||||
|
||||
// Attempt to sign in
|
||||
23
examples/blockchannel/index.js
Normal file
23
examples/blockchannel/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(true, './credcache') });
|
||||
|
||||
yt.session.on('auth-pending', (data) => {
|
||||
console.log(`Go to ${data.verification_url} in your browser and enter code ${data.user_code} to authenticate.`);
|
||||
});
|
||||
yt.session.on('auth', async () => {
|
||||
console.log('Sign in successful');
|
||||
await yt.session.oauth.cacheCredentials();
|
||||
});
|
||||
yt.session.on('update-credentials', async () => {
|
||||
await yt.session.oauth.cacheCredentials();
|
||||
});
|
||||
|
||||
// Attempt to sign in
|
||||
await yt.session.signIn();
|
||||
|
||||
// Block Channel for all kids / profiles on the signed-in account.
|
||||
const resp = await yt.kids.blockChannel('UCpbpfcZfo-hoDAx2m1blFhg');
|
||||
console.info('Blocked channel for ', resp.length, ' profiles.');
|
||||
})();
|
||||
@@ -2,25 +2,60 @@
|
||||
|
||||
YouTube.js works in the browser!
|
||||
|
||||
## How to use
|
||||
## Usage
|
||||
|
||||
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in `examples/browser/proxy/deno.ts`.
|
||||
|
||||
Once the proxy is set up you need to tell Innertube about it when instantiating it.
|
||||
We'll use our own fetch implementation to proxy requests through our server. This is a simple example, but you can use any fetch implementation you want.
|
||||
|
||||
```ts
|
||||
import { Innertube } from "youtubei.js/build/browser";
|
||||
import { Innertube } from "youtubei.js/web.bundle.min";
|
||||
|
||||
const yt = await Innertube.create({
|
||||
browser_proxy: {
|
||||
host: "localhost",
|
||||
schema: 'http',
|
||||
}
|
||||
})
|
||||
fetch: async (input, init) => {
|
||||
// url
|
||||
const url = typeof input === 'string'
|
||||
? new URL(input)
|
||||
: input instanceof URL
|
||||
? input
|
||||
: new URL(input.url);
|
||||
|
||||
// transform the url for use with our proxy
|
||||
url.searchParams.set('__host', url.host);
|
||||
url.host = 'localhost:8080';
|
||||
url.protocol = 'http';
|
||||
|
||||
const headers = init?.headers
|
||||
? new Headers(init.headers)
|
||||
: input instanceof Request
|
||||
? input.headers
|
||||
: new Headers();
|
||||
|
||||
// now serialize the headers
|
||||
url.searchParams.set('__headers', JSON.stringify([...headers]));
|
||||
|
||||
// copy over the request
|
||||
const request = new Request(
|
||||
url,
|
||||
input instanceof Request ? input : undefined,
|
||||
);
|
||||
|
||||
headers.delete('user-agent');
|
||||
|
||||
// fetch the url
|
||||
return fetch(request, init ? {
|
||||
...init,
|
||||
headers
|
||||
} : {
|
||||
headers
|
||||
});
|
||||
},
|
||||
cache: new UniversalCache(),
|
||||
});
|
||||
```
|
||||
|
||||
after that you can use the library as normal.
|
||||
After that, you can use the library as normal.
|
||||
|
||||
## Example
|
||||
|
||||
We've got a full example in `examples/browser/web` using vite.
|
||||
We've got a full example in `examples/browser/web` using vite.
|
||||
@@ -18,7 +18,7 @@ const handler = async (request: Request): Promise<Response> => {
|
||||
'Access-Control-Allow-Origin': request.headers.get('origin') || '*',
|
||||
'Access-Control-Allow-Methods': '*',
|
||||
'Access-Control-Allow-Headers':
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-origin, x-youtube-client-version, Accept-Language, Range',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-origin, x-youtube-client-version, Accept-Language, Range, Referer',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
}),
|
||||
@@ -45,7 +45,7 @@ const handler = async (request: Request): Promise<Response> => {
|
||||
JSON.parse(url.searchParams.get('__headers') || '{}'),
|
||||
);
|
||||
copyHeader('range', request_headers, request.headers);
|
||||
copyHeader('user-agent', request_headers, request.headers);
|
||||
!request_headers.has('user-agent') && copyHeader('user-agent', request_headers, request.headers);
|
||||
url.searchParams.delete('__headers');
|
||||
|
||||
// Make the request to YouTube
|
||||
@@ -62,6 +62,8 @@ const handler = async (request: Request): Promise<Response> => {
|
||||
copyHeader('content-length', headers, fetchRes.headers);
|
||||
copyHeader('content-type', headers, fetchRes.headers);
|
||||
copyHeader('content-disposition', headers, fetchRes.headers);
|
||||
copyHeader('accept-ranges', headers, fetchRes.headers);
|
||||
copyHeader('content-range', headers, fetchRes.headers);
|
||||
|
||||
// add cors headers
|
||||
headers.set(
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<form>
|
||||
<input type="text" name="id" placeholder="Video ID" />
|
||||
<input type="submit" value="Play" />
|
||||
</form>
|
||||
<span id="video_name">
|
||||
Library is loading...
|
||||
</span>
|
||||
<video></video>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="stylesheet" href="/src/assets/style.css" />
|
||||
<link rel="stylesheet" href="/src/assets/player.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YouTube.js Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<form>
|
||||
<input type="text" name="id" placeholder="Video ID or URL" />
|
||||
<input type="submit" value="Play" />
|
||||
</form>
|
||||
<div class="loader" id="loader"></div>
|
||||
<div id="video-container">
|
||||
<div class="shaka-container" id="shaka-container" data-shaka-player-container>
|
||||
<video class="videoel" id="videoel" data-shaka-player autoplay></video>
|
||||
</div>
|
||||
<h2 id="title"></h2>
|
||||
<div id="metadata"></div>
|
||||
<hr />
|
||||
<div id="description"></div>
|
||||
</div>
|
||||
<footer>
|
||||
<p>Powered by <a href="https://github.com/LuanRT/YouTube.js">YouTube.js</a></p>
|
||||
</footer>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,6 +13,6 @@
|
||||
"vite": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dashjs": "^4.4.0"
|
||||
"shaka-player": "^4.3.8"
|
||||
}
|
||||
}
|
||||
423
examples/browser/web/src/assets/player.css
Normal file
423
examples/browser/web/src/assets/player.css
Normal file
@@ -0,0 +1,423 @@
|
||||
@import url(https://fonts.googleapis.com/css?family=Material+Icons+Sharp);
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/roboto/v27/KFOmCnqEu92Fr1Me5Q.ttf) format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/roboto/v27/KFOlCnqEu92Fr1MmEU9vAw.ttf) format('truetype');
|
||||
}
|
||||
|
||||
.shaka-container {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-bottom-controls {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
padding-bottom: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-bottom-controls {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-box-direction: normal;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-ad-controls {
|
||||
-webkit-box-ordinal-group: 2;
|
||||
-ms-flex-order: 1;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-spinner .shaka-spinner-path {
|
||||
stroke: #ffffff;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-scrim-container {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-shrink: 1;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
transition: opacity cubic-bezier(.4, 0, .6, 1) .6s;
|
||||
background: linear-gradient(to top, hsla(0, 0%, 0%, 0.61), transparent 15%);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-play-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
filter: invert();
|
||||
box-shadow: none;
|
||||
-webkit-box-ordinal-group: -3;
|
||||
-ms-flex-order: -4;
|
||||
order: -4;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-small-play-button {
|
||||
-webkit-box-ordinal-group: -2;
|
||||
-ms-flex-order: -3;
|
||||
order: -3;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-mute-button {
|
||||
-webkit-box-ordinal-group: -1;
|
||||
-ms-flex-order: -2;
|
||||
order: -2;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-controls-button-panel>* {
|
||||
margin: 0;
|
||||
padding: 3px 8px;
|
||||
color: #EEE;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-controls-button-panel>*:hover {
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-controls-button-panel .shaka-volume-bar-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
left: -1px;
|
||||
-webkit-box-ordinal-group: 0;
|
||||
-ms-flex-order: -1;
|
||||
order: -1;
|
||||
opacity: 0;
|
||||
width: 0px;
|
||||
-webkit-transition: width 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
height: 3px;
|
||||
transition: width 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-controls-button-panel .shaka-volume-bar-container:hover,
|
||||
.shaka-container .shaka-controls-button-panel .shaka-volume-bar-container:focus {
|
||||
display: block;
|
||||
width: 50px;
|
||||
opacity: 1;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-mute-button:hover+div {
|
||||
opacity: 1;
|
||||
width: 50px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-current-time {
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container {
|
||||
height: 3px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container .shaka-range-element {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container:hover {
|
||||
height: 5px;
|
||||
top: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container:hover .shaka-range-element {
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container input[type=range]::-webkit-slider-thumb {
|
||||
background: #FF0000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container input[type=range]::-moz-range-thumb {
|
||||
background: #FF0000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container input[type=range]::-ms-thumb {
|
||||
background: #FF0000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-video-container * {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-video-container .material-icons-round {
|
||||
font-family: 'Material Icons Sharp';
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu,
|
||||
.shaka-container .shaka-settings-menu {
|
||||
border-radius: 2px;
|
||||
background: rgba(37, 37, 37, 0.9);
|
||||
text-shadow: 0 0 2px rgb(0 0 0%);
|
||||
-webkit-transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
animation: fade 0.3s;
|
||||
-webkit-user-select: none;
|
||||
right: 10px;
|
||||
bottom: 50px;
|
||||
padding: 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu {
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button span {
|
||||
margin-left: 33px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button[aria-selected="true"] {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button[aria-selected="true"] span {
|
||||
-webkit-box-ordinal-group: 3;
|
||||
-ms-flex-order: 2;
|
||||
order: 2;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button[aria-selected="true"] i {
|
||||
-webkit-box-ordinal-group: 2;
|
||||
-ms-flex-order: 1;
|
||||
order: 1;
|
||||
font-size: 18px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button i {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button .shaka-overflow-button-label {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-pack: justify;
|
||||
-ms-flex-pack: justify;
|
||||
justify-content: space-between;
|
||||
-webkit-box-orient: horizontal;
|
||||
-webkit-box-direction: normal;
|
||||
-ms-flex-direction: row;
|
||||
flex-direction: row;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
height: 40px;
|
||||
-webkit-box-flex: 0;
|
||||
-ms-flex: 0 0 100%;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button .shaka-overflow-button-label span {
|
||||
-ms-flex-negative: initial;
|
||||
flex-shrink: initial;
|
||||
padding-left: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu span+span {
|
||||
color: #FFF;
|
||||
font-weight: 400 !important;
|
||||
font-size: 12px !important;
|
||||
padding-right: 8px;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu span+span:after {
|
||||
content: "navigate_next";
|
||||
font-family: 'Material Icons Sharp';
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu .shaka-pip-button span+span {
|
||||
padding-right: 15px !important;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu .shaka-pip-button span+span:after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.shaka-container .shaka-back-to-overflow-button {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
font-size: 12px;
|
||||
color: #eee;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-back-to-overflow-button .material-icons-round {
|
||||
font-size: 15px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-back-to-overflow-button span {
|
||||
margin-left: 3px !important;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button:hover,
|
||||
.shaka-container .shaka-settings-menu button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button:hover label,
|
||||
.shaka-container .shaka-settings-menu button:hover label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button,
|
||||
.shaka-container .shaka-settings-menu button {
|
||||
color: #EEE;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-captions-off {
|
||||
color: #BFBFBF;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu-button {
|
||||
font-size: 18px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-fullscreen-button:hover {
|
||||
font-size: 25px;
|
||||
-webkit-transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu,
|
||||
.shaka-container .shaka-settings-menu {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
||||
.shaka-container .shaka-overflow-menu,
|
||||
.shaka-container .shaka-settings-menu {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu span+span,
|
||||
.shaka-container .shaka-overflow-menu button,
|
||||
.shaka-container .shaka-settings-menu button {
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.shaka-container .shaka-controls-button-panel {
|
||||
-webkit-box-ordinal-group: 3;
|
||||
-ms-flex-order: 2;
|
||||
order: 2;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.shaka-container .shaka-scrim-container {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-range-container {
|
||||
margin: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-mute-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu,
|
||||
.shaka-container .shaka-settings-menu {
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 80%;
|
||||
margin: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button,
|
||||
.shaka-container .shaka-settings-menu button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button span,
|
||||
.shaka-container .shaka-settings-menu button span {
|
||||
margin-left: 0;
|
||||
padding-left: 15px;
|
||||
}
|
||||
}
|
||||
135
examples/browser/web/src/assets/style.css
Normal file
135
examples/browser/web/src/assets/style.css
Normal file
@@ -0,0 +1,135 @@
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: #202020;
|
||||
color: rgb(255, 255, 255);
|
||||
line-height: 1.6;
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
background-color: rgb(68, 68, 68);
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0.5rem 0;
|
||||
display: none;
|
||||
border-radius: 0.3rem;
|
||||
background-color: rgb(68, 68, 68);
|
||||
}
|
||||
|
||||
form input {
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
form input[type="text"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
form input[type="text"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
form input[type="submit"] {
|
||||
color: rgb(255, 255, 255);
|
||||
background-color: rgba(0, 0, 0, 0.244);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: #ffffff;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
#loader {
|
||||
display: block;
|
||||
border: 10px solid rgb(68, 68, 68);
|
||||
border-top: 10px solid rgb(255, 255, 255);
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
align-self: center;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#shaka-container {
|
||||
height: 40vw;
|
||||
}
|
||||
|
||||
#video-container {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 70vw !important;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
#metadata {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-self: left;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
#metadata>#metadata-item {
|
||||
margin: 0 0.3rem;
|
||||
background-color: #ffffff;
|
||||
color: rgba(0, 0, 0, 0.757);
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
#video-container>#description {
|
||||
align-self: left;
|
||||
margin-left: 0.5rem;
|
||||
font-size: medium;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
video {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#shaka-container {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#video-container {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import './style.css';
|
||||
import { Innertube, UniversalCache } from '../../../../bundle/browser';
|
||||
import dashjs from 'dashjs';
|
||||
|
||||
// @ts-ignore - Shaka's TS support is not the best.
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui.js';
|
||||
|
||||
import "shaka-player/dist/controls.css";
|
||||
|
||||
const title = document.getElementById('title') as HTMLHeadingElement;
|
||||
const description = document.getElementById('description') as HTMLDivElement;
|
||||
const metadata = document.getElementById('metadata') as HTMLDivElement;
|
||||
const loader = document.getElementById('loader') as HTMLDivElement;
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
|
||||
async function main() {
|
||||
const yt = await Innertube.create({
|
||||
generate_session_locally: true,
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
// url
|
||||
const url = typeof input === 'string'
|
||||
? new URL(input)
|
||||
: input instanceof URL
|
||||
? input
|
||||
: new URL(input.url);
|
||||
? input
|
||||
: new URL(input.url);
|
||||
|
||||
// transform the url for use with our proxy
|
||||
// Transform the url for use with our proxy.
|
||||
url.searchParams.set('__host', url.host);
|
||||
url.host = 'localhost:8080';
|
||||
url.protocol = 'http';
|
||||
@@ -20,13 +29,18 @@ async function main() {
|
||||
const headers = init?.headers
|
||||
? new Headers(init.headers)
|
||||
: input instanceof Request
|
||||
? input.headers
|
||||
: new Headers();
|
||||
? input.headers
|
||||
: new Headers();
|
||||
|
||||
// now serialize the headers
|
||||
// Now serialize the headers.
|
||||
url.searchParams.set('__headers', JSON.stringify([...headers]));
|
||||
|
||||
// copy over the request
|
||||
if (input instanceof Request) {
|
||||
// @ts-ignore
|
||||
input.duplex = 'half';
|
||||
}
|
||||
|
||||
// Copy over the request.
|
||||
const request = new Request(
|
||||
url,
|
||||
input instanceof Request ? input : undefined,
|
||||
@@ -34,7 +48,6 @@ async function main() {
|
||||
|
||||
headers.delete('user-agent');
|
||||
|
||||
// fetch the url
|
||||
return fetch(request, init ? {
|
||||
...init,
|
||||
headers
|
||||
@@ -42,58 +55,227 @@ async function main() {
|
||||
headers
|
||||
});
|
||||
},
|
||||
cache: new UniversalCache(),
|
||||
cache: new UniversalCache(false),
|
||||
});
|
||||
|
||||
const span = document.getElementById('video_name') as HTMLSpanElement;
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
form.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
|
||||
form.style.display = 'block';
|
||||
|
||||
span.textContent = 'Library ready';
|
||||
showUI({ hidePlayer: true });
|
||||
|
||||
let player: dashjs.MediaPlayerClass | undefined;
|
||||
let player: shaka.Player | undefined;
|
||||
let ui: shaka.ui.Overlay | undefined;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
span.textContent = 'Loading...';
|
||||
if (player) {
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
const video_id = document.querySelector<HTMLInputElement>(
|
||||
'input[type=text]',
|
||||
)?.value;
|
||||
if (!video_id) {
|
||||
span.textContent = 'No video id';
|
||||
hideUI();
|
||||
|
||||
let videoId;
|
||||
|
||||
const videoIdOrURL = document.querySelector<HTMLInputElement>('input[type=text]')?.value;
|
||||
|
||||
if (!videoIdOrURL) {
|
||||
title.textContent = 'No video id or URL provided';
|
||||
showUI({ hidePlayer: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const video = await yt.getInfo(video_id);
|
||||
if (videoIdOrURL.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/)) {
|
||||
const endpoint = await yt.resolveURL(videoIdOrURL);
|
||||
|
||||
console.log(video);
|
||||
span.textContent = video.basic_info.title || null;
|
||||
if (!endpoint.payload.videoId) {
|
||||
title.textContent = 'Could not resolve URL';
|
||||
showUI({ hidePlayer: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const dash = video.toDash((url) => {
|
||||
url.searchParams.set('__host', url.host);
|
||||
url.host = 'localhost:8080';
|
||||
url.protocol = 'http';
|
||||
return url;
|
||||
});
|
||||
|
||||
const uri = 'data:application/dash+xml;charset=utf-8;base64,' +
|
||||
btoa(dash);
|
||||
|
||||
// create and append video element
|
||||
const video_element = document.querySelector('video') as HTMLVideoElement;
|
||||
video_element.setAttribute('controls', 'true');
|
||||
// use dash.js to parse the manifest
|
||||
if (player) {
|
||||
player.destroy();
|
||||
videoId = endpoint.payload.videoId;
|
||||
} else {
|
||||
videoId = videoIdOrURL;
|
||||
}
|
||||
|
||||
const info = await yt.getInfo(videoId);
|
||||
|
||||
title.textContent = info.basic_info.title || null;
|
||||
description.innerHTML = info.secondary_info?.description.toHTML() || '';
|
||||
title.textContent = info.basic_info.title || null;
|
||||
|
||||
document.title = info.basic_info.title || '';
|
||||
|
||||
metadata.innerHTML = '';
|
||||
metadata.innerHTML += `<div id="metadata-item">${info.primary_info?.published.toHTML()}</div>`;
|
||||
metadata.innerHTML += `<div id="metadata-item">${info.primary_info?.view_count.toHTML()}</div>`;
|
||||
metadata.innerHTML += `<div id="metadata-item">${info.basic_info.like_count} likes</div>`;
|
||||
|
||||
showUI({ hidePlayer: false });
|
||||
|
||||
const dash = await info.toDash();
|
||||
|
||||
const uri = 'data:application/dash+xml;charset=utf-8;base64,' + btoa(dash);
|
||||
|
||||
if (player) {
|
||||
await player.destroy();
|
||||
player = undefined;
|
||||
}
|
||||
|
||||
if (ui) {
|
||||
ui.destroy();
|
||||
ui = undefined;
|
||||
}
|
||||
|
||||
const videoEl = document.getElementById('videoel') as HTMLVideoElement;
|
||||
const shakaContainer = document.getElementById('shaka-container') as HTMLDivElement;
|
||||
|
||||
shakaContainer
|
||||
.querySelectorAll("div")
|
||||
.forEach(node => node.remove());
|
||||
|
||||
shaka.polyfill.installAll();
|
||||
|
||||
if (shaka.Player.isBrowserSupported()) {
|
||||
videoEl.poster = info.basic_info.thumbnail![0].url;
|
||||
|
||||
player = new shaka.Player(videoEl);
|
||||
ui = new shaka.ui.Overlay(player, shakaContainer, videoEl);
|
||||
|
||||
const config = {
|
||||
seekBarColors: {
|
||||
base: 'rgba(255,255,255,.2)',
|
||||
buffered: 'rgba(255,255,255,.4)',
|
||||
played: 'rgb(255,0,0)',
|
||||
},
|
||||
fadeDelay: 0,
|
||||
};
|
||||
|
||||
ui.configure(config);
|
||||
|
||||
const overflowMenuButton = document.querySelector('.shaka-overflow-menu-button');
|
||||
if (overflowMenuButton) {
|
||||
overflowMenuButton.innerHTML = 'settings';
|
||||
}
|
||||
|
||||
const backToOverflowButton = document.querySelector('.shaka-back-to-overflow-button .material-icons-round');
|
||||
if (backToOverflowButton) {
|
||||
backToOverflowButton.innerHTML = 'arrow_back_ios_new';
|
||||
}
|
||||
|
||||
player.configure({
|
||||
streaming: {
|
||||
bufferingGoal: 180,
|
||||
rebufferingGoal: 0.02,
|
||||
bufferBehind: 300
|
||||
}
|
||||
});
|
||||
|
||||
player.getNetworkingEngine()?.registerRequestFilter((_type: any, request: any) => {
|
||||
const uri = request.uris[0];
|
||||
const url = new URL(uri);
|
||||
const headers = request.headers;
|
||||
|
||||
if (url.host.endsWith(".googlevideo.com") || headers.Range) {
|
||||
url.searchParams.set('__host', url.host);
|
||||
url.host = 'localhost:8080';
|
||||
url.protocol = 'http';
|
||||
}
|
||||
|
||||
request.method = 'POST';
|
||||
|
||||
// protobuf - { 15: 0 }
|
||||
request.body = new Uint8Array([120, 0]);
|
||||
|
||||
if (url.pathname === "/videoplayback") {
|
||||
if (headers.Range) {
|
||||
request.headers = {};
|
||||
url.searchParams.set("range", headers.Range.split("=")[1]);
|
||||
url.searchParams.set("alr", "yes");
|
||||
}
|
||||
}
|
||||
|
||||
request.uris[0] = url.toString();
|
||||
});
|
||||
|
||||
// The UTF-8 characters "h", "t", "t", and "p".
|
||||
const HTTP_IN_HEX = 0x68747470;
|
||||
|
||||
const RequestType = shaka.net.NetworkingEngine.RequestType;
|
||||
|
||||
player.getNetworkingEngine()?.registerResponseFilter(async (type: any, response: any) => {
|
||||
const dataView = new DataView(response.data);
|
||||
|
||||
if (response.data.byteLength < 4 ||
|
||||
dataView.getUint32(0) != HTTP_IN_HEX) {
|
||||
// This doesn't start with "http", so it is not an ALR.
|
||||
return;
|
||||
}
|
||||
|
||||
// Interpret the response data as a URL string.
|
||||
const response_as_string = shaka.util.StringUtils.fromUTF8(response.data);
|
||||
|
||||
let retry_parameters;
|
||||
|
||||
if (type == RequestType.MANIFEST) {
|
||||
retry_parameters = player!.getConfiguration().manifest.retryParameters;
|
||||
} else if (type == RequestType.SEGMENT) {
|
||||
retry_parameters = player!.getConfiguration().streaming.retryParameters;
|
||||
} else if (type == RequestType.LICENSE) {
|
||||
retry_parameters = player!.getConfiguration().drm.retryParameters;
|
||||
} else {
|
||||
retry_parameters = shaka.net.NetworkingEngine.defaultRetryParameters();
|
||||
}
|
||||
|
||||
// Make another request for the redirect URL.
|
||||
const uris = [response_as_string];
|
||||
const redirect_request = shaka.net.NetworkingEngine.makeRequest(uris, retry_parameters);
|
||||
const request_operation = player!.getNetworkingEngine()!.request(type, redirect_request);
|
||||
const redirect_response = await request_operation.promise;
|
||||
|
||||
// Modify the original response to contain the results of the redirect
|
||||
// response.
|
||||
response.data = redirect_response.data;
|
||||
response.headers = redirect_response.headers;
|
||||
response.uri = redirect_response.uri;
|
||||
});
|
||||
|
||||
try {
|
||||
await player.load(uri);
|
||||
} catch (e) {
|
||||
console.error('Could not load manifest', e);
|
||||
}
|
||||
} else {
|
||||
console.error('Browser not supported!');
|
||||
}
|
||||
player = dashjs.MediaPlayer().create();
|
||||
player.initialize(video_element, uri, true);
|
||||
} catch (error) {
|
||||
span.textContent = 'An error occurred (see console)';
|
||||
title.textContent = 'An error occurred (see console)';
|
||||
showUI({ hidePlayer: true });
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
function showUI(args: { hidePlayer?: boolean } = {
|
||||
hidePlayer: true,
|
||||
}) {
|
||||
const ytplayer = document.getElementById('shaka-container') as HTMLDivElement;
|
||||
|
||||
ytplayer.style.display = args.hidePlayer ? 'none' : 'block';
|
||||
|
||||
const video_container = document.getElementById('video-container') as HTMLDivElement;
|
||||
video_container.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
|
||||
video_container.style.display = 'block';
|
||||
|
||||
loader.style.display = 'none';
|
||||
}
|
||||
|
||||
function hideUI() {
|
||||
const video_container = document.getElementById('video-container') as HTMLDivElement;
|
||||
video_container.style.display = 'none';
|
||||
loader.style.display = 'block';
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,12 +0,0 @@
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
video {
|
||||
max-width: calc(100vw - 1rem);
|
||||
width: fit-content;
|
||||
max-height: calc(90vh - 12rem);
|
||||
}
|
||||
@@ -1,39 +1,47 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
|
||||
|
||||
const channel = await yt.getChannel('UCX6OQ3DkcsbYNE6H8uQQuVA');
|
||||
|
||||
console.info('Viewing channel:', channel.header.author.name);
|
||||
console.info('Family Safe:', channel.metadata.is_family_safe);
|
||||
if (channel.header?.is(YTNodes.C4TabbedHeader)) {
|
||||
console.info('Viewing channel:', channel?.header?.author.name);
|
||||
console.info('Family Safe:', channel.metadata.is_family_safe);
|
||||
}
|
||||
|
||||
const about = await channel.getAbout();
|
||||
|
||||
console.info('Country:', about.country.toString());
|
||||
|
||||
console.info('\nLists the following videos:');
|
||||
console.info('\nVideos:');
|
||||
const videos = await channel.getVideos();
|
||||
|
||||
for (const video of videos.videos) {
|
||||
console.info('Video:', video.title.toString());
|
||||
}
|
||||
|
||||
console.info('\nLists the following playlists:');
|
||||
console.info('\nPopular videos:');
|
||||
const popular_videos = await videos.applyFilter('Popular');
|
||||
for (const video of popular_videos.videos) {
|
||||
console.info('Video:', video.title.toString());
|
||||
}
|
||||
|
||||
console.info('\nPlaylists:');
|
||||
const playlists = await channel.getPlaylists();
|
||||
|
||||
for (const playlist of playlists.playlists) {
|
||||
console.info('Playlist:', playlist.title.toString());
|
||||
}
|
||||
|
||||
console.info('\nLists the following channels:');
|
||||
console.info('\nChannels:');
|
||||
const channels = await channel.getChannels();
|
||||
|
||||
for (const channel of channels.channels) {
|
||||
console.info('Channel:', channel.author.name);
|
||||
}
|
||||
|
||||
console.info('\nLists the following community posts:');
|
||||
console.info('\nCommunity posts:');
|
||||
const posts = await channel.getCommunity();
|
||||
|
||||
for (const post of posts.posts) {
|
||||
|
||||
18
examples/cloudflare-worker/package.json
Normal file
18
examples/cloudflare-worker/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "cf-worker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240208.0",
|
||||
"typescript": "^5.0.4",
|
||||
"wrangler": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"youtubei.js": "latest"
|
||||
}
|
||||
}
|
||||
19
examples/cloudflare-worker/src/index.ts
Normal file
19
examples/cloudflare-worker/src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Innertube } from "youtubei.js/cf-worker";
|
||||
|
||||
export interface Env {}
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
request: Request,
|
||||
env: Env,
|
||||
ctx: ExecutionContext
|
||||
): Promise<Response> {
|
||||
// cannot initialize Innertube in global scope as it makes fetch requests
|
||||
const yt = await Innertube.create();
|
||||
|
||||
const video = await yt.getInfo("jNQXAC9IVRw");
|
||||
console.log("Video title is", video.basic_info.title);
|
||||
|
||||
return new Response("Hello World!");
|
||||
},
|
||||
};
|
||||
19
examples/cloudflare-worker/tsconfig.json
Normal file
19
examples/cloudflare-worker/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"lib": ["es2021"],
|
||||
"jsx": "react",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "node",
|
||||
"types": ["@cloudflare/workers-types/2023-07-01"],
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
3
examples/cloudflare-worker/wrangler.toml
Normal file
3
examples/cloudflare-worker/wrangler.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
name = "cf-worker-youtubei"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-02-08"
|
||||
@@ -1,5 +1,5 @@
|
||||
## Comment
|
||||
Contains information about a single comment. A [`Comment`](../../lib/parser/contents/classes/Comment.js) can be a top-level comment or a reply to a top-level comment.
|
||||
Contains information about a single comment. A [`Comment`](../../src/parser/classes/comments/Comment.ts) can be a top-level comment or a reply to a top-level comment.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -9,27 +9,42 @@ A `CommentThread` represents a top-level comment and its replies.
|
||||
* [.replies](#replies) ⇒ `Comment[]`
|
||||
* [.getReplies](#getreplies) ⇒ `function`
|
||||
* [.getContinuation](#getcontinuation) ⇒ `function`
|
||||
* [.has_continuation](#hascontinuation) ⇒ `boolean`
|
||||
* [.has_replies](#hasreplies) ⇒ `boolean`
|
||||
|
||||
<a name="comment"></a>
|
||||
### comment
|
||||
The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
|
||||
|
||||
**Type:** [`Comment`](../../lib/parser/contents/classes/Comment.js)
|
||||
**Type:** [`Comment`](../../src/parser/classes/comments/Comment.ts)
|
||||
|
||||
<a name="replies"></a>
|
||||
### replies
|
||||
An array of replies to the top-level comment. (not populated until [`getReplies()`](#getreplies) is called).
|
||||
|
||||
**Type:** [`Comment[]`](../../lib/parser/contents/classes/Comment.js)
|
||||
**Type:** [`Comment[]`](../../src/parser/classes/comments/Comment.ts)
|
||||
|
||||
<a name="getreplies"></a>
|
||||
### getReplies()
|
||||
Retrieves replies to the top-level comment and attaches a [`replies`](#replies) array to the original `CommentThread` object and returns it.
|
||||
|
||||
**Returns:** [`Promise.<CommentThread>`](../../lib/parser/contents/classes/CommentThread.js)
|
||||
**Returns:** [`Promise.<CommentThread>`](../../src/parser/classes/comments/CommentThread.ts)
|
||||
|
||||
<a name="getcontinuation"></a>
|
||||
### getContinuation()
|
||||
Retrieves next batch of replies and adds them to the [`replies`](#replies) array. **Note:** [`getReplies()`](#getreplies) must be called before using this.
|
||||
|
||||
**Returns:** [`Promise.<CommentThread>`](../../lib/parser/contents/classes/CommentThread.js)
|
||||
**Returns:** [`Promise.<CommentThread>`](../../src/parser/classes/comments/CommentThread.ts)
|
||||
|
||||
<a name="hascontinuation"></a>
|
||||
### has_continuation
|
||||
Whether there are more replies to be retrieved.
|
||||
|
||||
**Type:** `boolean`
|
||||
|
||||
<a name="hasreplies"></a>
|
||||
### has_replies
|
||||
|
||||
Whether there are replies to the top-level comment.
|
||||
|
||||
**Type:** `boolean`
|
||||
@@ -1,8 +1,8 @@
|
||||
## Comments
|
||||
YouTube.js has full support for comments, including comment actions such as liking, disliking, replying etc.
|
||||
YouTube.js has full support for comments, including comment actions such as translating, liking, disliking and replying.
|
||||
|
||||
## Usage
|
||||
Get a [`Comments`](../../lib/parser/youtube/Comments.js) instance:
|
||||
Get a [`Comments`](../../src/parser/youtube/Comments.ts) instance:
|
||||
|
||||
```js
|
||||
const comments = await yt.getComments(VIDEO_ID);
|
||||
@@ -11,15 +11,27 @@ const comments = await yt.getComments(VIDEO_ID);
|
||||
## API
|
||||
* Comments
|
||||
* [.contents](#commentthread) ⇒ `CommentThread[]`
|
||||
* [.applySort](#applysort) ⇒ `function`
|
||||
* [.createComment](#createComment) ⇒ `function`
|
||||
* [.getContinuation](#getc) ⇒ `function`
|
||||
* [.has_continuation](#has_continuation) ⇒ `getter`
|
||||
* [.page](#page) ⇒ `getter`
|
||||
|
||||
<a name="commentthread"></a>
|
||||
### contents
|
||||
A list of comment threads. **Note:** More about comment threads [**here**](./CommentThread.md).
|
||||
|
||||
**Type:** [`CommentThread[]`](../../lib/parser/contents/classes/CommentThread.js)
|
||||
**Type:** [`CommentThread[]`](../../src/parser/classes/comments/CommentThread.ts)
|
||||
|
||||
<a name="applysort"></a>
|
||||
### applySort(sort)
|
||||
Applies given sort option to the comments.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| sort | `string` | Sort option. Can be `TOP_COMMENTS`, `NEWEST_FIRST` |
|
||||
|
||||
**Returns:** [`Promise.<Comments>`](../../src/parser/youtube/Comments.ts)
|
||||
|
||||
<a name="createComment"></a>
|
||||
### createComment(text)
|
||||
@@ -35,7 +47,13 @@ Creates a top-level comment.
|
||||
### getContinuation()
|
||||
Retrieves next batch of comment threads.
|
||||
|
||||
**Returns:** [`Promise.<Comments>`](../../lib/parser/youtube/Comments.ts)
|
||||
**Returns:** [`Promise.<Comments>`](../../src/parser/youtube/Comments.ts)
|
||||
|
||||
<a name="has_continuation"></a>
|
||||
### has_continuation
|
||||
Returns whether there are more comments to be fetched.
|
||||
|
||||
**Type:** `boolean`
|
||||
|
||||
<a name="page"></a>
|
||||
### page
|
||||
@@ -44,4 +62,4 @@ Returns original InnerTube response (sanitized).
|
||||
**Returns:** `ParsedResponse`
|
||||
|
||||
## Example
|
||||
See [`index.ts`]('./index.ts').
|
||||
See [`index.ts`](./index.ts).
|
||||
@@ -1,39 +1,45 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
|
||||
|
||||
const comments = await yt.getComments('a-rqu-hjobc');
|
||||
|
||||
console.info(`This video has ${comments.header?.comments_count.toString() || 'N/A'} comments.\n`);
|
||||
const comment_section = await yt.getComments('a-rqu-hjobc');
|
||||
|
||||
for (const thread of comments.contents) {
|
||||
console.info(`This video has ${comment_section.header?.comments_count.toString() || 'N/A'} comments.\n`);
|
||||
|
||||
for (const thread of comment_section.contents) {
|
||||
const comment = thread.comment;
|
||||
|
||||
|
||||
if (comment) {
|
||||
console.info(
|
||||
`${comment.is_pinned ? '[Pinned]' : ''}`,
|
||||
`${comment.is_member ? `${comment.sponsor_comment_badge?.tooltip}` : ''}`,
|
||||
`${comment.author.name} • ${comment.published}\n`,
|
||||
`${comment.content.toString()}`, '\n',
|
||||
`Likes: ${comment.vote_count.short_text}`, '\n'
|
||||
`Likes: ${comment.vote_count}`, '\n'
|
||||
);
|
||||
|
||||
if (comment.reply_count > 0) {
|
||||
|
||||
if (thread.has_replies) {
|
||||
console.info('Replies:', '\n');
|
||||
|
||||
const comment_thread = await thread.getReplies();
|
||||
|
||||
if (comment_thread.replies) {
|
||||
for (const reply of comment_thread.replies) {
|
||||
let comment_thread = await thread.getReplies();
|
||||
|
||||
while (true) {
|
||||
for (const reply of comment_thread?.replies || []) {
|
||||
console.info(
|
||||
`> ${reply.author.name} • ${reply.published}\n`,
|
||||
`${reply.content.toString()}`, '\n',
|
||||
`Likes: ${reply.vote_count.short_text}`, '\n'
|
||||
`Likes: ${reply.vote_count}`, '\n'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
comment_thread = await comment_thread.getContinuation();
|
||||
} catch { break; };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log('\n');
|
||||
}
|
||||
})();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Innertube } from '../../bundle/browser.js';
|
||||
import { Innertube } from 'https://deno.land/x/youtubei/deno.ts';
|
||||
|
||||
const yt = await Innertube.create();
|
||||
|
||||
|
||||
@@ -1,45 +1,44 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
import { readFileSync, existsSync, mkdirSync, createWriteStream } from 'fs';
|
||||
import { streamToIterable } from 'youtubei.js/dist/src/utils/Utils';
|
||||
import { Innertube, UniversalCache, Utils } from 'youtubei.js';
|
||||
import { existsSync, mkdirSync, createWriteStream } from 'fs';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
|
||||
|
||||
const search = await yt.music.search('No Copyright Background Music', { type: 'album' });
|
||||
|
||||
|
||||
if (!search.results)
|
||||
throw new Error('Filter "type" must be used');
|
||||
|
||||
|
||||
const album = await yt.music.getAlbum(search.results[0].id as string);
|
||||
|
||||
|
||||
if (!album.contents)
|
||||
throw new Error('Album appears to be empty');
|
||||
|
||||
console.info(`Album "${album.header.title.toString()}" by ${album.header.author?.name}`, '\n');
|
||||
|
||||
|
||||
console.info(`Album "${album.header?.title.toString()}" by ${album.header?.author?.name}`, '\n');
|
||||
|
||||
for (const song of album.contents) {
|
||||
const stream = await yt.download(song.id as string, {
|
||||
type: 'audio', // audio, video or audio+video
|
||||
type: 'audio', // audio, video or video+audio
|
||||
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
|
||||
format: 'mp4' // media container format
|
||||
});
|
||||
|
||||
|
||||
console.info(`Downloading ${song.title} (${song.id})`);
|
||||
|
||||
const dir = `./${album.header.title.toString()}`;
|
||||
|
||||
|
||||
const dir = `./${album.header?.title.toString()}`;
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir);
|
||||
}
|
||||
|
||||
|
||||
const file = createWriteStream(`${dir}/${song.title?.replace(/\//g, '')}.m4a`);
|
||||
|
||||
for await (const chunk of streamToIterable(stream)) {
|
||||
|
||||
for await (const chunk of Utils.streamToIterable(stream)) {
|
||||
file.write(chunk);
|
||||
}
|
||||
|
||||
|
||||
console.info(`${song.id} - Done!`, '\n');
|
||||
}
|
||||
|
||||
console.info(`Downloaded ${album.header.song_count}!`);
|
||||
|
||||
console.info(`Downloaded ${album.header?.song_count}!`);
|
||||
})();
|
||||
@@ -1,16 +1,16 @@
|
||||
## Live Chat
|
||||
|
||||
The library's Live Chat parser and poller were heavily based on YouTube's original compiled code, this makes it behave in a similar if not identical way to YouTube's Live Chat. Here you can do all sorts of funny things, ex; track messages, donations, polls, and much more.
|
||||
Represents a livestream chat.
|
||||
|
||||
## Usage
|
||||
|
||||
Before fetching a Live Chat, you have to retrieve the target livestream's info:
|
||||
Before fetching a live chat, you have to retrieve the target livestream's info:
|
||||
|
||||
```js
|
||||
const info = await yt.getInfo('video_id');
|
||||
```
|
||||
|
||||
Then you may request a Live Chat instance:
|
||||
Then you may request a live chat instance:
|
||||
```js
|
||||
const livechat = await info.getLiveChat();
|
||||
```
|
||||
@@ -21,6 +21,8 @@ const livechat = await info.getLiveChat();
|
||||
* [.ev](#ev) ⇒ `EventEmitter`
|
||||
* [.start](#start) ⇒ `function`
|
||||
* [.stop](#stop) ⇒ `function`
|
||||
* [.applyFilter](#applyfilter) ⇒ `function`
|
||||
* [.getItemMenu](#getitemmenu) ⇒ `function`
|
||||
* [.sendMessage](#sendmessage) ⇒ `function`
|
||||
|
||||
<a name="ev"></a>
|
||||
@@ -30,6 +32,8 @@ Live Chat's EventEmitter.
|
||||
**Events:**
|
||||
|
||||
- `start`
|
||||
|
||||
Fired when the live chat is started.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
@@ -37,18 +41,35 @@ Live Chat's EventEmitter.
|
||||
| `LiveChatContinuation` | Initial chat data, actions, info, etc. |
|
||||
|
||||
- `chat-update`
|
||||
|
||||
|
||||
Fired when a new chat action is received.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `ChatAction` | Chat Action |
|
||||
| `ChatAction` | Chat action |
|
||||
|
||||
- `metadata-update`
|
||||
|
||||
Fired when the livestream's metadata is updated.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `LiveMetadata` | LiveStream Metadata |
|
||||
| `LiveMetadata` | Livestream metadata |
|
||||
|
||||
- `error`
|
||||
|
||||
Fired when an error occurs.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `Error` | Details about the error |
|
||||
|
||||
- `end`
|
||||
|
||||
Fired when the livestream ends.
|
||||
|
||||
<a name="start"></a>
|
||||
### start()
|
||||
@@ -58,6 +79,25 @@ Starts the Live Chat.
|
||||
### stop()
|
||||
Stops the Live Chat.
|
||||
|
||||
<a name="applyfilter"></a>
|
||||
### applyFilter(filter)
|
||||
|
||||
Applies given filter to the live chat.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| filter | `string` | Can be `TOP_CHAT` or `LIVE_CHAT` |
|
||||
|
||||
<a name="getitemmenu"></a>
|
||||
### getItemMenu(item)
|
||||
Retrieves given chat item's menu.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| item | `object` | Chat item |
|
||||
|
||||
**Returns:** `Promise<ItemMenu>`
|
||||
|
||||
<a name="sendmessage"></a>
|
||||
### sendMessage(text)
|
||||
Sends a message.
|
||||
@@ -69,4 +109,4 @@ Sends a message.
|
||||
**Returns:** `Promise<ObservedArray<AddChatItemAction>>`
|
||||
|
||||
## Example
|
||||
See [`index.ts`]('./index.ts').
|
||||
See [`index.ts`](./index.ts).
|
||||
|
||||
@@ -1,31 +1,37 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
import { LiveChatContinuation } from 'youtubei.js/dist/src/parser';
|
||||
|
||||
import LiveChat, { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
|
||||
|
||||
import Video from 'youtubei.js/dist/src/parser/classes/Video';
|
||||
import AddChatItemAction from 'youtubei.js/dist/src/parser/classes/livechat/AddChatItemAction';
|
||||
import MarkChatItemAsDeletedAction from 'youtubei.js/dist/src/parser/classes/livechat/MarkChatItemAsDeletedAction';
|
||||
|
||||
import LiveChatTextMessage from 'youtubei.js/dist/src/parser/classes/livechat/items/LiveChatTextMessage';
|
||||
import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/items/LiveChatPaidMessage';
|
||||
import { Innertube, UniversalCache, YTNodes, LiveChatContinuation } from 'youtubei.js';
|
||||
import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
|
||||
const search = await yt.search('Lofi girl live');
|
||||
const info = await yt.getInfo(search.videos[0].as(Video).id);
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
|
||||
|
||||
const search = await yt.search('lofi hip hop radio - beats to relax/study to');
|
||||
const info = await yt.getInfo(search.videos[0].as(YTNodes.Video).id);
|
||||
|
||||
const livechat = info.getLiveChat();
|
||||
|
||||
const livechat = await info.getLiveChat();
|
||||
|
||||
livechat.on('start', (initial_data: LiveChatContinuation) => {
|
||||
/**
|
||||
* Initial info is what you see when you first open a Live Chat — this is; inital actions (pinned messages, top donations..), account's info and so on.
|
||||
* Initial info is what you see when you first open a a live chat — this is; initial actions (pinned messages, top donations..), account's info and so forth.
|
||||
*/
|
||||
|
||||
console.info(`Hey ${initial_data.viewer_name || 'N/A'}, welcome to Live Chat!`);
|
||||
console.info(`Hey ${initial_data.viewer_name || 'Guest'}, welcome to Live Chat!`);
|
||||
|
||||
const pinned_action = initial_data.actions.firstOfType(YTNodes.AddBannerToLiveChatCommand);
|
||||
|
||||
if (pinned_action) {
|
||||
if (pinned_action.banner?.contents?.is(YTNodes.LiveChatTextMessage)) {
|
||||
console.info(
|
||||
'\n', 'Pinned message:\n',
|
||||
pinned_action.banner.contents.author?.name.toString(), '-', pinned_action?.banner.contents.message.toString(),
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
livechat.on('error', (error: Error) => console.info('Live chat error:', error));
|
||||
|
||||
livechat.on('end', () => console.info('This live stream has ended.'));
|
||||
|
||||
livechat.on('chat-update', (action: ChatAction) => {
|
||||
/**
|
||||
* An action represents what is being added to
|
||||
@@ -35,28 +41,38 @@ import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/it
|
||||
* Below are a few examples of how this can be used.
|
||||
*/
|
||||
|
||||
if (action.is(AddChatItemAction)) {
|
||||
const item = action.as(AddChatItemAction).item;
|
||||
|
||||
if (action.is(YTNodes.AddChatItemAction)) {
|
||||
const item = action.as(YTNodes.AddChatItemAction).item;
|
||||
|
||||
if (!item)
|
||||
return console.info('Action did not have an item.', action);
|
||||
|
||||
|
||||
const hours = new Date(item.hasKey('timestamp') ? item.timestamp : Date.now()).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
|
||||
switch (item.type) {
|
||||
case 'LiveChatTextMessage':
|
||||
console.info(
|
||||
`${hours} - ${item.as(LiveChatTextMessage).author?.name.toString()}:\n` +
|
||||
`${item.as(LiveChatTextMessage).message.toString()}\n`
|
||||
`${item.as(YTNodes.LiveChatTextMessage).author?.is_moderator ? '[MOD]' : ''}`,
|
||||
`${hours} - ${item.as(YTNodes.LiveChatTextMessage).author?.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatTextMessage).message.toString()}\n`
|
||||
);
|
||||
break;
|
||||
case 'LiveChatPaidMessage':
|
||||
console.info(
|
||||
`${hours} - ${item.as(LiveChatPaidMessage).author.name.toString()}:\n` +
|
||||
`${item.as(LiveChatPaidMessage).purchase_amount}\n`
|
||||
`${item.as(YTNodes.LiveChatPaidMessage).author?.is_moderator ? '[MOD]' : ''}`,
|
||||
`${hours} - ${item.as(YTNodes.LiveChatPaidMessage).author.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatPaidMessage).message.toString()}\n`,
|
||||
`${item.as(YTNodes.LiveChatPaidMessage).purchase_amount}\n`
|
||||
);
|
||||
break;
|
||||
case 'LiveChatPaidSticker':
|
||||
console.info(
|
||||
`${item.as(YTNodes.LiveChatPaidSticker).author?.is_moderator ? '[MOD]' : ''}`,
|
||||
`${hours} - ${item.as(YTNodes.LiveChatPaidSticker).author.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatPaidSticker).purchase_amount}\n`
|
||||
);
|
||||
break;
|
||||
default:
|
||||
@@ -64,9 +80,17 @@ import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/it
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.is(MarkChatItemAsDeletedAction)) {
|
||||
console.warn(`Message ${action.target_item_id} just got deleted and should be replaced with ${action.deleted_state_message.toString()}!`, '\n');
|
||||
|
||||
if (action.is(YTNodes.AddBannerToLiveChatCommand)) {
|
||||
console.info('Message pinned:', action.banner?.contents);
|
||||
}
|
||||
|
||||
if (action.is(YTNodes.RemoveBannerForLiveChatCommand)) {
|
||||
console.info(`Message with action id ${action.target_action_id} was unpinned.`);
|
||||
}
|
||||
|
||||
if (action.is(YTNodes.RemoveChatItemAction)) {
|
||||
console.warn(`Message with action id ${action.target_item_id} just got deleted!`, '\n');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { Parser } from 'youtubei.js';
|
||||
|
||||
import SectionList from 'youtubei.js/dist/src/parser/classes/SectionList';
|
||||
import SingleColumnBrowseResults from 'youtubei.js/dist/src/parser/classes/SingleColumnBrowseResults';
|
||||
|
||||
import MusicVisualHeader from 'youtubei.js/dist/src/parser/classes/MusicVisualHeader';
|
||||
import MusicImmersiveHeader from 'youtubei.js/dist/src/parser/classes/MusicImmersiveHeader';
|
||||
import MusicCarouselShelf from 'youtubei.js/dist/src/parser/classes/MusicCarouselShelf';
|
||||
import MusicDescriptionShelf from 'youtubei.js/dist/src/parser/classes/MusicDescriptionShelf';
|
||||
import MusicShelf from 'youtubei.js/dist/src/parser/classes/MusicShelf';
|
||||
|
||||
import { Parser, YTNodes } from 'youtubei.js';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Artist page response from YouTube Music
|
||||
const data = readFileSync('./artist.json').toString();
|
||||
|
||||
const page = Parser.parseResponse(JSON.parse(data));
|
||||
|
||||
const header = page.header.item().as(MusicImmersiveHeader, MusicVisualHeader);
|
||||
const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);
|
||||
|
||||
console.info('Header:', header);
|
||||
|
||||
@@ -24,14 +13,14 @@ console.info('Header:', header);
|
||||
// A proxy intercepts access to the actual data, allowing
|
||||
// the parser to add type safety and many utility methods
|
||||
// that make working with InnerTube much easier.
|
||||
const tab = page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: false });
|
||||
const tab = page.contents?.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
|
||||
|
||||
if (!tab)
|
||||
throw new Error('Target tab not found');
|
||||
|
||||
if (!tab.content)
|
||||
throw new Error('Target tab appears to be empty');
|
||||
|
||||
const sections = tab.content?.as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf, MusicShelf);
|
||||
|
||||
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
|
||||
|
||||
console.info('Sections:', sections);
|
||||
16
examples/transcript/index.ts
Normal file
16
examples/transcript/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Innertube } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ generate_session_locally: true });
|
||||
|
||||
const info = await yt.getInfo('hePb00CqvP0');
|
||||
|
||||
const defaultTranscriptInfo = await info.getTranscript();
|
||||
|
||||
console.log(`Got ${defaultTranscriptInfo.selectedLanguage} transcript with ${defaultTranscriptInfo.transcript.content.body.initial_segments.length} lines.`);
|
||||
|
||||
console.log("Fetching Hebrew transcript...");
|
||||
|
||||
const heTranscriptInfo = await defaultTranscriptInfo.selectLanguage('Hebrew');
|
||||
console.log(`Got ${heTranscriptInfo.selectedLanguage} transcript with ${heTranscriptInfo.transcript.content.body.initial_segments.length} lines.`);
|
||||
})();
|
||||
@@ -5,7 +5,7 @@ const creds_path = './my_yt_creds.json';
|
||||
const creds = existsSync(creds_path) ? JSON.parse(readFileSync(creds_path).toString()) : undefined;
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false) });
|
||||
|
||||
yt.session.on('auth-pending', (data: any) => {
|
||||
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.user_code}`);
|
||||
@@ -31,5 +31,5 @@ const creds = existsSync(creds_path) ? JSON.parse(readFileSync(creds_path).toStr
|
||||
privacy: 'UNLISTED'
|
||||
});
|
||||
|
||||
console.info('Done!');
|
||||
console.info('Done!', upload);
|
||||
})();
|
||||
|
||||
21
index.ts
21
index.ts
@@ -1,21 +0,0 @@
|
||||
import { getRuntime } from './src/utils/Utils';
|
||||
|
||||
// Polyfill fetch for node
|
||||
if (getRuntime() === 'node') {
|
||||
// eslint-disable-next-line
|
||||
const undici = require('undici');
|
||||
Reflect.set(globalThis, 'fetch', undici.fetch);
|
||||
Reflect.set(globalThis, 'Headers', undici.Headers);
|
||||
Reflect.set(globalThis, 'Request', undici.Request);
|
||||
Reflect.set(globalThis, 'Response', undici.Response);
|
||||
Reflect.set(globalThis, 'FormData', undici.FormData);
|
||||
Reflect.set(globalThis, 'File', undici.File);
|
||||
}
|
||||
|
||||
import Innertube from './src/Innertube';
|
||||
|
||||
export * from './src/utils';
|
||||
export { YTNodes } from './src/parser/map';
|
||||
export { default as Parser } from './src/parser';
|
||||
export { default as Innertube } from './src/Innertube';
|
||||
export default Innertube;
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
projects: [
|
||||
{
|
||||
displayName: 'node',
|
||||
|
||||
6624
package-lock.json
generated
6624
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
103
package.json
103
package.json
@@ -1,10 +1,60 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "2.0.2",
|
||||
"description": "Full-featured wrapper around YouTube's private API.",
|
||||
"main": "./dist/index.js",
|
||||
"browser": "./bundle/browser.js",
|
||||
"types": "./dist",
|
||||
"version": "9.2.1",
|
||||
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
|
||||
"type": "module",
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"agnostic": [
|
||||
"./dist/src/platform/lib.d.ts"
|
||||
],
|
||||
"web": [
|
||||
"./dist/src/platform/lib.d.ts"
|
||||
],
|
||||
"web.bundle": [
|
||||
"./dist/src/platform/lib.d.ts"
|
||||
],
|
||||
"web.bundle.min": [
|
||||
"./dist/src/platform/lib.d.ts"
|
||||
],
|
||||
"cf-worker": [
|
||||
"./dist/src/platform/lib.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"node": {
|
||||
"import": "./dist/src/platform/node.js",
|
||||
"require": "./bundle/node.cjs"
|
||||
},
|
||||
"deno": "./dist/src/platform/deno.js",
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"browser": "./dist/src/platform/web.js",
|
||||
"default": "./dist/src/platform/web.js"
|
||||
},
|
||||
"./agnostic": {
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"default": "./dist/src/platform/lib.js"
|
||||
},
|
||||
"./web": {
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"default": "./dist/src/platform/web.js"
|
||||
},
|
||||
"./web.bundle": {
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"default": "./bundle/browser.js"
|
||||
},
|
||||
"./web.bundle.min": {
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"default": "./bundle/browser.min.js"
|
||||
},
|
||||
"./cf-worker": {
|
||||
"types": "./dist/src/platform/lib.d.ts",
|
||||
"default": "./dist/src/platform/cf-worker.js"
|
||||
}
|
||||
},
|
||||
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
@@ -12,7 +62,9 @@
|
||||
"contributors": [
|
||||
"Wykerd (https://github.com/wykerd/)",
|
||||
"MasterOfBob777 (https://github.com/MasterOfBob777)",
|
||||
"patrickkfkan (https://github.com/patrickkfkan)"
|
||||
"patrickkfkan (https://github.com/patrickkfkan)",
|
||||
"akkadaska (https://github.com/akkadaska)",
|
||||
"Absidue (https://github.com/absidue)"
|
||||
],
|
||||
"directories": {
|
||||
"test": "./test",
|
||||
@@ -23,12 +75,15 @@
|
||||
"test": "npx jest --verbose",
|
||||
"lint": "npx eslint ./src",
|
||||
"lint:fix": "npx eslint --fix ./src",
|
||||
"build": "npm run build:parser-map && npm run build:proto && npm run bundle:browser && npm run bundle:browser:prod && npm run build:node",
|
||||
"build:node": "npx tsc",
|
||||
"bundle:browser": "npx tsc --module esnext && npx esbuild ./dist/browser.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --outfile=./bundle/browser.js --platform=browser",
|
||||
"build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod && npm run bundle:cf-worker",
|
||||
"build:parser-map": "node ./scripts/gen-parser-map.mjs",
|
||||
"build:proto": "npx pb-gen-ts --entry-path=\"src/proto\" --out-dir=\"src/proto/generated\" --ext-in-import=\".js\"",
|
||||
"build:esm": "npx tspc",
|
||||
"build:deno": "npx cpy ./src ./deno && npx esbuild ./src/utils/DashManifest.tsx --keep-names --format=esm --platform=neutral --target=es2020 --outfile=./deno/src/utils/DashManifest.js && npx cpy ./package.json ./deno && npx replace \".js';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'./DashManifest.ts';\" \"'./DashManifest.js';\" ./deno -r && npx replace \"'jintr';\" \"'https://esm.sh/jintr';\" ./deno -r",
|
||||
"bundle:node": "npx esbuild ./dist/src/platform/node.js --bundle --target=node10 --keep-names --format=cjs --platform=node --outfile=./bundle/node.cjs --external:jintr --external:undici --external:linkedom --external:tslib --sourcemap --banner:js=\"/* eslint-disable */\"",
|
||||
"bundle:browser": "npx esbuild ./dist/src/platform/web.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/browser.js --platform=browser",
|
||||
"bundle:browser:prod": "npm run bundle:browser -- --outfile=./bundle/browser.min.js --minify",
|
||||
"build:parser-map": "node ./scripts/build-parser-map.js",
|
||||
"build:proto": "npx protoc --ts_out ./src/proto --proto_path ./src/proto ./src/proto/youtube.proto",
|
||||
"bundle:cf-worker": "npx esbuild ./dist/src/platform/cf-worker.js --banner:js=\"/* eslint-disable */\" --bundle --target=es2020 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/cf-worker.js --platform=node",
|
||||
"prepare": "npm run build",
|
||||
"watch": "npx tsc --watch"
|
||||
},
|
||||
@@ -38,24 +93,31 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@protobuf-ts/runtime": "^2.7.0",
|
||||
"jintr": "^0.1.9",
|
||||
"linkedom": "^0.14.12",
|
||||
"undici": "^5.7.0"
|
||||
"jintr": "^1.1.0",
|
||||
"tslib": "^2.5.0",
|
||||
"undici": "^5.19.1"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"@protobuf-ts/plugin": "^2.7.0",
|
||||
"@types/glob": "^8.1.0",
|
||||
"@types/jest": "^28.1.7",
|
||||
"@types/node": "^17.0.45",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"cpy-cli": "^4.2.0",
|
||||
"esbuild": "^0.14.49",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.16",
|
||||
"glob": "^8.0.3",
|
||||
"jest": "^28.1.3",
|
||||
"pbkit": "^0.0.59",
|
||||
"replace": "^1.2.2",
|
||||
"ts-jest": "^28.0.8",
|
||||
"typescript": "^4.7.4"
|
||||
"ts-patch": "^3.0.2",
|
||||
"ts-transformer-inline-file": "^0.2.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/LuanRT/YouTube.js/issues"
|
||||
@@ -70,7 +132,7 @@
|
||||
"youtube-dl",
|
||||
"youtube-downloader",
|
||||
"youtube-music",
|
||||
"innertubeapi",
|
||||
"youtube-studio",
|
||||
"innertube",
|
||||
"unofficial",
|
||||
"downloader",
|
||||
@@ -79,7 +141,6 @@
|
||||
"upload",
|
||||
"ytmusic",
|
||||
"search",
|
||||
"comment",
|
||||
"music",
|
||||
"api"
|
||||
]
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
const glob = require('glob');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const import_list = [];
|
||||
|
||||
const json = [];
|
||||
|
||||
glob.sync('../src/parser/classes/**/*.{js,ts}', { cwd: __dirname })
|
||||
.forEach((file) => {
|
||||
if (file.includes('/misc/')) return;
|
||||
// Trim path
|
||||
file = file.replace('../src/parser/classes/', '').replace('.js', '').replace('.ts', '');
|
||||
const import_name = file.split('/').pop();
|
||||
import_list.push(`import { default as ${import_name} } from './classes/${file}';`);
|
||||
json.push(import_name);
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, '../src/parser/map.ts'),
|
||||
`// This file was auto generated, do not edit.
|
||||
// See ./scripts/build-parser-map.js
|
||||
import { YTNodeConstructor } from './helpers';
|
||||
|
||||
${import_list.join('\n')}
|
||||
|
||||
const map: Record<string, YTNodeConstructor> = {
|
||||
${json.join(',\n ')}
|
||||
};
|
||||
|
||||
export const YTNodes = map;
|
||||
|
||||
/**
|
||||
* @param name - Name of the node to be parsed
|
||||
*/
|
||||
export default function GetParserByName(name: string) {
|
||||
const ParserConstructor = map[name];
|
||||
|
||||
if (!ParserConstructor) {
|
||||
const error = new Error(\`Module not found: \${name}\`);
|
||||
(error as any).code = 'MODULE_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return ParserConstructor;
|
||||
}
|
||||
`
|
||||
);
|
||||
42
scripts/gen-parser-map.mjs
Normal file
42
scripts/gen-parser-map.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import glob from 'glob';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import url from 'url';
|
||||
|
||||
const import_list = [];
|
||||
const misc_imports = [];
|
||||
|
||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
|
||||
glob.sync('../src/parser/classes/**/*.{js,ts}', { cwd: __dirname })
|
||||
.forEach((file) => {
|
||||
// Trim path
|
||||
const is_misc = file.includes('/misc/');
|
||||
file = file.replace('../src/parser/classes/', '').replace('.js', '').replace('.ts', '');
|
||||
const import_name = file.split('/').pop();
|
||||
|
||||
if (is_misc) {
|
||||
const class_name = file.split('/').pop().replace('.js', '').replace('.ts', '');
|
||||
misc_imports.push(`export { default as ${class_name} } from './classes/${file}.js';`);
|
||||
} else {
|
||||
import_list.push(`export { default as ${import_name} } from './classes/${file}.js';`);
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, '../src/parser/nodes.ts'),
|
||||
`// This file was auto generated, do not edit.
|
||||
// See ./scripts/build-parser-map.js
|
||||
|
||||
${import_list.join('\n')}
|
||||
`
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, '../src/parser/misc.ts'),
|
||||
`// This file was auto generated, do not edit.
|
||||
// See ./scripts/build-parser-map.js
|
||||
|
||||
${misc_imports.join('\n')}
|
||||
`
|
||||
);
|
||||
@@ -1,10 +1,8 @@
|
||||
import { fetch } from "undici";
|
||||
import { gunzip } from "zlib";
|
||||
import { fetch } from 'undici';
|
||||
import { gunzip } from 'zlib';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { writeFile } from "fs/promises";
|
||||
|
||||
(async () => {
|
||||
import { writeFile } from 'fs/promises';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -13,18 +11,18 @@ const bytes = new Uint8Array(buf);
|
||||
|
||||
// Only get desktop and mobile agents
|
||||
const allowed_agents = new Set([
|
||||
'desktop',
|
||||
'mobile',
|
||||
])
|
||||
'desktop',
|
||||
'mobile'
|
||||
]);
|
||||
|
||||
const decompressed = await new Promise((resolve, reject) => {
|
||||
gunzip(bytes, (err, result) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(result.buffer);
|
||||
}
|
||||
});
|
||||
gunzip(bytes, (err, result) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(result.buffer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const contents = new TextDecoder().decode(decompressed);
|
||||
@@ -32,21 +30,19 @@ const contents = new TextDecoder().decode(decompressed);
|
||||
const agents = JSON.parse(contents);
|
||||
|
||||
if (!Array.isArray(agents)) {
|
||||
throw new Error('Invalid user-agents.json');
|
||||
throw new Error('Invalid user-agents.json');
|
||||
}
|
||||
|
||||
const agentsByDevice = agents.reduce((acc, agent) => {
|
||||
const device = agent.deviceCategory;
|
||||
if (!allowed_agents.has(device))
|
||||
return acc;
|
||||
if (!acc[device]) {
|
||||
acc[device] = [];
|
||||
}
|
||||
// we dont want to massive of a list of agents for each device
|
||||
if (acc[device].length <= 25) acc[device].push(agent.userAgent);
|
||||
const device = agent.deviceCategory;
|
||||
if (!allowed_agents.has(device))
|
||||
return acc;
|
||||
if (!acc[device]) {
|
||||
acc[device] = [];
|
||||
}
|
||||
// We dont want to massive of a list of agents for each device
|
||||
if (acc[device].length <= 25) acc[device].push(agent.userAgent);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
await writeFile(resolve(__dirname, '..', 'src', 'utils', 'user-agents.json'), JSON.stringify(agentsByDevice, null, 2));
|
||||
|
||||
})();
|
||||
await writeFile(resolve(__dirname, '..', 'src', 'utils', 'user-agents.ts'), `/* eslint-disable */\n/* Generated file do not edit */\nexport default ${JSON.stringify(agentsByDevice, null, 2)} as { desktop: string[], mobile: string[] };`);
|
||||
455
src/Innertube.ts
455
src/Innertube.ts
@@ -1,129 +1,201 @@
|
||||
import Session from './core/Session.js';
|
||||
import { Kids, Music, Studio } from './core/clients/index.js';
|
||||
import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.js';
|
||||
import { Feed, TabbedFeed } from './core/mixins/index.js';
|
||||
|
||||
import Session, { SessionOptions } from './core/Session';
|
||||
import {
|
||||
BrowseEndpoint,
|
||||
GetNotificationMenuEndpoint,
|
||||
GuideEndpoint,
|
||||
NextEndpoint,
|
||||
PlayerEndpoint,
|
||||
ResolveURLEndpoint,
|
||||
SearchEndpoint,
|
||||
Reel,
|
||||
Notification
|
||||
} from './core/endpoints/index.js';
|
||||
|
||||
import Search from './parser/youtube/Search';
|
||||
import Channel from './parser/youtube/Channel';
|
||||
import Playlist from './parser/youtube/Playlist';
|
||||
import Library from './parser/youtube/Library';
|
||||
import History from './parser/youtube/History';
|
||||
import Comments from './parser/youtube/Comments';
|
||||
import NotificationsMenu from './parser/youtube/NotificationsMenu';
|
||||
import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo';
|
||||
import NavigationEndpoint from './parser/classes/NavigationEndpoint';
|
||||
import {
|
||||
Channel,
|
||||
Comments,
|
||||
Guide,
|
||||
HashtagFeed,
|
||||
History,
|
||||
HomeFeed,
|
||||
Library,
|
||||
NotificationsMenu,
|
||||
Playlist,
|
||||
Search,
|
||||
VideoInfo
|
||||
} from './parser/youtube/index.js';
|
||||
|
||||
import { ParsedResponse } from './parser';
|
||||
import { ActionsResponse } from './core/Actions';
|
||||
import { VideoInfo as ShortsVideoInfo } from './parser/ytshorts/index.js';
|
||||
|
||||
import Feed from './core/Feed';
|
||||
import YTMusic from './core/Music';
|
||||
import Studio from './core/Studio';
|
||||
import AccountManager from './core/AccountManager';
|
||||
import PlaylistManager from './core/PlaylistManager';
|
||||
import InteractionManager from './core/InteractionManager';
|
||||
import FilterableFeed from './core/FilterableFeed';
|
||||
import TabbedFeed from './core/TabbedFeed';
|
||||
import Constants from './utils/Constants';
|
||||
import Proto from './proto/index';
|
||||
import NavigationEndpoint from './parser/classes/NavigationEndpoint.js';
|
||||
|
||||
import { throwIfMissing, generateRandomString } from './utils/Utils';
|
||||
import * as Proto from './proto/index.js';
|
||||
import * as Constants from './utils/Constants.js';
|
||||
import { InnertubeError, generateRandomString, throwIfMissing } from './utils/Utils.js';
|
||||
|
||||
export type InnertubeConfig = SessionOptions
|
||||
|
||||
export interface SearchFilters {
|
||||
/**
|
||||
* Filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
|
||||
*/
|
||||
upload_date?: 'any' | 'last_hour' | 'today' | 'this_week' | 'this_month' | 'this_year';
|
||||
/**
|
||||
* Filter results by type, can be: any | video | channel | playlist | movie
|
||||
*/
|
||||
type?: 'any' | 'video' | 'channel' | 'playlist' | 'movie';
|
||||
/**
|
||||
* Filter videos by duration, can be: any | short | medium | long
|
||||
*/
|
||||
duration?: 'any' | 'short' | 'medium' | 'long';
|
||||
/**
|
||||
* Filter video results by order, can be: relevance | rating | upload_date | view_count
|
||||
*/
|
||||
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
|
||||
}
|
||||
import type { ApiResponse } from './core/Actions.js';
|
||||
import type { INextRequest } from './types/index.js';
|
||||
import type { IBrowseResponse, IParsedResponse } from './parser/types/index.js';
|
||||
import type { DownloadOptions, FormatOptions } from './types/FormatUtils.js';
|
||||
import type { SessionOptions } from './core/Session.js';
|
||||
import type Format from './parser/classes/misc/Format.js';
|
||||
|
||||
export type InnerTubeClient = 'ANDROID' | 'YTMUSIC_ANDROID' | 'WEB' | 'YTMUSIC';
|
||||
export type InnertubeConfig = SessionOptions;
|
||||
|
||||
class Innertube {
|
||||
session;
|
||||
account;
|
||||
playlist;
|
||||
interact;
|
||||
music;
|
||||
studio;
|
||||
actions;
|
||||
export type InnerTubeClient = 'WEB' | 'iOS' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED' | 'YTKIDS';
|
||||
|
||||
export type SearchFilters = Partial<{
|
||||
upload_date: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
|
||||
type: 'all' | 'video' | 'channel' | 'playlist' | 'movie';
|
||||
duration: 'all' | 'short' | 'medium' | 'long';
|
||||
sort_by: 'relevance' | 'rating' | 'upload_date' | 'view_count';
|
||||
features: ('hd' | 'subtitles' | 'creative_commons' | '3d' | 'live' | 'purchased' | '4k' | '360' | 'location' | 'hdr' | 'vr180')[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Provides access to various services and modules in the YouTube API.
|
||||
*/
|
||||
export default class Innertube {
|
||||
#session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.session = session;
|
||||
this.account = new AccountManager(this.session.actions);
|
||||
this.playlist = new PlaylistManager(this.session.actions);
|
||||
this.interact = new InteractionManager(this.session.actions);
|
||||
this.music = new YTMusic(this.session);
|
||||
this.studio = new Studio(this.session);
|
||||
this.actions = this.session.actions;
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
static async create(config: InnertubeConfig = {}) {
|
||||
static async create(config: InnertubeConfig = {}): Promise<Innertube> {
|
||||
return new Innertube(await Session.create(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves video info.
|
||||
* @param target - The video id or `NavigationEndpoint`.
|
||||
* @param client - The client to use.
|
||||
*/
|
||||
async getInfo(video_id: string, client?: InnerTubeClient) {
|
||||
async getInfo(target: string | NavigationEndpoint, client?: InnerTubeClient): Promise<VideoInfo> {
|
||||
throwIfMissing({ target });
|
||||
|
||||
let next_payload: INextRequest;
|
||||
|
||||
if (target instanceof NavigationEndpoint) {
|
||||
next_payload = NextEndpoint.build({
|
||||
video_id: target.payload?.videoId,
|
||||
playlist_id: target.payload?.playlistId,
|
||||
params: target.payload?.params,
|
||||
playlist_index: target.payload?.index
|
||||
});
|
||||
} else if (typeof target === 'string') {
|
||||
next_payload = NextEndpoint.build({
|
||||
video_id: target
|
||||
});
|
||||
} else {
|
||||
throw new InnertubeError('Invalid target, expected either a video id or a valid NavigationEndpoint', target);
|
||||
}
|
||||
|
||||
if (!next_payload.videoId)
|
||||
throw new InnertubeError('Video id cannot be empty', next_payload);
|
||||
|
||||
const player_payload = PlayerEndpoint.build({
|
||||
video_id: next_payload.videoId,
|
||||
playlist_id: next_payload?.playlistId,
|
||||
client: client,
|
||||
sts: this.#session.player?.sts
|
||||
});
|
||||
|
||||
const player_response = this.actions.execute(PlayerEndpoint.PATH, player_payload);
|
||||
const next_response = this.actions.execute(NextEndpoint.PATH, next_payload);
|
||||
const response = await Promise.all([ player_response, next_response ]);
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = await this.actions.getVideoInfo(video_id, cpn, client);
|
||||
const continuation = this.actions.next({ video_id });
|
||||
|
||||
const response = await Promise.all([ initial_info, continuation ]);
|
||||
return new VideoInfo(response, this.actions, this.session.player, cpn);
|
||||
return new VideoInfo(response, this.actions, cpn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves basic video info.
|
||||
* @param video_id - The video id.
|
||||
* @param client - The client to use.
|
||||
*/
|
||||
async getBasicInfo(video_id: string, client?: InnerTubeClient) {
|
||||
const cpn = generateRandomString(16);
|
||||
const response = await this.actions.getVideoInfo(video_id, cpn, client);
|
||||
async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
return new VideoInfo([ response ], this.actions, this.session.player, cpn);
|
||||
const response = await this.actions.execute(
|
||||
PlayerEndpoint.PATH, PlayerEndpoint.build({
|
||||
video_id: video_id,
|
||||
client: client,
|
||||
sts: this.#session.player?.sts
|
||||
})
|
||||
);
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
return new VideoInfo([ response ], this.actions, cpn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves shorts info.
|
||||
* @param short_id - The short id.
|
||||
* @param client - The client to use.
|
||||
*/
|
||||
async getShortsWatchItem(short_id: string, client?: InnerTubeClient): Promise<ShortsVideoInfo> {
|
||||
throwIfMissing({ short_id });
|
||||
|
||||
const watchResponse = this.actions.execute(
|
||||
Reel.WatchEndpoint.PATH, Reel.WatchEndpoint.build({
|
||||
short_id: short_id,
|
||||
client: client
|
||||
})
|
||||
);
|
||||
|
||||
const sequenceResponse = this.actions.execute(
|
||||
Reel.WatchSequenceEndpoint.PATH, Reel.WatchSequenceEndpoint.build({
|
||||
sequenceParams: Proto.encodeReelSequence(short_id)
|
||||
})
|
||||
);
|
||||
|
||||
const response = await Promise.all([ watchResponse, sequenceResponse ]);
|
||||
|
||||
return new ShortsVideoInfo(response, this.actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches a given query.
|
||||
* @param query - search query.
|
||||
* @param filters - search filters.
|
||||
* @param query - The search query.
|
||||
* @param filters - Search filters.
|
||||
*/
|
||||
async search(query: string, filters: SearchFilters = {}) {
|
||||
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
|
||||
throwIfMissing({ query });
|
||||
const response = await this.actions.search({ query, filters });
|
||||
return new Search(this.actions, response.data);
|
||||
|
||||
const response = await this.actions.execute(
|
||||
SearchEndpoint.PATH, SearchEndpoint.build({
|
||||
query, params: filters ? Proto.encodeSearchFilters(filters) : undefined
|
||||
})
|
||||
);
|
||||
|
||||
return new Search(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions for a given query.
|
||||
* @param query - the search query.
|
||||
* @param query - The search query.
|
||||
*/
|
||||
async getSearchSuggestions(query: string): Promise<string[]> {
|
||||
throwIfMissing({ query });
|
||||
|
||||
const url = new URL(`${Constants.URLS.YT_SUGGESTIONS}search`);
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('hl', this.session.context.client.hl);
|
||||
url.searchParams.set('gl', this.session.context.client.gl);
|
||||
url.searchParams.set('hl', this.#session.context.client.hl);
|
||||
url.searchParams.set('gl', this.#session.context.client.gl);
|
||||
url.searchParams.set('ds', 'yt');
|
||||
url.searchParams.set('client', 'youtube');
|
||||
url.searchParams.set('xssi', 't');
|
||||
url.searchParams.set('oe', 'UTF');
|
||||
|
||||
const response = await this.session.http.fetch(url);
|
||||
const response = await this.#session.http.fetch(url);
|
||||
const response_data = await response.text();
|
||||
|
||||
const data = JSON.parse(response_data.replace(')]}\'', ''));
|
||||
@@ -134,94 +206,158 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Retrieves comments for a video.
|
||||
* @param video_id - the video id.
|
||||
* @param sort_by - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
|
||||
* @param video_id - The video id.
|
||||
* @param sort_by - Sorting options.
|
||||
*/
|
||||
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST') {
|
||||
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const payload = Proto.encodeCommentsSectionParams(video_id, {
|
||||
sort_by: sort_by || 'TOP_COMMENTS'
|
||||
});
|
||||
const response = await this.actions.execute(
|
||||
NextEndpoint.PATH, NextEndpoint.build({
|
||||
continuation: Proto.encodeCommentsSectionParams(video_id, {
|
||||
sort_by: sort_by || 'TOP_COMMENTS'
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
const response = await this.actions.next({ ctoken: payload });
|
||||
return new Comments(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves YouTube's home feed (aka recommendations).
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await this.actions.browse('FEwhat_to_watch');
|
||||
return new FilterableFeed(this.actions, response.data);
|
||||
async getHomeFeed(): Promise<HomeFeed> {
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEwhat_to_watch' })
|
||||
);
|
||||
return new HomeFeed(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves YouTube's content guide.
|
||||
*/
|
||||
async getGuide(): Promise<Guide> {
|
||||
const response = await this.actions.execute(GuideEndpoint.PATH);
|
||||
return new Guide(response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account's library.
|
||||
*/
|
||||
async getLibrary() {
|
||||
const response = await this.actions.browse('FElibrary');
|
||||
return new Library(response.data, this.actions);
|
||||
async getLibrary(): Promise<Library> {
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FElibrary' })
|
||||
);
|
||||
return new Library(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves watch history.
|
||||
* Which can also be achieved with {@link getLibrary}.
|
||||
*/
|
||||
async getHistory() {
|
||||
const response = await this.actions.browse('FEhistory');
|
||||
return new History(this.actions, response.data);
|
||||
async getHistory(): Promise<History> {
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEhistory' })
|
||||
);
|
||||
return new History(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves trending content.
|
||||
* Retrieves Trending content.
|
||||
*/
|
||||
async getTrending() {
|
||||
const response = await this.actions.browse('FEtrending');
|
||||
return new TabbedFeed(this.actions, response.data);
|
||||
async getTrending(): Promise<TabbedFeed<IBrowseResponse>> {
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEtrending' }), parse: true }
|
||||
);
|
||||
return new TabbedFeed(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves subscriptions feed.
|
||||
* Retrieves Subscriptions feed.
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await this.actions.browse('FEsubscriptions');
|
||||
return new Feed(this.actions, response.data);
|
||||
async getSubscriptionsFeed(): Promise<Feed<IBrowseResponse>> {
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEsubscriptions' }), parse: true }
|
||||
);
|
||||
return new Feed(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves Channels feed.
|
||||
*/
|
||||
async getChannelsFeed(): Promise<Feed<IBrowseResponse>> {
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEchannels' }), parse: true }
|
||||
);
|
||||
return new Feed(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves contents for a given channel.
|
||||
* @param id - channel id
|
||||
* @param id - Channel id
|
||||
*/
|
||||
async getChannel(id: string) {
|
||||
async getChannel(id: string): Promise<Channel> {
|
||||
throwIfMissing({ id });
|
||||
const response = await this.actions.browse(id);
|
||||
return new Channel(this.actions, response.data);
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: id })
|
||||
);
|
||||
return new Channel(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves notifications.
|
||||
*/
|
||||
async getNotifications() {
|
||||
const response = await this.actions.notifications('get_notification_menu');
|
||||
async getNotifications(): Promise<NotificationsMenu> {
|
||||
const response = await this.actions.execute(
|
||||
GetNotificationMenuEndpoint.PATH, GetNotificationMenuEndpoint.build({
|
||||
notifications_menu_request_type: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
|
||||
})
|
||||
);
|
||||
return new NotificationsMenu(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves unseen notifications count.
|
||||
*/
|
||||
async getUnseenNotificationsCount() {
|
||||
const response = await this.actions.notifications('get_unseen_count');
|
||||
return response.data.unseenCount;
|
||||
async getUnseenNotificationsCount(): Promise<number> {
|
||||
const response = await this.actions.execute(Notification.GetUnseenCountEndpoint.PATH);
|
||||
// TODO: properly parse this
|
||||
return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves playlist contents.
|
||||
* @param id - Playlist id
|
||||
*/
|
||||
async getPlaylist(id: string) {
|
||||
async getPlaylist(id: string): Promise<Playlist> {
|
||||
throwIfMissing({ id });
|
||||
const response = await this.actions.browse(`VL${id.replace(/VL/g, '')}`);
|
||||
return new Playlist(this.actions, response.data);
|
||||
|
||||
if (!id.startsWith('VL')) {
|
||||
id = `VL${id}`;
|
||||
}
|
||||
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: id })
|
||||
);
|
||||
|
||||
return new Playlist(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a given hashtag's page.
|
||||
* @param hashtag - The hashtag to fetch.
|
||||
*/
|
||||
async getHashtag(hashtag: string): Promise<HashtagFeed> {
|
||||
throwIfMissing({ hashtag });
|
||||
|
||||
const response = await this.actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
browse_id: 'FEhashtag',
|
||||
params: Proto.encodeHashtag(hashtag)
|
||||
})
|
||||
);
|
||||
|
||||
return new HashtagFeed(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,27 +365,100 @@ class Innertube {
|
||||
* Returns deciphered streaming data.
|
||||
*
|
||||
* If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}.
|
||||
* @param video_id - The video id.
|
||||
* @param options - Format options.
|
||||
*/
|
||||
async getStreamingData(video_id: string, options: FormatOptions = {}) {
|
||||
async getStreamingData(video_id: string, options: FormatOptions = {}): Promise<Format> {
|
||||
const info = await this.getBasicInfo(video_id);
|
||||
return info.chooseFormat(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a given video. If you only need the direct download link see {@link getStreamingData}.
|
||||
*
|
||||
* If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}.
|
||||
* @param video_id - The video id.
|
||||
* @param options - Download options.
|
||||
*/
|
||||
async download(video_id: string, options?: DownloadOptions) {
|
||||
async download(video_id: string, options?: DownloadOptions): Promise<ReadableStream<Uint8Array>> {
|
||||
const info = await this.getBasicInfo(video_id, options?.client);
|
||||
return info.download(options);
|
||||
}
|
||||
|
||||
call(endpoint: NavigationEndpoint, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
|
||||
call(endpoint: NavigationEndpoint, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
|
||||
call(endpoint: NavigationEndpoint, args?: object): Promise<ActionsResponse | ParsedResponse> {
|
||||
return endpoint.callTest(this.actions, args);
|
||||
/**
|
||||
* Resolves the given URL.
|
||||
* @param url - The URL.
|
||||
*/
|
||||
async resolveURL(url: string): Promise<NavigationEndpoint> {
|
||||
const response = await this.actions.execute(
|
||||
ResolveURLEndpoint.PATH, { ...ResolveURLEndpoint.build({ url }), parse: true }
|
||||
);
|
||||
return response.endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
export default Innertube;
|
||||
/**
|
||||
* Utility method to call an endpoint without having to use {@link Actions}.
|
||||
* @param endpoint -The endpoint to call.
|
||||
* @param args - Call arguments.
|
||||
*/
|
||||
call<T extends IParsedResponse>(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise<T>;
|
||||
call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise<ApiResponse>;
|
||||
call(endpoint: NavigationEndpoint, args?: object): Promise<IParsedResponse | ApiResponse> {
|
||||
return endpoint.call(this.actions, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface for interacting with YouTube Music.
|
||||
*/
|
||||
get music() {
|
||||
return new Music(this.#session);
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface for interacting with YouTube Studio.
|
||||
*/
|
||||
get studio() {
|
||||
return new Studio(this.#session);
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface for interacting with YouTube Kids.
|
||||
*/
|
||||
get kids() {
|
||||
return new Kids(this.#session);
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface for managing and retrieving account information.
|
||||
*/
|
||||
get account() {
|
||||
return new AccountManager(this.#session.actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface for managing playlists.
|
||||
*/
|
||||
get playlist() {
|
||||
return new PlaylistManager(this.#session.actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface for directly interacting with certain YouTube features.
|
||||
*/
|
||||
get interact() {
|
||||
return new InteractionManager(this.#session.actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* An internal class used to dispatch requests.
|
||||
*/
|
||||
get actions() {
|
||||
return this.#session.actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session used by this instance.
|
||||
*/
|
||||
get session() {
|
||||
return this.#session;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import Proto from '../proto/index';
|
||||
import Actions from './Actions';
|
||||
|
||||
import Analytics from '../parser/youtube/Analytics';
|
||||
import TimeWatched from '../parser/youtube/TimeWatched';
|
||||
import AccountInfo from '../parser/youtube/AccountInfo';
|
||||
import Settings from '../parser/youtube/Settings';
|
||||
|
||||
class AccountManager {
|
||||
#actions;
|
||||
channel;
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
|
||||
this.channel = {
|
||||
/**
|
||||
* Edits channel name.
|
||||
*/
|
||||
editName: (new_name: string) => this.#actions.channel('channel/edit_name', { new_name }),
|
||||
/**
|
||||
* Edits channel description.
|
||||
*
|
||||
*/
|
||||
editDescription: (new_description: string) => this.#actions.channel('channel/edit_description', { new_description }),
|
||||
/**
|
||||
* Retrieves basic channel analytics.
|
||||
*/
|
||||
getBasicAnalytics: () => this.getAnalytics()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves channel info.
|
||||
*/
|
||||
async getInfo() {
|
||||
const response = await this.#actions.execute('/account/accounts_list', { client: 'ANDROID' });
|
||||
return new AccountInfo(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves time watched statistics.
|
||||
*/
|
||||
async getTimeWatched() {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'SPtime_watched',
|
||||
client: 'ANDROID'
|
||||
});
|
||||
|
||||
return new TimeWatched(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens YouTube settings.
|
||||
*/
|
||||
async getSettings() {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'SPaccount_overview'
|
||||
});
|
||||
|
||||
return new Settings(this.#actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves basic channel analytics.
|
||||
*/
|
||||
async getAnalytics() {
|
||||
const info = await this.getInfo();
|
||||
|
||||
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
|
||||
const response = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
|
||||
|
||||
return new Analytics(response);
|
||||
}
|
||||
}
|
||||
|
||||
export default AccountManager;
|
||||
@@ -1,690 +1,63 @@
|
||||
import Proto from '../proto/index';
|
||||
import Session from './Session';
|
||||
import { Parser, NavigateAction } from '../parser/index.js';
|
||||
import { InnertubeError } from '../utils/Utils.js';
|
||||
|
||||
import Parser, { ParsedResponse } from '../parser/index';
|
||||
import type { Session } from './index.js';
|
||||
|
||||
import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
|
||||
import type {
|
||||
IBrowseResponse, IGetNotificationsMenuResponse,
|
||||
INextResponse, IPlayerResponse, IResolveURLResponse,
|
||||
ISearchResponse, IUpdatedMetadataResponse,
|
||||
IParsedResponse, IRawResponse
|
||||
} from '../parser/types/index.js';
|
||||
|
||||
export interface BrowseArgs {
|
||||
params?: string | null;
|
||||
is_ytm?: boolean;
|
||||
is_ctoken?: boolean;
|
||||
form_data?: {};
|
||||
client?: string;
|
||||
}
|
||||
|
||||
export interface EngageArgs {
|
||||
video_id?: string;
|
||||
channel_id?: string;
|
||||
comment_id?: string;
|
||||
comment_action?: string;
|
||||
params?: string;
|
||||
text?: string;
|
||||
target_language?: string;
|
||||
}
|
||||
|
||||
export interface AccountArgs {
|
||||
new_value?: string | boolean; // TODO: is this correct?
|
||||
setting_item_id?: string;
|
||||
client?: string;
|
||||
}
|
||||
|
||||
export interface SearchArgs {
|
||||
query?: string,
|
||||
options?: {
|
||||
period?: string,
|
||||
duration?: string,
|
||||
order?: string
|
||||
},
|
||||
client?: string,
|
||||
ctoken?: string,
|
||||
params?: string
|
||||
filters?: any // TODO: what is this type??
|
||||
}
|
||||
|
||||
export interface AxioslikeResponse {
|
||||
export interface ApiResponse {
|
||||
success: boolean;
|
||||
status_code: number;
|
||||
data: any;
|
||||
data: IRawResponse;
|
||||
}
|
||||
|
||||
export type ActionsResponse = Promise<AxioslikeResponse>;
|
||||
export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/reel' | '/updated_metadata' | '/notification/get_notification_menu' | string;
|
||||
|
||||
class Actions {
|
||||
#session;
|
||||
export type ParsedResponse<T> =
|
||||
T extends '/player' ? IPlayerResponse :
|
||||
T extends '/search' ? ISearchResponse :
|
||||
T extends '/browse' ? IBrowseResponse :
|
||||
T extends '/next' ? INextResponse :
|
||||
T extends '/updated_metadata' ? IUpdatedMetadataResponse :
|
||||
T extends '/navigation/resolve_url' ? IResolveURLResponse :
|
||||
T extends '/notification/get_notification_menu' ? IGetNotificationsMenuResponse :
|
||||
IParsedResponse;
|
||||
|
||||
export default class Actions {
|
||||
#session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
get session() {
|
||||
get session(): Session {
|
||||
return this.#session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mimmics the Axios API using Fetch's Response object.
|
||||
* @param response - The response object.
|
||||
*/
|
||||
async #wrap(response: Response, protobuf?: boolean) {
|
||||
async #wrap(response: Response): Promise<ApiResponse> {
|
||||
return {
|
||||
success: response.ok,
|
||||
status_code: response.status,
|
||||
data: protobuf ? await response.text() : JSON.parse(await response.text())
|
||||
data: JSON.parse(await response.text())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers `/browse` endpoint, mostly used to access
|
||||
* YouTube's sections such as the home feed, etc
|
||||
* and sometimes to retrieve continuations.
|
||||
*
|
||||
* @param id - browseId or a continuation token
|
||||
* @param args - additional arguments
|
||||
*/
|
||||
async browse(id: string, args: BrowseArgs = {}) {
|
||||
if (this.#needsLogin(id) && !this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
if (args.params)
|
||||
data.params = args.params;
|
||||
|
||||
if (args.is_ctoken) {
|
||||
data.continuation = id;
|
||||
} else {
|
||||
data.browseId = id;
|
||||
}
|
||||
|
||||
if (args.form_data) {
|
||||
data.formData = args.form_data;
|
||||
}
|
||||
|
||||
if (args.client) {
|
||||
data.client = args.client;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/browse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used to perform direct interactions
|
||||
* on YouTube.
|
||||
*/
|
||||
async engage(action: string, args: EngageArgs = {}) {
|
||||
if (!this.#session.logged_in && !args.hasOwnProperty('text'))
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'like/like':
|
||||
case 'like/dislike':
|
||||
case 'like/removelike':
|
||||
if (!hasKeys(args, 'video_id'))
|
||||
throw new MissingParamError('Arguments lacks video_id');
|
||||
|
||||
data.target = {};
|
||||
data.target.videoId = args.video_id;
|
||||
|
||||
if (args.params) {
|
||||
data.params = args.params;
|
||||
}
|
||||
break;
|
||||
case 'subscription/subscribe':
|
||||
case 'subscription/unsubscribe':
|
||||
if (!hasKeys(args, 'channel_id'))
|
||||
throw new MissingParamError('Arguments lacks channel_id');
|
||||
|
||||
data.channelIds = [ args.channel_id ];
|
||||
data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data.commentText = args.text;
|
||||
|
||||
if (!hasKeys(args, 'video_id'))
|
||||
throw new MissingParamError('Arguments lacks video_id');
|
||||
|
||||
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
|
||||
break;
|
||||
case 'comment/create_comment_reply':
|
||||
if (!hasKeys(args, 'comment_id', 'video_id', 'text'))
|
||||
throw new MissingParamError('Arguments lacks comment_id, video_id or text');
|
||||
|
||||
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
|
||||
data.commentText = args.text;
|
||||
break;
|
||||
case 'comment/perform_comment_action':
|
||||
const target_action = (() => {
|
||||
switch (args.comment_action) {
|
||||
case 'like':
|
||||
return Proto.encodeCommentActionParams(5, args);
|
||||
case 'dislike':
|
||||
return Proto.encodeCommentActionParams(4, args);
|
||||
case 'translate':
|
||||
return Proto.encodeCommentActionParams(22, args);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
})();
|
||||
data.actions = [ target_action ];
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints related to account management.
|
||||
*/
|
||||
async account(action: string, args: AccountArgs = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = { 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 InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used for search.
|
||||
*/
|
||||
async search(args: SearchArgs = {}) {
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
if (args.query) {
|
||||
data.query = args.query;
|
||||
}
|
||||
|
||||
if (args.ctoken) {
|
||||
data.continuation = args.ctoken;
|
||||
}
|
||||
|
||||
if (args.params) {
|
||||
data.params = args.params;
|
||||
}
|
||||
|
||||
if (args.filters) {
|
||||
if (args.client == 'YTMUSIC' && args.filters?.type && args.filters.type !== 'all') {
|
||||
data.params = Proto.encodeMusicSearchFilters(args.filters);
|
||||
} else {
|
||||
data.params = Proto.encodeSearchFilters(args.filters);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used fo Shorts' sound search.
|
||||
*/
|
||||
async searchSound(args: { query: string; }) {
|
||||
const data = {
|
||||
query: args.query,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/sfv/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel management endpoints.
|
||||
*/
|
||||
async channel(action: string, args: { new_name?: string; new_description?: string; client?: string; } = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = { 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 InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used for playlist management.
|
||||
*/
|
||||
async playlist(action: string, args: {
|
||||
title?: string;
|
||||
ids?: string[];
|
||||
playlist_id?: string;
|
||||
action?: string;
|
||||
} = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
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':
|
||||
if (!hasKeys(args, 'ids'))
|
||||
throw new MissingParamError('Arguments lacks ids');
|
||||
data.playlistId = args.playlist_id;
|
||||
data.actions = args.ids.map((id) => {
|
||||
switch (args.action) {
|
||||
case 'ACTION_ADD_VIDEO':
|
||||
return {
|
||||
action: args.action,
|
||||
addedVideoId: id
|
||||
};
|
||||
case 'ACTION_REMOVE_VIDEO':
|
||||
return {
|
||||
action: args.action,
|
||||
setVideoId: id
|
||||
};
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used for notifications management.
|
||||
*/
|
||||
async notifications(action: string, args: {
|
||||
pref?: string;
|
||||
channel_id?: string;
|
||||
ctoken?: string;
|
||||
params?: string
|
||||
} = {}) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'modify_channel_preference':
|
||||
if (!hasKeys(args, 'channel_id', 'pref'))
|
||||
throw new MissingParamError('Arguments lacks channel_id or pref');
|
||||
const pref_types = {
|
||||
PERSONALIZED: 1,
|
||||
ALL: 2,
|
||||
NONE: 3
|
||||
};
|
||||
if (!Object.keys(pref_types).includes(args.pref.toUpperCase()))
|
||||
throw new InnertubeError('Invalid preference type', args.pref);
|
||||
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase() as keyof typeof pref_types]);
|
||||
break;
|
||||
case 'get_notification_menu':
|
||||
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
|
||||
if (args.ctoken)
|
||||
data.ctoken = args.ctoken;
|
||||
break;
|
||||
case 'record_interactions':
|
||||
data.serializedRecordNotificationInteractionsRequest = args.params;
|
||||
break;
|
||||
case 'get_unseen_count':
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/notification/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers livechat endpoints.
|
||||
*/
|
||||
async livechat(action: string, args: {
|
||||
text?: string;
|
||||
video_id?: string;
|
||||
channel_id?: string;
|
||||
ctoken?: string;
|
||||
params?: string;
|
||||
client?: string;
|
||||
} = {}) {
|
||||
// TODO: should client be required?
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
switch (action) {
|
||||
case 'live_chat/get_live_chat':
|
||||
case 'live_chat/get_live_chat_replay':
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
case 'live_chat/send_message':
|
||||
if (!hasKeys(args, 'channel_id', 'video_id', 'text'))
|
||||
throw new MissingParamError('Arguments lacks channel_id, video_id or text');
|
||||
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
|
||||
data.clientMessageId = uuidv4();
|
||||
data.richMessage = {
|
||||
textSegments: [ {
|
||||
text: args.text
|
||||
} ]
|
||||
};
|
||||
break;
|
||||
case 'live_chat/get_item_context_menu':
|
||||
// Note: this is currently broken due to a recent refactor
|
||||
// TODO: this should be implemented
|
||||
break;
|
||||
case 'live_chat/moderate':
|
||||
data.params = args.params;
|
||||
break;
|
||||
case 'updated_metadata':
|
||||
data.videoId = args.video_id;
|
||||
if (args.ctoken)
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used to retrieve video thumbnails.
|
||||
*/
|
||||
async thumbnails(args: { video_id: string; }) {
|
||||
const data = {
|
||||
client: 'ANDROID',
|
||||
videoId: args.video_id
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/thumbnails', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(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);
|
||||
* ```
|
||||
*/
|
||||
async geo(action: string, args: { input: string; }) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data = {
|
||||
input: args.input,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch(`/geo/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers endpoints used to report content.
|
||||
*/
|
||||
async flag(action: string, args: { action: string; params?: string; }) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (action) {
|
||||
case 'flag/flag':
|
||||
data.action = args.action;
|
||||
break;
|
||||
case 'flag/get_form':
|
||||
data.params = args.params;
|
||||
break;
|
||||
default:
|
||||
throw new InnertubeError('Action not implemented', action);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(`/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers specific YouTube Music endpoints.
|
||||
*/
|
||||
async music(action: string, args: { input?: string; }) {
|
||||
const data = {
|
||||
input: args.input || '',
|
||||
client: 'YTMUSIC'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch(`/music/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mostly used for pagination and specific operations.
|
||||
*/
|
||||
async next(args: { video_id?: string; ctoken?: string; client?: string; playlist_id?: string; params?: string } = {}) {
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
|
||||
if (args.ctoken) {
|
||||
data.continuation = args.ctoken;
|
||||
}
|
||||
|
||||
if (args.video_id) {
|
||||
data.videoId = args.video_id;
|
||||
}
|
||||
|
||||
if (args.playlist_id) {
|
||||
data.playlistId = args.playlist_id;
|
||||
}
|
||||
|
||||
if (args.params) {
|
||||
data.params = args.params;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/next', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to retrieve video info.
|
||||
*/
|
||||
async getVideoInfo(id: string, cpn?: string, client?: string) {
|
||||
const data: Record<string, any> = {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
vis: 0,
|
||||
splay: false,
|
||||
referer: 'https://www.youtube.com',
|
||||
currentUrl: `/watch?v=${id}`,
|
||||
autonavState: 'STATE_OFF',
|
||||
signatureTimestamp: this.#session.player.sts,
|
||||
autoCaptionsDefaultOn: false,
|
||||
html5Preference: 'HTML5_PREF_WANTS',
|
||||
lactMilliseconds: '-1'
|
||||
}
|
||||
},
|
||||
attestationRequest: {
|
||||
omitBotguardData: true
|
||||
},
|
||||
videoId: id
|
||||
};
|
||||
|
||||
if (client) {
|
||||
data.client = client;
|
||||
}
|
||||
|
||||
if (cpn) {
|
||||
data.cpn = cpn;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/player', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint used to retrieve user mention suggestions.
|
||||
*/
|
||||
async getUserMentionSuggestions(args: { input: string; }) {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
|
||||
const data = {
|
||||
input: args.input,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/get_user_mention_suggestions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes calls to the playback tracking API.
|
||||
* @param url - The URL to call.
|
||||
* @param client - The client to use.
|
||||
* @param params - Call parameters.
|
||||
*/
|
||||
async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }) {
|
||||
async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }): Promise<Response> {
|
||||
const s_url = new URL(url);
|
||||
|
||||
s_url.searchParams.set('ver', '2');
|
||||
@@ -703,22 +76,25 @@ class Actions {
|
||||
|
||||
/**
|
||||
* Executes an API call.
|
||||
* @param action - endpoint
|
||||
* @param args - call arguments
|
||||
* @param endpoint - The endpoint to call.
|
||||
* @param args - Call arguments
|
||||
*/
|
||||
async execute(action: string, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }) : Promise<ParsedResponse>;
|
||||
async execute(action: string, args: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise<ActionsResponse>;
|
||||
async execute(action: string, args: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse | ActionsResponse> {
|
||||
async execute<T extends InnertubeEndpoint>(endpoint: T, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }): Promise<ParsedResponse<T>>;
|
||||
async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }): Promise<ApiResponse>;
|
||||
async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse<T> | ApiResponse> {
|
||||
let data;
|
||||
|
||||
if (!args.protobuf) {
|
||||
if (args && !args.protobuf) {
|
||||
data = { ...args };
|
||||
|
||||
if (Reflect.has(data, 'browseId')) {
|
||||
if (this.#needsLogin(data.browseId) && !this.#session.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'override_endpoint'))
|
||||
delete data.override_endpoint;
|
||||
|
||||
if (Reflect.has(data, 'parse'))
|
||||
delete data.parse;
|
||||
|
||||
@@ -745,25 +121,45 @@ class Actions {
|
||||
data.continuation = data.token;
|
||||
delete data.token;
|
||||
}
|
||||
} else {
|
||||
|
||||
if (data?.client === 'YTMUSIC') {
|
||||
data.isAudioOnly = true;
|
||||
}
|
||||
} else if (args) {
|
||||
data = args.serialized_data;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(action, {
|
||||
const target_endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : endpoint;
|
||||
|
||||
const response = await this.#session.http.fetch(target_endpoint, {
|
||||
method: 'POST',
|
||||
body: args.protobuf ? data : JSON.stringify(data),
|
||||
body: args?.protobuf ? data : JSON.stringify((data || {})),
|
||||
headers: {
|
||||
'Content-Type': args.protobuf ?
|
||||
'Content-Type': args?.protobuf ?
|
||||
'application/x-protobuf' :
|
||||
'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (args.parse) {
|
||||
return Parser.parseResponse(await response.json());
|
||||
if (args?.parse) {
|
||||
let parsed_response = Parser.parseResponse<ParsedResponse<T>>(await response.json());
|
||||
|
||||
// Handle redirects
|
||||
if (this.#isBrowse(parsed_response) && parsed_response.on_response_received_actions?.first()?.type === 'navigateAction') {
|
||||
const navigate_action = parsed_response.on_response_received_actions.firstOfType(NavigateAction);
|
||||
if (navigate_action) {
|
||||
parsed_response = await navigate_action.endpoint.call(this, { parse: true });
|
||||
}
|
||||
}
|
||||
|
||||
return parsed_response;
|
||||
}
|
||||
|
||||
return this.#wrap(response, args.protobuf);
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
#isBrowse(response: IParsedResponse): response is IBrowseResponse {
|
||||
return 'on_response_received_actions' in response;
|
||||
}
|
||||
|
||||
#needsLogin(id: string) {
|
||||
@@ -771,13 +167,13 @@ class Actions {
|
||||
'FElibrary',
|
||||
'FEhistory',
|
||||
'FEsubscriptions',
|
||||
'FEchannels',
|
||||
'FEmusic_listening_review',
|
||||
'FEmusic_library_landing',
|
||||
'SPaccount_overview',
|
||||
'SPaccount_notifications',
|
||||
'SPaccount_privacy',
|
||||
'SPtime_watched'
|
||||
].includes(id);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: maybe do this inferrance in a more elegant way
|
||||
export default Actions;
|
||||
}
|
||||
206
src/core/Feed.ts
206
src/core/Feed.ts
@@ -1,206 +0,0 @@
|
||||
import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index';
|
||||
import { Memo, ObservedArray } from '../parser/helpers';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
|
||||
import Post from '../parser/classes/Post';
|
||||
import BackstagePost from '../parser/classes/BackstagePost';
|
||||
|
||||
import Channel from '../parser/classes/Channel';
|
||||
import CompactVideo from '../parser/classes/CompactVideo';
|
||||
|
||||
import GridChannel from '../parser/classes/GridChannel';
|
||||
import GridPlaylist from '../parser/classes/GridPlaylist';
|
||||
import GridVideo from '../parser/classes/GridVideo';
|
||||
|
||||
import Playlist from '../parser/classes/Playlist';
|
||||
import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo';
|
||||
import PlaylistVideo from '../parser/classes/PlaylistVideo';
|
||||
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import ReelShelf from '../parser/classes/ReelShelf';
|
||||
import RichShelf from '../parser/classes/RichShelf';
|
||||
import Shelf from '../parser/classes/Shelf';
|
||||
|
||||
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults';
|
||||
import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults';
|
||||
import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo';
|
||||
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction';
|
||||
import ContinuationItem from '../parser/classes/ContinuationItem';
|
||||
|
||||
import Video from '../parser/classes/Video';
|
||||
|
||||
// TODO: add a way subdivide into sections and return subfeeds?
|
||||
class Feed {
|
||||
#page: ParsedResponse;
|
||||
#continuation?: ObservedArray<ContinuationItem>;
|
||||
#actions;
|
||||
#memo;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
if (data.on_response_received_actions || data.on_response_received_endpoints || already_parsed) {
|
||||
this.#page = data;
|
||||
} else {
|
||||
this.#page = Parser.parseResponse(data);
|
||||
}
|
||||
|
||||
// Xxx: this can be extremely confusing — maybe refactor?
|
||||
const memo =
|
||||
this.#page.on_response_received_commands ?
|
||||
this.#page.on_response_received_commands_memo :
|
||||
this.#page.on_response_received_endpoints ?
|
||||
this.#page.on_response_received_endpoints_memo :
|
||||
this.#page.contents ?
|
||||
this.#page.contents_memo :
|
||||
this.#page.on_response_received_actions ?
|
||||
this.#page.on_response_received_actions_memo : undefined;
|
||||
|
||||
if (!memo)
|
||||
throw new InnertubeError('No memo found in feed');
|
||||
|
||||
this.#memo = memo;
|
||||
this.#actions = actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all videos on a given page via memo
|
||||
*/
|
||||
static getVideosFromMemo(memo: Memo) {
|
||||
return memo.getType<Video | GridVideo | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
|
||||
Video,
|
||||
GridVideo,
|
||||
CompactVideo,
|
||||
PlaylistVideo,
|
||||
PlaylistPanelVideo,
|
||||
WatchCardCompactVideo
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all playlists on a given page via memo
|
||||
*/
|
||||
static getPlaylistsFromMemo(memo: Memo) {
|
||||
return memo.getType<Playlist | GridPlaylist>([ Playlist, GridPlaylist ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the videos in the feed
|
||||
*/
|
||||
get videos() {
|
||||
return Feed.getVideosFromMemo(this.#memo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the community posts in the feed
|
||||
*/
|
||||
get posts() {
|
||||
return this.#memo.getType<Post | BackstagePost>([ BackstagePost, Post ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the channels in the feed
|
||||
*/
|
||||
get channels() {
|
||||
return this.#memo.getType<Channel | GridChannel>([ Channel, GridChannel ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all playlists in the feed
|
||||
*/
|
||||
get playlists() {
|
||||
return Feed.getPlaylistsFromMemo(this.#memo);
|
||||
}
|
||||
|
||||
get memo() {
|
||||
return this.#memo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns contents from the page.
|
||||
*/
|
||||
get contents() {
|
||||
const tab_content = this.#memo.getType(Tab)?.[0]?.content;
|
||||
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)?.[0];
|
||||
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)?.[0];
|
||||
|
||||
return tab_content || reload_continuation_items || append_continuation_items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all segments/sections from the page.
|
||||
*/
|
||||
get shelves() {
|
||||
return this.#memo.getType<Shelf | RichShelf | ReelShelf>([ Shelf, RichShelf, ReelShelf ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds shelf by title.
|
||||
*/
|
||||
getShelf(title: string) {
|
||||
return this.shelves.find((shelf) => shelf.title.toString() === title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns secondary contents from the page.
|
||||
*/
|
||||
get secondary_contents() {
|
||||
if (!this.#page.contents.is_node)
|
||||
return undefined;
|
||||
|
||||
const node = this.#page.contents.item();
|
||||
|
||||
if (!node.is(TwoColumnBrowseResults, TwoColumnSearchResults))
|
||||
return undefined;
|
||||
|
||||
return node.secondary_contents;
|
||||
}
|
||||
|
||||
get actions() {
|
||||
return this.#actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original page data
|
||||
*/
|
||||
get page() {
|
||||
return this.#page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the feed has continuation.
|
||||
*/
|
||||
get has_continuation() {
|
||||
return (this.#memo.get('ContinuationItem') || []).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves continuation data as it is.
|
||||
*/
|
||||
async getContinuationData(): Promise<ParsedResponse | undefined> {
|
||||
if (this.#continuation) {
|
||||
if (this.#continuation.length > 1)
|
||||
throw new InnertubeError('There are too many continuations, you\'ll need to find the correct one yourself in this.page');
|
||||
if (this.#continuation.length === 0)
|
||||
throw new InnertubeError('There are no continuations');
|
||||
|
||||
const response = await this.#continuation[0].endpoint.call(this.#actions, undefined, true);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
this.#continuation = this.#memo.getType(ContinuationItem);
|
||||
|
||||
if (this.#continuation)
|
||||
return this.getContinuationData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves next batch of contents and returns a new {@link Feed} object.
|
||||
*/
|
||||
async getContinuation() {
|
||||
const continuation_data = await this.getContinuationData();
|
||||
return new Feed(this.actions, continuation_data, true);
|
||||
}
|
||||
}
|
||||
|
||||
export default Feed;
|
||||
@@ -1,103 +0,0 @@
|
||||
import { throwIfMissing } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
|
||||
class InteractionManager {
|
||||
#actions;
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Likes a given video.
|
||||
*/
|
||||
async like(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/like', { video_id });
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dislikes a given video.
|
||||
*/
|
||||
async dislike(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/dislike', { video_id });
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a like/dislike.
|
||||
*/
|
||||
async removeLike(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/removelike', { video_id });
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a given channel.
|
||||
*/
|
||||
async subscribe(channel_id: string) {
|
||||
throwIfMissing({ channel_id });
|
||||
const action = await this.#actions.engage('subscription/subscribe', { channel_id });
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from a given channel.
|
||||
*/
|
||||
async unsubscribe(channel_id: string) {
|
||||
throwIfMissing({ channel_id });
|
||||
const action = await this.#actions.engage('subscription/unsubscribe', { channel_id });
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a comment on a given video.
|
||||
*/
|
||||
async comment(video_id: string, text: string) {
|
||||
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 target_language - an ISO language code
|
||||
* @param args - optional arguments
|
||||
*/
|
||||
async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) {
|
||||
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 mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload;
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
status_code: response.status_code,
|
||||
translated_content: mutation.translatedContent.content,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes notification preferences for a given channel.
|
||||
* Only works with channels you are subscribed to.
|
||||
*/
|
||||
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE') {
|
||||
throwIfMissing({ channel_id, type });
|
||||
const action = await this.#actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' });
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
export default InteractionManager;
|
||||
@@ -1,255 +0,0 @@
|
||||
import Session from './Session';
|
||||
|
||||
import TrackInfo from '../parser/ytmusic/TrackInfo';
|
||||
|
||||
import Search from '../parser/ytmusic/Search';
|
||||
import HomeFeed from '../parser/ytmusic/HomeFeed';
|
||||
import Explore from '../parser/ytmusic/Explore';
|
||||
import Library from '../parser/ytmusic/Library';
|
||||
import Artist from '../parser/ytmusic/Artist';
|
||||
import Album from '../parser/ytmusic/Album';
|
||||
import Playlist from '../parser/ytmusic/Playlist';
|
||||
import Recap from '../parser/ytmusic/Recap';
|
||||
|
||||
import Parser from '../parser/index';
|
||||
import { observe, YTNode } from '../parser/helpers';
|
||||
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import Tabbed from '../parser/classes/Tabbed';
|
||||
import SingleColumnMusicWatchNextResults from '../parser/classes/SingleColumnMusicWatchNextResults';
|
||||
import WatchNextTabbedResults from '../parser/classes/WatchNextTabbedResults';
|
||||
import SectionList from '../parser/classes/SectionList';
|
||||
|
||||
import MusicQueue from '../parser/classes/MusicQueue';
|
||||
import PlaylistPanel from '../parser/classes/PlaylistPanel';
|
||||
import Message from '../parser/classes/Message';
|
||||
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf';
|
||||
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf';
|
||||
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection';
|
||||
|
||||
import { InnertubeError, throwIfMissing, generateRandomString } from '../utils/Utils';
|
||||
|
||||
class Music {
|
||||
#actions;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#actions = session.actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves track info.
|
||||
*/
|
||||
async getInfo(video_id: string) {
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = await this.#actions.getVideoInfo(video_id, cpn, 'YTMUSIC');
|
||||
const continuation = this.#actions.execute('/next', { client: 'YTMUSIC', videoId: video_id });
|
||||
|
||||
const response = await Promise.all([ initial_info, continuation ]);
|
||||
return new TrackInfo(response, this.#actions, cpn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches on YouTube Music.
|
||||
*/
|
||||
async search(query: string, filters: {
|
||||
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
|
||||
} = {}) {
|
||||
throwIfMissing({ query });
|
||||
const response = await this.#actions.search({ query, filters, client: 'YTMUSIC' });
|
||||
return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the home feed.
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' });
|
||||
return new HomeFeed(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Explore feed.
|
||||
*/
|
||||
async getExplore() {
|
||||
const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' });
|
||||
return new Explore(response);
|
||||
// TODO: return new Explore(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Library.
|
||||
*/
|
||||
getLibrary() {
|
||||
return new Library(this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves artist's info & content.
|
||||
*/
|
||||
async getArtist(artist_id: string) {
|
||||
throwIfMissing({ artist_id });
|
||||
|
||||
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
|
||||
throw new InnertubeError('Invalid artist id', artist_id);
|
||||
|
||||
const response = await this.#actions.browse(artist_id, { client: 'YTMUSIC' });
|
||||
return new Artist(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves album.
|
||||
*/
|
||||
async getAlbum(album_id: string) {
|
||||
throwIfMissing({ album_id });
|
||||
|
||||
if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
|
||||
throw new InnertubeError('Invalid album id', album_id);
|
||||
|
||||
const response = await this.#actions.browse(album_id, { client: 'YTMUSIC' });
|
||||
return new Album(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves playlist.
|
||||
*/
|
||||
async getPlaylist(playlist_id: string) {
|
||||
throwIfMissing({ playlist_id });
|
||||
|
||||
if (!playlist_id.startsWith('VL')) {
|
||||
playlist_id = `VL${playlist_id}`;
|
||||
}
|
||||
|
||||
const response = await this.#actions.browse(playlist_id, { client: 'YTMUSIC' });
|
||||
return new Playlist(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves song lyrics.
|
||||
*/
|
||||
async getLyrics(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
|
||||
|
||||
const data = Parser.parseResponse(response.data);
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
.as(Tabbed).contents.item()
|
||||
.as(WatchNextTabbedResults)
|
||||
.tabs.array().as(Tab);
|
||||
|
||||
const tab = tabs.get({ title: 'Lyrics' });
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
|
||||
|
||||
if (!page)
|
||||
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
|
||||
|
||||
if (page.contents.item().key('type').string() === 'Message')
|
||||
throw new InnertubeError(page.contents.item().as(Message).text, video_id);
|
||||
|
||||
const section_list = page.contents.item().as(SectionList).contents.array();
|
||||
const description_shelf = section_list.firstOfType(MusicDescriptionShelf);
|
||||
|
||||
return {
|
||||
text: description_shelf?.description.toString(),
|
||||
footer: description_shelf?.footer
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves up next.
|
||||
*/
|
||||
async getUpNext(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
|
||||
|
||||
const data = Parser.parseResponse(response.data);
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
.as(Tabbed).contents.item()
|
||||
.as(WatchNextTabbedResults)
|
||||
.tabs.array().as(Tab);
|
||||
|
||||
const tab = tabs.get({ title: 'Up next' });
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const music_queue = tab.content?.as(MusicQueue);
|
||||
|
||||
if (!music_queue || !music_queue.content)
|
||||
throw new InnertubeError('Music queue was empty, the given id is probably invalid.', music_queue);
|
||||
|
||||
const playlist_panel = music_queue.content.item().as(PlaylistPanel);
|
||||
|
||||
return playlist_panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves related content.
|
||||
*/
|
||||
async getRelated(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
|
||||
|
||||
const data = Parser.parseResponse(response.data);
|
||||
|
||||
const tabs = data.contents.item()
|
||||
.as(SingleColumnMusicWatchNextResults).contents.item()
|
||||
.as(Tabbed).contents.item()
|
||||
.as(WatchNextTabbedResults)
|
||||
.tabs.array().as(Tab);
|
||||
|
||||
const tab = tabs.get({ title: 'Related' });
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
|
||||
|
||||
if (!page)
|
||||
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
|
||||
|
||||
const shelves = page.contents.item().as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf);
|
||||
|
||||
return shelves;
|
||||
}
|
||||
|
||||
async getRecap() {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'FEmusic_listening_review',
|
||||
client: 'YTMUSIC_ANDROID'
|
||||
});
|
||||
|
||||
return new Recap(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions for the given query.
|
||||
*/
|
||||
async getSearchSuggestions(query: string) {
|
||||
const response = await this.#actions.execute('/music/get_search_suggestions', {
|
||||
parse: true,
|
||||
input: query,
|
||||
client: 'YTMUSIC'
|
||||
});
|
||||
|
||||
const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection)?.[0];
|
||||
|
||||
if (!search_suggestions_section.contents.is_array)
|
||||
return observe([] as YTNode[]);
|
||||
|
||||
return search_suggestions_section?.contents.array();
|
||||
}
|
||||
}
|
||||
|
||||
export default Music;
|
||||
@@ -1,7 +1,10 @@
|
||||
import Session from './Session';
|
||||
import Constants from '../utils/Constants';
|
||||
import { OAuthError, uuidv4 } from '../utils/Utils';
|
||||
import { Log, Constants } from '../utils/index.js';
|
||||
import { OAuthError, Platform } from '../utils/Utils.js';
|
||||
import type Session from './Session.js';
|
||||
|
||||
/**
|
||||
* Represents the credentials used for authentication.
|
||||
*/
|
||||
export interface Credentials {
|
||||
/**
|
||||
* Token used to sign in.
|
||||
@@ -15,6 +18,14 @@ export interface Credentials {
|
||||
* Access token's expiration date, which is usually 24hrs-ish.
|
||||
*/
|
||||
expires: Date;
|
||||
/**
|
||||
* Optional client ID.
|
||||
*/
|
||||
client_id?: string;
|
||||
/**
|
||||
* Optional client secret.
|
||||
*/
|
||||
client_secret?: string;
|
||||
}
|
||||
|
||||
// TODO: actual type info for this.
|
||||
@@ -28,7 +39,14 @@ export type OAuthAuthEventHandler = (data: {
|
||||
export type OAuthAuthPendingEventHandler = (data: OAuthAuthPendingData) => any;
|
||||
export type OAuthAuthErrorEventHandler = (err: OAuthError) => any;
|
||||
|
||||
class OAuth {
|
||||
export type OAuthClientIdentity = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
};
|
||||
|
||||
export default class OAuth {
|
||||
static TAG = 'OAuth';
|
||||
|
||||
#identity?: Record<string, string>;
|
||||
#session: Session;
|
||||
#credentials?: Credentials;
|
||||
@@ -41,7 +59,7 @@ class OAuth {
|
||||
/**
|
||||
* Starts the auth flow in case no valid credentials are available.
|
||||
*/
|
||||
async init(credentials?: Credentials) {
|
||||
async init(credentials?: Credentials): Promise<void> {
|
||||
this.#credentials = credentials;
|
||||
|
||||
if (this.validateCredentials()) {
|
||||
@@ -55,13 +73,13 @@ class OAuth {
|
||||
}
|
||||
}
|
||||
|
||||
async cacheCredentials() {
|
||||
async cacheCredentials(): Promise<void> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(JSON.stringify(this.#credentials));
|
||||
await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer);
|
||||
}
|
||||
|
||||
async #loadCachedCredentials() {
|
||||
async #loadCachedCredentials(): Promise<boolean> {
|
||||
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
|
||||
if (!data) return false;
|
||||
|
||||
@@ -71,6 +89,8 @@ class OAuth {
|
||||
this.#credentials = {
|
||||
access_token: credentials.access_token,
|
||||
refresh_token: credentials.refresh_token,
|
||||
client_id: credentials.client_id,
|
||||
client_secret: credentials.client_secret,
|
||||
expires: new Date(credentials.expires)
|
||||
};
|
||||
|
||||
@@ -82,21 +102,21 @@ class OAuth {
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeCache() {
|
||||
async removeCache(): Promise<void> {
|
||||
await this.#session.cache?.remove('youtubei_oauth_credentials');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server for a user code and verification URL.
|
||||
*/
|
||||
async #getUserCode() {
|
||||
async #getUserCode(): Promise<void> {
|
||||
this.#identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: this.#identity.client_id,
|
||||
scope: Constants.OAUTH.SCOPE,
|
||||
device_id: uuidv4(),
|
||||
model_name: Constants.OAUTH.MODEL_NAME
|
||||
device_id: Platform.shim.uuidv4(),
|
||||
device_model: Constants.OAUTH.MODEL_NAME
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), {
|
||||
@@ -117,7 +137,7 @@ class OAuth {
|
||||
/**
|
||||
* Polls the authorization server until access is granted by the user.
|
||||
*/
|
||||
#startPolling(device_code: string) {
|
||||
#startPolling(device_code: string): void {
|
||||
const poller = setInterval(async () => {
|
||||
const data = {
|
||||
...this.#identity,
|
||||
@@ -157,6 +177,8 @@ class OAuth {
|
||||
this.#credentials = {
|
||||
access_token: response_data.access_token,
|
||||
refresh_token: response_data.refresh_token,
|
||||
client_id: this.#identity?.client_id,
|
||||
client_secret: this.#identity?.client_secret,
|
||||
expires: expiration_date
|
||||
};
|
||||
|
||||
@@ -176,13 +198,13 @@ class OAuth {
|
||||
/**
|
||||
* Refresh access token if the same has expired.
|
||||
*/
|
||||
async refreshIfRequired() {
|
||||
async refreshIfRequired(): Promise<void> {
|
||||
if (this.has_access_token_expired) {
|
||||
await this.#refreshAccessToken();
|
||||
}
|
||||
}
|
||||
|
||||
async #refreshAccessToken() {
|
||||
async #refreshAccessToken(): Promise<void> {
|
||||
if (!this.#credentials) return;
|
||||
this.#identity = await this.#getClientIdentity();
|
||||
|
||||
@@ -206,6 +228,8 @@ class OAuth {
|
||||
this.#credentials = {
|
||||
access_token: response_data.access_token,
|
||||
refresh_token: response_data.refresh_token || this.#credentials.refresh_token,
|
||||
client_id: this.#identity.client_id,
|
||||
client_secret: this.#identity.client_secret,
|
||||
expires: expiration_date
|
||||
};
|
||||
|
||||
@@ -215,7 +239,7 @@ class OAuth {
|
||||
});
|
||||
}
|
||||
|
||||
async revokeCredentials() {
|
||||
async revokeCredentials(): Promise<Response | undefined> {
|
||||
if (!this.#credentials) return;
|
||||
await this.removeCache();
|
||||
return this.#session.http.fetch_function(new URL(`/o/oauth2/revoke?token=${encodeURIComponent(this.#credentials.access_token)}`, Constants.URLS.YT_BASE), {
|
||||
@@ -226,7 +250,15 @@ class OAuth {
|
||||
/**
|
||||
* Retrieves client identity from YouTube TV.
|
||||
*/
|
||||
async #getClientIdentity() {
|
||||
async #getClientIdentity(): Promise<OAuthClientIdentity> {
|
||||
if (this.#credentials?.client_id && this.credentials?.client_secret) {
|
||||
Log.info(OAuth.TAG, 'Using custom OAuth2 credentials.\n');
|
||||
return {
|
||||
client_id: this.#credentials.client_id,
|
||||
client_secret: this.credentials.client_secret
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch_function(new URL('/tv', Constants.URLS.YT_BASE), { headers: Constants.OAUTH.HEADERS });
|
||||
|
||||
const response_data = await response.text();
|
||||
@@ -235,22 +267,25 @@ class OAuth {
|
||||
if (!url_body)
|
||||
throw new OAuthError('Could not obtain script url.', { status: 'FAILED' });
|
||||
|
||||
Log.info(OAuth.TAG, `Got YouTubeTV script URL (${url_body})`);
|
||||
|
||||
const script = await this.#session.http.fetch(url_body, { baseURL: Constants.URLS.YT_BASE });
|
||||
|
||||
const client_identity = (await script.text())
|
||||
.replace(/\n/g, '')
|
||||
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
|
||||
|
||||
// TODO: check this.
|
||||
const groups = client_identity?.groups;
|
||||
const groups = client_identity?.groups as OAuthClientIdentity | null;
|
||||
|
||||
if (!groups)
|
||||
throw new OAuthError('Could not obtain client identity.', { status: 'FAILED' });
|
||||
|
||||
Log.info(OAuth.TAG, 'OAuth2 credentials retrieved.\n', groups);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
get credentials() {
|
||||
get credentials(): Credentials | undefined {
|
||||
return this.#credentials;
|
||||
}
|
||||
|
||||
@@ -265,6 +300,4 @@ class OAuth {
|
||||
Reflect.has(this.#credentials, 'refresh_token') &&
|
||||
Reflect.has(this.#credentials, 'expires') || false;
|
||||
}
|
||||
}
|
||||
|
||||
export default OAuth;
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Log, Constants } from '../utils/index.js';
|
||||
import { Platform, getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils.js';
|
||||
import type { ICache, FetchFunction } from '../types/index.js';
|
||||
|
||||
import { FetchFunction } from '../utils/HTTPClient';
|
||||
import { getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils';
|
||||
|
||||
import Constants from '../utils/Constants';
|
||||
import UniversalCache from '../utils/Cache';
|
||||
|
||||
// See https://github.com/LuanRT/Jinter
|
||||
import Jinter from 'jintr';
|
||||
|
||||
/**
|
||||
* Represents YouTube's player script. This is required to decipher signatures.
|
||||
*/
|
||||
export default class Player {
|
||||
static TAG = 'Player';
|
||||
|
||||
#nsig_sc;
|
||||
#sig_sc;
|
||||
#sig_sc_timestamp;
|
||||
@@ -17,13 +16,11 @@ export default class Player {
|
||||
constructor(signature_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) {
|
||||
this.#nsig_sc = nsig_sc;
|
||||
this.#sig_sc = sig_sc;
|
||||
|
||||
this.#sig_sc_timestamp = signature_timestamp;
|
||||
|
||||
this.#player_id = player_id;
|
||||
}
|
||||
|
||||
static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch) {
|
||||
static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch): Promise<Player> {
|
||||
const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
|
||||
const res = await fetch(url);
|
||||
|
||||
@@ -34,11 +31,14 @@ export default class Player {
|
||||
|
||||
const player_id = getStringBetweenStrings(js, 'player\\/', '\\/');
|
||||
|
||||
Log.info(Player.TAG, `Got player id (${player_id}). Checking for cached players..`);
|
||||
|
||||
if (!player_id)
|
||||
throw new PlayerError('Failed to get player id');
|
||||
|
||||
// We have the playerID now we can check if we have a cached player
|
||||
// We have the player id, now we can check if we have a cached player.
|
||||
if (cache) {
|
||||
Log.info(Player.TAG, 'Found a cached player.');
|
||||
const cached_player = await Player.fromCache(cache, player_id);
|
||||
if (cached_player)
|
||||
return cached_player;
|
||||
@@ -46,6 +46,8 @@ export default class Player {
|
||||
|
||||
const player_url = new URL(`/s/player/${player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE);
|
||||
|
||||
Log.info(Player.TAG, `Could not find any cached player. Will download a new player from ${player_url}.`);
|
||||
|
||||
const player_res = await fetch(player_url, {
|
||||
headers: {
|
||||
'user-agent': getRandomUserAgent('desktop')
|
||||
@@ -59,14 +61,15 @@ export default class Player {
|
||||
const player_js = await player_res.text();
|
||||
|
||||
const sig_timestamp = this.extractSigTimestamp(player_js);
|
||||
|
||||
const sig_sc = this.extractSigSourceCode(player_js);
|
||||
const nsig_sc = this.extractNSigSourceCode(player_js);
|
||||
|
||||
Log.info(Player.TAG, `Got signature timestamp (${sig_timestamp}) and algorithms needed to decipher signatures.`);
|
||||
|
||||
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
|
||||
}
|
||||
|
||||
decipher(url?: string, signature_cipher?: string, cipher?: string) {
|
||||
decipher(url?: string, signature_cipher?: string, cipher?: string, this_response_nsig_cache?: Map<string, string>): string {
|
||||
url = url || signature_cipher || cipher;
|
||||
|
||||
if (!url)
|
||||
@@ -75,13 +78,15 @@ export default class Player {
|
||||
const args = new URLSearchParams(url);
|
||||
const url_components = new URL(args.get('url') || url);
|
||||
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
|
||||
if (signature_cipher || cipher) {
|
||||
const sig_decipher = new Jinter(this.#sig_sc);
|
||||
sig_decipher.scope.set('sig', args.get('s'));
|
||||
const signature = Platform.shim.eval(this.#sig_sc, {
|
||||
sig: args.get('s')
|
||||
});
|
||||
|
||||
const signature = sig_decipher.interpret();
|
||||
Log.info(Player.TAG, `Transformed signature ${args.get('s')} to ${signature}.`);
|
||||
|
||||
if (typeof signature !== 'string')
|
||||
throw new PlayerError('Failed to decipher signature');
|
||||
|
||||
const sp = args.get('sp');
|
||||
|
||||
@@ -93,22 +98,61 @@ export default class Player {
|
||||
const n = url_components.searchParams.get('n');
|
||||
|
||||
if (n) {
|
||||
const nsig_decipher = new Jinter(this.#nsig_sc);
|
||||
nsig_decipher.scope.set('nsig', n);
|
||||
let nsig;
|
||||
|
||||
const nsig = nsig_decipher.interpret();
|
||||
if (this_response_nsig_cache && this_response_nsig_cache.has(n)) {
|
||||
nsig = this_response_nsig_cache.get(n) as string;
|
||||
} else {
|
||||
nsig = Platform.shim.eval(this.#nsig_sc, {
|
||||
nsig: n
|
||||
});
|
||||
|
||||
if (nsig.startsWith('enhanced_except_')) {
|
||||
console.warn('Warning:\nCould not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!');
|
||||
Log.info(Player.TAG, `Transformed nsig ${n} to ${nsig}.`);
|
||||
|
||||
if (typeof nsig !== 'string')
|
||||
throw new PlayerError('Failed to decipher nsig');
|
||||
|
||||
if (nsig.startsWith('enhanced_except_')) {
|
||||
Log.warn(Player.TAG, 'Could not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!');
|
||||
} else if (this_response_nsig_cache) {
|
||||
this_response_nsig_cache.set(n, nsig);
|
||||
}
|
||||
}
|
||||
|
||||
url_components.searchParams.set('n', nsig);
|
||||
}
|
||||
|
||||
const client = url_components.searchParams.get('c');
|
||||
|
||||
switch (client) {
|
||||
case 'WEB':
|
||||
url_components.searchParams.set('cver', Constants.CLIENTS.WEB.VERSION);
|
||||
break;
|
||||
case 'WEB_REMIX':
|
||||
url_components.searchParams.set('cver', Constants.CLIENTS.YTMUSIC.VERSION);
|
||||
break;
|
||||
case 'WEB_KIDS':
|
||||
url_components.searchParams.set('cver', Constants.CLIENTS.WEB_KIDS.VERSION);
|
||||
break;
|
||||
case 'ANDROID':
|
||||
url_components.searchParams.set('cver', Constants.CLIENTS.ANDROID.VERSION);
|
||||
break;
|
||||
case 'ANDROID_MUSIC':
|
||||
url_components.searchParams.set('cver', Constants.CLIENTS.YTMUSIC_ANDROID.VERSION);
|
||||
break;
|
||||
case 'TVHTML5_SIMPLY_EMBEDDED_PLAYER':
|
||||
url_components.searchParams.set('cver', Constants.CLIENTS.TV_EMBEDDED.VERSION);
|
||||
break;
|
||||
}
|
||||
|
||||
const result = url_components.toString();
|
||||
|
||||
Log.info(Player.TAG, `Full deciphered URL: ${result}`);
|
||||
|
||||
return url_components.toString();
|
||||
}
|
||||
|
||||
static async fromCache(cache: UniversalCache, player_id: string) {
|
||||
static async fromCache(cache: ICache, player_id: string): Promise<Player | null> {
|
||||
const buffer = await cache.get(player_id);
|
||||
|
||||
if (!buffer)
|
||||
@@ -134,13 +178,13 @@ export default class Player {
|
||||
return new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
|
||||
}
|
||||
|
||||
static async fromSource(cache: UniversalCache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) {
|
||||
static async fromSource(cache: ICache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string): Promise<Player> {
|
||||
const player = new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
|
||||
await player.cache(cache);
|
||||
return player;
|
||||
}
|
||||
|
||||
async cache(cache?: UniversalCache) {
|
||||
async cache(cache?: ICache): Promise<void> {
|
||||
if (!cache) return;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
@@ -161,46 +205,47 @@ export default class Player {
|
||||
await cache.set(this.#player_id, new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
static extractSigTimestamp(data: string) {
|
||||
static extractSigTimestamp(data: string): number {
|
||||
return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0');
|
||||
}
|
||||
|
||||
static extractSigSourceCode(data: string) {
|
||||
const funcs = getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
static extractSigSourceCode(data: string): string {
|
||||
const calls = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
const obj_name = calls?.split(/\.|\[/)?.[0]?.replace(';', '')?.trim();
|
||||
const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};');
|
||||
|
||||
if (!funcs || !calls)
|
||||
throw new PlayerError('Failed to extract signature decipher algorithm');
|
||||
if (!functions || !calls)
|
||||
Log.warn(Player.TAG, 'Failed to extract signature decipher algorithm.');
|
||||
|
||||
return `function descramble_sig(a) { a = a.split(""); ${funcs}}${calls} return a.join("") } descramble_sig(sig);`;
|
||||
return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`;
|
||||
}
|
||||
|
||||
static extractNSigSourceCode(data: string) {
|
||||
static extractNSigSourceCode(data: string): string {
|
||||
const sc = `function descramble_nsig(a) { let b=a.split("")${getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join(""); } descramble_nsig(nsig)`;
|
||||
|
||||
if (!sc)
|
||||
throw new PlayerError('Failed to extract n-token decipher algorithm');
|
||||
Log.warn(Player.TAG, 'Failed to extract n-token decipher algorithm');
|
||||
|
||||
return sc;
|
||||
}
|
||||
|
||||
get url() {
|
||||
get url(): string {
|
||||
return new URL(`/s/player/${this.#player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
|
||||
}
|
||||
|
||||
get sts() {
|
||||
get sts(): number {
|
||||
return this.#sig_sc_timestamp;
|
||||
}
|
||||
|
||||
get nsig_sc() {
|
||||
get nsig_sc(): string {
|
||||
return this.#nsig_sc;
|
||||
}
|
||||
|
||||
get sig_sc() {
|
||||
get sig_sc(): string {
|
||||
return this.#sig_sc;
|
||||
}
|
||||
|
||||
static get LIBRARY_VERSION() {
|
||||
static get LIBRARY_VERSION(): number {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import Playlist from '../parser/youtube/Playlist';
|
||||
import Actions from './Actions';
|
||||
import Feed from './Feed';
|
||||
|
||||
import { InnertubeError, throwIfMissing } from '../utils/Utils';
|
||||
|
||||
class PlaylistManager {
|
||||
#actions;
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a playlist.
|
||||
*/
|
||||
async create(title: string, video_ids: string[]) {
|
||||
throwIfMissing({ title, video_ids });
|
||||
|
||||
const response = await this.#actions.execute('/playlist/create', { title, ids: video_ids, parse: false });
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
status_code: response.status_code,
|
||||
playlist_id: response.data.playlistId,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a given playlist.
|
||||
*/
|
||||
async delete(playlist_id: string) {
|
||||
throwIfMissing({ playlist_id });
|
||||
|
||||
const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id });
|
||||
|
||||
return {
|
||||
playlist_id,
|
||||
success: response.success,
|
||||
status_code: response.status_code,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds videos to a given playlist.
|
||||
*/
|
||||
async addVideos(playlist_id: string, video_ids: string[]) {
|
||||
throwIfMissing({ playlist_id, video_ids });
|
||||
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', {
|
||||
playlistId: playlist_id,
|
||||
actions: video_ids.map((id) => ({
|
||||
action: 'ACTION_ADD_VIDEO',
|
||||
addedVideoId: id
|
||||
})),
|
||||
parse: false
|
||||
});
|
||||
|
||||
return {
|
||||
playlist_id,
|
||||
action_result: response.data.actions // TODO: implement actions in the parser
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes videos from a given playlist.
|
||||
*/
|
||||
async removeVideos(playlist_id: string, video_ids: string[]) {
|
||||
throwIfMissing({ playlist_id, video_ids });
|
||||
|
||||
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
|
||||
const playlist = new Playlist(this.#actions, info, true);
|
||||
|
||||
if (!playlist.info.is_editable)
|
||||
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
|
||||
|
||||
const payload = {
|
||||
playlistId: playlist_id,
|
||||
actions: [] as {
|
||||
action: string;
|
||||
setVideoId: string;
|
||||
}[]
|
||||
};
|
||||
|
||||
const getSetVideoIds = async (pl: Feed): Promise<void> => {
|
||||
const videos = pl.videos.filter((video) => video_ids.includes(video.key('id').string()));
|
||||
|
||||
videos.forEach((video) =>
|
||||
payload.actions.push({
|
||||
action: 'ACTION_REMOVE_VIDEO',
|
||||
setVideoId: video.key('set_video_id').string()
|
||||
})
|
||||
);
|
||||
|
||||
if (payload.actions.length < video_ids.length) {
|
||||
const next = await pl.getContinuation();
|
||||
return getSetVideoIds(next);
|
||||
}
|
||||
};
|
||||
|
||||
await getSetVideoIds(playlist);
|
||||
|
||||
if (!payload.actions.length)
|
||||
throw new InnertubeError('Given video ids were not found in this playlist.', video_ids);
|
||||
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
|
||||
|
||||
return {
|
||||
playlist_id,
|
||||
action_result: response.data.actions // TODO: implement actions in the parser
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a video to a new position within a given playlist.
|
||||
*/
|
||||
async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string) {
|
||||
throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id });
|
||||
|
||||
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
|
||||
const playlist = new Playlist(this.#actions, info, true);
|
||||
|
||||
if (!playlist.info.is_editable)
|
||||
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
|
||||
|
||||
const payload = {
|
||||
playlistId: playlist_id,
|
||||
actions: [] as {
|
||||
action: string,
|
||||
setVideoId?: string,
|
||||
movedSetVideoIdPredecessor?: string
|
||||
}[]
|
||||
};
|
||||
|
||||
let set_video_id_0: string | undefined, set_video_id_1: string | undefined;
|
||||
|
||||
const getSetVideoIds = async (pl: Feed): Promise<void> => {
|
||||
const video_0 = pl.videos.find((video) => moved_video_id === video.key('id').string());
|
||||
const video_1 = pl.videos.find((video) => predecessor_video_id === video.key('id').string());
|
||||
|
||||
set_video_id_0 = set_video_id_0 || video_0?.key('set_video_id').string();
|
||||
set_video_id_1 = set_video_id_1 || video_1?.key('set_video_id').string();
|
||||
|
||||
if (!set_video_id_0 || !set_video_id_1) {
|
||||
const next = await pl.getContinuation();
|
||||
return getSetVideoIds(next);
|
||||
}
|
||||
};
|
||||
|
||||
await getSetVideoIds(playlist);
|
||||
|
||||
payload.actions.push({
|
||||
action: 'ACTION_MOVE_VIDEO_AFTER',
|
||||
setVideoId: set_video_id_0,
|
||||
movedSetVideoIdPredecessor: set_video_id_1
|
||||
});
|
||||
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
|
||||
|
||||
return {
|
||||
playlist_id,
|
||||
action_result: response.data.actions // TODO: implement actions in the parser
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default PlaylistManager;
|
||||
@@ -1,77 +1,187 @@
|
||||
import Player from './Player';
|
||||
import Proto from '../proto/index';
|
||||
import Actions from './Actions';
|
||||
import Constants from '../utils/Constants';
|
||||
import UniversalCache from '../utils/Cache';
|
||||
import EventEmitterLike from '../utils/EventEmitterLike';
|
||||
import OAuth from './OAuth.js';
|
||||
import { Log, EventEmitter, HTTPClient } from '../utils/index.js';
|
||||
import * as Constants from '../utils/Constants.js';
|
||||
import * as Proto from '../proto/index.js';
|
||||
import Actions from './Actions.js';
|
||||
import Player from './Player.js';
|
||||
|
||||
import HTTPClient, { FetchFunction } from '../utils/HTTPClient';
|
||||
import { DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
|
||||
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth';
|
||||
import {
|
||||
generateRandomString, getRandomUserAgent,
|
||||
InnertubeError, Platform, SessionError
|
||||
} from '../utils/Utils.js';
|
||||
|
||||
import type { DeviceCategory } from '../utils/Utils.js';
|
||||
import type { FetchFunction, ICache } from '../types/index.js';
|
||||
import type {
|
||||
Credentials, OAuthAuthErrorEventHandler,
|
||||
OAuthAuthEventHandler, OAuthAuthPendingEventHandler
|
||||
} from './OAuth.js';
|
||||
|
||||
export enum ClientType {
|
||||
WEB = 'WEB',
|
||||
KIDS = 'WEB_KIDS',
|
||||
MUSIC = 'WEB_REMIX',
|
||||
IOS = 'iOS',
|
||||
ANDROID = 'ANDROID',
|
||||
ANDROID_MUSIC = 'ANDROID_MUSIC'
|
||||
ANDROID_MUSIC = 'ANDROID_MUSIC',
|
||||
ANDROID_CREATOR = 'ANDROID_CREATOR',
|
||||
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
client: {
|
||||
hl: string;
|
||||
gl: string;
|
||||
remoteHost: string;
|
||||
visitorData: string;
|
||||
userAgent: string;
|
||||
remoteHost?: string;
|
||||
screenDensityFloat?: number;
|
||||
screenHeightPoints?: number;
|
||||
screenPixelDensity?: number;
|
||||
screenWidthPoints?: number;
|
||||
visitorData?: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
androidSdkVersion?: string;
|
||||
clientScreen?: string,
|
||||
androidSdkVersion?: number;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
platform: string;
|
||||
clientFormFactor: string;
|
||||
userInterfaceTheme: string;
|
||||
userInterfaceTheme?: string;
|
||||
timeZone: string;
|
||||
browserName: string;
|
||||
browserVersion: string;
|
||||
originalUrl: string;
|
||||
userAgent?: string;
|
||||
browserName?: string;
|
||||
browserVersion?: string;
|
||||
originalUrl?: string;
|
||||
deviceMake: string;
|
||||
deviceModel: string;
|
||||
utcOffsetMinutes: number;
|
||||
kidsAppInfo?: {
|
||||
categorySettings: {
|
||||
enabledCategories: string[];
|
||||
};
|
||||
contentSettings: {
|
||||
corpusPreference: string;
|
||||
kidsNoSearchMode: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
user: {
|
||||
lockedSafetyMode: false;
|
||||
enableSafetyMode: boolean;
|
||||
lockedSafetyMode: boolean;
|
||||
onBehalfOfUser?: string;
|
||||
};
|
||||
request: {
|
||||
useSsl: true;
|
||||
thirdParty?: {
|
||||
embedUrl: string;
|
||||
};
|
||||
request?: {
|
||||
useSsl: boolean;
|
||||
internalExperimentFlags: any[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionOptions {
|
||||
/**
|
||||
* Language.
|
||||
*/
|
||||
lang?: string;
|
||||
/**
|
||||
* Geolocation.
|
||||
*/
|
||||
location?: string;
|
||||
/**
|
||||
* The account index to use. This is useful if you have multiple accounts logged in.
|
||||
* **NOTE:**
|
||||
* Only works if you are signed in with cookies.
|
||||
*/
|
||||
account_index?: number;
|
||||
/**
|
||||
* Specify the Page ID of the YouTube profile/channel to use, if the logged-in account has multiple profiles.
|
||||
*/
|
||||
on_behalf_of_user?: string;
|
||||
/**
|
||||
* Specifies whether to retrieve the JS player. Disabling this will make session creation faster.
|
||||
* **NOTE:** Deciphering formats is not possible without the JS player.
|
||||
*/
|
||||
retrieve_player?: boolean;
|
||||
/**
|
||||
* Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content.
|
||||
*/
|
||||
enable_safety_mode?: boolean;
|
||||
/**
|
||||
* Specifies whether to generate the session data locally or retrieve it from YouTube.
|
||||
* This can be useful if you need more performance.
|
||||
*/
|
||||
generate_session_locally?: boolean;
|
||||
/**
|
||||
* Platform to use for the session.
|
||||
*/
|
||||
device_category?: DeviceCategory;
|
||||
/**
|
||||
* InnerTube client type.
|
||||
*/
|
||||
client_type?: ClientType;
|
||||
/**
|
||||
* The time zone.
|
||||
*/
|
||||
timezone?: string;
|
||||
cache?: UniversalCache;
|
||||
/**
|
||||
* Used to cache the deciphering functions from the JS player.
|
||||
*/
|
||||
cache?: ICache;
|
||||
/**
|
||||
* YouTube cookies.
|
||||
*/
|
||||
cookie?: string;
|
||||
/**
|
||||
* Setting this to a valid and persistent visitor data string will allow YouTube to give this session tailored content even when not logged in.
|
||||
* A good way to get a valid one is by either grabbing it from a browser or calling InnerTube's `/visitor_id` endpoint.
|
||||
*/
|
||||
visitor_data?: string;
|
||||
/**
|
||||
* Fetch function to use.
|
||||
*/
|
||||
fetch?: FetchFunction;
|
||||
}
|
||||
|
||||
export default class Session extends EventEmitterLike {
|
||||
#api_version;
|
||||
#key;
|
||||
#context;
|
||||
#player;
|
||||
export interface SessionData {
|
||||
context: Context;
|
||||
api_key: string;
|
||||
api_version: string;
|
||||
}
|
||||
|
||||
oauth;
|
||||
http;
|
||||
logged_in;
|
||||
actions;
|
||||
cache;
|
||||
export type SessionArgs = {
|
||||
lang: string;
|
||||
location: string;
|
||||
time_zone: string;
|
||||
device_category: DeviceCategory;
|
||||
client_name: ClientType;
|
||||
enable_safety_mode: boolean;
|
||||
visitor_data: string;
|
||||
on_behalf_of_user: string | undefined;
|
||||
}
|
||||
|
||||
constructor(context: Context, api_key: string, api_version: string, player: Player, cookie?: string, fetch?: FetchFunction, cache?: UniversalCache) {
|
||||
/**
|
||||
* Represents an InnerTube session. This holds all the data needed to make requests to YouTube.
|
||||
*/
|
||||
export default class Session extends EventEmitter {
|
||||
static TAG = 'Session';
|
||||
|
||||
#api_version: string;
|
||||
#key: string;
|
||||
#context: Context;
|
||||
#account_index: number;
|
||||
#player?: Player;
|
||||
|
||||
oauth: OAuth;
|
||||
http: HTTPClient;
|
||||
logged_in: boolean;
|
||||
actions: Actions;
|
||||
cache?: ICache;
|
||||
|
||||
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: ICache) {
|
||||
super();
|
||||
this.#context = context;
|
||||
this.#account_index = account_index;
|
||||
this.#key = api_key;
|
||||
this.#api_version = api_version;
|
||||
this.#player = player;
|
||||
@@ -100,79 +210,186 @@ export default class Session extends EventEmitterLike {
|
||||
}
|
||||
|
||||
static async create(options: SessionOptions = {}) {
|
||||
const { context, api_key, api_version } = await Session.getSessionData(options.lang, options.device_category, options.client_type, options.timezone, options.fetch);
|
||||
return new Session(context, api_key, api_version, await Player.create(options.cache, options.fetch), options.cookie, options.fetch, options.cache);
|
||||
const { context, api_key, api_version, account_index } = await Session.getSessionData(
|
||||
options.lang,
|
||||
options.location,
|
||||
options.account_index,
|
||||
options.visitor_data,
|
||||
options.enable_safety_mode,
|
||||
options.generate_session_locally,
|
||||
options.device_category,
|
||||
options.client_type,
|
||||
options.timezone,
|
||||
options.fetch,
|
||||
options.on_behalf_of_user
|
||||
);
|
||||
|
||||
return new Session(
|
||||
context, api_key, api_version, account_index,
|
||||
options.retrieve_player === false ? undefined : await Player.create(options.cache, options.fetch),
|
||||
options.cookie, options.fetch, options.cache
|
||||
);
|
||||
}
|
||||
|
||||
static async getSessionData(
|
||||
lang = 'en-US',
|
||||
lang = '',
|
||||
location = '',
|
||||
account_index = 0,
|
||||
visitor_data = '',
|
||||
enable_safety_mode = false,
|
||||
generate_session_locally = false,
|
||||
device_category: DeviceCategory = 'desktop',
|
||||
client_name: ClientType = ClientType.WEB,
|
||||
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
fetch: FetchFunction = globalThis.fetch
|
||||
fetch: FetchFunction = Platform.shim.fetch,
|
||||
on_behalf_of_user?: string
|
||||
) {
|
||||
let session_data: SessionData;
|
||||
|
||||
const session_args = { lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data, on_behalf_of_user };
|
||||
|
||||
Log.info(Session.TAG, 'Retrieving InnerTube session.');
|
||||
|
||||
if (generate_session_locally) {
|
||||
session_data = this.#generateSessionData(session_args);
|
||||
} else {
|
||||
try {
|
||||
// This can fail if the data changes or the request is blocked for some reason.
|
||||
session_data = await this.#retrieveSessionData(session_args, fetch);
|
||||
} catch (err) {
|
||||
Log.error(Session.TAG, 'Failed to retrieve session data from server. Will try to generate it locally.');
|
||||
session_data = this.#generateSessionData(session_args);
|
||||
}
|
||||
}
|
||||
|
||||
Log.info(Session.TAG, 'Got session data.\n', session_data);
|
||||
|
||||
return { ...session_data, account_index };
|
||||
}
|
||||
|
||||
static #getVisitorID(visitor_data: string) {
|
||||
const decoded_visitor_data = Proto.decodeVisitorData(visitor_data);
|
||||
Log.info(Session.TAG, 'Custom visitor data decoded successfully.\n', decoded_visitor_data);
|
||||
return decoded_visitor_data.id;
|
||||
}
|
||||
|
||||
static async #retrieveSessionData(options: SessionArgs, fetch: FetchFunction = Platform.shim.fetch): Promise<SessionData> {
|
||||
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
|
||||
|
||||
let visitor_id = generateRandomString(11);
|
||||
|
||||
if (options.visitor_data) {
|
||||
visitor_id = this.#getVisitorID(options.visitor_data);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'accept-language': lang,
|
||||
'accept-language': options.lang || 'en-US',
|
||||
'user-agent': getRandomUserAgent('desktop'),
|
||||
'accept': '*/*',
|
||||
'referer': 'https://www.youtube.com/sw.js',
|
||||
'cookie': `PREF=tz=${tz.replace('/', '.')}`
|
||||
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')};VISITOR_INFO1_LIVE=${visitor_id};`
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new SessionError(`Failed to get session data: ${res.status}`);
|
||||
}
|
||||
if (!res.ok)
|
||||
throw new SessionError(`Failed to retrieve session data: ${res.status}`);
|
||||
|
||||
const text = await res.text();
|
||||
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
|
||||
|
||||
const ytcfg = data[0][2];
|
||||
|
||||
const api_version = `v${ytcfg[0][0][6]}`;
|
||||
const api_version = Constants.CLIENTS.WEB.API_VERSION;
|
||||
|
||||
const [ [ device_info ], api_key ] = ytcfg;
|
||||
|
||||
const id = generateRandomString(11);
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const visitor_data = Proto.encodeVisitorData(id, timestamp);
|
||||
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: device_info[0],
|
||||
gl: device_info[2],
|
||||
gl: options.location || device_info[2],
|
||||
remoteHost: device_info[3],
|
||||
visitorData: visitor_data,
|
||||
userAgent: device_info[14],
|
||||
clientName: client_name,
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 1080,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1920,
|
||||
visitorData: device_info[13],
|
||||
clientName: options.client_name,
|
||||
clientVersion: device_info[16],
|
||||
osName: device_info[17],
|
||||
osVersion: device_info[18],
|
||||
platform: device_category.toUpperCase(),
|
||||
platform: options.device_category.toUpperCase(),
|
||||
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
|
||||
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
|
||||
timeZone: device_info[79],
|
||||
timeZone: device_info[79] || options.time_zone,
|
||||
browserName: device_info[86],
|
||||
browserVersion: device_info[87],
|
||||
originalUrl: Constants.URLS.API.BASE,
|
||||
originalUrl: Constants.URLS.YT_BASE,
|
||||
deviceMake: device_info[11],
|
||||
deviceModel: device_info[12],
|
||||
utcOffsetMinutes: new Date().getTimezoneOffset()
|
||||
utcOffsetMinutes: -new Date().getTimezoneOffset()
|
||||
},
|
||||
user: {
|
||||
enableSafetyMode: options.enable_safety_mode,
|
||||
lockedSafetyMode: false
|
||||
},
|
||||
request: {
|
||||
useSsl: true
|
||||
useSsl: true,
|
||||
internalExperimentFlags: []
|
||||
}
|
||||
};
|
||||
|
||||
if (options.on_behalf_of_user)
|
||||
context.user.onBehalfOfUser = options.on_behalf_of_user;
|
||||
|
||||
return { context, api_key, api_version };
|
||||
}
|
||||
|
||||
static #generateSessionData(options: SessionArgs): SessionData {
|
||||
let visitor_id = generateRandomString(11);
|
||||
|
||||
if (options.visitor_data) {
|
||||
visitor_id = this.#getVisitorID(options.visitor_data);
|
||||
}
|
||||
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: options.lang || 'en',
|
||||
gl: options.location || 'US',
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 1080,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1920,
|
||||
visitorData: Proto.encodeVisitorData(visitor_id, Math.floor(Date.now() / 1000)),
|
||||
clientName: options.client_name,
|
||||
clientVersion: Constants.CLIENTS.WEB.VERSION,
|
||||
osName: 'Windows',
|
||||
osVersion: '10.0',
|
||||
platform: options.device_category.toUpperCase(),
|
||||
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
|
||||
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
|
||||
timeZone: options.time_zone,
|
||||
originalUrl: Constants.URLS.YT_BASE,
|
||||
deviceMake: '',
|
||||
deviceModel: '',
|
||||
utcOffsetMinutes: -new Date().getTimezoneOffset()
|
||||
},
|
||||
user: {
|
||||
enableSafetyMode: options.enable_safety_mode,
|
||||
lockedSafetyMode: false
|
||||
},
|
||||
request: {
|
||||
useSsl: true,
|
||||
internalExperimentFlags: []
|
||||
}
|
||||
};
|
||||
|
||||
if (options.on_behalf_of_user)
|
||||
context.user.onBehalfOfUser = options.on_behalf_of_user;
|
||||
|
||||
return { context, api_key: Constants.CLIENTS.WEB.API_KEY, api_version: Constants.CLIENTS.WEB.API_VERSION };
|
||||
}
|
||||
|
||||
async signIn(credentials?: Credentials): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const error_handler: OAuthAuthErrorEventHandler = (err) => reject(err);
|
||||
@@ -204,9 +421,12 @@ export default class Session extends EventEmitterLike {
|
||||
});
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
/**
|
||||
* Signs out of the current account and revokes the credentials.
|
||||
*/
|
||||
async signOut(): Promise<Response | undefined> {
|
||||
if (!this.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const response = await this.oauth.revokeCredentials();
|
||||
this.logged_in = false;
|
||||
@@ -214,31 +434,41 @@ export default class Session extends EventEmitterLike {
|
||||
return response;
|
||||
}
|
||||
|
||||
get key() {
|
||||
/**
|
||||
* InnerTube API key.
|
||||
*/
|
||||
get key(): string {
|
||||
return this.#key;
|
||||
}
|
||||
|
||||
get api_version() {
|
||||
/**
|
||||
* InnerTube API version.
|
||||
*/
|
||||
get api_version(): string {
|
||||
return this.#api_version;
|
||||
}
|
||||
|
||||
get client_version() {
|
||||
get client_version(): string {
|
||||
return this.#context.client.clientVersion;
|
||||
}
|
||||
|
||||
get client_name() {
|
||||
get client_name(): string {
|
||||
return this.#context.client.clientName;
|
||||
}
|
||||
|
||||
get context() {
|
||||
get account_index(): number {
|
||||
return this.#account_index;
|
||||
}
|
||||
|
||||
get context(): Context {
|
||||
return this.#context;
|
||||
}
|
||||
|
||||
get player() {
|
||||
get player(): Player | undefined {
|
||||
return this.#player;
|
||||
}
|
||||
|
||||
get lang() {
|
||||
get lang(): string {
|
||||
return this.#context.client.hl;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
import Feed from './Feed';
|
||||
|
||||
class TabbedFeed extends Feed {
|
||||
#tabs;
|
||||
#actions;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
this.#actions = actions;
|
||||
this.#tabs = this.page.contents_memo.getType(Tab);
|
||||
}
|
||||
|
||||
get tabs() {
|
||||
return this.#tabs.map((tab) => tab.title.toString());
|
||||
}
|
||||
|
||||
async getTab(title: string) {
|
||||
const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError(`Tab "${title}" not found`);
|
||||
|
||||
if (tab.selected)
|
||||
return this;
|
||||
|
||||
const response = await tab.endpoint.call(this.#actions);
|
||||
|
||||
if (!response)
|
||||
throw new InnertubeError('Failed to call endpoint');
|
||||
|
||||
return new TabbedFeed(this.#actions, response.data, false);
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.page.contents_memo.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export default TabbedFeed;
|
||||
118
src/core/clients/Kids.ts
Normal file
118
src/core/clients/Kids.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Parser } from '../../parser/index.js';
|
||||
import { Channel, HomeFeed, Search, VideoInfo } from '../../parser/ytkids/index.js';
|
||||
import { InnertubeError, generateRandomString } from '../../utils/Utils.js';
|
||||
import KidsBlocklistPickerItem from '../../parser/classes/ytkids/KidsBlocklistPickerItem.js';
|
||||
|
||||
import {
|
||||
BrowseEndpoint, NextEndpoint,
|
||||
PlayerEndpoint, SearchEndpoint
|
||||
} from '../endpoints/index.js';
|
||||
|
||||
import { BlocklistPickerEndpoint } from '../endpoints/kids/index.js';
|
||||
|
||||
import type { Session, ApiResponse } from '../index.js';
|
||||
|
||||
export default class Kids {
|
||||
#session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches the given query.
|
||||
* @param query - The query.
|
||||
*/
|
||||
async search(query: string): Promise<Search> {
|
||||
const response = await this.#session.actions.execute(
|
||||
SearchEndpoint.PATH, SearchEndpoint.build({ client: 'YTKIDS', query })
|
||||
);
|
||||
return new Search(this.#session.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves video info.
|
||||
* @param video_id - The video id.
|
||||
*/
|
||||
async getInfo(video_id: string): Promise<VideoInfo> {
|
||||
const player_payload = PlayerEndpoint.build({
|
||||
sts: this.#session.player?.sts,
|
||||
client: 'YTKIDS',
|
||||
video_id
|
||||
});
|
||||
|
||||
const next_payload = NextEndpoint.build({
|
||||
video_id,
|
||||
client: 'YTKIDS'
|
||||
});
|
||||
|
||||
const player_response = this.#session.actions.execute(PlayerEndpoint.PATH, player_payload);
|
||||
const next_response = this.#session.actions.execute(NextEndpoint.PATH, next_payload);
|
||||
const response = await Promise.all([ player_response, next_response ]);
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
return new VideoInfo(response, this.#session.actions, cpn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the contents of the given channel.
|
||||
* @param channel_id - The channel id.
|
||||
*/
|
||||
async getChannel(channel_id: string): Promise<Channel> {
|
||||
const response = await this.#session.actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
browse_id: channel_id,
|
||||
client: 'YTKIDS'
|
||||
})
|
||||
);
|
||||
return new Channel(this.#session.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the home feed.
|
||||
*/
|
||||
async getHomeFeed(): Promise<HomeFeed> {
|
||||
const response = await this.#session.actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
browse_id: 'FEkids_home',
|
||||
client: 'YTKIDS'
|
||||
})
|
||||
);
|
||||
return new HomeFeed(this.#session.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of supervised accounts that the signed-in user has
|
||||
* access to, and blocks the given channel for each of them.
|
||||
* @param channel_id - The channel id to block.
|
||||
* @returns A list of API responses.
|
||||
*/
|
||||
async blockChannel(channel_id: string): Promise<ApiResponse[]> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const blocklist_payload = BlocklistPickerEndpoint.build({ channel_id: channel_id });
|
||||
const response = await this.#session.actions.execute(BlocklistPickerEndpoint.PATH, blocklist_payload );
|
||||
const popup = response.data.command.confirmDialogEndpoint;
|
||||
const popup_fragment = { contents: popup.content, engagementPanels: [] };
|
||||
const kid_picker = Parser.parseResponse(popup_fragment);
|
||||
const kids = kid_picker.contents_memo?.getType(KidsBlocklistPickerItem);
|
||||
|
||||
if (!kids)
|
||||
throw new InnertubeError('Could not find any kids profiles or supervised accounts.');
|
||||
|
||||
// Iterate through the kids and block the channel if not already blocked.
|
||||
const responses: ApiResponse[] = [];
|
||||
|
||||
for (const kid of kids) {
|
||||
if (!kid.block_button?.is_toggled) {
|
||||
kid.setActions(this.#session.actions);
|
||||
// Block channel and add to the response list.
|
||||
responses.push(await kid.blockChannel());
|
||||
}
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
}
|
||||
367
src/core/clients/Music.ts
Normal file
367
src/core/clients/Music.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import * as Proto from '../../proto/index.js';
|
||||
import { InnertubeError, generateRandomString, throwIfMissing } from '../../utils/Utils.js';
|
||||
|
||||
import {
|
||||
Album, Artist, Explore,
|
||||
HomeFeed, Library, Playlist,
|
||||
Recap, Search, TrackInfo
|
||||
} from '../../parser/ytmusic/index.js';
|
||||
|
||||
import AutomixPreviewVideo from '../../parser/classes/AutomixPreviewVideo.js';
|
||||
import Message from '../../parser/classes/Message.js';
|
||||
import MusicCarouselShelf from '../../parser/classes/MusicCarouselShelf.js';
|
||||
import MusicDescriptionShelf from '../../parser/classes/MusicDescriptionShelf.js';
|
||||
import MusicQueue from '../../parser/classes/MusicQueue.js';
|
||||
import MusicTwoRowItem from '../../parser/classes/MusicTwoRowItem.js';
|
||||
import PlaylistPanel from '../../parser/classes/PlaylistPanel.js';
|
||||
import SearchSuggestionsSection from '../../parser/classes/SearchSuggestionsSection.js';
|
||||
import SectionList from '../../parser/classes/SectionList.js';
|
||||
import Tab from '../../parser/classes/Tab.js';
|
||||
|
||||
import {
|
||||
BrowseEndpoint,
|
||||
NextEndpoint,
|
||||
PlayerEndpoint,
|
||||
SearchEndpoint
|
||||
} from '../endpoints/index.js';
|
||||
|
||||
import { GetSearchSuggestionsEndpoint } from '../endpoints/music/index.js';
|
||||
|
||||
import type { ObservedArray } from '../../parser/helpers.js';
|
||||
import type { MusicSearchFilters } from '../../types/index.js';
|
||||
import type { Actions, Session } from '../index.js';
|
||||
|
||||
export default class Music {
|
||||
#session: Session;
|
||||
#actions: Actions;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
this.#actions = session.actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
|
||||
* @param target - Video id or a list item.
|
||||
*/
|
||||
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
|
||||
if (target instanceof MusicTwoRowItem) {
|
||||
return this.#fetchInfoFromListItem(target);
|
||||
} else if (typeof target === 'string') {
|
||||
return this.#fetchInfoFromVideoId(target);
|
||||
}
|
||||
|
||||
throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target);
|
||||
}
|
||||
|
||||
async #fetchInfoFromVideoId(video_id: string): Promise<TrackInfo> {
|
||||
const player_payload = PlayerEndpoint.build({
|
||||
video_id,
|
||||
sts: this.#session.player?.sts,
|
||||
client: 'YTMUSIC'
|
||||
});
|
||||
|
||||
const next_payload = NextEndpoint.build({
|
||||
video_id,
|
||||
client: 'YTMUSIC'
|
||||
});
|
||||
|
||||
const player_response = this.#actions.execute(PlayerEndpoint.PATH, player_payload);
|
||||
const next_response = this.#actions.execute(NextEndpoint.PATH, next_payload);
|
||||
const response = await Promise.all([ player_response, next_response ]);
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
return new TrackInfo(response, this.#actions, cpn);
|
||||
}
|
||||
|
||||
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): Promise<TrackInfo> {
|
||||
if (!list_item)
|
||||
throw new InnertubeError('List item cannot be undefined');
|
||||
|
||||
if (!list_item.endpoint)
|
||||
throw new Error('This item does not have an endpoint.');
|
||||
|
||||
const player_response = list_item.endpoint.call(this.#actions, {
|
||||
client: 'YTMUSIC',
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
...{
|
||||
signatureTimestamp: this.#session.player?.sts
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const next_response = list_item.endpoint.call(this.#actions, {
|
||||
client: 'YTMUSIC',
|
||||
enablePersistentPlaylistPanel: true,
|
||||
override_endpoint: '/next'
|
||||
});
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const response = await Promise.all([ player_response, next_response ]);
|
||||
return new TrackInfo(response, this.#actions, cpn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches on YouTube Music.
|
||||
* @param query - Search query.
|
||||
* @param filters - Search filters.
|
||||
*/
|
||||
async search(query: string, filters: MusicSearchFilters = {}): Promise<Search> {
|
||||
throwIfMissing({ query });
|
||||
|
||||
const response = await this.#actions.execute(
|
||||
SearchEndpoint.PATH, SearchEndpoint.build({
|
||||
query, client: 'YTMUSIC',
|
||||
params: filters.type && filters.type !== 'all' ? Proto.encodeMusicSearchFilters(filters) : undefined
|
||||
})
|
||||
);
|
||||
|
||||
return new Search(response, this.#actions, Reflect.has(filters, 'type') && filters.type !== 'all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the home feed.
|
||||
*/
|
||||
async getHomeFeed(): Promise<HomeFeed> {
|
||||
const response = await this.#actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
browse_id: 'FEmusic_home',
|
||||
client: 'YTMUSIC'
|
||||
})
|
||||
);
|
||||
|
||||
return new HomeFeed(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Explore feed.
|
||||
*/
|
||||
async getExplore(): Promise<Explore> {
|
||||
const response = await this.#actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
client: 'YTMUSIC',
|
||||
browse_id: 'FEmusic_explore'
|
||||
})
|
||||
);
|
||||
|
||||
return new Explore(response);
|
||||
// TODO: return new Explore(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the library.
|
||||
*/
|
||||
async getLibrary(): Promise<Library> {
|
||||
const response = await this.#actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
client: 'YTMUSIC',
|
||||
browse_id: 'FEmusic_library_landing'
|
||||
})
|
||||
);
|
||||
|
||||
return new Library(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves artist's info & content.
|
||||
* @param artist_id - The artist id.
|
||||
*/
|
||||
async getArtist(artist_id: string): Promise<Artist> {
|
||||
throwIfMissing({ artist_id });
|
||||
|
||||
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
|
||||
throw new InnertubeError('Invalid artist id', artist_id);
|
||||
|
||||
const response = await this.#actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
client: 'YTMUSIC',
|
||||
browse_id: artist_id
|
||||
})
|
||||
);
|
||||
|
||||
return new Artist(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves album.
|
||||
* @param album_id - The album id.
|
||||
*/
|
||||
async getAlbum(album_id: string): Promise<Album> {
|
||||
throwIfMissing({ album_id });
|
||||
|
||||
if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
|
||||
throw new InnertubeError('Invalid album id', album_id);
|
||||
|
||||
const response = await this.#actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
client: 'YTMUSIC',
|
||||
browse_id: album_id
|
||||
})
|
||||
);
|
||||
|
||||
return new Album(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves playlist.
|
||||
* @param playlist_id - The playlist id.
|
||||
*/
|
||||
async getPlaylist(playlist_id: string): Promise<Playlist> {
|
||||
throwIfMissing({ playlist_id });
|
||||
|
||||
if (!playlist_id.startsWith('VL')) {
|
||||
playlist_id = `VL${playlist_id}`;
|
||||
}
|
||||
|
||||
const response = await this.#actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
client: 'YTMUSIC',
|
||||
browse_id: playlist_id
|
||||
})
|
||||
);
|
||||
|
||||
return new Playlist(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves up next.
|
||||
* @param video_id - The video id.
|
||||
* @param automix - Whether to enable automix.
|
||||
*/
|
||||
async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.execute(
|
||||
NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
|
||||
);
|
||||
|
||||
const tabs = response.contents_memo?.getType(Tab);
|
||||
|
||||
const tab = tabs?.first();
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const music_queue = tab.content?.as(MusicQueue);
|
||||
|
||||
if (!music_queue || !music_queue.content)
|
||||
throw new InnertubeError('Music queue was empty, the given id is probably invalid.', music_queue);
|
||||
|
||||
const playlist_panel = music_queue.content.as(PlaylistPanel);
|
||||
|
||||
if (!playlist_panel.playlist_id && automix) {
|
||||
const automix_preview_video = playlist_panel.contents.firstOfType(AutomixPreviewVideo);
|
||||
|
||||
if (!automix_preview_video)
|
||||
throw new InnertubeError('Automix item not found');
|
||||
|
||||
const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
|
||||
videoId: video_id,
|
||||
client: 'YTMUSIC',
|
||||
parse: true
|
||||
});
|
||||
|
||||
if (!page || !page.contents_memo)
|
||||
throw new InnertubeError('Could not fetch automix');
|
||||
|
||||
return page.contents_memo.getType(PlaylistPanel).first();
|
||||
}
|
||||
|
||||
return playlist_panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves related content.
|
||||
* @param video_id - The video id.
|
||||
*/
|
||||
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.execute(
|
||||
NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
|
||||
);
|
||||
|
||||
const tabs = response.contents_memo?.getType(Tab);
|
||||
|
||||
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
|
||||
|
||||
if (!page.contents)
|
||||
throw new InnertubeError('Unexpected response', page);
|
||||
|
||||
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
|
||||
|
||||
return shelves;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves song lyrics.
|
||||
* @param video_id - The video id.
|
||||
*/
|
||||
async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const response = await this.#actions.execute(
|
||||
NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
|
||||
);
|
||||
|
||||
const tabs = response.contents_memo?.getType(Tab);
|
||||
|
||||
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError('Could not find target tab.');
|
||||
|
||||
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
|
||||
|
||||
if (!page.contents)
|
||||
throw new InnertubeError('Unexpected response', page);
|
||||
|
||||
if (page.contents.item().type === 'Message')
|
||||
throw new InnertubeError(page.contents.item().as(Message).text.toString(), video_id);
|
||||
|
||||
const section_list = page.contents.item().as(SectionList).contents;
|
||||
|
||||
return section_list.firstOfType(MusicDescriptionShelf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves recap.
|
||||
*/
|
||||
async getRecap(): Promise<Recap> {
|
||||
const response = await this.#actions.execute(
|
||||
BrowseEndpoint.PATH, BrowseEndpoint.build({
|
||||
client: 'YTMUSIC_ANDROID',
|
||||
browse_id: 'FEmusic_listening_review'
|
||||
})
|
||||
);
|
||||
|
||||
return new Recap(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions for the given query.
|
||||
* @param query - The query.
|
||||
*/
|
||||
async getSearchSuggestions(query: string): Promise<ObservedArray<SearchSuggestionsSection>> {
|
||||
const response = await this.#actions.execute(
|
||||
GetSearchSuggestionsEndpoint.PATH,
|
||||
{ ...GetSearchSuggestionsEndpoint.build({ input: query }), parse: true }
|
||||
);
|
||||
|
||||
if (!response.contents_memo)
|
||||
return [] as unknown as ObservedArray<SearchSuggestionsSection>;
|
||||
|
||||
const search_suggestions_sections = response.contents_memo.getType(SearchSuggestionsSection);
|
||||
|
||||
return search_suggestions_sections;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import Proto from '../proto';
|
||||
import Session from './Session';
|
||||
import { AxioslikeResponse } from './Actions';
|
||||
import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
|
||||
import { Constants } from '../utils';
|
||||
import * as Proto from '../../proto/index.js';
|
||||
import { Constants } from '../../utils/index.js';
|
||||
import { InnertubeError, MissingParamError, Platform } from '../../utils/Utils.js';
|
||||
import { CreateVideoEndpoint } from '../endpoints/upload/index.js';
|
||||
|
||||
export interface UploadResult {
|
||||
import type { UpdateVideoMetadataOptions, UploadedVideoMetadataOptions } from '../../types/Clients.js';
|
||||
import type { ApiResponse, Session } from '../index.js';
|
||||
|
||||
interface UploadResult {
|
||||
status: string;
|
||||
scottyResourceId: string;
|
||||
}
|
||||
|
||||
export interface InitialUploadData {
|
||||
interface InitialUploadData {
|
||||
frontend_upload_id: string;
|
||||
upload_id: string;
|
||||
upload_url: string;
|
||||
@@ -17,15 +19,8 @@ export interface InitialUploadData {
|
||||
chunk_granularity: string;
|
||||
}
|
||||
|
||||
export interface VideoMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
|
||||
is_draft?: boolean;
|
||||
}
|
||||
|
||||
class Studio {
|
||||
#session;
|
||||
export default class Studio {
|
||||
#session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
@@ -39,7 +34,10 @@ class Studio {
|
||||
* const response = await yt.studio.setThumbnail(video_id, buffer);
|
||||
* ```
|
||||
*/
|
||||
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<AxioslikeResponse> {
|
||||
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
if (!video_id || !buffer)
|
||||
throw new MissingParamError('One or more parameters are missing.');
|
||||
|
||||
@@ -53,6 +51,34 @@ class Studio {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a given video's metadata.
|
||||
* @example
|
||||
* ```ts
|
||||
* const response = await yt.studio.updateVideoMetadata('videoid', {
|
||||
* tags: [ 'astronomy', 'NASA', 'APOD' ],
|
||||
* title: 'Artemis Mission',
|
||||
* description: 'A nicely written description...',
|
||||
* category: 27,
|
||||
* license: 'creative_commons'
|
||||
* // ...
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async updateVideoMetadata(video_id: string, metadata: UpdateVideoMetadataOptions): Promise<ApiResponse> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);
|
||||
|
||||
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
|
||||
protobuf: true,
|
||||
serialized_data: payload
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a video to YouTube.
|
||||
* @example
|
||||
@@ -61,7 +87,10 @@ class Studio {
|
||||
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
|
||||
* ```
|
||||
*/
|
||||
async upload(file: BodyInit, metadata: VideoMetadata = {}): Promise<AxioslikeResponse> {
|
||||
async upload(file: BodyInit, metadata: UploadedVideoMetadataOptions = {}): Promise<ApiResponse> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const initial_data = await this.#getInitialUploadData();
|
||||
const upload_result = await this.#uploadVideo(initial_data.upload_url, file);
|
||||
|
||||
@@ -74,12 +103,12 @@ class Studio {
|
||||
}
|
||||
|
||||
async #getInitialUploadData(): Promise<InitialUploadData> {
|
||||
const frontend_upload_id = `innertube_android:${uuidv4()}:0:v=3,api=1,cf=3`;
|
||||
const frontend_upload_id = `innertube_android:${Platform.shim.uuidv4()}:0:v=3,api=1,cf=3`;
|
||||
|
||||
const payload = {
|
||||
frontendUploadId: frontend_upload_id,
|
||||
deviceDisplayName: 'Pixel 6 Pro',
|
||||
fileId: `goog-edited-video://generated?videoFileUri=content://media/external/video/media/${uuidv4()}`,
|
||||
fileId: `goog-edited-video://generated?videoFileUri=content://media/external/video/media/${Platform.shim.uuidv4()}`,
|
||||
mp4MoovAtomRelocationStatus: 'UNSUPPORTED',
|
||||
transcodeResult: 'DISABLED',
|
||||
connectionType: 'WIFI'
|
||||
@@ -128,38 +157,34 @@ class Studio {
|
||||
return data;
|
||||
}
|
||||
|
||||
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: VideoMetadata) {
|
||||
const metadata_payload = {
|
||||
resourceId: {
|
||||
scottyResourceId: {
|
||||
id: upload_result.scottyResourceId
|
||||
}
|
||||
},
|
||||
frontendUploadId: initial_data.frontend_upload_id,
|
||||
initialMetadata: {
|
||||
title: {
|
||||
newTitle: metadata.title || new Date().toDateString()
|
||||
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadataOptions) {
|
||||
const response = await this.#session.actions.execute(
|
||||
CreateVideoEndpoint.PATH, CreateVideoEndpoint.build({
|
||||
resource_id: {
|
||||
scotty_resource_id: {
|
||||
id: upload_result.scottyResourceId
|
||||
}
|
||||
},
|
||||
description: {
|
||||
newDescription: metadata.description || '',
|
||||
shouldSegment: true
|
||||
frontend_upload_id: initial_data.frontend_upload_id,
|
||||
initial_metadata: {
|
||||
title: {
|
||||
new_title: metadata.title || new Date().toDateString()
|
||||
},
|
||||
description: {
|
||||
new_description: metadata.description || '',
|
||||
should_segment: true
|
||||
},
|
||||
privacy: {
|
||||
new_privacy: metadata.privacy || 'PRIVATE'
|
||||
},
|
||||
draft_state: {
|
||||
is_draft: metadata.is_draft
|
||||
}
|
||||
},
|
||||
privacy: {
|
||||
newPrivacy: metadata.privacy || 'PRIVATE'
|
||||
},
|
||||
draftState: {
|
||||
isDraft: metadata.is_draft || false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const response = await this.#session.actions.execute('/upload/createvideo', {
|
||||
client: 'ANDROID',
|
||||
...metadata_payload
|
||||
});
|
||||
client: 'ANDROID'
|
||||
})
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export default Studio;
|
||||
}
|
||||
3
src/core/clients/index.ts
Normal file
3
src/core/clients/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Kids } from './Kids.js';
|
||||
export { default as Music } from './Music.js';
|
||||
export { default as Studio } from './Studio.js';
|
||||
19
src/core/endpoints/BrowseEndpoint.ts
Normal file
19
src/core/endpoints/BrowseEndpoint.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IBrowseRequest, BrowseEndpointOptions } from '../../types/index.js';
|
||||
|
||||
export const PATH = '/browse';
|
||||
|
||||
/**
|
||||
* Builds a `/browse` request payload.
|
||||
* @param opts - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: BrowseEndpointOptions): IBrowseRequest {
|
||||
return {
|
||||
...{
|
||||
browseId: opts.browse_id,
|
||||
params: opts.params,
|
||||
continuation: opts.continuation,
|
||||
client: opts.client
|
||||
}
|
||||
};
|
||||
}
|
||||
16
src/core/endpoints/GetNotificationMenuEndpoint.ts
Normal file
16
src/core/endpoints/GetNotificationMenuEndpoint.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IGetNotificationMenuRequest, GetNotificationMenuEndpointOptions } from '../../types/index.js';
|
||||
|
||||
export const PATH = '/notification/get_notification_menu';
|
||||
|
||||
/**
|
||||
* Builds a `/get_notification_menu` request payload.
|
||||
* @param opts - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: GetNotificationMenuEndpointOptions): IGetNotificationMenuRequest {
|
||||
return {
|
||||
...{
|
||||
notificationsMenuRequestType: opts.notifications_menu_request_type
|
||||
}
|
||||
};
|
||||
}
|
||||
1
src/core/endpoints/GuideEndpoint.ts
Normal file
1
src/core/endpoints/GuideEndpoint.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const PATH = '/guide';
|
||||
21
src/core/endpoints/NextEndpoint.ts
Normal file
21
src/core/endpoints/NextEndpoint.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { INextRequest, NextEndpointOptions } from '../../types/index.js';
|
||||
|
||||
export const PATH = '/next';
|
||||
|
||||
/**
|
||||
* Builds a `/next` request payload.
|
||||
* @param opts - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: NextEndpointOptions): INextRequest {
|
||||
return {
|
||||
...{
|
||||
videoId: opts.video_id,
|
||||
playlistId: opts.playlist_id,
|
||||
params: opts.params,
|
||||
playlistIndex: opts.playlist_index,
|
||||
client: opts.client,
|
||||
continuation: opts.continuation
|
||||
}
|
||||
};
|
||||
}
|
||||
50
src/core/endpoints/PlayerEndpoint.ts
Normal file
50
src/core/endpoints/PlayerEndpoint.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { encodeShortsParam } from '../../proto/index.js';
|
||||
import type { IPlayerRequest, PlayerEndpointOptions } from '../../types/index.js';
|
||||
|
||||
export const PATH = '/player';
|
||||
|
||||
/**
|
||||
* Builds a `/player` request payload.
|
||||
* @param opts - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: PlayerEndpointOptions): IPlayerRequest {
|
||||
const is_android =
|
||||
opts.client === 'ANDROID' ||
|
||||
opts.client === 'YTMUSIC_ANDROID' ||
|
||||
opts.client === 'YTSTUDIO_ANDROID';
|
||||
|
||||
return {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
vis: 0,
|
||||
splay: false,
|
||||
referer: opts.playlist_id ?
|
||||
`https://www.youtube.com/watch?v=${opts.video_id}&list=${opts.playlist_id}` :
|
||||
`https://www.youtube.com/watch?v=${opts.video_id}`,
|
||||
currentUrl: opts.playlist_id ?
|
||||
`/watch?v=${opts.video_id}&list=${opts.playlist_id}` :
|
||||
`/watch?v=${opts.video_id}`,
|
||||
autonavState: 'STATE_ON',
|
||||
autoCaptionsDefaultOn: false,
|
||||
html5Preference: 'HTML5_PREF_WANTS',
|
||||
lactMilliseconds: '-1',
|
||||
...{
|
||||
signatureTimestamp: opts.sts
|
||||
}
|
||||
}
|
||||
},
|
||||
attestationRequest: {
|
||||
omitBotguardData: true
|
||||
},
|
||||
racyCheckOk: true,
|
||||
contentCheckOk: true,
|
||||
videoId: opts.video_id,
|
||||
...{
|
||||
client: opts.client,
|
||||
playlistId: opts.playlist_id,
|
||||
// Workaround streaming URLs returning 403 or getting throttled when using Android based clients.
|
||||
params: is_android ? encodeShortsParam() : opts.params
|
||||
}
|
||||
};
|
||||
}
|
||||
16
src/core/endpoints/ResolveURLEndpoint.ts
Normal file
16
src/core/endpoints/ResolveURLEndpoint.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IResolveURLRequest, ResolveURLEndpointOptions } from '../../types/index.js';
|
||||
|
||||
export const PATH = '/navigation/resolve_url';
|
||||
|
||||
/**
|
||||
* Builds a `/resolve_url` request payload.
|
||||
* @param opts - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: ResolveURLEndpointOptions): IResolveURLRequest {
|
||||
return {
|
||||
...{
|
||||
url: opts.url
|
||||
}
|
||||
};
|
||||
}
|
||||
19
src/core/endpoints/SearchEndpoint.ts
Normal file
19
src/core/endpoints/SearchEndpoint.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ISearchRequest, SearchEndpointOptions } from '../../types/index.js';
|
||||
|
||||
export const PATH = '/search';
|
||||
|
||||
/**
|
||||
* Builds a `/search` request payload.
|
||||
* @param opts - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: SearchEndpointOptions): ISearchRequest {
|
||||
return {
|
||||
...{
|
||||
query: opts.query,
|
||||
params: opts.params,
|
||||
continuation: opts.continuation,
|
||||
client: opts.client
|
||||
}
|
||||
};
|
||||
}
|
||||
13
src/core/endpoints/account/AccountListEndpoint.ts
Normal file
13
src/core/endpoints/account/AccountListEndpoint.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { IAccountListRequest } from '../../../types/index.js';
|
||||
|
||||
export const PATH = '/account/accounts_list';
|
||||
|
||||
/**
|
||||
* Builds a `/account/accounts_list` request payload.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(): IAccountListRequest {
|
||||
return {
|
||||
client: 'ANDROID'
|
||||
};
|
||||
}
|
||||
1
src/core/endpoints/account/index.ts
Normal file
1
src/core/endpoints/account/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as AccountListEndpoint from './AccountListEndpoint.js';
|
||||
24
src/core/endpoints/browse/EditPlaylistEndpoint.ts
Normal file
24
src/core/endpoints/browse/EditPlaylistEndpoint.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { IEditPlaylistRequest, EditPlaylistEndpointOptions } from '../../../types/index.js';
|
||||
|
||||
export const PATH = '/browse/edit_playlist';
|
||||
|
||||
/**
|
||||
* Builds a `/browse/edit_playlist` request payload.
|
||||
* @param opts - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: EditPlaylistEndpointOptions): IEditPlaylistRequest {
|
||||
return {
|
||||
playlistId: opts.playlist_id,
|
||||
actions: opts.actions.map((action) => ({
|
||||
action: action.action,
|
||||
...{
|
||||
addedVideoId: action.added_video_id,
|
||||
setVideoId: action.set_video_id,
|
||||
movedSetVideoIdPredecessor: action.moved_set_video_id_predecessor,
|
||||
playlistDescription: action.playlist_description,
|
||||
playlistName: action.playlist_name
|
||||
}
|
||||
}))
|
||||
};
|
||||
}
|
||||
1
src/core/endpoints/browse/index.ts
Normal file
1
src/core/endpoints/browse/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as EditPlaylistEndpoint from './EditPlaylistEndpoint.js';
|
||||
15
src/core/endpoints/channel/EditDescriptionEndpoint.ts
Normal file
15
src/core/endpoints/channel/EditDescriptionEndpoint.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { IChannelEditDescriptionRequest, ChannelEditDescriptionEndpointOptions } from '../../../types/Endpoints.js';
|
||||
|
||||
export const PATH = '/channel/edit_description';
|
||||
|
||||
/**
|
||||
* Builds a `/channel/edit_description` request payload.
|
||||
* @param options - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(options: ChannelEditDescriptionEndpointOptions): IChannelEditDescriptionRequest {
|
||||
return {
|
||||
givenDescription: options.given_description,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
}
|
||||
15
src/core/endpoints/channel/EditNameEndpoint.ts
Normal file
15
src/core/endpoints/channel/EditNameEndpoint.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { IChannelEditNameRequest, ChannelEditNameEndpointOptions } from '../../../types/index.js';
|
||||
|
||||
export const PATH = '/channel/edit_name';
|
||||
|
||||
/**
|
||||
* Builds a `/channel/edit_name` request payload.
|
||||
* @param options - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(options: ChannelEditNameEndpointOptions): IChannelEditNameRequest {
|
||||
return {
|
||||
givenName: options.given_name,
|
||||
client: 'ANDROID'
|
||||
};
|
||||
}
|
||||
2
src/core/endpoints/channel/index.ts
Normal file
2
src/core/endpoints/channel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as EditNameEndpoint from './EditNameEndpoint.js';
|
||||
export * as EditDescriptionEndpoint from './EditDescriptionEndpoint.js';
|
||||
18
src/core/endpoints/comment/CreateCommentEndpoint.ts
Normal file
18
src/core/endpoints/comment/CreateCommentEndpoint.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ICreateCommentRequest, CreateCommentEndpointOptions } from '../../../types/index.js';
|
||||
|
||||
export const PATH = '/comment/create_comment';
|
||||
|
||||
/**
|
||||
* Builds a `/comment/create_comment` request payload.
|
||||
* @param options - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(options: CreateCommentEndpointOptions): ICreateCommentRequest {
|
||||
return {
|
||||
commentText: options.comment_text,
|
||||
createCommentParams: options.create_comment_params,
|
||||
...{
|
||||
client: options.client
|
||||
}
|
||||
};
|
||||
}
|
||||
17
src/core/endpoints/comment/PerformCommentActionEndpoint.ts
Normal file
17
src/core/endpoints/comment/PerformCommentActionEndpoint.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { IPerformCommentActionRequest, PerformCommentActionEndpointOptions } from '../../../types/index.js';
|
||||
|
||||
export const PATH = '/comment/perform_comment_action';
|
||||
|
||||
/**
|
||||
* Builds a `/comment/perform_comment_action` request payload.
|
||||
* @param options - The options to use.
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(options: PerformCommentActionEndpointOptions): IPerformCommentActionRequest {
|
||||
return {
|
||||
actions: options.actions,
|
||||
...{
|
||||
client: options.client
|
||||
}
|
||||
};
|
||||
}
|
||||
2
src/core/endpoints/comment/index.ts
Normal file
2
src/core/endpoints/comment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as PerformCommentActionEndpoint from './PerformCommentActionEndpoint.js';
|
||||
export * as CreateCommentEndpoint from './CreateCommentEndpoint.js';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user