mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
532 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c9856a8359 | ||
|
|
4b29ad74de | ||
|
|
60730a5531 | ||
|
|
70f2398180 | ||
|
|
5b3109afef | ||
|
|
60fe4b1829 | ||
|
|
ddbf9e93da | ||
|
|
e3d483ed75 | ||
|
|
320c007396 | ||
|
|
28a651ea3a | ||
|
|
85fc468cc9 | ||
|
|
f9da261441 | ||
|
|
4484f78394 | ||
|
|
4181969d52 | ||
|
|
ecac5f4d7e | ||
|
|
a8322e35f5 | ||
|
|
3a6f4ffa9d | ||
|
|
3dc357bee0 | ||
|
|
982a086760 | ||
|
|
75959105bd | ||
|
|
80496d30a3 | ||
|
|
4bddc771b2 | ||
|
|
c26a07dc73 | ||
|
|
e498815795 | ||
|
|
60ef3eabd3 | ||
|
|
1da8043c18 | ||
|
|
4f015536ac | ||
|
|
c3f98246f0 | ||
|
|
53cb26546e | ||
|
|
e3d38ad107 | ||
|
|
74d53f388a | ||
|
|
7a7c657733 | ||
|
|
d34a8d7fc4 | ||
|
|
f8c07101bf | ||
|
|
dccb2b7e50 | ||
|
|
573ebf2568 | ||
|
|
898cb56c71 | ||
|
|
b9e6e16ce9 | ||
|
|
c99364942c | ||
|
|
317bca261c | ||
|
|
173aec65f5 | ||
|
|
13a86cb4e7 | ||
|
|
05b4593e0a | ||
|
|
6fe4d235ff | ||
|
|
f4ce4d2f74 | ||
|
|
541cdc455f | ||
|
|
c000bd8d5f | ||
|
|
f3d77b3e97 | ||
|
|
22b2953ec8 | ||
|
|
a4965ee43d | ||
|
|
842c185f4d | ||
|
|
790d528a2d | ||
|
|
ed79551314 | ||
|
|
34281e2445 | ||
|
|
b101a39d30 | ||
|
|
dc2f0055cc | ||
|
|
ecdac38458 | ||
|
|
31326ec9eb | ||
|
|
dba34dc5ae | ||
|
|
713fd13c74 | ||
|
|
f6a2a418be | ||
|
|
e82302a6ea | ||
|
|
59d37e9ed6 | ||
|
|
c10cce1e2a | ||
|
|
63ae9061eb | ||
|
|
03b183be70 | ||
|
|
2d7fe04a8a | ||
|
|
4d6067937a | ||
|
|
52207df393 | ||
|
|
9a914e29ba | ||
|
|
34022fddfb | ||
|
|
9b4d86b81f | ||
|
|
dc79b19d56 | ||
|
|
ad3ab4f637 | ||
|
|
60ff0513f1 | ||
|
|
4ab2bb744a | ||
|
|
40fc24b043 | ||
|
|
709c448053 | ||
|
|
3833b333a7 | ||
|
|
38280290f7 | ||
|
|
d5f34982f4 | ||
|
|
3ff3d3c633 | ||
|
|
a788c9c80f | ||
|
|
9e2443d1aa | ||
|
|
bb3ed9dcd3 | ||
|
|
51f9eb15ae | ||
|
|
d6398296c3 | ||
|
|
af6856ced4 | ||
|
|
3cdaab8b7a | ||
|
|
daaba3745e | ||
|
|
323b90a98c | ||
|
|
3abcde7e67 | ||
|
|
2599e734b8 | ||
|
|
c10006fa57 | ||
|
|
61f8b2a9a0 | ||
|
|
cdbdfec057 | ||
|
|
4d332402db | ||
|
|
c66940ae65 | ||
|
|
ff9aeeedce | ||
|
|
88a6ee907e | ||
|
|
72c3af84b0 | ||
|
|
99233bcf7a | ||
|
|
adae925367 | ||
|
|
5a99190136 | ||
|
|
6008d4cf0d | ||
|
|
f4b947f8e2 | ||
|
|
00cd35867a | ||
|
|
7ba09a66d8 | ||
|
|
c16d632b31 | ||
|
|
9ef765dbc1 | ||
|
|
dbfcb36fd7 | ||
|
|
0393ab7f38 | ||
|
|
eb5d49d14e | ||
|
|
a83518d021 | ||
|
|
95079ced09 | ||
|
|
616b1405c3 | ||
|
|
ef6ec59402 | ||
|
|
a2103963b4 | ||
|
|
8ed6cc9e24 | ||
|
|
9c44cfc7f8 | ||
|
|
c487a65e8f | ||
|
|
9c7850d197 | ||
|
|
c12b1482fe | ||
|
|
851afddf51 | ||
|
|
8b9cd236ae | ||
|
|
0fb0c2318a | ||
|
|
dfd09e9683 | ||
|
|
6da69b4f18 | ||
|
|
60e6326402 | ||
|
|
4bf4639902 | ||
|
|
0f8c25a5f0 | ||
|
|
6a5ebeb8ee | ||
|
|
fb68e6bcfe | ||
|
|
e2f455d7bd | ||
|
|
39d2c4c09d | ||
|
|
2e3b1c2bf2 | ||
|
|
0d4bca5a9d | ||
|
|
1ce2feb18b | ||
|
|
7ded405de0 | ||
|
|
7400b8a9d9 | ||
|
|
2247026da1 | ||
|
|
d8266ff786 | ||
|
|
d1f2369e43 | ||
|
|
4fe349389c | ||
|
|
68cb841c00 | ||
|
|
947fd7895b | ||
|
|
0509b704a8 | ||
|
|
f924a39409 | ||
|
|
03f9fc5c2e | ||
|
|
8a5073b0b9 | ||
|
|
0356dafa96 | ||
|
|
bd7279f800 | ||
|
|
11d553b2c0 | ||
|
|
670b918642 | ||
|
|
5a14fe3c4c | ||
|
|
ae1a2a7f84 | ||
|
|
1837d4929c | ||
|
|
d729972251 | ||
|
|
d7267d9aa5 | ||
|
|
650b563301 | ||
|
|
fd52556603 | ||
|
|
ff81c2afe8 | ||
|
|
9c97434e5e | ||
|
|
021a7fd97a | ||
|
|
a011f62a90 | ||
|
|
dff535a9e2 | ||
|
|
f52d15cdb0 | ||
|
|
84d5edb6f0 | ||
|
|
d7d6a4e019 | ||
|
|
3bdcdf7cf1 | ||
|
|
b314458ed9 | ||
|
|
1d62e469a9 | ||
|
|
0a851bde31 | ||
|
|
3e2b932844 | ||
|
|
263b4887c3 | ||
|
|
4f994c338b | ||
|
|
ef9a22e85a | ||
|
|
8849a01ecf | ||
|
|
a948c2e480 | ||
|
|
f5c6dbc63e | ||
|
|
829181ba6f | ||
|
|
7ec6d6dd21 | ||
|
|
d2b3eead41 | ||
|
|
96857ccadf | ||
|
|
c24e6256c5 | ||
|
|
00c2db791f | ||
|
|
20556970a7 | ||
|
|
1681a9b84c | ||
|
|
b3c5e340af | ||
|
|
bb3f3cc584 | ||
|
|
86291fe1f9 | ||
|
|
6eef4b746b | ||
|
|
4088ef59c6 | ||
|
|
4a7c9d7b31 | ||
|
|
36f02cdcdb | ||
|
|
97d4cc1056 | ||
|
|
e90285bfab | ||
|
|
7fc9b526b0 | ||
|
|
99b88e2684 | ||
|
|
748e34758f | ||
|
|
a556aacfdd | ||
|
|
9ffaaacb3e | ||
|
|
4c7a42d8d4 | ||
|
|
1d2c1ed69b | ||
|
|
5af2a9972e | ||
|
|
1efbef6f49 | ||
|
|
4e1f6af736 | ||
|
|
98e7afda87 | ||
|
|
58809c2280 | ||
|
|
1484e3c2aa | ||
|
|
e0546944a8 | ||
|
|
d246008eab | ||
|
|
455556ba89 | ||
|
|
eaa16244d2 | ||
|
|
919a35d024 | ||
|
|
d54fc282ad | ||
|
|
51f7adf397 | ||
|
|
d990fc9b88 | ||
|
|
418dcac80a | ||
|
|
60075f8726 | ||
|
|
41aa54b8d9 | ||
|
|
662bccf2c2 | ||
|
|
abe045762b | ||
|
|
67d526e15d | ||
|
|
940b8322cc | ||
|
|
d6bbe8f183 | ||
|
|
28d51fcc4f | ||
|
|
e8a81084e6 | ||
|
|
4ef546b3f0 | ||
|
|
ec5a2aa7fd | ||
|
|
2cbb0179ae | ||
|
|
b594dad510 | ||
|
|
6d7609c32a | ||
|
|
75e0453f69 | ||
|
|
f6af3faa41 | ||
|
|
3458bb422a | ||
|
|
521029de52 | ||
|
|
4a102878d8 | ||
|
|
43470efb6e | ||
|
|
0067ccd438 | ||
|
|
62811bd8f1 | ||
|
|
71309a0788 | ||
|
|
7e6f944a4b | ||
|
|
3d0b217743 | ||
|
|
3c98244c3b | ||
|
|
20600fcc04 | ||
|
|
564a5deaec | ||
|
|
54a50d5704 | ||
|
|
49688a0ad6 | ||
|
|
040b382590 | ||
|
|
60b67a399c | ||
|
|
3f22a44ba9 | ||
|
|
6aa30648fe | ||
|
|
5f08be7991 | ||
|
|
79d6b84dda | ||
|
|
7142a63b1d | ||
|
|
5fd9f7ea83 | ||
|
|
ee71e6a55f | ||
|
|
b6a898f733 | ||
|
|
797c545b80 | ||
|
|
b3da6b11f8 | ||
|
|
81bbbaebe2 | ||
|
|
2254b69670 | ||
|
|
a7ee98820a | ||
|
|
c7474d7087 | ||
|
|
d167a0b807 | ||
|
|
95f713ff53 | ||
|
|
53965630b7 | ||
|
|
9840acc63d | ||
|
|
1676b11b0e | ||
|
|
afa39753d5 | ||
|
|
659df51115 | ||
|
|
dab89545fe | ||
|
|
73de36b946 | ||
|
|
049fd16aab | ||
|
|
bcaa02f10c | ||
|
|
153238aefc | ||
|
|
b2014c80f4 | ||
|
|
018092eb78 | ||
|
|
4ee6ec0d20 | ||
|
|
cbac2e1c81 | ||
|
|
fc191ae3d9 | ||
|
|
0661563656 | ||
|
|
2c3f37191d | ||
|
|
4f7de3cc50 | ||
|
|
5ec2a5512e | ||
|
|
ebbfb86600 | ||
|
|
07b83a823c | ||
|
|
688fd55117 | ||
|
|
87534c6489 | ||
|
|
12618c1a0b | ||
|
|
55fd4e8143 | ||
|
|
359020193b | ||
|
|
0b4853cb81 | ||
|
|
4ad5a5da64 | ||
|
|
f05270daee | ||
|
|
4ccb4b07b7 | ||
|
|
71c4b16654 | ||
|
|
82e8620a77 | ||
|
|
91dc854668 | ||
|
|
f0565ec924 | ||
|
|
15437e3937 | ||
|
|
c7c0ac8b54 | ||
|
|
1e23cdb510 | ||
|
|
a85e9ef667 | ||
|
|
865b6870a1 | ||
|
|
7284425618 | ||
|
|
05f74fe004 | ||
|
|
864f10f2e9 | ||
|
|
369e1048d1 | ||
|
|
b1cf5d33b8 | ||
|
|
19008e126d | ||
|
|
c525163f28 | ||
|
|
155dc9bd15 | ||
|
|
5560ba3ce4 | ||
|
|
6aaf9c70b9 | ||
|
|
e0c7496e37 | ||
|
|
fa79e5cad2 | ||
|
|
98a2b49395 | ||
|
|
17978193d0 | ||
|
|
13f571a6dc | ||
|
|
9f3f8ad820 | ||
|
|
2ba7a5c64e | ||
|
|
d7d1c96d8c | ||
|
|
0219c075c7 | ||
|
|
759351c38e | ||
|
|
6312e97f95 | ||
|
|
c60babcf25 | ||
|
|
c48cfcd8a0 | ||
|
|
594202d61d | ||
|
|
7a5490452a | ||
|
|
b4bb44b797 | ||
|
|
43f3c3fbf8 | ||
|
|
b48ae0b8d3 | ||
|
|
8cf3e67f79 | ||
|
|
ffa243bc07 | ||
|
|
a08580eeee | ||
|
|
039ebb7c0c | ||
|
|
46a385aa06 | ||
|
|
f656ccd690 | ||
|
|
ddd276d99f | ||
|
|
5fbeaeabb6 | ||
|
|
18e62f6ff8 | ||
|
|
6235985871 | ||
|
|
4eef0ddab0 | ||
|
|
6127690b4c | ||
|
|
b6cfdb733c | ||
|
|
b565213f11 | ||
|
|
a5c9c9d863 | ||
|
|
cf95d82d3e | ||
|
|
00e0131672 | ||
|
|
2315306d9f | ||
|
|
1dfd4b6263 | ||
|
|
b0a861dec8 | ||
|
|
4943685e57 | ||
|
|
b773f5668c | ||
|
|
4fd7371cf3 | ||
|
|
16bb879689 | ||
|
|
a852cd22c8 | ||
|
|
90bb3e20c0 | ||
|
|
eab40c0034 | ||
|
|
19f7336a48 | ||
|
|
75895e5492 | ||
|
|
0cdfac1812 | ||
|
|
446966fb2d | ||
|
|
29897981f0 | ||
|
|
7e8a517de9 | ||
|
|
a8b9487b58 | ||
|
|
80a338e5ff | ||
|
|
e2ca022a47 | ||
|
|
2ebcd49f02 | ||
|
|
98a62c31da | ||
|
|
1bfe2676d8 | ||
|
|
4db0a0358f | ||
|
|
6bdccb89e5 | ||
|
|
bbfecdb015 | ||
|
|
f79d4b635d | ||
|
|
283c06e64f | ||
|
|
5c572dba66 | ||
|
|
aa943a46a8 | ||
|
|
d634892b01 | ||
|
|
2010714f50 | ||
|
|
c6c96fd223 | ||
|
|
db41fa40d2 | ||
|
|
02ece1ddda | ||
|
|
b175e02f6d | ||
|
|
d3394f846a | ||
|
|
07b73ab78d | ||
|
|
d743b5a088 | ||
|
|
bb206c044c | ||
|
|
d48065405d | ||
|
|
dbc8b62ba2 | ||
|
|
e32981728b | ||
|
|
7b33dcbb79 | ||
|
|
4c6bf49bbe | ||
|
|
4bbc2d50f4 | ||
|
|
440d80063d | ||
|
|
c49147523a | ||
|
|
e221c79448 | ||
|
|
291d04e703 | ||
|
|
12baec0b0d | ||
|
|
b793c61fd8 | ||
|
|
b9e15b5fbd | ||
|
|
d0c54f2b8b | ||
|
|
6ff984df66 | ||
|
|
4fa2e5c127 | ||
|
|
725f186bd9 | ||
|
|
07340931a0 | ||
|
|
46d62bf83f | ||
|
|
c28da62ec1 | ||
|
|
c7fc18b516 |
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
8
.eslintignore
Normal file
8
.eslintignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.github
|
||||
test/
|
||||
cache/
|
||||
src/proto/youtube.ts
|
||||
coverage/
|
||||
node_modules/
|
||||
dist/
|
||||
87
.eslintrc.yml
Normal file
87
.eslintrc.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
plugins:
|
||||
[ '@typescript-eslint', 'eslint-plugin-tsdoc' ]
|
||||
env:
|
||||
commonjs: true
|
||||
es2021: true
|
||||
node: true
|
||||
extends: [ eslint:recommended, 'plugin:@typescript-eslint/recommended' ]
|
||||
parser: '@typescript-eslint/parser'
|
||||
parserOptions:
|
||||
ecmaVersion: latest
|
||||
overrides:
|
||||
-
|
||||
files:
|
||||
- '**/*.js'
|
||||
rules:
|
||||
'tsdoc/syntax': 'off'
|
||||
rules:
|
||||
max-len:
|
||||
- error
|
||||
-
|
||||
code: 200
|
||||
ignoreComments: true
|
||||
ignoreTrailingComments: true
|
||||
ignoreStrings: true
|
||||
ignoreTemplateLiterals: true
|
||||
ignoreRegExpLiterals: true
|
||||
|
||||
quotes: [error, single]
|
||||
|
||||
'@typescript-eslint/ban-types': 'off'
|
||||
'tsdoc/syntax': 'warn'
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
|
||||
no-template-curly-in-string: error
|
||||
no-unreachable-loop: error
|
||||
no-unused-private-class-members: 'off'
|
||||
no-prototype-builtins: 'off'
|
||||
no-async-promise-executor: 'off'
|
||||
no-case-declarations: 'off'
|
||||
no-return-assign: 'off'
|
||||
no-floating-decimal: error
|
||||
no-implied-eval: error
|
||||
arrow-spacing: error
|
||||
no-invalid-this: error
|
||||
no-lone-blocks: 'off'
|
||||
no-new-func: error
|
||||
no-new-wrappers: error
|
||||
no-new: error
|
||||
no-void: error
|
||||
no-octal-escape: error
|
||||
no-self-compare: error
|
||||
no-sequences: error
|
||||
no-throw-literal: error
|
||||
no-unmodified-loop-condition: error
|
||||
no-useless-call: error
|
||||
no-useless-concat: error
|
||||
no-useless-escape: error
|
||||
no-useless-return: error
|
||||
no-else-return: error
|
||||
no-lonely-if: error
|
||||
no-undef-init: error
|
||||
no-unneeded-ternary: error
|
||||
no-var: error
|
||||
no-multi-spaces: error
|
||||
no-multiple-empty-lines: ["error", { "max": 2, "maxEOF": 0 }]
|
||||
no-tabs: error
|
||||
no-trailing-spaces: error
|
||||
|
||||
brace-style: error
|
||||
new-parens: error
|
||||
space-infix-ops: error
|
||||
template-curly-spacing: error
|
||||
wrap-regex: error
|
||||
capitalized-comments: error
|
||||
prefer-template: error
|
||||
|
||||
keyword-spacing: ["error", { "before": true } ]
|
||||
array-bracket-spacing: ["error", "always"]
|
||||
arrow-parens: ["error", "always"]
|
||||
comma-dangle: ["error", "never"]
|
||||
comma-spacing: ["error", { "before": false, "after": true }]
|
||||
computed-property-spacing: ["error", "never"]
|
||||
func-call-spacing: ["error", "never"]
|
||||
indent: ["error", 2, { "SwitchCase": 1 }]
|
||||
key-spacing: ["error", { "beforeColon": false }]
|
||||
semi: ["error", "always"]
|
||||
operator-assignment: ["error", "always"]
|
||||
12
.github/FUNDING.yml
vendored
12
.github/FUNDING.yml
vendored
@@ -1,12 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: luanrt
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Use this template for requesting new features
|
||||
title: "[FEATURE NAME]"
|
||||
labels: enhancement
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Please describe the behavior you are expecting
|
||||
|
||||
## Current Behavior
|
||||
|
||||
What is the current behavior?
|
||||
|
||||
## Sample Code
|
||||
|
||||
If applicable, provide a sample code snippet that demonstrates the gist of the feature you're proposing. This can be either from a usage standpoint, or an implementation standpoint.
|
||||
38
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
38
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Issue Report
|
||||
about: Use this template to report a problem
|
||||
title: "[VERSION] [PROBLEM SUMMARY]"
|
||||
labels: bug
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Please describe the behavior you are expecting
|
||||
|
||||
## Current Behavior
|
||||
|
||||
What is the current behavior?
|
||||
|
||||
## Failure Information (for bugs)
|
||||
|
||||
Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
Please provide detailed steps for reproducing the issue.
|
||||
|
||||
1. step 1
|
||||
2. step 2
|
||||
3. you get it...
|
||||
|
||||
### Failure Logs
|
||||
|
||||
Please include any relevant log snippets or files here.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I am running the latest version
|
||||
- [ ] I checked the documentation and found no answer
|
||||
- [ ] I checked to make sure that this issue has not already been filed
|
||||
- [ ] I have provided sufficient information
|
||||
15
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
15
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: Question
|
||||
about: Use this template to ask a question about the project
|
||||
title: "[QUESTION SUMMARY]"
|
||||
labels: question
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Question
|
||||
|
||||
State your question
|
||||
|
||||
## Sample Code
|
||||
|
||||
Please include relevant code snippets or files that provide context for your question.
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +1 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
|
||||
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Feature request
|
||||
description: Use this template to suggest new features
|
||||
labels: [enhancement]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Describe your suggestion
|
||||
placeholder: How would it work?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I am running the latest version.
|
||||
required: true
|
||||
- label: I checked the documentation and found no answer.
|
||||
required: true
|
||||
- label: I have searched the existing issues and made sure this is not a duplicate.
|
||||
required: true
|
||||
- label: I have provided sufficient information.
|
||||
required: true
|
||||
77
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
77
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Issue Report
|
||||
title: "<version> <title>"
|
||||
description: Use this template to report a problem
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Please provide detailed steps for reproducing the issue.
|
||||
placeholder: |
|
||||
Example:
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
3. You get it..
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: failure-logs
|
||||
attributes:
|
||||
label: Failure Logs
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: current-behavior
|
||||
attributes:
|
||||
label: Current behavior
|
||||
description: What is the current behavior?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of the library are you running?
|
||||
options:
|
||||
- Default
|
||||
- Edge
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I am running the latest version.
|
||||
required: false
|
||||
- label: I checked the documentation and found no answer.
|
||||
required: true
|
||||
- label: I have searched the existing issues and made sure this is not a duplicate.
|
||||
required: true
|
||||
- label: I have provided sufficient information.
|
||||
required: true
|
||||
33
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Question
|
||||
description: Use this template to ask a question about the project
|
||||
labels: [question]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
placeholder: What do you want to know?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I am running the latest version.
|
||||
required: true
|
||||
- label: I checked the documentation and found no answer.
|
||||
required: true
|
||||
- label: I have searched the existing issues and made sure this is not a duplicate.
|
||||
required: true
|
||||
- label: I have provided sufficient information.
|
||||
required: true
|
||||
59
.github/labeler_config.yml
vendored
Normal file
59
.github/labeler_config.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
version: 1
|
||||
labels:
|
||||
- label: "breaking-change"
|
||||
title: "^refactor!:.*"
|
||||
|
||||
- label: "enhancement"
|
||||
title: "^feat:.*"
|
||||
|
||||
- label: "bug"
|
||||
title: "^fix:.*"
|
||||
|
||||
- label: "github"
|
||||
files:
|
||||
- ".github/.*"
|
||||
|
||||
- label: "git"
|
||||
files:
|
||||
- ".gitignore"
|
||||
- ".gitattributes"
|
||||
|
||||
- label: "tests"
|
||||
files:
|
||||
- "test/.*"
|
||||
|
||||
- label: "docs"
|
||||
files:
|
||||
- "docs/.*"
|
||||
- "README.md"
|
||||
|
||||
- label: "parser"
|
||||
files:
|
||||
- "src/parser/.*"
|
||||
- "src/Innertube.ts"
|
||||
|
||||
- label: "core"
|
||||
files:
|
||||
- "src/core/.*"
|
||||
|
||||
- label: "protobuf"
|
||||
files:
|
||||
- "src/proto/.*"
|
||||
|
||||
- label: "xsmall-diff"
|
||||
size-below: 10
|
||||
|
||||
- label: "small-diff"
|
||||
size-above: 9
|
||||
size-below: 100
|
||||
|
||||
- label: "medium-diff"
|
||||
size-above: 99
|
||||
size-below: 500
|
||||
|
||||
- label: "large-diff"
|
||||
size-above: 499
|
||||
size-below: 1000
|
||||
|
||||
- label: "xlarge-diff"
|
||||
size-above: 999
|
||||
20
.github/release.yml
vendored
Normal file
20
.github/release.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
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:
|
||||
- "*"
|
||||
16
.github/workflows/labeler.yml
vendored
Normal file
16
.github/workflows/labeler.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Label PRs
|
||||
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: srvaroa/labeler@master
|
||||
with:
|
||||
config_path: .github/labeler_config.yml
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
17
.github/workflows/lint.yml
vendored
Normal file
17
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Lint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
|
||||
- name: npm install and lint
|
||||
run: |
|
||||
npm install
|
||||
npm run lint
|
||||
9
.github/workflows/node.js.yml
vendored
9
.github/workflows/node.js.yml
vendored
@@ -1,7 +1,4 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Build
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -16,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [ 14.x, 15.x, 16.x ]
|
||||
node-version: [ 16.x, 18.x ]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
@@ -26,4 +23,4 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
- run: npm run test
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -15,5 +15,5 @@ jobs:
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.'
|
||||
stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. Remove the stale label or comment or this will be closed in 2 days'
|
||||
days-before-stale: 6
|
||||
days-before-close: 2
|
||||
days-before-stale: 60
|
||||
days-before-close: 4
|
||||
71
.gitignore
vendored
Normal file
71
.gitignore
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
# YouTube player cache directory
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# pnpm
|
||||
.pnpm-debug.log*
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Downloaded assets
|
||||
*.mp4
|
||||
*.webm
|
||||
*.mkv
|
||||
|
||||
# Temporary files for testing
|
||||
tmp/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
bundle/*.js.*
|
||||
bundle/*.js
|
||||
|
||||
# MacOS
|
||||
.DS_Store
|
||||
6
.npmignore
Normal file
6
.npmignore
Normal file
@@ -0,0 +1,6 @@
|
||||
**
|
||||
|
||||
src/
|
||||
!dist/**
|
||||
!README.md
|
||||
!bundle/**
|
||||
81
CONTRIBUTING.md
Normal file
81
CONTRIBUTING.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Contributing to YouTube.js
|
||||
|
||||
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](#build)
|
||||
|
||||
## 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.
|
||||
|
||||
<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. Documentation updates and grammar fixes are also appreciated!
|
||||
|
||||
<a id="changes"></a>
|
||||
## Make changes
|
||||
|
||||
1. Fork the repository
|
||||
2. Install or update to **Node.js v16**
|
||||
3. Create a working branch and start with your changes!
|
||||
|
||||
<a id="changes-1"></a>
|
||||
#### Commit your updates
|
||||
|
||||
Commit the changes once you're happy with them.
|
||||
|
||||
<a id="changes-2"></a>
|
||||
#### Pull Request
|
||||
|
||||
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.
|
||||
|
||||
- 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
|
||||
npm run test
|
||||
```
|
||||
|
||||
<a id="lint"></a>
|
||||
#### Lint
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
<a id="build"></a>
|
||||
#### Build
|
||||
|
||||
```bash
|
||||
# Node
|
||||
npm run build:node
|
||||
|
||||
# Browser
|
||||
npm run build:browser
|
||||
npm run build:browser:prod
|
||||
|
||||
# Protobuf
|
||||
npm run build:proto
|
||||
|
||||
# Parser map
|
||||
npm run build:parser-map
|
||||
```
|
||||
11
browser.ts
Normal file
11
browser.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// 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 as Session } from './src/core/Session';
|
||||
export { default as Player } from './src/core/Player';
|
||||
export default Innertube;
|
||||
1
bundle/browser.d.ts
vendored
Normal file
1
bundle/browser.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../dist/browser';
|
||||
112
docs/API/account.md
Normal file
112
docs/API/account.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Account
|
||||
|
||||
YouTube account manager.
|
||||
|
||||
## API
|
||||
|
||||
* Account
|
||||
* [.channel](#channel)
|
||||
* [.getInfo()](#getinfo)
|
||||
* [.getTimeWatched()](#gettimewatched)
|
||||
* [.getSettings()](#getsettings)
|
||||
* [.getAnalytics](#getanalytics)
|
||||
|
||||
<a name="channel"></a>
|
||||
### channel
|
||||
|
||||
Channel settings.
|
||||
|
||||
**Returns:** `object`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<channel>#editName(new_name)`
|
||||
- Edits the name of the channel.
|
||||
|
||||
- `<channel>#editDescription(new_description)`
|
||||
- Edits channel description.
|
||||
|
||||
- `<channel>#getBasicAnalytics()`
|
||||
- Alias for [`Account#getAnalytics()`](#getanalytics) — returns basic channel analytics.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getinfo"></a>
|
||||
### getInfo()
|
||||
|
||||
Retrieves account information.
|
||||
|
||||
**Returns:** `Promise.<AccountInfo>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<accountinfo>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="gettimewatched"></a>
|
||||
### getTimeWatched()
|
||||
|
||||
Retrieves time watched statistics.
|
||||
|
||||
**Returns:** `Promise.<TimeWatched>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<timewatched>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getsettings"></a>
|
||||
### getSettings()
|
||||
|
||||
Retrieves YouTube settings.
|
||||
|
||||
**Returns:** `Promise.<Settings>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<settings>#selectSidebarItem(name)`
|
||||
- Selects an item from the sidebar menu. Use `settings#sidebar_items` to see available items.
|
||||
|
||||
- `<settings>#getSettingOption(name)`
|
||||
- Finds a setting by name and returns it. Use `settings#setting_options` to see available options.
|
||||
|
||||
- `<settings>#setting_options`
|
||||
- Returns settings available in the page.
|
||||
|
||||
- `<settings>#sidebar_items`
|
||||
- Returns options available in the sidebar menu.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getanalytics"></a>
|
||||
### getAnalytics()
|
||||
|
||||
Retrieves basic channel analytics.
|
||||
|
||||
**Returns:** `Promise.<Analytics>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<analytics>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
108
docs/API/interaction-manager.md
Normal file
108
docs/API/interaction-manager.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# InteractionManager
|
||||
|
||||
Handles direct interactions.
|
||||
|
||||
## API
|
||||
|
||||
* InteractionManager
|
||||
* [.like(video_id)](#like)
|
||||
* [.dislike(video_id)](#dislike)
|
||||
* [.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)
|
||||
|
||||
<a name="like"></a>
|
||||
### like(video_id)
|
||||
|
||||
Likes given video.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="dislike"></a>
|
||||
### dislike(video_id)
|
||||
|
||||
Dislikes given video.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="removerating"></a>
|
||||
### removeLike(video_id)
|
||||
|
||||
Remover like/dislike.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="subscribe"></a>
|
||||
### subscribe(channel_id)
|
||||
|
||||
Subscribes to given channel.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | Channel id |
|
||||
|
||||
<a name="unsubscribe"></a>
|
||||
### unsubscribe(channel_id)
|
||||
|
||||
Unsubscribes from given channel.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | Channel id |
|
||||
|
||||
<a name="comment"></a>
|
||||
### comment(video_id, text)
|
||||
|
||||
Posts a comment on given video.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| text | `string` | Comment content |
|
||||
|
||||
<a name="translate"></a>
|
||||
### translate(text, target_language, args?)
|
||||
|
||||
Translates given text using YouTube's comment translation feature.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| text | `string` | Text to be translated |
|
||||
| target_language | `string` | ISO language code |
|
||||
| args? | `object` | Additional arguments |
|
||||
|
||||
<a name="setnotificationpreferences"></a>
|
||||
### setNotificationPreferences(channel_id, type)
|
||||
|
||||
Changes notification preferences for a given channel.
|
||||
Only works with channels you are subscribed to.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | Channel id |
|
||||
| type | `string` | `PERSONALIZED`, `ALL` or `NONE` |
|
||||
320
docs/API/music.md
Normal file
320
docs/API/music.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Music
|
||||
|
||||
YouTube Music class.
|
||||
|
||||
## API
|
||||
|
||||
* Music
|
||||
* [.getInfo(target)](#getinfo)
|
||||
* [.search(query, filters?)](#search)
|
||||
* [.getHomeFeed()](#gethomefeed)
|
||||
* [.getExplore()](#getexplore)
|
||||
* [.getLibrary()](#getlibrary)
|
||||
* [.getArtist(artist_id)](#getartist)
|
||||
* [.getAlbum(album_id)](#getalbum)
|
||||
* [.getPlaylist(playlist_id)](#getplaylist)
|
||||
* [.getLyrics(video_id)](#getlyrics)
|
||||
* [.getUpNext(video_id, automix?)](#getupnext)
|
||||
* [.getRelated(video_id)](#getrelated)
|
||||
* [.getRecap()](#getrecap)
|
||||
* [.getSearchSuggestions(query)](#getsearchsuggestions)
|
||||
|
||||
<a name="getinfo"></a>
|
||||
### getInfo(target)
|
||||
|
||||
Retrieves track info.
|
||||
|
||||
**Returns:** `Promise.<TrackInfo>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| target | `string` or `MusicTwoRowItem` | video id or list item |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<info>#getTab(title)`
|
||||
- Retrieves contents of the given tab.
|
||||
|
||||
- `<info>#getUpNext(automix?)`
|
||||
- Retrieves up next.
|
||||
|
||||
- `<info>#getRelated()`
|
||||
- Retrieves related content.
|
||||
|
||||
- `<info>#getLyrics()`
|
||||
- Retrieves song lyrics.
|
||||
|
||||
- `<info>#available_tabs`
|
||||
- Returns available tabs.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="search"></a>
|
||||
### search(query, filters?)
|
||||
|
||||
Searches on YouTube Music.
|
||||
|
||||
**Returns:** `Promise.<Search>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| query | `string` | Search query |
|
||||
| filters? | `object` | Search filters |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<search>#getMore(shelf)`
|
||||
- Equivalent to clicking on the shelf to load more items.
|
||||
|
||||
- `<search>#getContinuation()`
|
||||
- Retrieves continuation, only works for individual sections or filtered results.
|
||||
|
||||
- `<search>#selectFilter(name)`
|
||||
- Applies given filter to the search.
|
||||
|
||||
- `<search>#has_continuation`
|
||||
- Checks if continuation is available.
|
||||
|
||||
- `<search>#filters`
|
||||
- Returns available filters.
|
||||
|
||||
- `<search>#songs`
|
||||
- Returns songs shelf.
|
||||
|
||||
- `<search>#videos`
|
||||
- Returns videos shelf.
|
||||
|
||||
- `<search>#albums`
|
||||
- Returns albums shelf.
|
||||
|
||||
- `<search>#artists`
|
||||
- Returns artists shelf.
|
||||
|
||||
- `<search>#playlists`
|
||||
- Returns songs shelf.
|
||||
|
||||
- `<search>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="gethomefeed"></a>
|
||||
### getHomeFeed()
|
||||
|
||||
Retrieves home feed.
|
||||
|
||||
**Returns:** `Promise.<HomeFeed>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<homefeed>#getContinuation()`
|
||||
- Retrieves continuation, only works for individual sections or filtered results.
|
||||
|
||||
- `<homefeed>#has_continuation`
|
||||
- Checks if continuation is available.
|
||||
|
||||
- `<homefeed>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getexplore"></a>
|
||||
### getExplore()
|
||||
|
||||
Retrieves “Explore” feed.
|
||||
|
||||
**Returns:** `Promise.<Explore>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<explore>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getlibrary"></a>
|
||||
### getLibrary()
|
||||
|
||||
Retrieves library.
|
||||
|
||||
**Returns:** `Library`
|
||||
|
||||
<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 original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getartist"></a>
|
||||
### getArtist(artist_id)
|
||||
|
||||
Retrieves artist's info & content.
|
||||
|
||||
**Returns:** `Promise.<Artist>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| artist_id | `string` | Artist id |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<artist>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getalbum"></a>
|
||||
### getAlbum(album_id)
|
||||
|
||||
Retrieves given album.
|
||||
|
||||
**Returns:** `Promise.<Album>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| album_id | `string` | Album id |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<album>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getplaylist"></a>
|
||||
### getPlaylist(playlist_id)
|
||||
|
||||
Retrieves given playlist.
|
||||
|
||||
**Returns:** `Promise.<Playlist>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| playlist_id | `string` | Playlist id |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<playlist>#getRelated()`
|
||||
- Retrieves related playlists.
|
||||
|
||||
- `<playlist>#getSuggestions()`
|
||||
- Retrieves playlist suggestions.
|
||||
|
||||
- `<playlist>#getContinuation()`
|
||||
- Retrieves continuation.
|
||||
|
||||
- `<playlist>#has_continuation`
|
||||
- Checks if continuation is available.
|
||||
|
||||
- `<playlist>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getlyrics"></a>
|
||||
### getLyrics(video_id)
|
||||
|
||||
Retrieves song lyrics.
|
||||
|
||||
**Returns:** `Promise.<MusicDescriptionShelf | undefined>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="getupnext"></a>
|
||||
### getUpNext(video_id, automix?)
|
||||
|
||||
Retrieves up next content.
|
||||
|
||||
**Returns:** `Promise.<PlaylistPanel>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| automix? | `boolean` | if automix should be fetched |
|
||||
|
||||
<a name="getrelated"></a>
|
||||
### getRelated(video_id)
|
||||
|
||||
Retrieves related content.
|
||||
|
||||
**Returns:** `Promise.<Array.<MusicCarouselShelf | MusicDescriptionShelf>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="getrecap"></a>
|
||||
### getRecap()
|
||||
|
||||
Retrieves your YouTube Music recap.
|
||||
|
||||
**Returns:** `Promise.<Recap>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<recap>#getPlaylist()`
|
||||
- Retrieves recap playlist.
|
||||
|
||||
- `<recap>#page`
|
||||
- Returns original InnerTube response (sanitized).
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getsearchsuggestions"></a>
|
||||
### getSearchSuggestions(query)
|
||||
|
||||
Retrieves search suggestions.
|
||||
|
||||
**Returns:** `Promise.<Array.<SearchSuggestion | HistorySuggestion>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| query | `string` | Search query |
|
||||
72
docs/API/playlist.md
Normal file
72
docs/API/playlist.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# PlaylistManager
|
||||
|
||||
Playlist management class.
|
||||
|
||||
## API
|
||||
|
||||
* PlaylistManager
|
||||
* [.create(title, video_ids)](#create)
|
||||
* [.delete(playlist_id)](#delete)
|
||||
* [.addVideos(playlist_id, video_ids)](#addvideos)
|
||||
* [.removeVideos(playlist_id, video_ids)](#removevideos)
|
||||
* [.moveVideo(playlist_id, moved_video_id, predecessor_video_id)](#movevideo)
|
||||
|
||||
<a name="create"></a>
|
||||
### create(title, video_ids)
|
||||
|
||||
Creates a playlist.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| title | `string` | Playlist name |
|
||||
| video_ids | `string[]` | array of videos |
|
||||
|
||||
<a name="delete"></a>
|
||||
### delete(playlist_id)
|
||||
|
||||
Deletes given playlist.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| playlist_id | `string` | Playlist id |
|
||||
|
||||
<a name="addvideos"></a>
|
||||
### addVideos(playlist_id, video_ids)
|
||||
|
||||
Adds videos to given playlist.
|
||||
|
||||
**Returns:** `Promise.<{ playlist_id: string; action_result: any[] }>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| playlist_id | `string` | Playlist id |
|
||||
| video_ids | `string` | array of videos |
|
||||
|
||||
<a name="removevideos"></a>
|
||||
### removeVideos(playlist_id, video_ids)
|
||||
|
||||
Removes videos from given playlist.
|
||||
|
||||
**Returns:** `Promise.<{ playlist_id: string; action_result: any[] }>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| playlist_id | `string` | Playlist id |
|
||||
| video_ids | `string` | array of videos |
|
||||
|
||||
<a name="movevideo"></a>
|
||||
### moveVideo(playlist_id, moved_video_id, predecessor_video_id)
|
||||
|
||||
Moves a video to a new position within a given playlist.
|
||||
|
||||
**Returns:** `Promise.<{ playlist_id: string; action_result: any[] }>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
83
docs/API/session.md
Normal file
83
docs/API/session.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Session
|
||||
|
||||
Represents an InnerTube session.
|
||||
|
||||
## API
|
||||
|
||||
* Session
|
||||
* [.signIn(credentials?)](#signin) ⇒ `function`
|
||||
* [.signOut()](#signout) ⇒ `function`
|
||||
* [.key](#key) ⇒ `getter`
|
||||
* [.api_version](#api_version) ⇒ `getter`
|
||||
* [.client_version](#client_version) ⇒ `getter`
|
||||
* [.client_name](#client_name) ⇒ `getter`
|
||||
* [.context](#context) ⇒ `getter`
|
||||
* [.player](#player) ⇒ `getter`
|
||||
* [.lang](#lang) ⇒ `getter`
|
||||
|
||||
<a name="signin"></a>
|
||||
### signIn(credentials?)
|
||||
|
||||
Signs in with given credentials.
|
||||
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| credentials? | `Credentials` | OAuth credentials |
|
||||
|
||||
<a name="signout"></a>
|
||||
### signOut()
|
||||
|
||||
Signs out of the current account.
|
||||
|
||||
**Returns:** `Promise<ActionsResponse>`
|
||||
|
||||
<a name="key"></a>
|
||||
### key
|
||||
|
||||
InnerTube API key.
|
||||
|
||||
**Returns:** `string`
|
||||
|
||||
<a name="api_version"></a>
|
||||
### api_version
|
||||
|
||||
InnerTube API version.
|
||||
|
||||
**Returns:** `string`
|
||||
|
||||
<a name="client_version"></a>
|
||||
### client_version
|
||||
|
||||
InnerTube client version.
|
||||
|
||||
**Returns:** `string`
|
||||
|
||||
<a name="client_name"></a>
|
||||
### client_name
|
||||
|
||||
InnerTube client name.
|
||||
|
||||
**Returns:** `string`
|
||||
|
||||
<a name="context"></a>
|
||||
### context
|
||||
|
||||
InnerTube context.
|
||||
|
||||
**Returns:** `Context`
|
||||
|
||||
<a name="player"></a>
|
||||
### player
|
||||
|
||||
Player script object.
|
||||
|
||||
**Returns:** `Player`
|
||||
|
||||
<a name="lang"></a>
|
||||
### lang
|
||||
|
||||
Client language.
|
||||
|
||||
**Returns:** `string`
|
||||
46
docs/API/studio.md
Normal file
46
docs/API/studio.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Studio
|
||||
|
||||
YouTube Studio class (WIP).
|
||||
|
||||
## API
|
||||
|
||||
* Studio
|
||||
* [.setThumbnail(video_id, buffer)](#setthumbnail)
|
||||
* [.updateVideoMetadata(video_id, metadata)](#updatemetadata)
|
||||
* [.upload(file, metadata)](#upload)
|
||||
|
||||
<a name="setthumbnail"></a>
|
||||
### setThumbnail(video_id, buffer)
|
||||
|
||||
Uploads a custom thumbnail and sets it for a video.
|
||||
|
||||
**Returns:** `Promise.<ActionsResponse>`
|
||||
|
||||
| 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.<ActionsResponse>`
|
||||
|
||||
| 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>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| file | `BodyInit` | Video file |
|
||||
| metadata | `UploadedVideoMetadata` | Video metadata |
|
||||
54
docs/updating-the-parser.md
Normal file
54
docs/updating-the-parser.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Updating the parser
|
||||
|
||||
YouTube is constantly changing, so it is not rare 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 (a.k.a YTNodes) dynamically without hard-coding their path in the response. This way, whenever a new renderer pops up (e.g; YouTube adds a new feature / minor UI changes) the library will print a warning similar to this:
|
||||
```
|
||||
InnertubeError: 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!
|
||||
at Parser.printError (...)
|
||||
at Parser.parseItem (...)
|
||||
at Parser.parseArray (...) {
|
||||
info: {
|
||||
// renderer data, can be used as a reference to implement the renderer parser
|
||||
},
|
||||
date: 2022-05-22T22:16:06.831Z,
|
||||
version: '2.2.3'
|
||||
}
|
||||
```
|
||||
|
||||
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, say we found a new renderer named `verticalListRenderer`, to let the parser know it exists we would have to create a file with the following structure:
|
||||
|
||||
> `../classes/VerticalList.ts`
|
||||
```ts
|
||||
import Parser from '..';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class VerticalList extends YTNode {
|
||||
static type = 'VerticalList';
|
||||
|
||||
header;
|
||||
contents;
|
||||
|
||||
constructor(data: any) {
|
||||
// parse the data here, ex;
|
||||
this.header = Parser.parseItem(data.header);
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
}
|
||||
}
|
||||
|
||||
export default VerticalList;
|
||||
```
|
||||
|
||||
Then update the parser map:
|
||||
```bash
|
||||
npm run build:parser-map
|
||||
```
|
||||
|
||||
And that's it!
|
||||
49
examples/auth/README.md
Normal file
49
examples/auth/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Authentication via OAuth
|
||||
|
||||
## Usage
|
||||
|
||||
Before using any methods which require authentication, you have to authenticate the session:
|
||||
|
||||
```js
|
||||
// 'auth-pending' is fired with the info needed to sign in via OAuth.
|
||||
yt.session.on('auth-pending', (data) => {
|
||||
// data.verification_url contains the URL to visit to authenticate.
|
||||
// data.user_code contains the code to enter on the website.
|
||||
});
|
||||
|
||||
// 'auth' is fired once the authentication is complete
|
||||
yt.session.on('auth', ({ credentials }) => {
|
||||
// do something with the credentials, eg; save them in a database.
|
||||
console.log('Sign in successful');
|
||||
});
|
||||
|
||||
// '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 }) => {
|
||||
// do something with the updated credentials
|
||||
});
|
||||
|
||||
await yt.session.signIn(/* credentials */);
|
||||
```
|
||||
|
||||
### Cache Credentials
|
||||
|
||||
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.
|
||||
|
||||
```js
|
||||
// If you use this, the next call to signIn won't fire 'auth-pending' instead just 'auth'
|
||||
await yt.session.oauth.cacheCredentials();
|
||||
```
|
||||
|
||||
**Note:** When using cached credentials, you are still required to make a call to `Session#signIn()`.
|
||||
|
||||
### Sign Out
|
||||
|
||||
The sign out method may be used to sign out of the current session. This should also remove the cached credentials.
|
||||
|
||||
```js
|
||||
await yt.session.signOut();
|
||||
|
||||
// if you don't want to sign out of the current session
|
||||
// and only want to delete the cached credentials, use:
|
||||
await yt.session.oauth.removeCache();
|
||||
```
|
||||
37
examples/auth/index.js
Normal file
37
examples/auth/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const { Innertube, UniversalCache } = require('youtubei.js');
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({
|
||||
// required if you wish to use OAuth#cacheCredentials
|
||||
cache: new UniversalCache()
|
||||
});
|
||||
|
||||
// 'auth-pending' is fired with the info needed to sign in via OAuth.
|
||||
yt.session.on('auth-pending', (data) => {
|
||||
console.log(`Go to ${data.verification_url} in your browser and enter code ${data.user_code} to authenticate.`);
|
||||
});
|
||||
|
||||
// 'auth' is fired once the authentication is complete
|
||||
yt.session.on('auth', ({ credentials }) => {
|
||||
console.log('Sign in successful:', credentials);
|
||||
});
|
||||
|
||||
// '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 }) => {
|
||||
console.log('Credentials updated:', credentials);
|
||||
});
|
||||
|
||||
// Attempt to sign in
|
||||
await yt.session.signIn();
|
||||
|
||||
// ... do something after sign in
|
||||
|
||||
// You may cache the session for later use
|
||||
// If you use this, the next call to signIn won't fire 'auth-pending' instead just 'auth'.
|
||||
await yt.session.oauth.cacheCredentials();
|
||||
|
||||
|
||||
// Sign out of the session
|
||||
// this will also remove the cached credentials
|
||||
await yt.session.signOut();
|
||||
})();
|
||||
61
examples/browser/README.md
Normal file
61
examples/browser/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Browser Usage Example
|
||||
|
||||
YouTube.js works in the browser!
|
||||
|
||||
## How to use
|
||||
|
||||
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`.
|
||||
|
||||
We'll use our own fetch implementation to proxy requests through our server. This is a simple example, but you can use any fetch implementation you want.
|
||||
|
||||
```ts
|
||||
import { Innertube } from "youtubei.js/build/browser";
|
||||
|
||||
const yt = await Innertube.create({
|
||||
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.
|
||||
|
||||
## Example
|
||||
|
||||
We've got a full example in `examples/browser/web` using vite.
|
||||
84
examples/browser/proxy/deno.ts
Normal file
84
examples/browser/proxy/deno.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { serve } from 'https://deno.land/std@0.148.0/http/server.ts';
|
||||
|
||||
const port = 8080;
|
||||
|
||||
function copyHeader(headerName: string, to: Headers, from: Headers) {
|
||||
const hdrVal = from.get(headerName);
|
||||
if (hdrVal) {
|
||||
to.set(headerName, hdrVal);
|
||||
}
|
||||
}
|
||||
|
||||
const handler = async (request: Request): Promise<Response> => {
|
||||
// if options send do CORS preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
const response = new Response('', {
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'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, Referer',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
}),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
const url = new URL(request.url, `http://localhost/`);
|
||||
if (!url.searchParams.has('__host')) {
|
||||
return new Response(
|
||||
'Request is formatted incorrectly. Please include __host in the query string.',
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Set the URL host to the __host parameter
|
||||
url.host = url.searchParams.get('__host')!;
|
||||
url.protocol = 'https';
|
||||
url.port = '443';
|
||||
url.searchParams.delete('__host');
|
||||
|
||||
// Copy headers from the request to the new request
|
||||
const request_headers = new Headers(
|
||||
JSON.parse(url.searchParams.get('__headers') || '{}'),
|
||||
);
|
||||
copyHeader('range', 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
|
||||
const fetchRes = await fetch(url, {
|
||||
method: request.method,
|
||||
headers: request_headers,
|
||||
body: request.body,
|
||||
});
|
||||
|
||||
// Construct the return headers
|
||||
const headers = new Headers();
|
||||
|
||||
// copy content headers
|
||||
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(
|
||||
'Access-Control-Allow-Origin',
|
||||
request.headers.get('origin') || '*',
|
||||
);
|
||||
headers.set('Access-Control-Allow-Headers', '*');
|
||||
headers.set('Access-Control-Allow-Methods', '*');
|
||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// Return the proxied response
|
||||
return new Response(fetchRes.body, {
|
||||
status: fetchRes.status,
|
||||
headers: headers,
|
||||
});
|
||||
};
|
||||
|
||||
await serve(handler, { port });
|
||||
24
examples/browser/web/.gitignore
vendored
Normal file
24
examples/browser/web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
15
examples/browser/web/favicon.svg
Normal file
15
examples/browser/web/favicon.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
|
||||
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
20
examples/browser/web/index.html
Normal file
20
examples/browser/web/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!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>
|
||||
18
examples/browser/web/package.json
Normal file
18
examples/browser/web/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dashjs": "^4.4.0"
|
||||
}
|
||||
}
|
||||
3
examples/browser/web/public/service-worker.js
Normal file
3
examples/browser/web/public/service-worker.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable */
|
||||
var u=new Set(["www.youtube.com","music.youtube.com","suggestqueries.google.com","youtubei.googleapis.com","youtubei.googleapis.com","green-youtubei.sandbox.googleapis.com","release-youtubei.sandbox.googleapis.com","test-youtubei.sandbox.googleapis.com","cami-youtubei.sandbox.googleapis.com","uytfe.sandbox.google.com"]);self.addEventListener("fetch",o=>{try{let s=new URL(o.request.url).hostname;if(!u.has(s))return}catch(s){return}let e=new URL(o.request.url);e.searchParams.set("__host",e.host),e.host=e.searchParams.get("__proxy");let t=new Request(e,o.request);o.respondWith(fetch(t))});
|
||||
//# sourceMappingURL=service-worker.js.map
|
||||
7
examples/browser/web/public/service-worker.js.map
Normal file
7
examples/browser/web/public/service-worker.js.map
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../browser/client/service-worker.ts"],
|
||||
"sourcesContent": ["// We need to proxy requests to youtube to our own server to avoid CORS issues\n\n/// <reference lib=\"WebWorker\" />\n\n// export empty type because of tsc --isolatedModules flag\nexport type {};\ndeclare const self: ServiceWorkerGlobalScope;\n\nconst hosts = new Set([\n \"www.youtube.com\",\n \"music.youtube.com\",\n \"suggestqueries.google.com\",\n \"youtubei.googleapis.com\",\n \"youtubei.googleapis.com\",\n \"green-youtubei.sandbox.googleapis.com\",\n \"release-youtubei.sandbox.googleapis.com\",\n \"test-youtubei.sandbox.googleapis.com\",\n \"cami-youtubei.sandbox.googleapis.com\",\n \"uytfe.sandbox.google.com\"\n]);\n\nself.addEventListener('fetch', event => {\n try {\n const host = new URL(event.request.url).hostname;\n if (!hosts.has(host))\n return;\n } catch {\n return;\n }\n const url = new URL(event.request.url);\n url.searchParams.set('__host', url.host);\n url.host = url.searchParams.get('__proxy')!;\n\n // we should proxy this to our own server\n const request = new Request(url, event.request);\n\n event.respondWith(fetch(request));\n});\n"],
|
||||
"mappings": ";AAQA,GAAM,GAAQ,GAAI,KAAI,CAClB,kBACA,oBACA,4BACA,0BACA,0BACA,wCACA,0CACA,uCACA,uCACA,0BACJ,CAAC,EAED,KAAK,iBAAiB,QAAS,GAAS,CACpC,GAAI,CACA,GAAM,GAAO,GAAI,KAAI,EAAM,QAAQ,GAAG,EAAE,SACxC,GAAI,CAAC,EAAM,IAAI,CAAI,EACf,MACR,OAAQ,EAAN,CACE,MACJ,CACA,GAAM,GAAO,GAAI,KAAI,EAAM,QAAQ,GAAG,EACtC,EAAI,aAAa,IAAI,SAAU,EAAI,IAAI,EACvC,EAAI,KAAO,EAAI,aAAa,IAAI,SAAS,EAGzC,GAAM,GAAU,GAAI,SAAQ,EAAK,EAAM,OAAO,EAE9C,EAAM,YAAY,MAAM,CAAO,CAAC,CACpC,CAAC",
|
||||
"names": []
|
||||
}
|
||||
1
examples/browser/web/public/vite.svg
Normal file
1
examples/browser/web/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
99
examples/browser/web/src/main.ts
Normal file
99
examples/browser/web/src/main.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import './style.css';
|
||||
import { Innertube, UniversalCache } from '../../../../bundle/browser';
|
||||
import dashjs from 'dashjs';
|
||||
|
||||
async function main() {
|
||||
const yt = await Innertube.create({
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
// 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(),
|
||||
});
|
||||
|
||||
const span = document.getElementById('video_name') as HTMLSpanElement;
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
|
||||
span.textContent = 'Library ready';
|
||||
|
||||
let player: dashjs.MediaPlayerClass | undefined;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
span.textContent = 'Loading...';
|
||||
|
||||
const video_id = document.querySelector<HTMLInputElement>(
|
||||
'input[type=text]',
|
||||
)?.value;
|
||||
if (!video_id) {
|
||||
span.textContent = 'No video id';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const video = await yt.getInfo(video_id);
|
||||
|
||||
console.log(video);
|
||||
span.textContent = video.basic_info.title || null;
|
||||
|
||||
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();
|
||||
}
|
||||
player = dashjs.MediaPlayer().create();
|
||||
player.initialize(video_element, uri, true);
|
||||
} catch (error) {
|
||||
span.textContent = 'An error occurred (see console)';
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
12
examples/browser/web/src/style.css
Normal file
12
examples/browser/web/src/style.css
Normal file
@@ -0,0 +1,12 @@
|
||||
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
examples/browser/web/src/vite-env.d.ts
vendored
Normal file
1
examples/browser/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
examples/browser/web/tsconfig.json
Normal file
20
examples/browser/web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
50
examples/channel/basic-info.ts
Normal file
50
examples/channel/basic-info.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
|
||||
const channel = await yt.getChannel('UCX6OQ3DkcsbYNE6H8uQQuVA');
|
||||
|
||||
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('\nVideos:');
|
||||
const videos = await channel.getVideos();
|
||||
|
||||
for (const video of videos.videos) {
|
||||
console.info('Video:', video.title.toString());
|
||||
}
|
||||
|
||||
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('\nChannels:');
|
||||
const channels = await channel.getChannels();
|
||||
|
||||
for (const channel of channels.channels) {
|
||||
console.info('Channel:', channel.author.name);
|
||||
}
|
||||
|
||||
console.info('\nCommunity posts:');
|
||||
const posts = await channel.getCommunity();
|
||||
|
||||
for (const post of posts.posts) {
|
||||
console.info('Post:', post.content.toString().substring(0, 20) + '...');
|
||||
}
|
||||
})();
|
||||
34
examples/comments/Comment.md
Normal file
34
examples/comments/Comment.md
Normal file
@@ -0,0 +1,34 @@
|
||||
## 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
|
||||
|
||||
* Comment
|
||||
* [.like](#like) ⇒ `function`
|
||||
* [.dislike](#dislike) ⇒ `function`
|
||||
* [.reply](#comment) ⇒ `function`
|
||||
* [.translate](#translate) ⇒ `function`
|
||||
|
||||
<a name="like"></a>
|
||||
### like()
|
||||
Likes the comment.
|
||||
|
||||
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object }>`
|
||||
|
||||
<a name="dislike"></a>
|
||||
### dislike()
|
||||
Dislikes the comment.
|
||||
|
||||
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object }>`
|
||||
|
||||
<a name="reply"></a>
|
||||
### reply(text)
|
||||
Creates a reply to the comment. **Note:** To create a top-level comment, use the [`Comments#comment(text)`](./README.md#comment) method.
|
||||
|
||||
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object }>`
|
||||
|
||||
<a name="translate"></a>
|
||||
### translate(target_language)
|
||||
Translates the comment to the given language.
|
||||
|
||||
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object, content: string }>`
|
||||
50
examples/comments/CommentThread.md
Normal file
50
examples/comments/CommentThread.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# CommentThread
|
||||
|
||||
A `CommentThread` represents a top-level comment and its replies.
|
||||
|
||||
## API
|
||||
|
||||
* CommentThread
|
||||
* [.comment](#comment) ⇒ `Comment`
|
||||
* [.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`](../../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[]`](../../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>`](../../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>`](../../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`
|
||||
65
examples/comments/README.md
Normal file
65
examples/comments/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
## Comments
|
||||
YouTube.js has full support for comments, including comment actions such as translating, liking, disliking and replying.
|
||||
|
||||
## Usage
|
||||
Get a [`Comments`](../../src/parser/youtube/Comments.ts) instance:
|
||||
|
||||
```js
|
||||
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[]`](../../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)
|
||||
Creates a top-level comment.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| text | `string` | Comment content |
|
||||
|
||||
**Returns:** `Promise<ActionsResponse>`
|
||||
|
||||
<a name="getc"></a>
|
||||
### getContinuation()
|
||||
Retrieves next batch of comment threads.
|
||||
|
||||
**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
|
||||
Returns original InnerTube response (sanitized).
|
||||
|
||||
**Returns:** `ParsedResponse`
|
||||
|
||||
## Example
|
||||
See [`index.ts`]('./index.ts').
|
||||
45
examples/comments/index.ts
Normal file
45
examples/comments/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
|
||||
const comment_section = await yt.getComments('a-rqu-hjobc');
|
||||
|
||||
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}`, '\n'
|
||||
);
|
||||
|
||||
if (thread.has_replies) {
|
||||
console.info('Replies:', '\n');
|
||||
|
||||
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}`, '\n'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
comment_thread = await comment_thread.getContinuation();
|
||||
} catch { break; };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
}
|
||||
})();
|
||||
7
examples/deno/README.md
Normal file
7
examples/deno/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Deno example
|
||||
|
||||
Run this example with:
|
||||
|
||||
```
|
||||
deno run --allow-net --allow-write index.ts
|
||||
```
|
||||
16
examples/deno/index.ts
Normal file
16
examples/deno/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Innertube } from '../../bundle/browser.js';
|
||||
|
||||
const yt = await Innertube.create();
|
||||
|
||||
const video = await yt.getInfo('dQw4w9WgXcQ');
|
||||
|
||||
console.log('Video title is', video.basic_info.title);
|
||||
|
||||
const file = await Deno.open('test.mp4', {
|
||||
write: true,
|
||||
create: true,
|
||||
});
|
||||
|
||||
const stream = await video.download();
|
||||
|
||||
stream.pipeTo(file.writable);
|
||||
45
examples/download/index.ts
Normal file
45
examples/download/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
import { readFileSync, existsSync, mkdirSync, createWriteStream } from 'fs';
|
||||
import { streamToIterable } from 'youtubei.js/dist/src/utils/Utils';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
|
||||
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');
|
||||
|
||||
for (const song of album.contents) {
|
||||
const stream = await yt.download(song.id as string, {
|
||||
type: 'audio', // audio, video or audio+video
|
||||
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()}`;
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir);
|
||||
}
|
||||
|
||||
const file = createWriteStream(`${dir}/${song.title?.replace(/\//g, '')}.m4a`);
|
||||
|
||||
for await (const chunk of streamToIterable(stream)) {
|
||||
file.write(chunk);
|
||||
}
|
||||
|
||||
console.info(`${song.id} - Done!`, '\n');
|
||||
}
|
||||
|
||||
console.info(`Downloaded ${album.header?.song_count}!`);
|
||||
})();
|
||||
@@ -1,92 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
const creds_path = './yt_oauth_creds.json';
|
||||
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
|
||||
youtube.ev.on('auth', (data) => {
|
||||
if (data.status === 'AUTHORIZATION_PENDING') {
|
||||
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
|
||||
} else if (data.status === 'SUCCESS') {
|
||||
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Successfully signed-in, enjoy!');
|
||||
}
|
||||
});
|
||||
|
||||
youtube.ev.on('update-credentials', (data) => {
|
||||
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Credentials updated!', data);
|
||||
});
|
||||
|
||||
await youtube.signIn(creds);
|
||||
|
||||
const search = await youtube.search('Looking for life on Mars - documentary');
|
||||
console.info('Search results:', search);
|
||||
|
||||
const video = await youtube.getDetails(search.videos[0].id);
|
||||
console.info('Video details:', video);
|
||||
|
||||
if (youtube.logged_in) {
|
||||
const myNotifications = await youtube.getNotifications();
|
||||
console.info('My notifications:', myNotifications);
|
||||
|
||||
const like = await video.like();
|
||||
if (like.success) {
|
||||
console.info('Video marked as liked!');
|
||||
}
|
||||
|
||||
const dislike = await video.dislike();
|
||||
if (dislike.success) {
|
||||
console.info('Video marked as disliked!');
|
||||
}
|
||||
|
||||
const removeDislikeOrLike = await video.removeLike();
|
||||
if (removeDislikeOrLike.success) {
|
||||
console.info('Removed the dislike/like!')
|
||||
}
|
||||
|
||||
const myComment = await video.comment('Haha, nice!');
|
||||
if (myComment.success) {
|
||||
console.info('Comment successfully posted!')
|
||||
}
|
||||
|
||||
const subscribe = await video.subscribe();
|
||||
if (subscribe.success) {
|
||||
console.info('Just subscribed to', video.metadata.channel_name + '!');
|
||||
}
|
||||
|
||||
const unsubscribe = await video.unsubscribe();
|
||||
if (unsubscribe.success) {
|
||||
console.info('Just unsubscribed from', video.metadata.channel_name + ' :(');
|
||||
}
|
||||
}
|
||||
|
||||
// Downloading videos:
|
||||
const stream = youtube.download(search.videos[0].id, {
|
||||
format: 'mp4', // Optional, ignored when type is set to audio and defaults to mp4, and I recommend to leave it as it is
|
||||
quality: '360p', // if a video doesn't have a specific quality it'll fall back to 360p, this is ignored when type is set to audio
|
||||
type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
|
||||
});
|
||||
|
||||
stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
|
||||
|
||||
stream.on('start', () => {
|
||||
console.info('[DOWNLOADER]', 'Starting download now!');
|
||||
});
|
||||
|
||||
stream.on('info', (info) => {
|
||||
console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
console.info('[DOWNLOADER]', 'Done!');
|
||||
});
|
||||
|
||||
stream.on('error', (err) => console.error('[ERROR]', err));
|
||||
}
|
||||
|
||||
start();
|
||||
112
examples/livechat/README.md
Normal file
112
examples/livechat/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
## Live Chat
|
||||
|
||||
Represents a livestream chat.
|
||||
|
||||
## Usage
|
||||
|
||||
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:
|
||||
```js
|
||||
const livechat = await info.getLiveChat();
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
* LiveChat
|
||||
* [.ev](#ev) ⇒ `EventEmitter`
|
||||
* [.start](#start) ⇒ `function`
|
||||
* [.stop](#stop) ⇒ `function`
|
||||
* [.applyFilter](#applyfilter) ⇒ `function`
|
||||
* [.getItemMenu](#getitemmenu) ⇒ `function`
|
||||
* [.sendMessage](#sendmessage) ⇒ `function`
|
||||
|
||||
<a name="ev"></a>
|
||||
### ev
|
||||
Live Chat's EventEmitter.
|
||||
|
||||
**Events:**
|
||||
|
||||
- `start`
|
||||
|
||||
Fired when the live chat is started.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `LiveChatContinuation` | Initial chat data, actions, info, etc. |
|
||||
|
||||
- `chat-update`
|
||||
|
||||
Fired when a new chat action is received.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `ChatAction` | Chat action |
|
||||
|
||||
- `metadata-update`
|
||||
|
||||
Fired when the livestream's metadata is updated.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `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()
|
||||
Starts the Live Chat.
|
||||
|
||||
<a name="stop"></a>
|
||||
### 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.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| text | `string` | Message content |
|
||||
|
||||
**Returns:** `Promise<ObservedArray<AddChatItemAction>>`
|
||||
|
||||
## Example
|
||||
See [`index.ts`]('./index.ts').
|
||||
107
examples/livechat/index.ts
Normal file
107
examples/livechat/index.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
|
||||
import { LiveChatContinuation } from 'youtubei.js/dist/src/parser';
|
||||
import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(), 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();
|
||||
|
||||
livechat.on('start', (initial_data: LiveChatContinuation) => {
|
||||
/**
|
||||
* 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 || '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
|
||||
* the live chat. All actions have a `type` property,
|
||||
* including their item (if the action has an item).
|
||||
*
|
||||
* Below are a few examples of how this can be used.
|
||||
*/
|
||||
|
||||
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(
|
||||
`${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(
|
||||
`${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:
|
||||
console.debug(action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
livechat.on('metadata-update', (metadata: LiveMetadata) => {
|
||||
console.info(`
|
||||
VIEWS: ${metadata.views?.view_count.toString()}
|
||||
LIKES: ${metadata.likes?.default_text}
|
||||
DATE: ${metadata.date?.date_text}
|
||||
`);
|
||||
});
|
||||
|
||||
livechat.start();
|
||||
})();
|
||||
1
examples/parser/artist.json
Normal file
1
examples/parser/artist.json
Normal file
File diff suppressed because one or more lines are too long
26
examples/parser/index.ts
Normal file
26
examples/parser/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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(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(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(YTNodes.SectionList).contents.array().as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
|
||||
|
||||
console.info('Sections:', sections);
|
||||
35
examples/upload/index.ts
Normal file
35
examples/upload/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
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() });
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
yt.session.on('auth', (data: any) => {
|
||||
writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Successfully signed in!');
|
||||
});
|
||||
|
||||
yt.session.on('update-credentials', (data: any) => {
|
||||
writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Credentials updated!', data);
|
||||
});
|
||||
|
||||
await yt.session.signIn(creds);
|
||||
|
||||
const file = readFileSync('./my_awesome_video.mp4');
|
||||
|
||||
const upload = await yt.studio.upload(file.buffer, {
|
||||
title: 'Wow!',
|
||||
description: new Date().toString(),
|
||||
privacy: 'UNLISTED'
|
||||
});
|
||||
|
||||
console.info('Done!');
|
||||
})();
|
||||
28
index.ts
Normal file
28
index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
const { ReadableStream } = require('node:stream/web');
|
||||
Reflect.set(globalThis, 'ReadableStream', ReadableStream);
|
||||
} catch { /* do nothing */ }
|
||||
}
|
||||
|
||||
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 as Session } from './src/core/Session';
|
||||
export { default as Player } from './src/core/Player';
|
||||
export default Innertube;
|
||||
17
jest.config.js
Normal file
17
jest.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
|
||||
module.exports = {
|
||||
projects: [
|
||||
{
|
||||
displayName: 'node',
|
||||
roots: [ '<rootDir>/test' ],
|
||||
testTimeout: 10000,
|
||||
transform: {
|
||||
"^.+\\.(ts|tsx)$": "ts-jest",
|
||||
},
|
||||
moduleFileExtensions: ["ts", "tsx", "js"],
|
||||
testMatch: [ '**/*.test.ts' ],
|
||||
setupFiles: []
|
||||
}
|
||||
]
|
||||
};
|
||||
1137
lib/Innertube.js
1137
lib/Innertube.js
File diff suppressed because it is too large
Load Diff
@@ -1,413 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Uuid = require('uuid');
|
||||
const Axios = require('axios');
|
||||
const Proto = require('../proto');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
/**
|
||||
* Performs direct interactions on YouTube.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} engagement_type - Type of engagement.
|
||||
* @param {object} args - Engagement arguments.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function engage(session, engagement_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed in');
|
||||
|
||||
const data = { context: session.context };
|
||||
switch (engagement_type) {
|
||||
case 'like/like':
|
||||
case 'like/dislike':
|
||||
case 'like/removelike':
|
||||
data.target = {
|
||||
videoId: args.video_id
|
||||
}
|
||||
break;
|
||||
case 'subscription/subscribe':
|
||||
case 'subscription/unsubscribe':
|
||||
data.channelIds = [args.channel_id];
|
||||
data.params = engagement_type == 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data.commentText = args.text;
|
||||
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
|
||||
break;
|
||||
case 'comment/create_comment_reply':
|
||||
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
|
||||
data.commentText = args.text;
|
||||
break;
|
||||
case 'comment/perform_comment_action':
|
||||
const action = ({
|
||||
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id, args.channel_id),
|
||||
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id, args.channel_id),
|
||||
})[args.comment_action]();
|
||||
data.actions = [action];
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesses YouTube's various sections.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action argumenets.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function browse(session, action, args = {}) {
|
||||
if (!session.logged_in && action != 'home_feed' &&
|
||||
action !== 'lyrics' && action !== 'music_playlist' &&
|
||||
action !== 'playlist')
|
||||
throw new Error('You are not signed in');
|
||||
|
||||
const data = { context: session.context };
|
||||
switch (action) {
|
||||
case 'account_notifications':
|
||||
data.browseId = 'SPaccount_notifications';
|
||||
break;
|
||||
case 'account_privacy':
|
||||
data.browseId = 'SPaccount_privacy';
|
||||
break;
|
||||
case 'history':
|
||||
data.browseId = 'FEhistory';
|
||||
break;
|
||||
case 'home_feed':
|
||||
data.browseId = 'FEwhat_to_watch';
|
||||
break;
|
||||
case 'subscriptions_feed':
|
||||
data.browseId = 'FEsubscriptions';
|
||||
break;
|
||||
case 'lyrics':
|
||||
case 'music_playlist':
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data.context = context;
|
||||
data.browseId = args.browse_id;
|
||||
break;
|
||||
case 'channel':
|
||||
case 'playlist':
|
||||
data.browseId = args.browse_id;
|
||||
break;
|
||||
case 'continuation':
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const requester = args.ytmusic && session.YTMRequester || session.YTRequester;
|
||||
const response = await requester.post('/browse', JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Account settings endpoints.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action argumenets.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function account(session, action, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
switch (action) {
|
||||
case 'account/account_menu':
|
||||
data.context = session.context;
|
||||
break;
|
||||
case 'account/set_setting':
|
||||
data.context = session.context;
|
||||
data.newValue = { boolValue: args.new_value };
|
||||
data.settingItemId = args.setting_item_id;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesses YouTube Music endpoints (/youtubei/v1/music/).
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action arguments.
|
||||
* @todo Implement more actions.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function music(session, action, args) {
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
let data;
|
||||
|
||||
switch (action) {
|
||||
case 'get_search_suggestions':
|
||||
data.context = context;
|
||||
data.input = args.input || '';
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.YTMRequester.post(`/music/${action}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches a given query on YouTube/YTMusic.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} client - YouTube client: YOUTUBE | YTMUSIC
|
||||
* @param {object} args - Search arguments.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function search(session, client, args = {}) {
|
||||
const data = { context: session.context };
|
||||
switch (client) {
|
||||
case 'YOUTUBE':
|
||||
if (args.query) {
|
||||
data.params = Proto.encodeSearchFilter(args.options.period, args.options.duration, args.options.order);
|
||||
data.query = args.query;
|
||||
} else {
|
||||
data.continuation = args.ctoken;
|
||||
}
|
||||
break;
|
||||
case 'YTMUSIC':
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data.context = context;
|
||||
data.query = args.query;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid client', action);
|
||||
}
|
||||
|
||||
const requester = client == 'YOUTUBE' && session.YTRequester || session.YTMRequester;
|
||||
const response = await requester.post('/search', JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interacts with YouTube's notification system.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action arguments.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function notifications(session, action, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
switch (action) {
|
||||
case 'modify_channel_preference':
|
||||
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
|
||||
data.context = session.context;
|
||||
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
|
||||
break;
|
||||
case 'get_notification_menu':
|
||||
data.context = session.context;
|
||||
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
|
||||
args.ctoken && (data.ctoken = args.ctoken);
|
||||
break;
|
||||
case 'get_unseen_count':
|
||||
data.context = session.context;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.YTRequester.post(`/notification/${action}`, JSON.stringify(data)).catch((err) => err);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
if (action === 'modify_channel_preference') return { success: true, status_code: response.status };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interacts with YouTube's livechat system.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action arguments.
|
||||
* @returns {Promise.<{ success: boolean; data: object; message?: string }>}
|
||||
*/
|
||||
async function livechat(session, action, args = {}) {
|
||||
const data = {};
|
||||
switch (action) {
|
||||
case 'live_chat/get_live_chat':
|
||||
data.context = session.context;
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
case 'live_chat/send_message':
|
||||
data.context = session.context;
|
||||
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
|
||||
data.clientMessageId = `ytjs-${Uuid.v4()}`;
|
||||
data.richMessage = {
|
||||
textSegments: [{ text: args.text }]
|
||||
}
|
||||
break;
|
||||
case 'live_chat/get_item_context_menu':
|
||||
data.context = session.context;
|
||||
break;
|
||||
case 'live_chat/moderate':
|
||||
data.context = session.context;
|
||||
data.params = args.cmd_params;
|
||||
break;
|
||||
case 'updated_metadata':
|
||||
data.context = session.context;
|
||||
data.videoId = args.video_id;
|
||||
args.continuation && (data.continuation = args.continuation);
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((err) => err);
|
||||
if (response instanceof Error) return { success: false, message: response.message };
|
||||
|
||||
return { success: true, data: response.data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests continuation for previously performed actions.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {object} args - Continuation arguments.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function next(session, args = {}) {
|
||||
let data = { context: session.context };
|
||||
args.continuation_token && (data.continuation = args.continuation_token);
|
||||
|
||||
if (args.video_id) {
|
||||
data.videoId = args.video_id;
|
||||
if (args.ytmusic) {
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data.context = context;
|
||||
data.isAudioOnly = true;
|
||||
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL';
|
||||
} else {
|
||||
data.racyCheckOk = true;
|
||||
data.contentCheckOk = false;
|
||||
data.autonavState = 'STATE_NONE';
|
||||
data.playbackContext = { vis: 0, lactMilliseconds: '-1' };
|
||||
data.captionsRequested = false;
|
||||
}
|
||||
}
|
||||
|
||||
const requester = args.ytmusic && session.YTMRequester || session.YTRequester;
|
||||
const response = await requester.post('/next', JSON.stringify(data)).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) return {
|
||||
success: false,
|
||||
status_code: response.response?.status || 0,
|
||||
message: response.message
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves video data.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {object} args - Request arguments.
|
||||
* @returns {Promise.<object>} - Video data.
|
||||
*/
|
||||
async function getVideoInfo(session, args = {}) {
|
||||
const response = await session.YTRequester.post(`/player`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context))).catch((err) => err);
|
||||
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets search suggestions.
|
||||
*
|
||||
* @param {Innertube} session - A valid innertube session
|
||||
* @param {string} query - Search query
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function getYTSearchSuggestions(session, query) {
|
||||
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(query)}`,
|
||||
Constants.DEFAULT_HEADERS(session)).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) return {
|
||||
success: false,
|
||||
status_code: response.status,
|
||||
message: response.message
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getYTSearchSuggestions };
|
||||
@@ -1,139 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Actions = require('./Actions');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class Livechat extends EventEmitter {
|
||||
constructor(session, token, channel_id, video_id) {
|
||||
super(session);
|
||||
|
||||
if (!token)
|
||||
throw new Error('Could not retrieve livechat data');
|
||||
|
||||
this.ctoken = token;
|
||||
this.session = session;
|
||||
this.video_id = video_id;
|
||||
this.channel_id = channel_id;
|
||||
|
||||
this.message_queue = [];
|
||||
this.id_cache = [];
|
||||
|
||||
this.poll_intervals_ms = 1000;
|
||||
this.running = true;
|
||||
|
||||
this.#poll();
|
||||
}
|
||||
|
||||
async #poll() {
|
||||
if (!this.running) return;
|
||||
|
||||
const livechat = await Actions.livechat(this.session, 'live_chat/get_live_chat', { ctoken: this.ctoken });
|
||||
if (!livechat.success) {
|
||||
this.emit('error', { message: `Failed polling livechat: ${livechat.message}. Retrying...` });
|
||||
return await this.#poll();
|
||||
}
|
||||
|
||||
const continuation_contents = livechat.data.continuationContents;
|
||||
const action_group = continuation_contents.liveChatContinuation.actions;
|
||||
this.#enqueueActionGroup(action_group);
|
||||
|
||||
this.message_queue.forEach((message) => {
|
||||
if (this.id_cache.includes(message.id)) return;
|
||||
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
|
||||
this.id_cache.push(message.id);
|
||||
});
|
||||
|
||||
this.message_queue = [];
|
||||
|
||||
const data = { video_id: this.video_id };
|
||||
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
|
||||
|
||||
const updated_metadata = await Actions.livechat(this.session, 'updated_metadata', data);
|
||||
if (!updated_metadata.success) {
|
||||
this.emit('error', { message: `Failed polling livechat metadata: ${livechat.message}.` });
|
||||
}
|
||||
|
||||
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
|
||||
|
||||
const metadata = updated_metadata.data.actions;
|
||||
this.emit('update-metadata', {
|
||||
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
view_count: {
|
||||
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
|
||||
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
|
||||
}
|
||||
});
|
||||
|
||||
this.livechat_poller = setTimeout(async () => await this.#poll(), this.poll_intervals_ms);
|
||||
}
|
||||
|
||||
#enqueueActionGroup(group) {
|
||||
group.forEach((action) => {
|
||||
if (!action.addChatItemAction) return; //TODO: handle different action types
|
||||
const message_content = action.addChatItemAction.item.liveChatTextMessageRenderer;
|
||||
if (!message_content) return;
|
||||
|
||||
const message = {
|
||||
text: message_content.message.runs.map((item) => item.text).join(' '),
|
||||
author: {
|
||||
name: message_content.authorName && message_content.authorName.simpleText || 'N/',
|
||||
channel_id: message_content.authorExternalChannelId,
|
||||
profile_picture: message_content.authorPhoto.thumbnails
|
||||
},
|
||||
timestamp: message_content.timestampUsec,
|
||||
id: message_content.id
|
||||
};
|
||||
|
||||
this.message_queue.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(text) {
|
||||
const message = await Actions.livechat(this.session, 'live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id });
|
||||
if (!message.success) return message;
|
||||
|
||||
const deleteMessage = async () => {
|
||||
const menu = await Actions.livechat(this.session, 'live_chat/get_item_context_menu', { params: { params: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params, pbj: 1 } });
|
||||
if (!menu.success) return menu;
|
||||
|
||||
const chat_item_menu = menu.data.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0];
|
||||
|
||||
const cmd = await Actions.livechat(this.session, 'live_chat/moderate', { cmd_params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params });
|
||||
if (!cmd.success) return cmd;
|
||||
|
||||
return { success: true, status_code: cmd.status_code };
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: message.status_code,
|
||||
deleteMessage: deleteMessage,
|
||||
message_data: {
|
||||
text: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message.runs.map((item) => item.text).join(' '),
|
||||
author: {
|
||||
name: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName && message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName.simpleText || 'N/',
|
||||
channel_id: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorExternalChannelId,
|
||||
profile_picture: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorPhoto.thumbnails
|
||||
},
|
||||
timestamp: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.timestampUsec,
|
||||
id: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks a user.
|
||||
* @todo Implement this method.
|
||||
* @param {object} msg_params
|
||||
*/
|
||||
async blockUser(msg_params) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
clearTimeout(this.livechat_poller);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Livechat;
|
||||
@@ -1,199 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Constants = require('../utils/Constants');
|
||||
const EventEmitter = require('events');
|
||||
const Uuid = require('uuid');
|
||||
|
||||
class OAuth extends EventEmitter {
|
||||
constructor(auth_info) {
|
||||
super();
|
||||
this.auth_info = auth_info;
|
||||
this.refresh_interval = 5;
|
||||
|
||||
this.oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
|
||||
this.oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
|
||||
|
||||
this.model_name = Constants.OAUTH.MODEL_NAME;
|
||||
this.grant_type = Constants.OAUTH.GRANT_TYPE;
|
||||
this.scope = Constants.OAUTH.SCOPE;
|
||||
|
||||
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
|
||||
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/;
|
||||
|
||||
if (auth_info.access_token) return;
|
||||
this.#requestAuthCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the OAuth server for an auth code.
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async #requestAuthCode() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
this.client_id = identity.id;
|
||||
this.client_secret = identity.secret;
|
||||
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
scope: this.scope,
|
||||
device_id: Uuid.v4(),
|
||||
model_name: this.model_name
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get auth code.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('auth', {
|
||||
code: response.data.user_code,
|
||||
status: 'AUTHORIZATION_PENDING',
|
||||
expires_in: response.data.expires_in,
|
||||
verification_url: response.data.verification_url
|
||||
});
|
||||
|
||||
this.refresh_interval = response.data.interval;
|
||||
|
||||
this.#waitForAuth(response.data.device_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for sign-in authorization.
|
||||
*
|
||||
* @param {string} device_code Client's device code.
|
||||
* @returns
|
||||
*/
|
||||
#waitForAuth(device_code) {
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
client_secret: this.client_secret,
|
||||
code: device_code,
|
||||
grant_type: this.grant_type
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get authentication token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
if (response.data.error) {
|
||||
switch (response.data.error) {
|
||||
case 'slow_down':
|
||||
case 'authorization_pending':
|
||||
this.#waitForAuth(device_code);
|
||||
break;
|
||||
case 'access_denied':
|
||||
this.emit('auth', {
|
||||
error: 'Access was denied.',
|
||||
status: 'ACCESS_DENIED'
|
||||
});
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.emit('auth', {
|
||||
error: 'The device code has expired, requesting a new one.',
|
||||
status: 'DEVICE_CODE_EXPIRED'
|
||||
});
|
||||
this.#requestAuthCode();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
this.emit('auth', {
|
||||
credentials: {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
expires: expiration_date,
|
||||
},
|
||||
token_type: response.data.token_type,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
}, 1000 * this.refresh_interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new access token using a refresh token.
|
||||
* @returns {Promise.<{ credentials: { access_token: string; refresh_token: string; expires: Date }; status: string }>}
|
||||
*/
|
||||
async refreshAccessToken() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: identity.id,
|
||||
client_secret: identity.secret,
|
||||
refresh_token: this.auth_info.refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
this.emit('auth', {
|
||||
error: 'Could not refresh access token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
access_token: this.auth_info.access_token,
|
||||
refresh_token: this.auth_info.refresh_token,
|
||||
expires: this.auth_info.expires
|
||||
},
|
||||
status: 'FAILED'
|
||||
};
|
||||
}
|
||||
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
refresh_token: this.auth_info.refresh_token,
|
||||
access_token: response.data.access_token,
|
||||
expires: expiration_date
|
||||
},
|
||||
token_type: response.data.token_type,
|
||||
status: 'SUCCESS'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets client identity data.
|
||||
* @returns {Promise.<{ id: string; secret: string }>}
|
||||
*/
|
||||
async #getClientIdentity() {
|
||||
// This request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
|
||||
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
|
||||
|
||||
// Here we download the script and extract the necessary data to proceed with the auth flow.
|
||||
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
|
||||
const script_url = `${Constants.URLS.YT_BASE}/${url_body}`;
|
||||
|
||||
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
|
||||
|
||||
const client_identity = response.data.replace(/\n/g, '').match(this.identity_regex);
|
||||
return client_identity.groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks access token validity.
|
||||
* @returns {boolean} true | false
|
||||
*/
|
||||
isTokenValid() {
|
||||
const timestamp = new Date(this.auth_info.expires).getTime();
|
||||
const is_valid = new Date().getTime() < timestamp;
|
||||
return is_valid;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth;
|
||||
@@ -1,49 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
class Player {
|
||||
constructor(session) {
|
||||
this.session = session;
|
||||
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
|
||||
this.tmp_cache_dir = __dirname.slice(0, -8) + 'cache';
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (Fs.existsSync(`${this.tmp_cache_dir}/${this.player_name}.js`)) {
|
||||
const player_data = Fs.readFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`).toString();
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(player_data);
|
||||
this.ntoken_sc = this.#getNEncoder(player_data);
|
||||
} else {
|
||||
const response = await Axios.get(`${Constants.URLS.YT_BASE}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message);
|
||||
|
||||
try {
|
||||
// Deletes old players
|
||||
Fs.existsSync(this.tmp_cache_dir) && Fs.rmSync(this.tmp_cache_dir, { recursive: true });
|
||||
|
||||
// Caches the current player so we don't have to download it all the time.
|
||||
Fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
|
||||
Fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data);
|
||||
} catch (err) {}
|
||||
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
|
||||
this.ntoken_sc = this.#getNEncoder(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
#getSigDecipherCode(data) {
|
||||
const sig_alg_sc = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const sig_data = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
return sig_alg_sc + sig_data;
|
||||
}
|
||||
|
||||
#getNEncoder(data) {
|
||||
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Player;
|
||||
@@ -1,139 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
class NToken {
|
||||
constructor(raw_code, n) {
|
||||
this.n = n;
|
||||
this.raw_code = raw_code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solves throttling challange by transforming the n token.
|
||||
* @returns {string} transformed token.
|
||||
*/
|
||||
transform() {
|
||||
let n_token = this.n.split('');
|
||||
|
||||
try {
|
||||
let transformations = this.#getTransformationData();
|
||||
transformations = transformations.map((el) => {
|
||||
if (el != null && typeof el != 'number') {
|
||||
const is_reverse_base64 = el.includes('case 65:');
|
||||
(({ // Identifies the transformation functions
|
||||
[Constants.FUNCS.PUSH]: () => el = (arr, i) => this.#push(arr, i),
|
||||
[Constants.FUNCS.SPLICE]: () => el = (arr, i) => this.#splice(arr, i),
|
||||
[Constants.FUNCS.SWAP0_1]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.SWAP0_2]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.ROTATE_1]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.ROTATE_2]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.REVERSE_1]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.REVERSE_2]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.BASE64_DIA]: () => el = () => this.#getBase64Dia(is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_1]: () => el = (arr, token) => this.#translate1(arr, token, is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_2]: () => el = (arr, token, base64_dic) => this.#translate2(arr, token, base64_dic)
|
||||
})[this.#getFunc(el)] || (() => el === 'b' && (el = n_token)))();
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
// Fills all placeholders with the transformations array
|
||||
const placeholder_indexes = [...this.raw_code.matchAll(Constants.NTOKEN_REGEX.PLACEHOLDERS)].map((item) => parseInt(item[1]));
|
||||
placeholder_indexes.forEach((i) => transformations[i] = transformations);
|
||||
|
||||
// Parses and emulates calls to the functions of the transformations array
|
||||
const function_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch')
|
||||
.matchAll(Constants.NTOKEN_REGEX.CALLS)].map((params) => ({ index: params[1], params: params[2] }));
|
||||
|
||||
function_calls.forEach((data) => {
|
||||
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
|
||||
const base64_dia = (param_index[2] && transformations[param_index[2]]());
|
||||
transformations[data.index](transformations[param_index[0]], transformations[param_index[1]], base64_dia);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Could not transform n-token (${this.n}), download may be throttled:`, err.message);
|
||||
return this.n;
|
||||
}
|
||||
return n_token.join('');
|
||||
}
|
||||
|
||||
#getFunc(el) {
|
||||
return el.match(Constants.FUNCS_REGEX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the n-transform data, refines it, and then returns a readable json array.
|
||||
* @returns {object}
|
||||
*/
|
||||
#getTransformationData() {
|
||||
const data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
|
||||
return JSON.parse(Utils.refineNTokenData(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a base64 alphabet and uses it as a lookup table to modify n.
|
||||
* @returns
|
||||
*/
|
||||
#translate1(arr, token, is_reverse_base64) {
|
||||
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
|
||||
arr.forEach(function(char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
|
||||
}, token.split(''));
|
||||
}
|
||||
|
||||
#translate2(arr, token, characters) {
|
||||
let chars_length = characters.length;
|
||||
arr.forEach(function(char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + index + chars_length--) % characters.length]);
|
||||
}, token.split(''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the requested base64 dialect, currently this is only used by 'translate2'.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
#getBase64Dia(is_reverse_base64) {
|
||||
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
|
||||
return characters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the first element with the one at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#swap0(arr, index) {
|
||||
const old_elem = arr[0];
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr[0] = arr[index];
|
||||
arr[index] = old_elem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates elements of the array.
|
||||
* @returns
|
||||
*/
|
||||
#rotate(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(-index).reverse().forEach((el) => arr.unshift(el));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one element at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#splice(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(index, 1);
|
||||
}
|
||||
|
||||
#reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
|
||||
#push(arr, item) {
|
||||
arr.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NToken;
|
||||
@@ -1,75 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const QueryString = require('querystring');
|
||||
|
||||
class SigDecipher {
|
||||
constructor(url, player) {
|
||||
this.url = url;
|
||||
this.player = player;
|
||||
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
|
||||
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deciphers signature.
|
||||
*/
|
||||
decipher() {
|
||||
const args = QueryString.parse(this.url);
|
||||
const functions = this.#getFunctions();
|
||||
|
||||
function splice(arr, end) {
|
||||
arr.splice(0, end);
|
||||
}
|
||||
|
||||
function swap(arr, index) {
|
||||
let origArrI = arr[0];
|
||||
arr[0] = arr[index % arr.length];
|
||||
arr[index % arr.length] = origArrI;
|
||||
}
|
||||
|
||||
function reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
|
||||
let actions;
|
||||
let signature = args.s.split('');
|
||||
|
||||
while ((actions = this.actions_regex.exec(this.player.sig_decipher_sc)) !== null) {
|
||||
switch (actions[1]) {
|
||||
case functions[0]:
|
||||
reverse(signature, actions[2]);
|
||||
break;
|
||||
case functions[1]:
|
||||
splice(signature, actions[2]);
|
||||
break;
|
||||
case functions[2]:
|
||||
swap(signature, actions[2]);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
const url_components = new URL(args.url);
|
||||
args.sp ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
|
||||
return url_components.toString();
|
||||
}
|
||||
|
||||
#getFunctions() {
|
||||
let func;
|
||||
let func_name = [];
|
||||
|
||||
while ((func = this.func_regex.exec(this.player.sig_decipher_sc)) !== null) {
|
||||
if (func[0].includes('reverse()')) {
|
||||
func_name[0] = func[1];
|
||||
} else if (func[0].includes('splice')) {
|
||||
func_name[1] = func[1];
|
||||
} else {
|
||||
func_name[2] = func[1];
|
||||
}
|
||||
}
|
||||
|
||||
return func_name;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SigDecipher;
|
||||
@@ -1,255 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../utils/Utils');
|
||||
const Actions = require('../core/Actions');
|
||||
const Constants = require('../utils/Constants');
|
||||
const YTDataItems = require('./youtube');
|
||||
const YTMusicDataItems = require('./ytmusic');
|
||||
|
||||
class Parser {
|
||||
constructor(session, data, args = {}) {
|
||||
this.session = session;
|
||||
this.data = data;
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
parse() {
|
||||
const client = this.args.client;
|
||||
const data_type = this.args.data_type
|
||||
|
||||
let processed_data;
|
||||
|
||||
switch (client) {
|
||||
case 'YOUTUBE':
|
||||
processed_data = ({
|
||||
SEARCH: () => this.#processSearch(),
|
||||
PLAYLIST: () => this.#processPlaylist(),
|
||||
VIDEO_INFO: () => this.#processVideoInfo()
|
||||
})[data_type]()
|
||||
break;
|
||||
case 'YTMUSIC':
|
||||
processed_data = ({
|
||||
SEARCH: () => this.#processMusicSearch(),
|
||||
PLAYLIST: () => this.#processMusicPlaylist()
|
||||
})[data_type]();
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid client');
|
||||
}
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processSearch() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'contents', 5);
|
||||
|
||||
const processed_data = {};
|
||||
|
||||
const parseItems = (contents) => {
|
||||
const content = contents[0].itemSectionRenderer.contents;
|
||||
|
||||
processed_data.query = content[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
|
||||
processed_data.corrected_query = content[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || 'N/A';
|
||||
processed_data.estimated_results = parseInt(this.data.estimatedResults);
|
||||
|
||||
processed_data.videos = YTDataItems.VideoResultItem.parse(content);
|
||||
|
||||
processed_data.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await Actions.search(this.session, 'YOUTUBE', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get continuation', response);
|
||||
|
||||
const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
|
||||
return parseItems(continuation_items);
|
||||
};
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
#processMusicSearch() {
|
||||
const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
|
||||
const contents = Utils.findNode(tabs, '0', 'contents', 5);
|
||||
|
||||
const did_you_mean_item = contents.find((content) => content.itemSectionRenderer);
|
||||
const did_you_mean_renderer = did_you_mean_item?.itemSectionRenderer.contents[0].didYouMeanRenderer;
|
||||
|
||||
const processed_data = {
|
||||
query: '',
|
||||
corrected_query: '',
|
||||
results: {}
|
||||
};
|
||||
|
||||
processed_data.query = this.args.query;
|
||||
processed_data.corrected_query = did_you_mean_renderer?.correctedQuery.runs.map((run) => run.text).join('') || 'N/A';
|
||||
|
||||
contents.forEach((content) => {
|
||||
const section = content?.musicShelfRenderer;
|
||||
if (section) {
|
||||
const section_title = section.title.runs[0].text;
|
||||
|
||||
const section_items = ({
|
||||
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents), // console.log(JSON.stringify(section.contents, null, 4)),
|
||||
['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents),
|
||||
['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents),
|
||||
['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
|
||||
['Community playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
|
||||
['Artists']: () => YTMusicDataItems.ArtistResultItem.parse(section.contents),
|
||||
['Albums']: () => YTMusicDataItems.AlbumResultItem.parse(section.contents)
|
||||
})[section_title]();
|
||||
|
||||
processed_data.results[section_title.replace(/ /g, '_').toLowerCase()] = section_items;
|
||||
}
|
||||
});
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processPlaylist() {
|
||||
const details = this.data.sidebar.playlistSidebarRenderer.items[0];
|
||||
|
||||
const metadata = {
|
||||
title: this.data.metadata.playlistMetadataRenderer.title,
|
||||
description: details.playlistSidebarPrimaryInfoRenderer.description.simpleText || 'N/A',
|
||||
total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0].text,
|
||||
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1].text,
|
||||
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
|
||||
}
|
||||
|
||||
const list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
|
||||
const items = YTDataItems.PlaylistItem.parse(list.contents);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#processMusicPlaylist() {
|
||||
const details = this.data.header.musicDetailHeaderRenderer;
|
||||
|
||||
const metadata = {
|
||||
title: details?.title?.runs[0].text,
|
||||
description: details?.description?.runs?.map((run) => run.text).join('') || 'N/A',
|
||||
total_items: parseInt(details?.secondSubtitle?.runs[0].text.match(/\d+/g)),
|
||||
duration: details?.secondSubtitle?.runs[2].text,
|
||||
year: details?.subtitle?.runs[4].text
|
||||
};
|
||||
|
||||
const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
|
||||
|
||||
const items = YTMusicDataItems.PlaylistItem.parse(playlist_content);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
|
||||
*/
|
||||
#processVideoInfo() {
|
||||
const playability_status = this.data.playabilityStatus;
|
||||
|
||||
if (playability_status.status == 'ERROR')
|
||||
throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
|
||||
|
||||
const details = this.data.videoDetails;
|
||||
const microformat = this.data.microformat.playerMicroformatRenderer;
|
||||
const streaming_data = this.data.streamingData;
|
||||
|
||||
const mf_raw_data = Object.entries(microformat);
|
||||
const dt_raw_data = Object.entries(details);
|
||||
|
||||
const processed_data = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
thumbnail: [],
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
// Extracts most of the metadata
|
||||
mf_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
|
||||
key == 'owner_profile_url' && (processed_data.metadata.channel_url = entry[1]) ||
|
||||
key == 'owner_channel_name' && (processed_data.metadata.channel_name = entry[1]) ||
|
||||
(processed_data.metadata[key] = entry[1]);
|
||||
} else {
|
||||
processed_data[key] = entry[1];
|
||||
}
|
||||
});
|
||||
|
||||
// Extracts extra details
|
||||
dt_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
|
||||
(processed_data.metadata[key] = entry[1]);
|
||||
} else {
|
||||
key == 'short_description' && (processed_data.description = entry[1]) ||
|
||||
key == 'thumbnail' && (processed_data.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
|
||||
key == 'video_id' && (processed_data.id = entry[1]) ||
|
||||
(processed_data[key] = entry[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Data continuation is only required for getDetails()
|
||||
if (this.data.continuation) {
|
||||
const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
|
||||
|
||||
const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
|
||||
|
||||
const like_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
|
||||
|
||||
const dislike_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
|
||||
|
||||
const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
|
||||
?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
|
||||
|
||||
// These will always be false if logged out.
|
||||
processed_data.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
|
||||
processed_data.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
|
||||
processed_data.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
|
||||
|
||||
processed_data.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
|
||||
processed_data.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
|
||||
.state.buttonRenderer.icon.iconType || 'N/A';
|
||||
|
||||
// Simpler version of publish_date
|
||||
processed_data.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
|
||||
|
||||
// Only parse like count if it's enabled
|
||||
if (processed_data.metadata.allow_ratings) {
|
||||
processed_data.metadata.likes = {
|
||||
count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
|
||||
short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
|
||||
};
|
||||
}
|
||||
|
||||
processed_data.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
|
||||
}
|
||||
|
||||
streaming_data && streaming_data.adaptiveFormats &&
|
||||
(processed_data.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
|
||||
.map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]) ||
|
||||
(processed_data.metadata.available_qualities = []);
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Parser;
|
||||
@@ -1,6 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const VideoResultItem = require('./search/VideoResultItem');
|
||||
const PlaylistItem = require('./others/PlaylistItem');
|
||||
|
||||
module.exports = { VideoResultItem, PlaylistItem };
|
||||
@@ -1,26 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
|
||||
class PlaylistItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
if (item.playlistVideoRenderer)
|
||||
return {
|
||||
id: item?.playlistVideoRenderer?.videoId,
|
||||
title: item?.playlistVideoRenderer?.title?.runs[0]?.text,
|
||||
author: item?.playlistVideoRenderer?.shortBylineText?.runs[0]?.text,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(item?.playlistVideoRenderer?.lengthText?.simpleText || '0'),
|
||||
simple_text: item?.playlistVideoRenderer?.lengthText?.simpleText || 'N/A',
|
||||
accessibility_label: item?.playlistVideoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
|
||||
},
|
||||
thumbnails: item?.playlistVideoRenderer?.thumbnail?.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistItem;
|
||||
@@ -1,43 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
const Constants = require('../../../utils/Constants');
|
||||
|
||||
class VideoResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const renderer = item.videoRenderer || item.compactVideoRenderer;
|
||||
if (renderer) return {
|
||||
id: renderer.videoId,
|
||||
url: `https://youtu.be/${renderer.videoId}`,
|
||||
title: renderer.title.runs[0].text,
|
||||
description: renderer?.detailedMetadataSnippets && renderer?.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
|
||||
channel: {
|
||||
id: renderer?.ownerText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
|
||||
name: renderer?.ownerText?.runs[0]?.text,
|
||||
url: `${Constants.URLS.YT_BASE}${renderer.ownerText.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
|
||||
},
|
||||
metadata: {
|
||||
view_count: renderer?.viewCountText?.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: renderer?.shortViewCountText?.simpleText || 'N/A',
|
||||
accessibility_label: renderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
|
||||
},
|
||||
thumbnails: renderer?.thumbnail.thumbnails,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(renderer?.lengthText?.simpleText || '0'),
|
||||
simple_text: renderer?.lengthText?.simpleText || 'N/A',
|
||||
accessibility_label: renderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
|
||||
},
|
||||
published: renderer?.publishedTimeText?.simpleText || 'N/A',
|
||||
badges: renderer?.badges?.map((item) => item.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: renderer?.ownerBadges?.map((item) => item.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VideoResultItem;
|
||||
@@ -1,11 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const SongResultItem = require('./search/SongResultItem');
|
||||
const VideoResultItem = require('./search/VideoResultItem');
|
||||
const AlbumResultItem = require('./search/AlbumResultItem');
|
||||
const ArtistResultItem = require('./search/ArtistResultItem');
|
||||
const PlaylistResultItem = require('./search/PlaylistResultItem');
|
||||
const TopResultItem = require('./search/TopResultItem');
|
||||
const PlaylistItem = require('./others/PlaylistItem');
|
||||
|
||||
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, TopResultItem, PlaylistItem };
|
||||
@@ -1,28 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
|
||||
class PlaylistItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item.id);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const item_renderer = item.musicResponsiveListItemRenderer;
|
||||
const fixed_columns = item_renderer.fixedColumns;
|
||||
const flex_columns = item_renderer.flexColumns;
|
||||
|
||||
return {
|
||||
id: item_renderer.playlistItemData && item_renderer.playlistItemData.videoId,
|
||||
title: flex_columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
author: flex_columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text || '0'),
|
||||
simple_text: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
|
||||
},
|
||||
thumbnails: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistItem;
|
||||
@@ -1,21 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class AlbumResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
return {
|
||||
id: list_item.navigationEndpoint.browseEndpoint.browseId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
year: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
|
||||
.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
|
||||
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AlbumResultItem;
|
||||
@@ -1,19 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class ArtistResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
return {
|
||||
id: list_item.navigationEndpoint.browseEndpoint.browseId,
|
||||
name: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
subscribers: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ArtistResultItem;
|
||||
@@ -1,23 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class PlaylistResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
const watch_playlist_endpoint = list_item.overlay.musicItemThumbnailOverlayRenderer
|
||||
.content.musicPlayButtonRenderer.playNavigationEndpoint.watchPlaylistEndpoint;
|
||||
|
||||
return {
|
||||
id: watch_playlist_endpoint?.playlistId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
channel_id: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.navigationEndpoint?.browseEndpoint.browseId || '0',
|
||||
total_items: parseInt(list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text.match(/\d+/g)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistResultItem;
|
||||
@@ -1,22 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class SongResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
if (list_item.playlistItemData) return {
|
||||
id: list_item.playlistItemData.videoId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
artist: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
album: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
|
||||
duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
|
||||
.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
|
||||
thumbnails: list_item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SongResultItem;
|
||||
@@ -1,32 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const SongResultItem = require('./SongResultItem');
|
||||
const VideoResultItem = require('./VideoResultItem');
|
||||
const AlbumResultItem = require('./AlbumResultItem');
|
||||
const ArtistResultItem = require('./ArtistResultItem');
|
||||
const PlaylistResultItem = require('./PlaylistResultItem');
|
||||
|
||||
class TopResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
|
||||
const runs = list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs;
|
||||
const type = runs[0].text.toLowerCase();
|
||||
|
||||
const parsed_item = ({
|
||||
playlist: () => PlaylistResultItem.parseItem(item),
|
||||
song: () => SongResultItem.parseItem(item),
|
||||
video: () => VideoResultItem.parseItem(item),
|
||||
artist: () => ArtistResultItem.parseItem(item),
|
||||
album: () => ArtistResultItem.parseItem(item)
|
||||
}[type])();
|
||||
|
||||
parsed_item.type = type;
|
||||
|
||||
return parsed_item;
|
||||
}).filter((item) => item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TopResultItem;
|
||||
@@ -1,22 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class VideoResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
if (list_item.playlistItemData) return {
|
||||
id: list_item.playlistItemData.videoId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
views: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
|
||||
duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
|
||||
.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
|
||||
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VideoResultItem;
|
||||
@@ -1,133 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Proto = require('protons');
|
||||
|
||||
/**
|
||||
* Encodes advanced search filters.
|
||||
*
|
||||
* @param {string} period - Period in which a video is uploaded: any | hour | day | week | month | year
|
||||
* @param {string} duration - The duration of a video: any | short | long
|
||||
* @param {string} order - The order of the search results: relevance | rating | age | views
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeSearchFilter(period, duration, order) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 };
|
||||
const durations = { 'any': null, 'short': 1, 'long': 2 };
|
||||
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 };
|
||||
|
||||
const search_filter_buff = youtube_proto.SearchFilter.encode({
|
||||
number: orders[order],
|
||||
filter: {
|
||||
param_0: periods[period],
|
||||
param_1: (period == 'hour' && order == 'relevance') ? null : 1,
|
||||
param_2: durations[duration]
|
||||
}
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes livestream message parameters.
|
||||
*
|
||||
* @param {string} channel_id - The id of the channel hosting the livestream.
|
||||
* @param {string} video_id - The id of the livestream.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeMessageParams(channel_id, video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.LiveMessageParams.encode({
|
||||
params: {
|
||||
ids: { channel_id, video_id }
|
||||
},
|
||||
number_0: 1,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes comment parameters.
|
||||
*
|
||||
* @param {string} video_id - The id of the video you're commenting on.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeCommentParams(video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.CreateCommentParams.encode({
|
||||
video_id,
|
||||
params: { index: 0 },
|
||||
number: 7
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes comment reply parameters.
|
||||
*
|
||||
* @param {string} comment_id - The id of the comment.
|
||||
* @param {string} video_id - The id of the video you're commenting on.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeCommentReplyParams(comment_id, video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.CreateCommentReplyParams.encode({
|
||||
video_id, comment_id,
|
||||
params: { unk_num: 0 },
|
||||
unk_num: 7
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes comment action parameters (liking, disliking, reporting a comment etc).
|
||||
*
|
||||
* @param {string} type - Type of action.
|
||||
* @param {string} comment_id - The id of the comment.
|
||||
* @param {string} video_id - The id of the video you're commenting on.
|
||||
* @param {string} channel_id - The id of the channel.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeCommentActionParams(type, comment_id, video_id, channel_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.PeformCommentActionParams.encode({
|
||||
type, comment_id, channel_id, video_id,
|
||||
unk_num: 2, unk_num_1: 0, unk_num_2: 0,
|
||||
unk_num_3: "0", unk_num_4: 0,
|
||||
unk_num_5: 12, unk_num_6: 0,
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes notification preferences.
|
||||
*
|
||||
* @param {string} channel_id - The id of the channel.
|
||||
* @param {string} index - The index of the preference id.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeNotificationPref(channel_id, index) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.NotificationPreferences.encode({
|
||||
channel_id,
|
||||
pref_id: { index },
|
||||
number_0: 0,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
module.exports = { encodeMessageParams, encodeCommentParams, encodeCommentReplyParams, encodeCommentActionParams, encodeNotificationPref, encodeSearchFilter };
|
||||
@@ -1,75 +0,0 @@
|
||||
syntax = "proto2";
|
||||
package proto;
|
||||
|
||||
message NotificationPreferences {
|
||||
string channel_id = 1;
|
||||
message Preference {
|
||||
int32 index = 1;
|
||||
}
|
||||
Preference pref_id = 2;
|
||||
int32 number_0 = 3;
|
||||
int32 number_1 = 4;
|
||||
}
|
||||
|
||||
message LiveMessageParams {
|
||||
message Params {
|
||||
message Ids {
|
||||
string channel_id = 1;
|
||||
string video_id = 2;
|
||||
}
|
||||
Ids ids = 5;
|
||||
}
|
||||
Params params = 1;
|
||||
int32 number_0 = 2;
|
||||
int32 number_1 = 3;
|
||||
}
|
||||
|
||||
message CreateCommentParams {
|
||||
string video_id = 2;
|
||||
message Params {
|
||||
int32 index = 1;
|
||||
}
|
||||
Params params = 5;
|
||||
int32 number = 10;
|
||||
}
|
||||
|
||||
message CreateCommentReplyParams {
|
||||
string video_id = 2;
|
||||
string comment_id = 4;
|
||||
|
||||
message UnknownParams {
|
||||
int32 unk_num = 1;
|
||||
}
|
||||
UnknownParams params = 5;
|
||||
|
||||
int32 unk_num = 10;
|
||||
}
|
||||
|
||||
message PeformCommentActionParams {
|
||||
int32 type = 1;
|
||||
int32 unk_num = 2;
|
||||
|
||||
string comment_id = 3;
|
||||
string video_id = 5;
|
||||
|
||||
int32 unk_num_1 = 6;
|
||||
int32 unk_num_2 = 7;
|
||||
|
||||
string unk_num_3 = 9;
|
||||
|
||||
int32 unk_num_4 = 10;
|
||||
int32 unk_num_5 = 21;
|
||||
|
||||
string channel_id = 23;
|
||||
int32 unk_num_6 = 30;
|
||||
}
|
||||
|
||||
message SearchFilter {
|
||||
int32 number = 1;
|
||||
message Filter {
|
||||
int32 param_0 = 1;
|
||||
int32 param_1 = 2;
|
||||
int32 param_2 = 3;
|
||||
}
|
||||
Filter filter = 2;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
|
||||
module.exports = {
|
||||
URLS: {
|
||||
YT_BASE: 'https://www.youtube.com',
|
||||
YT_BASE_API: 'https://www.youtube.com/youtubei/',
|
||||
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/',
|
||||
YT_MUSIC: 'https://music.youtube.com',
|
||||
YT_MUSIC_BASE_API: 'https://music.youtube.com/youtubei/'
|
||||
},
|
||||
OAUTH: {
|
||||
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
|
||||
GRANT_TYPE: 'http://oauth.net/grant_type/device/1.0',
|
||||
MODEL_NAME: 'ytlr::',
|
||||
HEADERS: {
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'origin': 'https://www.youtube.com',
|
||||
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
|
||||
'content-type': 'application/json',
|
||||
'referer': `https://www.youtube.com/tv`,
|
||||
'accept-language': 'en-US'
|
||||
}
|
||||
}
|
||||
},
|
||||
DEFAULT_HEADERS: (session) => {
|
||||
return {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Referer': 'https://www.google.com/',
|
||||
'Accept': 'text/html',
|
||||
'Accept-Language': 'en-US,en',
|
||||
'Accept-Encoding': 'gzip'
|
||||
}
|
||||
};
|
||||
},
|
||||
STREAM_HEADERS: {
|
||||
'Accept': '*/*',
|
||||
'User-Agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Connection': 'keep-alive',
|
||||
'Origin': 'https://www.youtube.com',
|
||||
'Referer': 'https://www.youtube.com',
|
||||
'DNT': '?1'
|
||||
},
|
||||
INNERTUBE_HEADERS: (info) => {
|
||||
const origin = info.ytmusic && 'https://music.youtube.com' || 'https://www.youtube.com';
|
||||
|
||||
const headers = {
|
||||
'accept': '*/*',
|
||||
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'content-type': 'application/json',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
'x-goog-authuser': 0,
|
||||
'x-goog-visitor-id': info.session.context.client.visitorData || '',
|
||||
'x-youtube-client-name': 1,
|
||||
'x-youtube-client-version': info.session.context.client.clientVersion,
|
||||
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
|
||||
'x-origin': origin,
|
||||
'origin': origin
|
||||
};
|
||||
|
||||
if (info.session.logged_in) {
|
||||
|
||||
headers.Cookie = info.session.cookie;
|
||||
headers.authorization = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}`;
|
||||
}
|
||||
|
||||
return headers
|
||||
},
|
||||
VIDEO_INFO_REQBODY: (id, sts, context) => {
|
||||
return {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
'currentUrl': '/watch?v=' + id,
|
||||
'vis': 0,
|
||||
'splay': false,
|
||||
'autoCaptionsDefaultOn': false,
|
||||
'autonavState': 'STATE_OFF',
|
||||
'html5Preference': 'HTML5_PREF_WANTS',
|
||||
'signatureTimestamp': sts,
|
||||
'referer': 'https://www.youtube.com',
|
||||
'lactMilliseconds': '-1'
|
||||
}
|
||||
},
|
||||
context: context,
|
||||
videoId: id
|
||||
};
|
||||
},
|
||||
YTMUSIC_VERSION: '1.20211213.00.00',
|
||||
METADATA_KEYS: [
|
||||
'embed', 'view_count', 'average_rating', 'allow_ratings',
|
||||
'length_seconds', 'channel_id', 'channel_url',
|
||||
'external_channel_id', 'is_live_content', 'is_family_safe',
|
||||
'is_unlisted', 'is_private', 'has_ypc_metadata',
|
||||
'category', 'owner_channel_name', 'publish_date',
|
||||
'upload_date', 'keywords', 'available_countries',
|
||||
'owner_profile_url'
|
||||
],
|
||||
BLACKLISTED_KEYS: [
|
||||
'is_owner_viewing', 'is_unplugged_corpus',
|
||||
'is_crawlable', 'author'
|
||||
],
|
||||
ACCOUNT_SETTINGS: {
|
||||
// Notifications
|
||||
SUBSCRIPTIONS: 'NOTIFICATION_SUBSCRIPTION_NOTIFICATIONS',
|
||||
RECOMMENDED_VIDEOS: 'NOTIFICATION_RECOMMENDATION_WEB_CONTROL',
|
||||
CHANNEL_ACTIVITY: 'NOTIFICATION_COMMENT_WEB_CONTROL',
|
||||
COMMENT_REPLIES: 'NOTIFICATION_COMMENT_REPLY_OTHER_WEB_CONTROL',
|
||||
USER_MENTION: 'NOTIFICATION_USER_MENTION_WEB_CONTROL',
|
||||
SHARED_CONTENT: 'NOTIFICATION_RETUBING_WEB_CONTROL',
|
||||
|
||||
// Privacy
|
||||
PLAYLISTS_PRIVACY: 'PRIVACY_DISCOVERABLE_SAVED_PLAYLISTS',
|
||||
SUBSCRIPTIONS_PRIVACY: 'PRIVACY_DISCOVERABLE_SUBSCRIPTIONS'
|
||||
},
|
||||
BASE64_DIALECT: {
|
||||
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
|
||||
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
|
||||
},
|
||||
NTOKEN_REGEX: {
|
||||
CALLS: /c\[(.*?)\]\((.+?)\)/g,
|
||||
PLACEHOLDERS: /c\[(.*?)\]=c/g,
|
||||
},
|
||||
FUNCS_REGEX: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/,
|
||||
FUNCS: {
|
||||
PUSH: 'd.push(e)',
|
||||
REVERSE_1: 'd.reverse()',
|
||||
REVERSE_2: 'function(d){for(var',
|
||||
SPLICE: 'd.length;d.splice(e,1)',
|
||||
SWAP0_1: 'd[0])[0])',
|
||||
SWAP0_2: 'f=d[0];d[0]',
|
||||
ROTATE_1: 'reverse().forEach',
|
||||
ROTATE_2: 'unshift(d.pop())',
|
||||
BASE64_DIA: 'function(){for(var',
|
||||
TRANSLATE_1: 'function(d,e){for(var f',
|
||||
TRANSLATE_2: 'function(d,e,f){var'
|
||||
}
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Crypto = require('crypto');
|
||||
const UserAgent = require('user-agents');
|
||||
const Flatten = require('flat');
|
||||
|
||||
function InnertubeError(message, info) {
|
||||
this.info = info;
|
||||
this.stack = Error(message).stack;
|
||||
}
|
||||
|
||||
InnertubeError.prototype = Object.create(Error.prototype);
|
||||
InnertubeError.prototype.constructor = InnertubeError;
|
||||
|
||||
class ParsingError extends InnertubeError {};
|
||||
class DownloadError extends InnertubeError {};
|
||||
class MissingParamError extends InnertubeError {};
|
||||
class UnavailableContentError extends InnertubeError {};
|
||||
class NoStreamingDataError extends InnertubeError {};
|
||||
|
||||
/**
|
||||
* Utility to help access deep properties of an object.
|
||||
*
|
||||
* @param {object} obj - The object.
|
||||
* @param {string} key - Key of the property being accessed.
|
||||
* @param {string} target - Anything that might be inside of the property.
|
||||
* @param {number} depth - Maximum number of nested objects to flatten.
|
||||
* @param {boolean} safe - If set to true arrays will be preserved.
|
||||
*/
|
||||
function findNode(obj, key, target, depth, safe = true) {
|
||||
const flat_obj = Flatten(obj, { safe, maxDepth: depth || 2 });
|
||||
const result = Object.keys(flat_obj).find((entry) => entry.includes(key) && JSON.stringify(flat_obj[entry] || '{}').includes(target));
|
||||
if (!result) throw new ParsingError(`Expected to find "${key}" with content "${target}" but got ${result}`, { key, target, data_snippet: `${JSON.stringify(flat_obj).slice(0, 300)}..` });
|
||||
return flat_obj[result];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a string between two delimiters.
|
||||
*
|
||||
* @param {string} data - The data.
|
||||
* @param {string} start_string - Start string.
|
||||
* @param {string} end_string - End string.
|
||||
*/
|
||||
function getStringBetweenStrings(data, start_string, end_string) {
|
||||
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, 's');
|
||||
const match = data.match(regex);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
function escapeStringRegexp(string) {
|
||||
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random user agent.
|
||||
*
|
||||
* @param {string} type - mobile | desktop
|
||||
* @returns {object}
|
||||
*/
|
||||
function getRandomUserAgent(type) {
|
||||
switch (type) {
|
||||
case 'mobile':
|
||||
return new UserAgent(/Android/).data;
|
||||
case 'desktop':
|
||||
return new UserAgent({ deviceCategory: 'desktop' }).data;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an authentication token from a cookies' sid.
|
||||
*
|
||||
* @param {string} sid - Sid extracted from cookies
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateSidAuth(sid) {
|
||||
const youtube = 'https://www.youtube.com';
|
||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||
const input = [timestamp, sid, youtube].join(' ');
|
||||
|
||||
let hash = Crypto.createHash('sha1');
|
||||
let data = hash.update(input, 'utf-8');
|
||||
let gen_hash = data.digest('hex');
|
||||
|
||||
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts time (h:m:s) to seconds.
|
||||
*
|
||||
* @param {string} time
|
||||
* @returns {number} seconds
|
||||
*/
|
||||
function timeToSeconds(time) {
|
||||
let params = time.split(':');
|
||||
return parseInt(({
|
||||
3: +params[0] * 3600 + +params[1] * 60 + +params[2],
|
||||
2: +params[0] * 60 + +params[1],
|
||||
1: +params[0]
|
||||
})[params.length]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts strings in camelCase to snake_case.
|
||||
*
|
||||
* @param {string} string The string in camelCase.
|
||||
* @returns {string}
|
||||
*/
|
||||
function camelToSnake(string) {
|
||||
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the ntoken transform data into a valid json array
|
||||
*
|
||||
* @param {string} data
|
||||
* @returns {string}
|
||||
*/
|
||||
function refineNTokenData(data) {
|
||||
return data
|
||||
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
|
||||
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
|
||||
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)').replace(/,b,/g, ',"b",')
|
||||
.replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
|
||||
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",')
|
||||
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
|
||||
}
|
||||
|
||||
const errors = { UnavailableContentError, ParsingError, DownloadError, InnertubeError, MissingParamError, NoStreamingDataError };
|
||||
const functions = { findNode, getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, refineNTokenData };
|
||||
|
||||
module.exports = { ...functions, ...errors };
|
||||
9638
package-lock.json
generated
9638
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
88
package.json
88
package.json
@@ -1,31 +1,62 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.4.0",
|
||||
"description": "A full-featured library that allows you to get detailed info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!",
|
||||
"main": "index.js",
|
||||
"version": "2.9.0",
|
||||
"description": "Full-featured wrapper around YouTube's private API. Supports YouTube, YouTube Music and YouTube Studio (WIP).",
|
||||
"main": "./dist/index.js",
|
||||
"browser": "./bundle/browser.js",
|
||||
"types": "./dist",
|
||||
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
|
||||
"funding": "https://ko-fi.com/luanrt",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
],
|
||||
"contributors": [
|
||||
"Wykerd (https://github.com/wykerd/)",
|
||||
"MasterOfBob777 (https://github.com/MasterOfBob777)",
|
||||
"patrickkfkan (https://github.com/patrickkfkan)",
|
||||
"akkadaska (https://github.com/akkadaska)"
|
||||
],
|
||||
"directories": {
|
||||
"test": "./test",
|
||||
"examples": "./examples",
|
||||
"dist": "./dist"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node test"
|
||||
},
|
||||
"directories": {
|
||||
"example": "examples",
|
||||
"lib": "lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"flat": "^5.0.2",
|
||||
"protons": "^2.0.3",
|
||||
"user-agents": "^1.0.778",
|
||||
"uuid": "^8.3.2"
|
||||
"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",
|
||||
"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",
|
||||
"prepare": "npm run build",
|
||||
"watch": "npx tsc --watch"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https//github.com/LuanRT/YouTube.js.git"
|
||||
"url": "git+https://github.com/LuanRT/YouTube.js.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@protobuf-ts/runtime": "^2.7.0",
|
||||
"jintr": "^0.3.1",
|
||||
"linkedom": "^0.14.12",
|
||||
"undici": "^5.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@protobuf-ts/plugin": "^2.7.0",
|
||||
"@types/jest": "^28.1.7",
|
||||
"@types/node": "^17.0.45",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"esbuild": "^0.14.49",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.16",
|
||||
"glob": "^8.0.3",
|
||||
"jest": "^28.1.3",
|
||||
"ts-jest": "^28.0.8",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/LuanRT/YouTube.js/issues"
|
||||
@@ -33,20 +64,25 @@
|
||||
"homepage": "https://github.com/LuanRT/YouTube.js#readme",
|
||||
"keywords": [
|
||||
"yt",
|
||||
"dl",
|
||||
"ytdl",
|
||||
"youtube",
|
||||
"youtube-dl",
|
||||
"youtubedl",
|
||||
"youtube-dl",
|
||||
"youtube-downloader",
|
||||
"innertube",
|
||||
"youtube-music",
|
||||
"youtube-studio",
|
||||
"innertubeapi",
|
||||
"innertube",
|
||||
"unofficial",
|
||||
"downloader",
|
||||
"livechat",
|
||||
"dislike",
|
||||
"studio",
|
||||
"upload",
|
||||
"ytmusic",
|
||||
"search",
|
||||
"comment",
|
||||
"like",
|
||||
"api",
|
||||
"dl"
|
||||
"music",
|
||||
"api"
|
||||
]
|
||||
}
|
||||
|
||||
48
scripts/build-parser-map.js
Normal file
48
scripts/build-parser-map.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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')}
|
||||
|
||||
export const YTNodes = {
|
||||
${json.join(',\n ')}
|
||||
};
|
||||
|
||||
const map: Record<string, YTNodeConstructor> = YTNodes;
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
`
|
||||
);
|
||||
52
scripts/get-agents.mjs
Normal file
52
scripts/get-agents.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import { fetch } from "undici";
|
||||
import { gunzip } from "zlib";
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { writeFile } from "fs/promises";
|
||||
|
||||
(async () => {
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const buf = await (await fetch('https://github.com/intoli/user-agents/blob/master/src/user-agents.json.gz?raw=true')).arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
|
||||
// Only get desktop and mobile agents
|
||||
const allowed_agents = new Set([
|
||||
'desktop',
|
||||
'mobile',
|
||||
])
|
||||
|
||||
const decompressed = await new Promise((resolve, reject) => {
|
||||
gunzip(bytes, (err, result) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(result.buffer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const contents = new TextDecoder().decode(decompressed);
|
||||
|
||||
const agents = JSON.parse(contents);
|
||||
|
||||
if (!Array.isArray(agents)) {
|
||||
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);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
await writeFile(resolve(__dirname, '..', 'src', 'utils', 'user-agents.json'), JSON.stringify(agentsByDevice, null, 2));
|
||||
|
||||
})();
|
||||
288
src/Innertube.ts
Normal file
288
src/Innertube.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
|
||||
import Session, { SessionOptions } from './core/Session';
|
||||
import type { ParsedResponse } from './parser';
|
||||
import type { ActionsResponse } from './core/Actions';
|
||||
|
||||
import NavigationEndpoint from './parser/classes/NavigationEndpoint';
|
||||
import Channel from './parser/youtube/Channel';
|
||||
import Comments from './parser/youtube/Comments';
|
||||
import History from './parser/youtube/History';
|
||||
import Library from './parser/youtube/Library';
|
||||
import NotificationsMenu from './parser/youtube/NotificationsMenu';
|
||||
import Playlist from './parser/youtube/Playlist';
|
||||
import Search from './parser/youtube/Search';
|
||||
import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo';
|
||||
|
||||
import AccountManager from './core/AccountManager';
|
||||
import Feed from './core/Feed';
|
||||
import InteractionManager from './core/InteractionManager';
|
||||
import YTMusic from './core/Music';
|
||||
import PlaylistManager from './core/PlaylistManager';
|
||||
import Studio from './core/Studio';
|
||||
import TabbedFeed from './core/TabbedFeed';
|
||||
import HomeFeed from './parser/youtube/HomeFeed';
|
||||
import Proto from './proto/index';
|
||||
import Constants from './utils/Constants';
|
||||
|
||||
import type Actions from './core/Actions';
|
||||
import type Format from './parser/classes/misc/Format';
|
||||
|
||||
import { generateRandomString, throwIfMissing } from './utils/Utils';
|
||||
|
||||
export type InnertubeConfig = SessionOptions;
|
||||
|
||||
export interface SearchFilters {
|
||||
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')[];
|
||||
}
|
||||
|
||||
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED';
|
||||
|
||||
class Innertube {
|
||||
session: Session;
|
||||
account: AccountManager;
|
||||
playlist: PlaylistManager;
|
||||
interact: InteractionManager;
|
||||
music: YTMusic;
|
||||
studio: Studio;
|
||||
actions: Actions;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static async create(config: InnertubeConfig = {}): Promise<Innertube> {
|
||||
return new Innertube(await Session.create(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves video info.
|
||||
* @param video_id - The video id.
|
||||
* @param client - The client to use.
|
||||
*/
|
||||
async getInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = this.actions.getVideoInfo(video_id, cpn, client);
|
||||
const continuation = this.actions.execute('/next', { videoId: video_id });
|
||||
|
||||
const response = await Promise.all([ initial_info, continuation ]);
|
||||
return new VideoInfo(response, this.actions, this.session.player, cpn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves basic video info.
|
||||
* @param video_id - The video id.
|
||||
* @param client - The client to use.
|
||||
*/
|
||||
async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
const cpn = generateRandomString(16);
|
||||
const response = await this.actions.getVideoInfo(video_id, cpn, client);
|
||||
|
||||
return new VideoInfo([ response ], this.actions, this.session.player, cpn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches a given query.
|
||||
* @param query - The search query.
|
||||
* @param filters - Search filters.
|
||||
*/
|
||||
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
|
||||
throwIfMissing({ query });
|
||||
|
||||
const args = {
|
||||
query,
|
||||
...{
|
||||
params: filters ? Proto.encodeSearchFilters(filters) : undefined
|
||||
}
|
||||
};
|
||||
|
||||
const response = await this.actions.execute('/search', args);
|
||||
|
||||
return new Search(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions for a given 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('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_data = await response.text();
|
||||
|
||||
const data = JSON.parse(response_data.replace(')]}\'', ''));
|
||||
const suggestions = data[1].map((suggestion: any) => suggestion[0]);
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves comments for a video.
|
||||
* @param video_id - The video id.
|
||||
* @param sort_by - Sorting options.
|
||||
*/
|
||||
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('/next', { continuation: payload });
|
||||
|
||||
return new Comments(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves YouTube's home feed (aka recommendations).
|
||||
*/
|
||||
async getHomeFeed(): Promise<HomeFeed> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
|
||||
return new HomeFeed(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account's library.
|
||||
*/
|
||||
async getLibrary(): Promise<Library> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FElibrary' });
|
||||
return new Library(response.data, this.actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves watch history.
|
||||
* Which can also be achieved with {@link getLibrary}.
|
||||
*/
|
||||
async getHistory(): Promise<History> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
|
||||
return new History(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves trending content.
|
||||
*/
|
||||
async getTrending(): Promise<TabbedFeed> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEtrending' });
|
||||
return new TabbedFeed(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves subscriptions feed.
|
||||
*/
|
||||
async getSubscriptionsFeed(): Promise<Feed> {
|
||||
const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions' });
|
||||
return new Feed(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves contents for a given channel.
|
||||
* @param id - Channel id
|
||||
*/
|
||||
async getChannel(id: string): Promise<Channel> {
|
||||
throwIfMissing({ id });
|
||||
const response = await this.actions.execute('/browse', { browseId: id });
|
||||
return new Channel(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves notifications.
|
||||
*/
|
||||
async getNotifications(): Promise<NotificationsMenu> {
|
||||
const response = await this.actions.execute('/notification/get_notification_menu', { notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' });
|
||||
return new NotificationsMenu(this.actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves unseen notifications count.
|
||||
*/
|
||||
async getUnseenNotificationsCount(): Promise<number> {
|
||||
const response = await this.actions.execute('/notification/get_unseen_count');
|
||||
// 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): Promise<Playlist> {
|
||||
throwIfMissing({ id });
|
||||
|
||||
if (!id.startsWith('VL')) {
|
||||
id = `VL${id}`;
|
||||
}
|
||||
|
||||
const response = await this.actions.execute('/browse', { browseId: id });
|
||||
return new Playlist(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* An alternative to {@link download}.
|
||||
* 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 = {}): 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): Promise<ReadableStream<Uint8Array>> {
|
||||
const info = await this.getBasicInfo(video_id, options?.client);
|
||||
return info.download(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the given URL.
|
||||
* @param url - The URL.
|
||||
*/
|
||||
async resolveURL(url: string): Promise<NavigationEndpoint> {
|
||||
const response = await this.actions.execute('/navigation/resolve_url', { url, parse: true });
|
||||
return response.endpoint as NavigationEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to call an endpoint without having to use {@link Actions}.
|
||||
* @param endpoint -The endpoint to call.
|
||||
* @param args - Call arguments.
|
||||
*/
|
||||
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.call(this.actions, args);
|
||||
}
|
||||
}
|
||||
|
||||
export default Innertube;
|
||||
110
src/core/AccountManager.ts
Normal file
110
src/core/AccountManager.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import Proto from '../proto/index';
|
||||
import type Actions from './Actions';
|
||||
import type { ActionsResponse } 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';
|
||||
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
|
||||
class AccountManager {
|
||||
#actions: Actions;
|
||||
|
||||
channel: {
|
||||
editName: (new_name: string) => Promise<ActionsResponse>;
|
||||
editDescription: (new_description: string) => Promise<ActionsResponse>;
|
||||
getBasicAnalytics: () => Promise<Analytics>;
|
||||
};
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
|
||||
this.channel = {
|
||||
/**
|
||||
* Edits channel name.
|
||||
* @param new_name - The new channel name.
|
||||
*/
|
||||
editName: (new_name: string) => {
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
return this.#actions.execute('/channel/edit_name', {
|
||||
givenName: new_name,
|
||||
client: 'ANDROID'
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Edits channel description.
|
||||
* @param new_description - The new description.
|
||||
*/
|
||||
editDescription: (new_description: string) => {
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
return this.#actions.execute('/channel/edit_description', {
|
||||
givenDescription: new_description,
|
||||
client: 'ANDROID'
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Retrieves basic channel analytics.
|
||||
*/
|
||||
getBasicAnalytics: () => this.getAnalytics()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves channel info.
|
||||
*/
|
||||
async getInfo(): Promise<AccountInfo> {
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const response = await this.#actions.execute('/account/accounts_list', { client: 'ANDROID' });
|
||||
return new AccountInfo(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves time watched statistics.
|
||||
*/
|
||||
async getTimeWatched(): Promise<TimeWatched> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'SPtime_watched',
|
||||
client: 'ANDROID'
|
||||
});
|
||||
|
||||
return new TimeWatched(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens YouTube settings.
|
||||
*/
|
||||
async getSettings(): Promise<Settings> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'SPaccount_overview'
|
||||
});
|
||||
|
||||
return new Settings(this.#actions, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves basic channel analytics.
|
||||
*/
|
||||
async getAnalytics(): Promise<Analytics> {
|
||||
const info = await this.getInfo();
|
||||
|
||||
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
|
||||
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'FEanalytics_screen',
|
||||
client: 'ANDROID',
|
||||
params
|
||||
});
|
||||
|
||||
return new Analytics(response);
|
||||
}
|
||||
}
|
||||
|
||||
export default AccountManager;
|
||||
199
src/core/Actions.ts
Normal file
199
src/core/Actions.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import Parser, { ParsedResponse } from '../parser/index';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import type Session from './Session';
|
||||
|
||||
export interface ApiResponse {
|
||||
success: boolean;
|
||||
status_code: number;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export type ActionsResponse = Promise<ApiResponse>;
|
||||
|
||||
class Actions {
|
||||
#session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = 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): Promise<ApiResponse> {
|
||||
return {
|
||||
success: response.ok,
|
||||
status_code: response.status,
|
||||
data: JSON.parse(await response.text())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to retrieve video info.
|
||||
* @param id - The video ID.
|
||||
* @param cpn - Content Playback Nonce.
|
||||
* @param client - The client to use.
|
||||
* @param playlist_id - The playlist ID.
|
||||
*/
|
||||
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise<ActionsResponse> {
|
||||
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 || 0,
|
||||
autoCaptionsDefaultOn: false,
|
||||
html5Preference: 'HTML5_PREF_WANTS',
|
||||
lactMilliseconds: '-1'
|
||||
}
|
||||
},
|
||||
attestationRequest: {
|
||||
omitBotguardData: true
|
||||
},
|
||||
videoId: id
|
||||
};
|
||||
|
||||
if (client) {
|
||||
data.client = client;
|
||||
}
|
||||
|
||||
if (cpn) {
|
||||
data.cpn = cpn;
|
||||
}
|
||||
|
||||
if (playlist_id) {
|
||||
data.playlistId = playlist_id;
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch('/player', {
|
||||
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 }): Promise<Response> {
|
||||
const s_url = new URL(url);
|
||||
|
||||
s_url.searchParams.set('ver', '2');
|
||||
s_url.searchParams.set('c', client.client_name.toLowerCase());
|
||||
s_url.searchParams.set('cbrver', client.client_version);
|
||||
s_url.searchParams.set('cver', client.client_version);
|
||||
|
||||
for (const key of Object.keys(params)) {
|
||||
s_url.searchParams.set(key, params[key]);
|
||||
}
|
||||
|
||||
const response = await this.#session.http.fetch(s_url);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an API call.
|
||||
* @param action - 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> {
|
||||
let data;
|
||||
|
||||
if (args && !args.protobuf) {
|
||||
data = { ...args };
|
||||
|
||||
if (Reflect.has(data, 'browseId')) {
|
||||
if (this.#needsLogin(data.browseId) && !this.#session.logged_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;
|
||||
|
||||
if (Reflect.has(data, 'request'))
|
||||
delete data.request;
|
||||
|
||||
if (Reflect.has(data, 'clientActions'))
|
||||
delete data.clientActions;
|
||||
|
||||
if (Reflect.has(data, 'settingItemIdForClient'))
|
||||
delete data.settingItemIdForClient;
|
||||
|
||||
if (Reflect.has(data, 'action')) {
|
||||
data.actions = [ data.action ];
|
||||
delete data.action;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'boolValue')) {
|
||||
data.newValue = { boolValue: data.boolValue };
|
||||
delete data.boolValue;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'token')) {
|
||||
data.continuation = data.token;
|
||||
delete data.token;
|
||||
}
|
||||
|
||||
if (data?.client === 'YTMUSIC') {
|
||||
data.isAudioOnly = true;
|
||||
}
|
||||
} else if (args) {
|
||||
data = args.serialized_data;
|
||||
}
|
||||
|
||||
const endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : action;
|
||||
|
||||
const response = await this.#session.http.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: args?.protobuf ? data : JSON.stringify((data || {})),
|
||||
headers: {
|
||||
'Content-Type': args?.protobuf ?
|
||||
'application/x-protobuf' :
|
||||
'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (args?.parse) {
|
||||
return Parser.parseResponse(await response.json());
|
||||
}
|
||||
|
||||
return this.#wrap(response);
|
||||
}
|
||||
|
||||
#needsLogin(id: string) {
|
||||
return [
|
||||
'FElibrary',
|
||||
'FEhistory',
|
||||
'FEsubscriptions',
|
||||
'FEmusic_listening_review',
|
||||
'FEmusic_library_landing',
|
||||
'SPaccount_overview',
|
||||
'SPaccount_notifications',
|
||||
'SPaccount_privacy',
|
||||
'SPtime_watched'
|
||||
].includes(id);
|
||||
}
|
||||
}
|
||||
|
||||
export default Actions;
|
||||
204
src/core/Feed.ts
Normal file
204
src/core/Feed.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers';
|
||||
import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index';
|
||||
import { concatMemos, InnertubeError } from '../utils/Utils';
|
||||
import type Actions from './Actions';
|
||||
|
||||
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 Post from '../parser/classes/Post';
|
||||
import ReelItem from '../parser/classes/ReelItem';
|
||||
import ReelShelf from '../parser/classes/ReelShelf';
|
||||
import RichShelf from '../parser/classes/RichShelf';
|
||||
import Shelf from '../parser/classes/Shelf';
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import Video from '../parser/classes/Video';
|
||||
|
||||
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction';
|
||||
import ContinuationItem from '../parser/classes/ContinuationItem';
|
||||
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults';
|
||||
import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults';
|
||||
import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo';
|
||||
|
||||
import type MusicQueue from '../parser/classes/MusicQueue';
|
||||
import type RichGrid from '../parser/classes/RichGrid';
|
||||
import type SectionList from '../parser/classes/SectionList';
|
||||
|
||||
class Feed {
|
||||
#page: ParsedResponse;
|
||||
#continuation?: ObservedArray<ContinuationItem>;
|
||||
#actions: Actions;
|
||||
#memo: 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);
|
||||
}
|
||||
|
||||
const memo = concatMemos(
|
||||
this.#page.contents_memo,
|
||||
this.#page.on_response_received_commands_memo,
|
||||
this.#page.on_response_received_endpoints_memo,
|
||||
this.#page.on_response_received_actions_memo,
|
||||
this.#page.sidebar_memo,
|
||||
this.#page.header_memo
|
||||
);
|
||||
|
||||
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 | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
|
||||
Video,
|
||||
GridVideo,
|
||||
ReelItem,
|
||||
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 page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand {
|
||||
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.get({ title });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns secondary contents from the page.
|
||||
*/
|
||||
get secondary_contents(): SuperParsedResult<YTNode> | undefined {
|
||||
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(): Actions {
|
||||
return this.#actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original page data
|
||||
*/
|
||||
get page(): ParsedResponse {
|
||||
return this.#page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the feed has continuation.
|
||||
*/
|
||||
get has_continuation(): boolean {
|
||||
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, { parse: 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(): Promise<Feed> {
|
||||
const continuation_data = await this.getContinuationData();
|
||||
return new Feed(this.actions, continuation_data, true);
|
||||
}
|
||||
}
|
||||
|
||||
export default Feed;
|
||||
69
src/core/FilterableFeed.ts
Normal file
69
src/core/FilterableFeed.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import ChipCloudChip from '../parser/classes/ChipCloudChip';
|
||||
import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar';
|
||||
import Feed from './Feed';
|
||||
|
||||
import type { ObservedArray } from '../parser/helpers';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import type Actions from './Actions';
|
||||
|
||||
class FilterableFeed extends Feed {
|
||||
#chips?: ObservedArray<ChipCloudChip>;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filter chips.
|
||||
*/
|
||||
get filter_chips(): ObservedArray<ChipCloudChip> {
|
||||
if (this.#chips)
|
||||
return this.#chips || [];
|
||||
|
||||
if (this.memo.getType(FeedFilterChipBar)?.length > 1)
|
||||
throw new InnertubeError('There are too many feed filter chipbars, you\'ll need to find the correct one yourself in this.page');
|
||||
|
||||
if (this.memo.getType(FeedFilterChipBar)?.length === 0)
|
||||
throw new InnertubeError('There are no feed filter chipbars');
|
||||
|
||||
this.#chips = this.memo.getType(ChipCloudChip);
|
||||
|
||||
return this.#chips || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available filters.
|
||||
*/
|
||||
get filters(): string[] {
|
||||
return this.filter_chips.map((chip) => chip.text.toString()) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies given filter and returns a new {@link Feed} object.
|
||||
*/
|
||||
async getFilteredFeed(filter: string | ChipCloudChip): Promise<Feed> {
|
||||
let target_filter: ChipCloudChip | undefined;
|
||||
|
||||
if (typeof filter === 'string') {
|
||||
if (!this.filters.includes(filter))
|
||||
throw new InnertubeError('Filter not found', { available_filters: this.filters });
|
||||
target_filter = this.filter_chips.find((chip) => chip.text.toString() === filter);
|
||||
} else if (filter.type === 'ChipCloudChip') {
|
||||
target_filter = filter;
|
||||
} else {
|
||||
throw new InnertubeError('Invalid filter');
|
||||
}
|
||||
|
||||
if (!target_filter)
|
||||
throw new InnertubeError('Filter not found');
|
||||
|
||||
if (target_filter.is_selected)
|
||||
return this;
|
||||
|
||||
const response = await target_filter.endpoint?.call(this.actions, { parse: true });
|
||||
|
||||
return new Feed(this.actions, response, true);
|
||||
}
|
||||
}
|
||||
|
||||
export default FilterableFeed;
|
||||
187
src/core/InteractionManager.ts
Normal file
187
src/core/InteractionManager.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import Proto from '../proto';
|
||||
import type Actions from './Actions';
|
||||
import type { ApiResponse } from './Actions';
|
||||
import { throwIfMissing } from '../utils/Utils';
|
||||
|
||||
class InteractionManager {
|
||||
#actions: Actions;
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Likes a given video.
|
||||
* @param video_id - The video ID
|
||||
*/
|
||||
async like(video_id: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/like/like', {
|
||||
client: 'ANDROID',
|
||||
target: {
|
||||
videoId: video_id
|
||||
}
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dislikes a given video.
|
||||
* @param video_id - The video ID
|
||||
*/
|
||||
async dislike(video_id: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/like/dislike', {
|
||||
client: 'ANDROID',
|
||||
target: {
|
||||
videoId: video_id
|
||||
}
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a like/dislike.
|
||||
* @param video_id - The video ID
|
||||
*/
|
||||
async removeRating(video_id: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ video_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/like/removelike', {
|
||||
client: 'ANDROID',
|
||||
target: {
|
||||
videoId: video_id
|
||||
}
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a given channel.
|
||||
* @param channel_id - The channel ID
|
||||
*/
|
||||
async subscribe(channel_id: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ channel_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/subscription/subscribe', {
|
||||
client: 'ANDROID',
|
||||
channelIds: [ channel_id ],
|
||||
params: 'EgIIAhgA'
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from a given channel.
|
||||
* @param channel_id - The channel ID
|
||||
*/
|
||||
async unsubscribe(channel_id: string): Promise<ApiResponse>{
|
||||
throwIfMissing({ channel_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/subscription/unsubscribe', {
|
||||
client: 'ANDROID',
|
||||
channelIds: [ channel_id ],
|
||||
params: 'CgIIAhgA'
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a comment on a given video.
|
||||
* @param video_id - The video ID
|
||||
* @param text - The comment text
|
||||
*/
|
||||
async comment(video_id: string, text: string): Promise<ApiResponse> {
|
||||
throwIfMissing({ video_id, text });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const action = await this.#actions.execute('/comment/create_comment', {
|
||||
client: 'ANDROID',
|
||||
commentText: text,
|
||||
createCommentParams: Proto.encodeCommentParams(video_id)
|
||||
});
|
||||
|
||||
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 target_action = Proto.encodeCommentActionParams(22, { text, target_language, ...args });
|
||||
|
||||
const response = await this.#actions.execute('/comment/perform_comment_action', {
|
||||
client: 'ANDROID',
|
||||
actions: [ target_action ]
|
||||
});
|
||||
|
||||
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.
|
||||
* @param channel_id - The channel ID.
|
||||
* @param type - The notification type.
|
||||
*/
|
||||
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE'): Promise<ApiResponse> {
|
||||
throwIfMissing({ channel_id, type });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new Error('You must be signed in to perform this operation.');
|
||||
|
||||
const pref_types = {
|
||||
PERSONALIZED: 1,
|
||||
ALL: 2,
|
||||
NONE: 3
|
||||
};
|
||||
|
||||
if (!Object.keys(pref_types).includes(type.toUpperCase()))
|
||||
throw new Error(`Invalid notification preference type: ${type}`);
|
||||
|
||||
const action = await this.#actions.execute('/notification/modify_channel_preference', {
|
||||
client: 'WEB',
|
||||
params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types])
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
export default InteractionManager;
|
||||
360
src/core/Music.ts
Normal file
360
src/core/Music.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
|
||||
import Album from '../parser/ytmusic/Album';
|
||||
import Artist from '../parser/ytmusic/Artist';
|
||||
import Explore from '../parser/ytmusic/Explore';
|
||||
import HomeFeed from '../parser/ytmusic/HomeFeed';
|
||||
import Library from '../parser/ytmusic/Library';
|
||||
import Playlist from '../parser/ytmusic/Playlist';
|
||||
import Recap from '../parser/ytmusic/Recap';
|
||||
import Search from '../parser/ytmusic/Search';
|
||||
import TrackInfo from '../parser/ytmusic/TrackInfo';
|
||||
|
||||
import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo';
|
||||
import Message from '../parser/classes/Message';
|
||||
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf';
|
||||
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf';
|
||||
import MusicQueue from '../parser/classes/MusicQueue';
|
||||
import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem';
|
||||
import PlaylistPanel from '../parser/classes/PlaylistPanel';
|
||||
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection';
|
||||
import SectionList from '../parser/classes/SectionList';
|
||||
import Tab from '../parser/classes/Tab';
|
||||
|
||||
import { observe } from '../parser/helpers';
|
||||
import Proto from '../proto';
|
||||
import { generateRandomString, InnertubeError, throwIfMissing } from '../utils/Utils';
|
||||
|
||||
import type { ObservedArray, YTNode } from '../parser/helpers';
|
||||
import type Actions from './Actions';
|
||||
import type Session from './Session';
|
||||
|
||||
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 cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = this.#actions.execute('/player', {
|
||||
cpn,
|
||||
client: 'YTMUSIC',
|
||||
videoId: video_id,
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: this.#session.player?.sts || 0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const continuation = this.#actions.execute('/next', {
|
||||
client: 'YTMUSIC',
|
||||
videoId: video_id
|
||||
});
|
||||
|
||||
const response = await Promise.all([ initial_info, continuation ]);
|
||||
return new TrackInfo(response, this.#actions, cpn);
|
||||
}
|
||||
|
||||
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): 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 cpn = generateRandomString(16);
|
||||
|
||||
const initial_info = list_item.endpoint.call(this.#actions, {
|
||||
cpn,
|
||||
client: 'YTMUSIC',
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: this.#session.player?.sts || 0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const continuation = list_item.endpoint.call(this.#actions, {
|
||||
client: 'YTMUSIC',
|
||||
enablePersistentPlaylistPanel: true,
|
||||
override_endpoint: '/next'
|
||||
});
|
||||
|
||||
const response = await Promise.all([ initial_info, continuation ]);
|
||||
return new TrackInfo(response, this.#actions, cpn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches on YouTube Music.
|
||||
* @param query - Search query.
|
||||
* @param filters - Search filters.
|
||||
*/
|
||||
async search(query: string, filters: {
|
||||
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
|
||||
} = {}): Promise<Search> {
|
||||
throwIfMissing({ query });
|
||||
|
||||
const payload: {
|
||||
query: string;
|
||||
client: string;
|
||||
params?: string;
|
||||
} = { query, client: 'YTMUSIC' };
|
||||
|
||||
if (filters.type && filters.type !== 'all') {
|
||||
payload.params = Proto.encodeMusicSearchFilters(filters);
|
||||
}
|
||||
|
||||
const response = await this.#actions.execute('/search', payload);
|
||||
|
||||
return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the home feed.
|
||||
*/
|
||||
async getHomeFeed(): Promise<HomeFeed> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: 'FEmusic_home'
|
||||
});
|
||||
|
||||
return new HomeFeed(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Explore feed.
|
||||
*/
|
||||
async getExplore(): Promise<Explore> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: '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('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: '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('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: 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('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: album_id
|
||||
});
|
||||
|
||||
return new Album(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('/browse', {
|
||||
client: 'YTMUSIC',
|
||||
browseId: 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 data = await this.#actions.execute('/next', {
|
||||
videoId: video_id,
|
||||
client: 'YTMUSIC',
|
||||
parse: true
|
||||
});
|
||||
|
||||
const tabs = data.contents_memo.getType(Tab);
|
||||
|
||||
const tab = tabs?.[0];
|
||||
|
||||
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)
|
||||
throw new InnertubeError('Could not fetch automix');
|
||||
|
||||
return page.contents_memo.getType(PlaylistPanel)?.[0];
|
||||
}
|
||||
|
||||
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 data = await this.#actions.execute('/next', {
|
||||
videoId: video_id,
|
||||
client: 'YTMUSIC',
|
||||
parse: true
|
||||
});
|
||||
|
||||
const tabs = data.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 });
|
||||
|
||||
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 data = await this.#actions.execute('/next', {
|
||||
videoId: video_id,
|
||||
client: 'YTMUSIC',
|
||||
parse: true
|
||||
});
|
||||
|
||||
const tabs = data.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.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;
|
||||
return section_list.firstOfType(MusicDescriptionShelf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves recap.
|
||||
*/
|
||||
async getRecap(): Promise<Recap> {
|
||||
const response = await this.#actions.execute('/browse', {
|
||||
browseId: 'FEmusic_listening_review',
|
||||
client: 'YTMUSIC_ANDROID'
|
||||
});
|
||||
|
||||
return new Recap(response, this.#actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions for the given query.
|
||||
* @param query - The query.
|
||||
*/
|
||||
async getSearchSuggestions(query: string): Promise<ObservedArray<YTNode>> {
|
||||
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;
|
||||
269
src/core/OAuth.ts
Normal file
269
src/core/OAuth.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import Constants from '../utils/Constants';
|
||||
import { OAuthError, uuidv4 } from '../utils/Utils';
|
||||
import type Session from './Session';
|
||||
|
||||
export interface Credentials {
|
||||
/**
|
||||
* Token used to sign in.
|
||||
*/
|
||||
access_token: string;
|
||||
/**
|
||||
* Token used to get a new access token.
|
||||
*/
|
||||
refresh_token: string;
|
||||
/**
|
||||
* Access token's expiration date, which is usually 24hrs-ish.
|
||||
*/
|
||||
expires: Date;
|
||||
}
|
||||
|
||||
// TODO: actual type info for this.
|
||||
export type OAuthAuthPendingData = any;
|
||||
|
||||
export type OAuthAuthEventHandler = (data: {
|
||||
credentials: Credentials;
|
||||
status: 'SUCCESS';
|
||||
}) => any;
|
||||
|
||||
export type OAuthAuthPendingEventHandler = (data: OAuthAuthPendingData) => any;
|
||||
export type OAuthAuthErrorEventHandler = (err: OAuthError) => any;
|
||||
|
||||
class OAuth {
|
||||
#identity?: Record<string, string>;
|
||||
#session: Session;
|
||||
#credentials?: Credentials;
|
||||
#polling_interval = 5;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the auth flow in case no valid credentials are available.
|
||||
*/
|
||||
async init(credentials?: Credentials): Promise<void> {
|
||||
this.#credentials = credentials;
|
||||
|
||||
if (this.validateCredentials()) {
|
||||
if (!this.has_access_token_expired)
|
||||
this.#session.emit('auth', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
} else if (!(await this.#loadCachedCredentials())) {
|
||||
await this.#getUserCode();
|
||||
}
|
||||
}
|
||||
|
||||
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(): Promise<boolean> {
|
||||
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
|
||||
if (!data) return false;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const credentials = JSON.parse(decoder.decode(data));
|
||||
|
||||
this.#credentials = {
|
||||
access_token: credentials.access_token,
|
||||
refresh_token: credentials.refresh_token,
|
||||
expires: new Date(credentials.expires)
|
||||
};
|
||||
|
||||
this.#session.emit('auth', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeCache(): Promise<void> {
|
||||
await this.#session.cache?.remove('youtubei_oauth_credentials');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server for a user code and verification URL.
|
||||
*/
|
||||
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
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const response_data = await response.json();
|
||||
|
||||
this.#session.emit('auth-pending', response_data);
|
||||
this.#polling_interval = response_data.interval;
|
||||
this.#startPolling(response_data.device_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the authorization server until access is granted by the user.
|
||||
*/
|
||||
#startPolling(device_code: string): void {
|
||||
const poller = setInterval(async () => {
|
||||
const data = {
|
||||
...this.#identity,
|
||||
code: device_code,
|
||||
grant_type: Constants.OAUTH.GRANT_TYPE
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const response_data = await response.json();
|
||||
|
||||
if (response_data.error) {
|
||||
switch (response_data.error) {
|
||||
case 'access_denied':
|
||||
this.#session.emit('auth-error', new OAuthError('Access was denied.', { status: 'ACCESS_DENIED' }));
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.#session.emit('auth-error', new OAuthError('The device code has expired, restarting auth flow.', { status: 'DEVICE_CODE_EXPIRED' }));
|
||||
clearInterval(poller);
|
||||
this.#getUserCode();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
|
||||
|
||||
this.#credentials = {
|
||||
access_token: response_data.access_token,
|
||||
refresh_token: response_data.refresh_token,
|
||||
expires: expiration_date
|
||||
};
|
||||
|
||||
this.#session.emit('auth', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
|
||||
clearInterval(poller);
|
||||
} catch (err) {
|
||||
clearInterval(poller);
|
||||
return this.#session.emit('auth-error', new OAuthError('Could not obtain user code.', { status: 'FAILED', error: err }));
|
||||
}
|
||||
}, this.#polling_interval * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token if the same has expired.
|
||||
*/
|
||||
async refreshIfRequired(): Promise<void> {
|
||||
if (this.has_access_token_expired) {
|
||||
await this.#refreshAccessToken();
|
||||
}
|
||||
}
|
||||
|
||||
async #refreshAccessToken(): Promise<void> {
|
||||
if (!this.#credentials) return;
|
||||
this.#identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
...this.#identity,
|
||||
refresh_token: this.#credentials.refresh_token,
|
||||
grant_type: 'refresh_token'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const response_data = await response.json();
|
||||
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
|
||||
|
||||
this.#credentials = {
|
||||
access_token: response_data.access_token,
|
||||
refresh_token: response_data.refresh_token || this.#credentials.refresh_token,
|
||||
expires: expiration_date
|
||||
};
|
||||
|
||||
this.#session.emit('update-credentials', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
|
||||
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), {
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves client identity from YouTube TV.
|
||||
*/
|
||||
async #getClientIdentity(): Promise<{ [key: string]: string; }> {
|
||||
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();
|
||||
const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(response_data)?.[1];
|
||||
|
||||
if (!url_body)
|
||||
throw new OAuthError('Could not obtain script url.', { status: 'FAILED' });
|
||||
|
||||
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);
|
||||
|
||||
const groups = client_identity?.groups;
|
||||
|
||||
if (!groups)
|
||||
throw new OAuthError('Could not obtain client identity.', { status: 'FAILED' });
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
get credentials(): Credentials | undefined {
|
||||
return this.#credentials;
|
||||
}
|
||||
|
||||
get has_access_token_expired(): boolean {
|
||||
const timestamp = this.#credentials ? new Date(this.#credentials.expires).getTime() : -Infinity;
|
||||
return new Date().getTime() > timestamp;
|
||||
}
|
||||
|
||||
validateCredentials(): this is this & { credentials: Credentials } {
|
||||
return this.#credentials &&
|
||||
Reflect.has(this.#credentials, 'access_token') &&
|
||||
Reflect.has(this.#credentials, 'refresh_token') &&
|
||||
Reflect.has(this.#credentials, 'expires') || false;
|
||||
}
|
||||
}
|
||||
|
||||
export default OAuth;
|
||||
207
src/core/Player.ts
Normal file
207
src/core/Player.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
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';
|
||||
|
||||
import type { FetchFunction } from '../utils/HTTPClient';
|
||||
|
||||
export default class Player {
|
||||
#nsig_sc;
|
||||
#sig_sc;
|
||||
#sig_sc_timestamp;
|
||||
#player_id;
|
||||
|
||||
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): Promise<Player> {
|
||||
const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
|
||||
const res = await fetch(url);
|
||||
|
||||
if (res.status !== 200)
|
||||
throw new PlayerError('Failed to request player id');
|
||||
|
||||
const js = await res.text();
|
||||
|
||||
const player_id = getStringBetweenStrings(js, 'player\\/', '\\/');
|
||||
|
||||
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
|
||||
if (cache) {
|
||||
const cached_player = await Player.fromCache(cache, player_id);
|
||||
if (cached_player)
|
||||
return cached_player;
|
||||
}
|
||||
|
||||
const player_url = new URL(`/s/player/${player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE);
|
||||
|
||||
const player_res = await fetch(player_url, {
|
||||
headers: {
|
||||
'user-agent': getRandomUserAgent('desktop')
|
||||
}
|
||||
});
|
||||
|
||||
if (!player_res.ok) {
|
||||
throw new PlayerError(`Failed to get player data: ${player_res.status}`);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
|
||||
}
|
||||
|
||||
decipher(url?: string, signature_cipher?: string, cipher?: string): string {
|
||||
url = url || signature_cipher || cipher;
|
||||
|
||||
if (!url)
|
||||
throw new PlayerError('No valid URL to decipher');
|
||||
|
||||
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 = sig_decipher.interpret();
|
||||
|
||||
const sp = args.get('sp');
|
||||
|
||||
sp ?
|
||||
url_components.searchParams.set(sp, signature) :
|
||||
url_components.searchParams.set('signature', signature);
|
||||
}
|
||||
|
||||
const n = url_components.searchParams.get('n');
|
||||
|
||||
if (n) {
|
||||
const nsig_decipher = new Jinter(this.#nsig_sc);
|
||||
nsig_decipher.scope.set('nsig', n);
|
||||
|
||||
const nsig = nsig_decipher.interpret();
|
||||
|
||||
if (nsig.startsWith('enhanced_except_')) {
|
||||
console.warn('Warning:\nCould not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!');
|
||||
}
|
||||
|
||||
url_components.searchParams.set('n', nsig);
|
||||
}
|
||||
|
||||
return url_components.toString();
|
||||
}
|
||||
|
||||
static async fromCache(cache: UniversalCache, player_id: string): Promise<Player | null> {
|
||||
const buffer = await cache.get(player_id);
|
||||
|
||||
if (!buffer)
|
||||
return null;
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const version = view.getUint32(0, true);
|
||||
|
||||
if (version !== Player.LIBRARY_VERSION)
|
||||
return null;
|
||||
|
||||
const sig_timestamp = view.getUint32(4, true);
|
||||
|
||||
const sig_len = view.getUint32(8, true);
|
||||
const sig_buf = buffer.slice(12, 12 + sig_len);
|
||||
const nsig_buf = buffer.slice(12 + sig_len);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const sig_sc = decoder.decode(sig_buf);
|
||||
const nsig_sc = decoder.decode(nsig_buf);
|
||||
|
||||
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): Promise<Player> {
|
||||
const player = new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
|
||||
await player.cache(cache);
|
||||
return player;
|
||||
}
|
||||
|
||||
async cache(cache?: UniversalCache): Promise<void> {
|
||||
if (!cache) return;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const sig_buf = encoder.encode(this.#sig_sc);
|
||||
const nsig_buf = encoder.encode(this.#nsig_sc);
|
||||
|
||||
const buffer = new ArrayBuffer(12 + sig_buf.byteLength + nsig_buf.byteLength);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint32(0, Player.LIBRARY_VERSION, true);
|
||||
view.setUint32(4, this.#sig_sc_timestamp, true);
|
||||
view.setUint32(8, sig_buf.byteLength, true);
|
||||
|
||||
new Uint8Array(buffer).set(sig_buf, 12);
|
||||
new Uint8Array(buffer).set(nsig_buf, 12 + sig_buf.byteLength);
|
||||
|
||||
await cache.set(this.#player_id, new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
static extractSigTimestamp(data: string): number {
|
||||
return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0');
|
||||
}
|
||||
|
||||
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 (!functions || !calls)
|
||||
console.warn(new PlayerError('Failed to extract signature decipher algorithm'));
|
||||
|
||||
return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`;
|
||||
}
|
||||
|
||||
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)
|
||||
console.warn(new PlayerError('Failed to extract n-token decipher algorithm'));
|
||||
|
||||
return sc;
|
||||
}
|
||||
|
||||
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(): number {
|
||||
return this.#sig_sc_timestamp;
|
||||
}
|
||||
|
||||
get nsig_sc(): string {
|
||||
return this.#nsig_sc;
|
||||
}
|
||||
|
||||
get sig_sc(): string {
|
||||
return this.#sig_sc;
|
||||
}
|
||||
|
||||
static get LIBRARY_VERSION(): number {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
209
src/core/PlaylistManager.ts
Normal file
209
src/core/PlaylistManager.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import type Feed from './Feed';
|
||||
import type Actions from './Actions';
|
||||
import Playlist from '../parser/youtube/Playlist';
|
||||
|
||||
import { InnertubeError, throwIfMissing } from '../utils/Utils';
|
||||
|
||||
class PlaylistManager {
|
||||
#actions: Actions;
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a playlist.
|
||||
* @param title - The title of the playlist.
|
||||
* @param video_ids - An array of video IDs to add to the playlist.
|
||||
*/
|
||||
async create(title: string, video_ids: string[]): Promise<{ success: boolean; status_code: number; playlist_id: string; data: any }> {
|
||||
throwIfMissing({ title, video_ids });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
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.
|
||||
* @param playlist_id - The playlist ID.
|
||||
*/
|
||||
async delete(playlist_id: string): Promise<{ playlist_id: string; success: boolean; status_code: number; data: any }> {
|
||||
throwIfMissing({ playlist_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
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.
|
||||
* @param playlist_id - The playlist ID.
|
||||
* @param video_ids - An array of video IDs to add to the playlist.
|
||||
*/
|
||||
async addVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
|
||||
throwIfMissing({ playlist_id, video_ids });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
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.
|
||||
* @param playlist_id - The playlist ID.
|
||||
* @param video_ids - An array of video IDs to remove from the playlist.
|
||||
*/
|
||||
async removeVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
|
||||
throwIfMissing({ playlist_id, video_ids });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
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.
|
||||
* @param playlist_id - The playlist ID.
|
||||
* @param moved_video_id - The video ID to move.
|
||||
* @param predecessor_video_id - The video ID to move the moved video before.
|
||||
*/
|
||||
async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string): Promise<{ playlist_id: string; action_result: any; }> {
|
||||
throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id });
|
||||
|
||||
if (!this.#actions.session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
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;
|
||||
405
src/core/Session.ts
Normal file
405
src/core/Session.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import UniversalCache from '../utils/Cache';
|
||||
import Constants, { CLIENTS } from '../utils/Constants';
|
||||
import EventEmitterLike from '../utils/EventEmitterLike';
|
||||
import Actions from './Actions';
|
||||
import Player from './Player';
|
||||
|
||||
import HTTPClient, { FetchFunction } from '../utils/HTTPClient';
|
||||
import { DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
|
||||
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth';
|
||||
import Proto from '../proto';
|
||||
|
||||
export enum ClientType {
|
||||
WEB = 'WEB',
|
||||
MUSIC = 'WEB_REMIX',
|
||||
ANDROID = 'ANDROID',
|
||||
ANDROID_MUSIC = 'ANDROID_MUSIC',
|
||||
ANDROID_CREATOR = 'ANDROID_CREATOR',
|
||||
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
client: {
|
||||
hl: string;
|
||||
gl: string;
|
||||
remoteHost?: string;
|
||||
screenDensityFloat: number;
|
||||
screenHeightPoints: number;
|
||||
screenPixelDensity: number;
|
||||
screenWidthPoints: number;
|
||||
visitorData: string;
|
||||
userAgent: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
clientScreen?: string,
|
||||
androidSdkVersion?: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
platform: string;
|
||||
clientFormFactor: string;
|
||||
userInterfaceTheme: string;
|
||||
timeZone: string;
|
||||
browserName?: string;
|
||||
browserVersion?: string;
|
||||
originalUrl: string;
|
||||
deviceMake: string;
|
||||
deviceModel: string;
|
||||
utcOffsetMinutes: number;
|
||||
};
|
||||
user: {
|
||||
enableSafetyMode: boolean;
|
||||
lockedSafetyMode: boolean;
|
||||
};
|
||||
thirdParty?: {
|
||||
embedUrl: string;
|
||||
};
|
||||
request: {
|
||||
useSsl: true;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Used to cache the deciphering functions from the JS player.
|
||||
*/
|
||||
cache?: UniversalCache;
|
||||
/**
|
||||
* YouTube cookies.
|
||||
*/
|
||||
cookie?: string;
|
||||
/**
|
||||
* Fetch function to use.
|
||||
*/
|
||||
fetch?: FetchFunction;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
context: Context;
|
||||
api_key: string;
|
||||
api_version: string;
|
||||
}
|
||||
|
||||
export default class Session extends EventEmitterLike {
|
||||
#api_version: string;
|
||||
#key: string;
|
||||
#context: Context;
|
||||
#account_index: number;
|
||||
#player?: Player;
|
||||
|
||||
oauth: OAuth;
|
||||
http: HTTPClient;
|
||||
logged_in: boolean;
|
||||
actions: Actions;
|
||||
cache?: UniversalCache;
|
||||
|
||||
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: UniversalCache) {
|
||||
super();
|
||||
this.#context = context;
|
||||
this.#account_index = account_index;
|
||||
this.#key = api_key;
|
||||
this.#api_version = api_version;
|
||||
this.#player = player;
|
||||
this.http = new HTTPClient(this, cookie, fetch);
|
||||
this.actions = new Actions(this);
|
||||
this.oauth = new OAuth(this);
|
||||
this.logged_in = !!cookie;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
on(type: 'auth', listener: OAuthAuthEventHandler): void;
|
||||
on(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
|
||||
on(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
|
||||
on(type: 'update-credentials', listener: OAuthAuthEventHandler): void;
|
||||
|
||||
on(type: string, listener: (...args: any[]) => void): void {
|
||||
super.on(type, listener);
|
||||
}
|
||||
|
||||
once(type: 'auth', listener: OAuthAuthEventHandler): void;
|
||||
once(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
|
||||
once(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
|
||||
|
||||
once(type: string, listener: (...args: any[]) => void): void {
|
||||
super.once(type, listener);
|
||||
}
|
||||
|
||||
static async create(options: SessionOptions = {}) {
|
||||
const { context, api_key, api_version, account_index } = await Session.getSessionData(
|
||||
options.lang,
|
||||
options.location,
|
||||
options.account_index,
|
||||
options.enable_safety_mode,
|
||||
options.generate_session_locally,
|
||||
options.device_category,
|
||||
options.client_type,
|
||||
options.timezone,
|
||||
options.fetch
|
||||
);
|
||||
|
||||
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 = '',
|
||||
location = '',
|
||||
account_index = 0,
|
||||
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
|
||||
) {
|
||||
let session_data: SessionData;
|
||||
|
||||
if (generate_session_locally) {
|
||||
session_data = this.#generateSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode });
|
||||
} else {
|
||||
session_data = await this.#retrieveSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode }, fetch);
|
||||
}
|
||||
|
||||
return { ...session_data, account_index };
|
||||
}
|
||||
|
||||
static async #retrieveSessionData(options: {
|
||||
lang: string;
|
||||
location: string;
|
||||
time_zone: string;
|
||||
device_category: string;
|
||||
client_name: string;
|
||||
enable_safety_mode: boolean;
|
||||
}, fetch: FetchFunction = globalThis.fetch): Promise<SessionData> {
|
||||
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'accept-language': options.lang || 'en-US',
|
||||
'user-agent': getRandomUserAgent('desktop'),
|
||||
'accept': '*/*',
|
||||
'referer': 'https://www.youtube.com/sw.js',
|
||||
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')}`
|
||||
}
|
||||
});
|
||||
|
||||
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 [ [ device_info ], api_key ] = ytcfg;
|
||||
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: device_info[0],
|
||||
gl: options.location || device_info[2],
|
||||
remoteHost: device_info[3],
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 1080,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1920,
|
||||
visitorData: device_info[13],
|
||||
userAgent: device_info[14],
|
||||
clientName: options.client_name,
|
||||
clientVersion: device_info[16],
|
||||
osName: device_info[17],
|
||||
osVersion: device_info[18],
|
||||
platform: options.device_category.toUpperCase(),
|
||||
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
|
||||
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
|
||||
timeZone: device_info[79] || options.time_zone,
|
||||
browserName: device_info[86],
|
||||
browserVersion: device_info[87],
|
||||
originalUrl: Constants.URLS.YT_BASE,
|
||||
deviceMake: device_info[11],
|
||||
deviceModel: device_info[12],
|
||||
utcOffsetMinutes: new Date().getTimezoneOffset()
|
||||
},
|
||||
user: {
|
||||
enableSafetyMode: options.enable_safety_mode,
|
||||
lockedSafetyMode: false
|
||||
},
|
||||
request: {
|
||||
useSsl: true
|
||||
}
|
||||
};
|
||||
|
||||
return { context, api_key, api_version };
|
||||
}
|
||||
|
||||
static #generateSessionData(options: {
|
||||
lang: string;
|
||||
location: string;
|
||||
time_zone: string;
|
||||
device_category: DeviceCategory;
|
||||
client_name: string;
|
||||
enable_safety_mode: boolean
|
||||
}): SessionData {
|
||||
const id = generateRandomString(11);
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: options.lang || 'en',
|
||||
gl: options.location || 'US',
|
||||
screenDensityFloat: 1,
|
||||
screenHeightPoints: 1080,
|
||||
screenPixelDensity: 1,
|
||||
screenWidthPoints: 1920,
|
||||
visitorData: Proto.encodeVisitorData(id, timestamp),
|
||||
userAgent: getRandomUserAgent('desktop'),
|
||||
clientName: options.client_name,
|
||||
clientVersion: 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
|
||||
}
|
||||
};
|
||||
|
||||
return { context, api_key: CLIENTS.WEB.API_KEY, api_version: CLIENTS.WEB.API_VERSION };
|
||||
}
|
||||
|
||||
async signIn(credentials?: Credentials): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const error_handler: OAuthAuthErrorEventHandler = (err) => reject(err);
|
||||
|
||||
this.once('auth', (data) => {
|
||||
this.off('auth-error', error_handler);
|
||||
|
||||
if (data.status === 'SUCCESS') {
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
}
|
||||
|
||||
reject(data);
|
||||
});
|
||||
|
||||
this.once('auth-error', error_handler);
|
||||
|
||||
try {
|
||||
await this.oauth.init(credentials);
|
||||
|
||||
if (this.oauth.validateCredentials()) {
|
||||
await this.oauth.refreshIfRequired();
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs out of the current account and revokes the credentials.
|
||||
*/
|
||||
async signOut(): Promise<Response | undefined> {
|
||||
if (!this.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const response = await this.oauth.revokeCredentials();
|
||||
this.logged_in = false;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* InnerTube API key.
|
||||
*/
|
||||
get key(): string {
|
||||
return this.#key;
|
||||
}
|
||||
|
||||
/**
|
||||
* InnerTube API version.
|
||||
*/
|
||||
get api_version(): string {
|
||||
return this.#api_version;
|
||||
}
|
||||
|
||||
get client_version(): string {
|
||||
return this.#context.client.clientVersion;
|
||||
}
|
||||
|
||||
get client_name(): string {
|
||||
return this.#context.client.clientName;
|
||||
}
|
||||
|
||||
get account_index(): number {
|
||||
return this.#account_index;
|
||||
}
|
||||
|
||||
get context(): Context {
|
||||
return this.#context;
|
||||
}
|
||||
|
||||
get player(): Player | undefined {
|
||||
return this.#player;
|
||||
}
|
||||
|
||||
get lang(): string {
|
||||
return this.#context.client.hl;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user