mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
675 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7230a2d927 | ||
|
|
924693349c | ||
|
|
1ab302319d | ||
|
|
bbc1d0135b | ||
|
|
9c1e34c9ab | ||
|
|
c5eea2b4ff | ||
|
|
60130f4d0f | ||
|
|
5090c572d5 | ||
|
|
c9c72d0f31 | ||
|
|
7635f49191 | ||
|
|
c932e65dad | ||
|
|
23717aab11 | ||
|
|
85df28a7fb | ||
|
|
9f4970b3ee | ||
|
|
82bbc715ff | ||
|
|
3ec111212c | ||
|
|
7ca4b2bb45 | ||
|
|
8d411f25c8 | ||
|
|
80fe969917 | ||
|
|
13c94fbb8a | ||
|
|
60ce869054 | ||
|
|
1268ac83a6 | ||
|
|
5e588d0db5 | ||
|
|
8b37bd99b1 | ||
|
|
08741de831 | ||
|
|
574a595a01 | ||
|
|
16928ee71b | ||
|
|
de6283080b | ||
|
|
23ab8bca4d | ||
|
|
068b86b410 | ||
|
|
0b001c0956 | ||
|
|
4c14662d42 | ||
|
|
f1a9d5d77b | ||
|
|
398cd8728d | ||
|
|
459c30528e | ||
|
|
6e1e96610c | ||
|
|
6d30aa3228 | ||
|
|
d33cb0b576 | ||
|
|
51af4c3ffe | ||
|
|
b577a79893 | ||
|
|
da0c5e5887 | ||
|
|
b47350894d | ||
|
|
c0387017e3 | ||
|
|
b286bc43df | ||
|
|
61028a2ab9 | ||
|
|
254588da81 | ||
|
|
ef3e54775c | ||
|
|
30cec36660 | ||
|
|
427a1bd396 | ||
|
|
cf4901fd3c | ||
|
|
2fd98a021f | ||
|
|
cd64e30b69 | ||
|
|
2b5027eb06 | ||
|
|
0c9f7135bf | ||
|
|
ce8a109398 | ||
|
|
6aaa3360e8 | ||
|
|
89c018c431 | ||
|
|
339a01f3a9 | ||
|
|
dd3f4c0009 | ||
|
|
7cd41e1d8a | ||
|
|
6ac8561af2 | ||
|
|
b4607d531f | ||
|
|
b3a1cdc1cd | ||
|
|
fd662df93d | ||
|
|
8a1f4b4e55 | ||
|
|
4ff83bdc3f | ||
|
|
c81e8e29ac | ||
|
|
d5f884ff9b | ||
|
|
5517c2f202 | ||
|
|
3493a82765 | ||
|
|
07f02d0dc1 | ||
|
|
b2afa86744 | ||
|
|
a1caa60750 | ||
|
|
e1dd718832 | ||
|
|
222bf1e61f | ||
|
|
3b48de20dd | ||
|
|
348d901935 | ||
|
|
94b12002ff | ||
|
|
2720e8f251 | ||
|
|
a8a1ec2182 | ||
|
|
ee0d1bef40 | ||
|
|
5cad39ee44 | ||
|
|
e8ca248919 | ||
|
|
44d09026b5 | ||
|
|
ff044f4216 | ||
|
|
8153e6178c | ||
|
|
ee3f1b4638 | ||
|
|
86c8a7e0d2 | ||
|
|
b375ae2f06 | ||
|
|
2ff4b2ea95 | ||
|
|
599ab69107 | ||
|
|
c6c6dc24bd | ||
|
|
fa2e0724c6 | ||
|
|
6af689ada6 | ||
|
|
9997c0d939 | ||
|
|
3dee7fc12f | ||
|
|
4dff129b74 | ||
|
|
7e86bb15e0 | ||
|
|
d0de164722 | ||
|
|
5d165ebb61 | ||
|
|
2ad19adbe4 | ||
|
|
cabbdc9f50 | ||
|
|
fe84f31432 | ||
|
|
22c605f528 | ||
|
|
6777b59116 | ||
|
|
de70d851d8 | ||
|
|
e20e671d16 | ||
|
|
d0e1140029 | ||
|
|
bf483256fe | ||
|
|
d4c32d47e1 | ||
|
|
70feab80da | ||
|
|
c006f49dc1 | ||
|
|
aeff0c3fdc | ||
|
|
00d67ed417 | ||
|
|
78f93c7118 | ||
|
|
6db3f0ad91 | ||
|
|
cf48385f72 | ||
|
|
e70eab2416 | ||
|
|
771c6050c4 | ||
|
|
5670228a4f | ||
|
|
62ae384f27 | ||
|
|
185cdbd6ce | ||
|
|
5dd6ef9e24 | ||
|
|
309942090d | ||
|
|
af4a4b8b82 | ||
|
|
b9c9d40077 | ||
|
|
62fbc166c5 | ||
|
|
c3991dda32 | ||
|
|
95e804e8ea | ||
|
|
67a8435421 | ||
|
|
1847558d50 | ||
|
|
bde915bce3 | ||
|
|
a9ad3a31b5 | ||
|
|
e52e6138bd | ||
|
|
76248ad143 | ||
|
|
94f441a4e2 | ||
|
|
685e14fcc1 | ||
|
|
3cd115461f | ||
|
|
6da4ee8fd4 | ||
|
|
b095044baa | ||
|
|
ba2b757fdb | ||
|
|
9d7d0d83e1 | ||
|
|
b893e46634 | ||
|
|
d8ab6f3887 | ||
|
|
eea5ebfd04 | ||
|
|
b2117f11b9 | ||
|
|
389b0f362f | ||
|
|
6ce4a89766 | ||
|
|
4d7573c46f | ||
|
|
445de3546d | ||
|
|
3b265119d6 |
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']
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
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
|
||||
27
.github/pull_request_template.md
vendored
Normal file
27
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Pull Request Template
|
||||
|
||||
## Description
|
||||
|
||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] I have checked my code and corrected any misspellings
|
||||
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
|
||||
10
.github/workflows/node.js.yml
vendored
10
.github/workflows/node.js.yml
vendored
@@ -1,8 +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
|
||||
# TODO: Make actual tests instead of just running the examples file
|
||||
|
||||
name: Build
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -17,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [ 12.x, 14.x, 15.x]
|
||||
node-version: [ 16.x, 18.x ]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
@@ -27,4 +23,4 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm install
|
||||
- run: node ./examples
|
||||
- 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
|
||||
```
|
||||
2
LICENSE
2
LICENSE
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
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>#applySortFilter(filter)`
|
||||
- Applies given sort filter 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_filters`
|
||||
- Returns available sort filters.
|
||||
|
||||
- `<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`](../../lib/parser/contents/classes/Comment.js) 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 }>`
|
||||
35
examples/comments/CommentThread.md
Normal file
35
examples/comments/CommentThread.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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`
|
||||
|
||||
<a name="comment"></a>
|
||||
### comment
|
||||
The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
|
||||
|
||||
**Type:** [`Comment`](../../lib/parser/contents/classes/Comment.js)
|
||||
|
||||
<a name="replies"></a>
|
||||
### replies
|
||||
An array of replies to the top-level comment. (not populated until [`getReplies()`](#getreplies) is called).
|
||||
|
||||
**Type:** [`Comment[]`](../../lib/parser/contents/classes/Comment.js)
|
||||
|
||||
<a name="getreplies"></a>
|
||||
### getReplies()
|
||||
Retrieves replies to the top-level comment and attaches a [`replies`](#replies) array to the original `CommentThread` object and returns it.
|
||||
|
||||
**Returns:** [`Promise.<CommentThread>`](../../lib/parser/contents/classes/CommentThread.js)
|
||||
|
||||
<a name="getcontinuation"></a>
|
||||
### getContinuation()
|
||||
Retrieves next batch of replies and adds them to the [`replies`](#replies) array. **Note:** [`getReplies()`](#getreplies) must be called before using this.
|
||||
|
||||
**Returns:** [`Promise.<CommentThread>`](../../lib/parser/contents/classes/CommentThread.js)
|
||||
47
examples/comments/README.md
Normal file
47
examples/comments/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
## Comments
|
||||
YouTube.js has full support for comments, including comment actions such as liking, disliking, replying etc.
|
||||
|
||||
## Usage
|
||||
Get a [`Comments`](../../lib/parser/youtube/Comments.js) instance:
|
||||
|
||||
```js
|
||||
const comments = await yt.getComments(VIDEO_ID);
|
||||
```
|
||||
|
||||
## API
|
||||
* Comments
|
||||
* [.contents](#commentthread) ⇒ `CommentThread[]`
|
||||
* [.createComment](#createComment) ⇒ `function`
|
||||
* [.getContinuation](#getc) ⇒ `function`
|
||||
* [.page](#page) ⇒ `getter`
|
||||
|
||||
<a name="commentthread"></a>
|
||||
### contents
|
||||
A list of comment threads. **Note:** More about comment threads [**here**](./CommentThread.md).
|
||||
|
||||
**Type:** [`CommentThread[]`](../../lib/parser/contents/classes/CommentThread.js)
|
||||
|
||||
<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>`](../../lib/parser/youtube/Comments.ts)
|
||||
|
||||
<a name="page"></a>
|
||||
### page
|
||||
Returns original InnerTube response (sanitized).
|
||||
|
||||
**Returns:** `ParsedResponse`
|
||||
|
||||
## Example
|
||||
See [`index.ts`]('./index.ts').
|
||||
39
examples/comments/index.ts
Normal file
39
examples/comments/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache() });
|
||||
|
||||
const comments = await yt.getComments('a-rqu-hjobc');
|
||||
|
||||
console.info(`This video has ${comments.header?.comments_count.toString() || 'N/A'} comments.\n`);
|
||||
|
||||
for (const thread of comments.contents) {
|
||||
const comment = thread.comment;
|
||||
|
||||
if (comment) {
|
||||
console.info(
|
||||
`${comment.author.name} • ${comment.published}\n`,
|
||||
`${comment.content.toString()}`, '\n',
|
||||
`Likes: ${comment.vote_count.short_text}`, '\n'
|
||||
);
|
||||
|
||||
if (comment.reply_count > 0) {
|
||||
console.info('Replies:', '\n');
|
||||
|
||||
const comment_thread = await thread.getReplies();
|
||||
|
||||
if (comment_thread.replies) {
|
||||
for (const reply of comment_thread.replies) {
|
||||
console.info(
|
||||
`> ${reply.author.name} • ${reply.published}\n`,
|
||||
`${reply.content.toString()}`, '\n',
|
||||
`Likes: ${reply.vote_count.short_text}`, '\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,79 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
|
||||
// Searching, getting details about videos & making interactions:
|
||||
const search = await youtube.search('Looking for life on Mars - documentary');
|
||||
console.info('Search results:', search);
|
||||
|
||||
if (search.videos.length === 0)
|
||||
return console.info('[INFO]', 'Could not find any video about that on YouTube.');
|
||||
|
||||
const video = await youtube.getDetails(search.videos[0].id);
|
||||
console.info('Video details:', video);
|
||||
if (video.error) return;
|
||||
|
||||
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();
|
||||
83
examples/livechat/README.md
Normal file
83
examples/livechat/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
## Live Chat
|
||||
|
||||
The library's Live Chat parser and poller were heavily based on YouTube's original compiled code, this makes it behave in a similar if not identical way to YouTube's Live Chat. Here you can do all sorts of funny things, ex; track messages, donations, polls, and much more.
|
||||
|
||||
## 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`
|
||||
* [.getItemMenu](#getitemmenu) ⇒ `function`
|
||||
* [.sendMessage](#sendmessage) ⇒ `function`
|
||||
|
||||
<a name="ev"></a>
|
||||
### ev
|
||||
Live Chat's EventEmitter.
|
||||
|
||||
**Events:**
|
||||
|
||||
- `start`
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `LiveChatContinuation` | Initial chat data, actions, info, etc. |
|
||||
|
||||
- `chat-update`
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `ChatAction` | Chat Action |
|
||||
|
||||
- `metadata-update`
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `LiveMetadata` | LiveStream Metadata |
|
||||
|
||||
<a name="start"></a>
|
||||
### start()
|
||||
Starts the Live Chat.
|
||||
|
||||
<a name="stop"></a>
|
||||
### stop()
|
||||
Stops the 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').
|
||||
76
examples/livechat/index.ts
Normal file
76
examples/livechat/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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() });
|
||||
|
||||
|
||||
const search = await yt.search('Lofi girl live');
|
||||
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 Live Chat — this is; inital actions (pinned messages, top donations..), account's info and so on.
|
||||
*/
|
||||
|
||||
console.info(`Hey ${initial_data.viewer_name || 'N/A'}, welcome to Live Chat!`);
|
||||
});
|
||||
|
||||
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(
|
||||
`${hours} - ${item.as(YTNodes.LiveChatTextMessage).author?.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatTextMessage).message.toString()}\n`
|
||||
);
|
||||
break;
|
||||
case 'LiveChatPaidMessage':
|
||||
console.info(
|
||||
`${hours} - ${item.as(YTNodes.LiveChatPaidMessage).author.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatPaidMessage).purchase_amount}\n`
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.debug(action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.is(YTNodes.MarkChatItemAsDeletedAction)) {
|
||||
console.warn(`Message ${action.target_item_id} just got deleted and should be replaced with ${action.deleted_state_message.toString()}!`, '\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: []
|
||||
}
|
||||
]
|
||||
};
|
||||
168
lib/Actions.js
168
lib/Actions.js
@@ -1,168 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
const Uuid = require('uuid');
|
||||
|
||||
async function engage(session, engagement_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not logged in');
|
||||
let data = {};
|
||||
switch (engagement_type) {
|
||||
case 'like/like':
|
||||
case 'like/dislike':
|
||||
case 'like/removelike':
|
||||
data = {
|
||||
context: session.context,
|
||||
target: {
|
||||
videoId: args.video_id
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'subscription/subscribe':
|
||||
case 'subscription/unsubscribe':
|
||||
data = {
|
||||
context: session.context,
|
||||
channelIds: [args.channel_id]
|
||||
};
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data = {
|
||||
context: session.context,
|
||||
commentText: args.text,
|
||||
createCommentParams: Utils.generateCommentParams(args.video_id)
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, id: args.video_id, data })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status
|
||||
};
|
||||
}
|
||||
|
||||
async function browse(session, action_type) {
|
||||
if (!session.logged_in) throw new Error('You are not logged in');
|
||||
let data;
|
||||
switch (action_type) {
|
||||
case 'subscriptions_feed':
|
||||
data = {
|
||||
context: session.context,
|
||||
browseId: 'FEsubscriptions'
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function notifications(session, action_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not logged in');
|
||||
let data;
|
||||
switch (action_type) {
|
||||
case 'modify_channel_preference':
|
||||
let pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
|
||||
data = {
|
||||
context: session.context,
|
||||
params: Utils.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()])
|
||||
};
|
||||
break;
|
||||
case 'get_notification_menu':
|
||||
data = {
|
||||
context: session.context,
|
||||
notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
|
||||
};
|
||||
break;
|
||||
case 'get_unseen_count':
|
||||
data = {
|
||||
context: session.context
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function livechat(session, action_type, args = {}) {
|
||||
let data;
|
||||
switch (action_type) {
|
||||
case 'live_chat/send_message':
|
||||
data = {
|
||||
context: session.context,
|
||||
params: Utils.generateMessageParams(args.channel_id, args.video_id),
|
||||
clientMessageId: `INntLiB${Uuid.v4()}`,
|
||||
richMessage: {
|
||||
textSegments: [{ text: args.text }]
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'live_chat/get_item_context_menu':
|
||||
data = {
|
||||
context: session.context
|
||||
};
|
||||
break;
|
||||
case 'live_chat/moderate':
|
||||
data = {
|
||||
context: session.context,
|
||||
params: args.cmd_params
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, params: args.params })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function getContinuation(session, info = {}) {
|
||||
let data = { context: session.context };
|
||||
|
||||
if (info.continuation_token) {
|
||||
data.continuation = info.continuation_token;
|
||||
}
|
||||
|
||||
if (info.video_id) {
|
||||
data.videoId = info.video_id;
|
||||
data.racyCheckOk = true;
|
||||
data.contentCheckOk = false;
|
||||
data.autonavState = 'STATE_NONE';
|
||||
data.playbackContext = {
|
||||
vis: 0,
|
||||
lactMilliseconds: '-1'
|
||||
};
|
||||
data.captionsRequested = false;
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { engage, browse, notifications, livechat, getContinuation };
|
||||
335
lib/Constants.js
335
lib/Constants.js
@@ -1,335 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
|
||||
const urls = {
|
||||
YT_BASE_URL: 'https://www.youtube.com',
|
||||
YT_MOBILE_URL: 'https://m.youtube.com',
|
||||
YT_WATCH_PAGE: 'https://m.youtube.com/watch',
|
||||
};
|
||||
|
||||
const 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::'
|
||||
};
|
||||
|
||||
const oauth_reqopts = {
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'origin': urls.YT_BASE_URL,
|
||||
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
|
||||
'content-type': 'application/json',
|
||||
'x-requested-with': 'mark.via.gp',
|
||||
'referer': `${urls.YT_BASE_URL}/tv`,
|
||||
'accept-language': 'en-US'
|
||||
}
|
||||
};
|
||||
|
||||
const default_headers = (session) => {
|
||||
return {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Referer': 'https://www.google.com/',
|
||||
'Accept': 'text/html',
|
||||
'Accept-Language': 'en-US,en',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Upgrade-Insecure-Requests': 1
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const innertube_request_opts = (info) => {
|
||||
if (info.desktop === undefined) info.desktop = true;
|
||||
let req_opts = {
|
||||
params: info.params || {},
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'user-agent': Utils.getRandomUserAgent(info.desktop ? 'desktop' : 'mobile').userAgent,
|
||||
'content-type': 'application/json',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
'x-goog-authuser': 0,
|
||||
'x-goog-visitor-id': info.session.context.client.visitorData,
|
||||
'x-youtube-client-name': info.desktop ? 1 : 2,
|
||||
'x-youtube-client-version': info.session.context.client.clientVersion,
|
||||
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
|
||||
'x-origin': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
|
||||
'origin': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
|
||||
}
|
||||
};
|
||||
|
||||
if (info.session.logged_in && info.desktop) {
|
||||
req_opts.headers.Cookie = info.session.cookie;
|
||||
req_opts.headers.authorization = info.session.cookie.length < 1 ? `Bearer ${info.session.access_token}` : info.session.auth_apisid;
|
||||
}
|
||||
|
||||
if (info.id) {
|
||||
req_opts.headers.referer = (info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL) + '/watch?v=' + info.id;
|
||||
}
|
||||
|
||||
return req_opts;
|
||||
};
|
||||
|
||||
const video_details_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': urls.YT_BASE_URL,
|
||||
'lactMilliseconds': '-1'
|
||||
}
|
||||
},
|
||||
context: context,
|
||||
videoId: id
|
||||
};
|
||||
};
|
||||
|
||||
const stream_headers = (range) => {
|
||||
let headers = {
|
||||
'Accept': '*/*',
|
||||
'User-Agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Connection': 'keep-alive',
|
||||
'Origin': urls.YT_BASE_URL,
|
||||
'Referer': urls.YT_BASE_URL,
|
||||
'DNT': '?1'
|
||||
};
|
||||
if (range) {
|
||||
headers.Range = range;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const formatVideoData = (data, context, desktop) => {
|
||||
let video_details = {};
|
||||
let metadata = {};
|
||||
|
||||
if (desktop) {
|
||||
metadata.embed = data.microformat.playerMicroformatRenderer.embed;
|
||||
metadata.view_count = parseInt(data.videoDetails.viewCount);
|
||||
metadata.average_rating = data.videoDetails.averageRating;
|
||||
metadata.length_seconds = data.microformat.playerMicroformatRenderer.lengthSeconds;
|
||||
metadata.channel_id = data.videoDetails.channelId;
|
||||
metadata.channel_url = data.microformat.playerMicroformatRenderer.ownerProfileUrl;
|
||||
metadata.external_channel_id = data.microformat.playerMicroformatRenderer.externalChannelId;
|
||||
metadata.is_live_content = data.videoDetails.isLiveContent;
|
||||
metadata.is_family_safe = data.microformat.playerMicroformatRenderer.isFamilySafe;
|
||||
metadata.is_unlisted = data.microformat.playerMicroformatRenderer.isUnlisted;
|
||||
metadata.is_private = data.videoDetails.isPrivate;
|
||||
metadata.has_ypc_metadata = data.microformat.playerMicroformatRenderer.hasYpcMetadata;
|
||||
metadata.category = data.microformat.playerMicroformatRenderer.category;
|
||||
metadata.channel_name = data.microformat.playerMicroformatRenderer.ownerChannelName;
|
||||
metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A';
|
||||
metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A';
|
||||
metadata.keywords = data.videoDetails.keywords || [];
|
||||
|
||||
video_details.title = data.videoDetails.title;
|
||||
video_details.description = data.videoDetails.shortDescription;
|
||||
video_details.thumbnail = data.videoDetails.thumbnail.thumbnails.slice(-1)[0];
|
||||
video_details.metadata = metadata;
|
||||
} else {
|
||||
metadata.embed = data[2].playerResponse.microformat.playerMicroformatRenderer.embed;
|
||||
metadata.likes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
|
||||
metadata.dislikes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
|
||||
metadata.view_count = parseInt(data[2].playerResponse.videoDetails.viewCount);
|
||||
metadata.average_rating = data[2].playerResponse.videoDetails.averageRating;
|
||||
metadata.length_seconds = data[2].playerResponse.microformat.playerMicroformatRenderer.lengthSeconds;
|
||||
metadata.channel_id = data[2].playerResponse.videoDetails.channelId;
|
||||
metadata.channel_url = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerProfileUrl;
|
||||
metadata.external_channel_id = data[2].playerResponse.microformat.playerMicroformatRenderer.externalChannelId;
|
||||
metadata.is_live_content = data[2].playerResponse.videoDetails.isLiveContent;
|
||||
metadata.is_family_safe = data[2].playerResponse.microformat.playerMicroformatRenderer.isFamilySafe;
|
||||
metadata.is_unlisted = data[2].playerResponse.microformat.playerMicroformatRenderer.isUnlisted;
|
||||
metadata.is_private = data[2].playerResponse.videoDetails.isPrivate;
|
||||
metadata.has_ypc_metadata = data[2].playerResponse.microformat.playerMicroformatRenderer.hasYpcMetadata;
|
||||
metadata.category = data[2].playerResponse.microformat.playerMicroformatRenderer.category;
|
||||
metadata.channel_name = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerChannelName;
|
||||
metadata.publish_date = data[2].playerResponse.microformat.playerMicroformatRenderer.publishDate;
|
||||
metadata.upload_date = data[2].playerResponse.microformat.playerMicroformatRenderer.uploadDate;
|
||||
metadata.keywords = data[2].playerResponse.videoDetails.keywords;
|
||||
|
||||
video_details.title = data[2].playerResponse.videoDetails.title;
|
||||
video_details.description = data[2].playerResponse.videoDetails.shortDescription;
|
||||
video_details.thumbnail = data[2].playerResponse.videoDetails.thumbnail.thumbnails.slice(-1)[0];
|
||||
|
||||
// Functions
|
||||
video_details.like = () => {};
|
||||
video_details.dislike = () => {};
|
||||
video_details.removeLike = () => {};
|
||||
video_details.subscribe = () => {};
|
||||
video_details.unsubscribe = () => {};
|
||||
video_details.comment = () => {};
|
||||
video_details.getComments = () => {};
|
||||
video_details.setNotificationPref = () => {};
|
||||
video_details.getLivechat = () => {};
|
||||
|
||||
// Additional metadata
|
||||
video_details.metadata = metadata;
|
||||
}
|
||||
return video_details;
|
||||
};
|
||||
|
||||
|
||||
const base64_alphabet = {
|
||||
normal: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
|
||||
reverse: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
|
||||
};
|
||||
|
||||
const filters = (order) => {
|
||||
// TODO: Refactor this with protobuf encoding
|
||||
switch (order) {
|
||||
case 'any,any,relevance':
|
||||
return 'EgIQAQ%3D%3D';
|
||||
case 'hour,any,relevance':
|
||||
return 'EgIIAQ%3D%3D';
|
||||
case 'day,any,relevance':
|
||||
return 'EgQIAhAB';
|
||||
case 'week,any,relevance':
|
||||
return 'EgQIAxAB';
|
||||
case 'month,any,relevance':
|
||||
return 'EgQIBBAB';
|
||||
case 'year,any,relevance':
|
||||
return 'EgQIBRAB';
|
||||
case 'any,short,relevance':
|
||||
return 'EgQQARgB';
|
||||
case 'hour,short,relevance':
|
||||
return 'EgYIARABGAE%3D';
|
||||
case 'day,short,relevance':
|
||||
return 'EgYIAhABGAE%3D';
|
||||
case 'week,short,relevance':
|
||||
return 'EgYIAxABGAE%3D';
|
||||
case 'month,short,relevance':
|
||||
return 'EgYIBBABGAE%3D';
|
||||
case 'year,short,relevance':
|
||||
return 'EgYIBRABGAE%3D';
|
||||
case 'any,long,relevance':
|
||||
return 'EgQQARgC';
|
||||
case 'hour,long,relevance':
|
||||
return 'EgYIARABGAI%3D';
|
||||
case 'day,long,relevance':
|
||||
return 'EgYIAhABGAI%3D';
|
||||
case 'week,long,relevance':
|
||||
return 'EgYIAxABGAI%3D';
|
||||
case 'month,long,relevance':
|
||||
return 'EgYIBBABGAI%3D';
|
||||
case 'year,long,relevance':
|
||||
return 'EgYIBRABGAI%3D';
|
||||
case 'any,any,age':
|
||||
return 'CAISAhAB';
|
||||
case 'hour,any,age':
|
||||
return 'CAISBAgBEAE%3D';
|
||||
case 'day,any,age':
|
||||
return 'CAISBAgCEAE%3D';
|
||||
case 'week,any,age':
|
||||
return 'CAISBAgDEAE%3D';
|
||||
case 'month,any,age':
|
||||
return 'CAISBAgEEAE%3D';
|
||||
case 'year,any,age':
|
||||
return 'CAISBAgFEAE%3D';
|
||||
case 'any,short,age':
|
||||
return 'CAISBBABGAE%3D';
|
||||
case 'hour,short,age':
|
||||
return 'CAISBggBEAEYAQ%3D%3D';
|
||||
case 'day,short,age':
|
||||
return 'CAISBggCEAEYAQ%3D%3D';
|
||||
case 'week,short,age':
|
||||
return 'CAISBggDEAEYAQ%3D%3D';
|
||||
case 'month,short,age':
|
||||
return 'CAISBggEEAEYAQ%3D%3D';
|
||||
case 'year,short,age':
|
||||
return 'CAISBggFEAEYAQ%3D%3D';
|
||||
case 'any,long,age':
|
||||
return 'CAISBBABGAI%3D';
|
||||
case 'hour,long,age':
|
||||
return 'CAISBggBEAEYAg%3D%3D';
|
||||
case 'day,long,age':
|
||||
return 'CAISBggCEAEYAg%3D%3D';
|
||||
case 'week,long,age':
|
||||
return 'CAISBggDEAEYAg%3D%3D';
|
||||
case 'month,long,age':
|
||||
return 'CAISBggEEAEYAg%3D%3D';
|
||||
case 'year,long,age':
|
||||
return 'CAISBggFEAEYAg%3D%3D';
|
||||
case 'any,any,views':
|
||||
return 'CAMSAhAB';
|
||||
case 'hour,any,views':
|
||||
return 'CAMSBAgBEAE%3D';
|
||||
case 'day,any,views':
|
||||
return 'CAMSBAgCEAE%3D';
|
||||
case 'week,any,views':
|
||||
return 'CAMSBAgDEAE%3D';
|
||||
case 'month,any,views':
|
||||
return 'CAMSBAgEEAE%3D';
|
||||
case 'year,any,views':
|
||||
return 'CAMSBAgFEAE%3D';
|
||||
case 'any,short,views':
|
||||
return 'CAMSBBABGAE%3D';
|
||||
case 'hour,short,views':
|
||||
return 'CAMSBggBEAEYAQ%3D%3D';
|
||||
case 'day,short,views':
|
||||
return 'CAMSBggCEAEYAQ%3D%3D';
|
||||
case 'week,short,views':
|
||||
return 'CAMSBggDEAEYAQ%3D%3D';
|
||||
case 'month,short,views':
|
||||
return 'CAMSBggEEAEYAQ%3D%3D';
|
||||
case 'year,short,views':
|
||||
return 'CAMSBggFEAEYAQ%3D%3D';
|
||||
case 'any,long,views':
|
||||
return 'CAMSBBABGAI%3D';
|
||||
case 'hour,long,views':
|
||||
return 'CAMSBggBEAEYAg%3D%3D';
|
||||
case 'day,long,views':
|
||||
return 'CAMSBggCEAEYAg%3D%3D';
|
||||
case 'week,long,views':
|
||||
return 'CAMSBggDEAEYAg%3D%3D';
|
||||
case 'month,long,views':
|
||||
return 'CAMSBggEEAEYAg%3D%3D';
|
||||
case 'year,long,views':
|
||||
return 'CAMSBggFEAEYAg%3D%3D';
|
||||
case 'any,any,rating':
|
||||
return 'CAESAhAB';
|
||||
case 'hour,any,rating':
|
||||
return 'CAESBAgBEAE%3D';
|
||||
case 'day,any,rating':
|
||||
return 'CAESBAgCEAE%3D';
|
||||
case 'week,any,rating':
|
||||
return 'CAESBAgDEAE%3D';
|
||||
case 'month,any,rating':
|
||||
return 'CAESBAgEEAE%3D';
|
||||
case 'year,any,rating':
|
||||
return 'CAESBAgFEAE%3D';
|
||||
case 'any,short,rating':
|
||||
return 'CAESBBABGAE%3D';
|
||||
case 'hour,short,rating':
|
||||
return 'CAESBggBEAEYAQ%3D%3D';
|
||||
case 'day,short,rating':
|
||||
return 'CAESBggCEAEYAQ%3D%3D';
|
||||
case 'week,short,rating':
|
||||
return 'CAESBggDEAEYAQ%3D%3D';
|
||||
case 'month,short,rating':
|
||||
return 'CAESBggEEAEYAQ%3D%3D';
|
||||
case 'year,short,rating':
|
||||
return 'CAESBggFEAEYAQ%3D%3D';
|
||||
case 'any,long,rating':
|
||||
return 'CAESBBABGAI%3D';
|
||||
case 'hour,long,rating':
|
||||
return 'CAESBggBEAEYAg%3D%3D';
|
||||
case 'day,long,rating':
|
||||
return 'CAESBggCEAEYAg%3D%3D';
|
||||
case 'week,long,rating':
|
||||
return 'CAESBggDEAEYAg%3D%3D';
|
||||
case 'month,long,rating':
|
||||
return 'CAESBggEEAEYAg%3D%3D';
|
||||
case 'year,long,rating':
|
||||
return 'CAESBggFEAEYAg%3D%3D';
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { urls, oauth, oauth_reqopts, default_headers, innertube_request_opts, video_details_reqbody, stream_headers, formatVideoData, base64_alphabet, filters };
|
||||
469
lib/Innertube.js
469
lib/Innertube.js
@@ -1,469 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Stream = require('stream');
|
||||
const OAuth = require('./OAuth');
|
||||
const Utils = require('./Utils');
|
||||
const Player = require('./Player');
|
||||
const NToken = require('./NToken');
|
||||
const Actions = require('./Actions');
|
||||
const Livechat = require('./Livechat');
|
||||
const Constants = require('./Constants');
|
||||
const SigDecipher = require('./SigDecipher');
|
||||
const EventEmitter = require('events');
|
||||
const TimeToSeconds = require('time-to-seconds');
|
||||
const CancelToken = Axios.CancelToken;
|
||||
|
||||
class Innertube extends EventEmitter {
|
||||
constructor(cookie) {
|
||||
super();
|
||||
this.cookie = cookie || '';
|
||||
return this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
const response = await Axios.get(Constants.urls.YT_BASE_URL, Constants.default_headers(this)).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error('Could not retrieve Innertube configuration data: ' + response.message);
|
||||
let innertube_data = JSON.parse('{' + Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') + '}');
|
||||
if (innertube_data.INNERTUBE_CONTEXT) {
|
||||
this.context = innertube_data.INNERTUBE_CONTEXT;
|
||||
this.key = innertube_data.INNERTUBE_API_KEY;
|
||||
this.id_token = innertube_data.ID_TOKEN;
|
||||
this.session_token = innertube_data.XSRF_TOKEN;
|
||||
this.player_url = innertube_data.PLAYER_JS_URL;
|
||||
this.logged_in = innertube_data.LOGGED_IN;
|
||||
this.sts = innertube_data.STS;
|
||||
this.context.client.hl = 'en';
|
||||
this.context.client.gl = 'US';
|
||||
|
||||
this.player = new Player(this);
|
||||
await this.player.init();
|
||||
|
||||
if (this.logged_in && this.cookie.length > 1) {
|
||||
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
|
||||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
} else {
|
||||
this.initialized = false;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
signIn(credentials = {}) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const oauth = new OAuth(credentials);
|
||||
if (credentials.access_token && credentials.refresh_token) {
|
||||
let token_validity = await oauth.checkTokenValidity(credentials.access_token, this);
|
||||
if (token_validity === 'VALID') {
|
||||
this.access_token = credentials.access_token;
|
||||
this.refresh_token = credentials.refresh_token;
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
} else {
|
||||
oauth.refreshAccessToken(credentials.refresh_token);
|
||||
oauth.on('refresh-token', (data) => {
|
||||
this.access_token = data.access_token;
|
||||
this.refresh_token = credentials.refresh_token;
|
||||
this.logged_in = true;
|
||||
this.emit('update-credentials', {
|
||||
access_token: data.access_token,
|
||||
refresh_token: credentials.refresh_token,
|
||||
status: data.status
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
oauth.on('auth', (data) => {
|
||||
if (data.status === 'SUCCESS') {
|
||||
this.emit('auth', data);
|
||||
this.access_token = data.access_token;
|
||||
this.refresh_token = data.refresh_token;
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
} else {
|
||||
this.emit('auth', data);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async search(query, options = { period: 'any', order: 'relevance', duration: 'any' }) {
|
||||
if (!query) throw new Error('No query was provided');
|
||||
if (!this.initialized) throw new Error('Missing Innertube data.');
|
||||
|
||||
const yt_response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/search${this.logged_in && this.cookie.length < 1 ? '' : `?key=${this.key}`}`, JSON.stringify({ context: this.context, params: Constants.filters(options.period + ',' + options.duration + ',' + options.order), query }), Constants.innertube_request_opts({ session: this })).catch((error) => error);
|
||||
if (yt_response instanceof Error) throw new Error('Could not search on YouTube: ' + yt_response.message);
|
||||
|
||||
let content = yt_response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents;
|
||||
let search_response = {};
|
||||
|
||||
search_response.search_metadata = {};
|
||||
search_response.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query;
|
||||
search_response.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query;
|
||||
search_response.search_metadata.estimated_results = parseInt(yt_response.data.estimatedResults);
|
||||
search_response.videos = content.map((data) => {
|
||||
if (!data.videoRenderer) return;
|
||||
let video = data.videoRenderer;
|
||||
return {
|
||||
title: video.title.runs[0].text,
|
||||
description: video.detailedMetadataSnippets ? video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') : 'N/A',
|
||||
author: video.ownerText.runs[0].text,
|
||||
id: video.videoId,
|
||||
url: 'https://youtu.be/' + video.videoId,
|
||||
channel_url: Constants.urls.YT_BASE_URL + video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url,
|
||||
metadata: {
|
||||
view_count: video.viewCountText ? video.viewCountText.simpleText : 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: video.shortViewCountText ? video.shortViewCountText.simpleText : 'N/A',
|
||||
accessibility_label: video.shortViewCountText ? (video.shortViewCountText.accessibility ? video.shortViewCountText.accessibility.accessibilityData.label : 'N/A') : 'N/A',
|
||||
},
|
||||
thumbnails: video.thumbnail.thumbnails,
|
||||
duration: {
|
||||
seconds: TimeToSeconds(video.lengthText ? video.lengthText.simpleText : '0'),
|
||||
simple_text: video.lengthText ? video.lengthText.simpleText : 'N/A',
|
||||
accessibility_label: video.lengthText ? video.lengthText.accessibility.accessibilityData.label : 'N/A'
|
||||
},
|
||||
published: video.publishedTimeText ? video.publishedTimeText.simpleText : 'N/A',
|
||||
badges: video.badges ? video.badges.map((item) => item.metadataBadgeRenderer.label) : 'N/A',
|
||||
owner_badges: video.ownerBadges ? video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) : 'N/A '
|
||||
}
|
||||
};
|
||||
}).filter((video_block) => video_block !== undefined);
|
||||
return search_response;
|
||||
}
|
||||
|
||||
async getDetails(id) {
|
||||
if (!id) return { error: 'Missing video id' };
|
||||
|
||||
const data = await this.requestVideoInfo(id, false);
|
||||
const video_data = Constants.formatVideoData(data, this, false);
|
||||
|
||||
if (video_data.metadata.is_live_content) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id: id });
|
||||
if (!data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) return;
|
||||
video_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, video_data.metadata.channel_id, id);
|
||||
} else {
|
||||
video_data.getLivechat = () => {};
|
||||
}
|
||||
|
||||
video_data.like = () => Actions.engage(this, 'like/like', { video_id: id });
|
||||
video_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id });
|
||||
video_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id });
|
||||
video_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
|
||||
video_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
|
||||
video_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text });
|
||||
video_data.getComments = () => this.getComments(id);
|
||||
video_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: video_data.metadata.channel_id, pref: pref || 'NONE' });
|
||||
|
||||
return video_data;
|
||||
}
|
||||
|
||||
async getComments(video_id, token) {
|
||||
let comment_section_token;
|
||||
|
||||
if (!token) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id });
|
||||
const item_section_renderer = data_continuation.data.contents.twoColumnWatchNextResults.results.results.contents.find((item) => item.itemSectionRenderer);
|
||||
comment_section_token = item_section_renderer.itemSectionRenderer.contents[0].continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
}
|
||||
|
||||
const response = await Actions.getContinuation(this, { continuation_token: comment_section_token || token });
|
||||
if (!response.success) throw new Error('Could not fetch comment section');
|
||||
|
||||
const comments_section = { comments: [] };
|
||||
!token && (comments_section.comment_count = response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems && response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems[0].commentsHeaderRenderer.countText.runs[0].text || 'N/A');
|
||||
|
||||
let continuation_token;
|
||||
!token && (continuation_token = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token)
|
||||
|| (continuation_token = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token);
|
||||
|
||||
comments_section.getContinuation = () => this.getComments(video_id, continuation_token);
|
||||
|
||||
let contents;
|
||||
!token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems)
|
||||
|| (contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems);
|
||||
|
||||
contents.forEach((thread) => {
|
||||
if (!thread.commentThreadRenderer) return;
|
||||
const comment = {
|
||||
text: thread.commentThreadRenderer.comment.commentRenderer.contentText.runs.map((t) => t.text).join(' '),
|
||||
author: {
|
||||
name: thread.commentThreadRenderer.comment.commentRenderer.authorText.simpleText,
|
||||
thumbnail: thread.commentThreadRenderer.comment.commentRenderer.authorThumbnail.thumbnails,
|
||||
channel_id: thread.commentThreadRenderer.comment.commentRenderer.authorEndpoint.browseEndpoint.browseId
|
||||
},
|
||||
metadata: {
|
||||
published: thread.commentThreadRenderer.comment.commentRenderer.publishedTimeText.runs[0].text,
|
||||
is_liked: thread.commentThreadRenderer.comment.commentRenderer.isLiked,
|
||||
is_channel_owner: thread.commentThreadRenderer.comment.commentRenderer.authorIsChannelOwner,
|
||||
like_count: thread.commentThreadRenderer.comment.commentRenderer.voteCount.simpleText,
|
||||
reply_count: thread.commentThreadRenderer.comment.commentRenderer.replyCount || 0,
|
||||
id: thread.commentThreadRenderer.comment.commentRenderer.commentId,
|
||||
}
|
||||
};
|
||||
comments_section.comments.push(comment);
|
||||
});
|
||||
|
||||
return comments_section;
|
||||
}
|
||||
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await Actions.browse(this, 'subscriptions_feed');
|
||||
if (!response.success) throw new Error('Could not fetch subscriptions feed');
|
||||
|
||||
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
const subscriptions_feed = {};
|
||||
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
|
||||
const section_contents = section.itemSectionRenderer.contents[0];
|
||||
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
|
||||
|
||||
const key = section_contents.shelfRenderer.title.runs[0].text;
|
||||
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')] = [];
|
||||
|
||||
section_items.forEach((item) => {
|
||||
const content = {
|
||||
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
id: item.gridVideoRenderer.videoId,
|
||||
channel: item.gridVideoRenderer.shortBylineText && item.gridVideoRenderer.shortBylineText.runs[0].text || 'N/A',
|
||||
metadata: {
|
||||
view_count: item.gridVideoRenderer.viewCountText && item.gridVideoRenderer.viewCountText.simpleText || 'N/A',
|
||||
thumbnail: item.gridVideoRenderer.thumbnail && item.gridVideoRenderer.thumbnail.thumbnails || [],
|
||||
published: item.gridVideoRenderer.publishedTimeText && item.gridVideoRenderer.publishedTimeText.simpleText || 'N/A',
|
||||
badges: item.gridVideoRenderer.badges && item.gridVideoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
|
||||
owner_badges: item.gridVideoRenderer.ownerBadges && item.gridVideoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
|
||||
}
|
||||
};
|
||||
|
||||
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')].push(content);
|
||||
});
|
||||
});
|
||||
|
||||
return subscriptions_feed;
|
||||
}
|
||||
|
||||
async getNotifications() {
|
||||
const response = await Actions.notifications(this, 'get_notification_menu');
|
||||
if (!response.success) throw new Error('Could not fetch notifications');
|
||||
|
||||
const contents = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
|
||||
if (!contents.multiPageMenuNotificationSectionRenderer) return { error: 'You don\'t have any notification.' };
|
||||
return contents.multiPageMenuNotificationSectionRenderer.items.map((notification) => {
|
||||
if (!notification.notificationRenderer) return;
|
||||
notification = notification.notificationRenderer;
|
||||
return {
|
||||
title: notification.shortMessage.simpleText,
|
||||
sent_time: notification.sentTimeText.simpleText,
|
||||
channel_name: notification.contextualMenu.menuRenderer.items[1].menuServiceItemRenderer.text.runs[1].text,
|
||||
channel_thumbnail: notification.thumbnail.thumbnails[0],
|
||||
video_thumbnail: notification.videoThumbnail.thumbnails[0],
|
||||
video_url: `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}`,
|
||||
read: notification.read,
|
||||
notification_id: notification.notificationId,
|
||||
};
|
||||
}).filter((notification_block) => notification_block);
|
||||
}
|
||||
|
||||
async getUnseenNotificationsCount() {
|
||||
const response = await Actions.notifications(this, 'get_unseen_count');
|
||||
if (!response.success) throw new Error('Could not fetch unseen notifications count');
|
||||
return response.data.unseenCount;
|
||||
}
|
||||
|
||||
async requestVideoInfo(id, desktop) {
|
||||
let response;
|
||||
if (!desktop) {
|
||||
response = await Axios.get(`${Constants.urls.YT_WATCH_PAGE}?v=${id}&t=8s&pbj=1`, Constants.innertube_request_opts({ session: this, id, desktop: false })).catch((error) => error);
|
||||
} else {
|
||||
response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/player${this.logged_in && this.cookie.length < 1 ? '' : `?key=${this.key}`}`, JSON.stringify(Constants.video_details_reqbody(id, this.sts, this.context)), Constants.innertube_request_opts({ session: this, id, desktop: true })).catch((error) => error);
|
||||
}
|
||||
if (response instanceof Error) throw new Error('Could not retrieve watch page info: ' + response.message);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
download(id, options = {}) {
|
||||
if (!id) throw new Error('Missing video id');
|
||||
|
||||
options.quality = options.quality || '360p';
|
||||
options.type = options.type || 'videoandaudio';
|
||||
options.format = options.format || 'mp4';
|
||||
|
||||
let cancel;
|
||||
let cancelled = false;
|
||||
|
||||
const stream = new Stream.PassThrough();
|
||||
this.requestVideoInfo(id, true).then(async (video_data) => {
|
||||
let formats = [];
|
||||
|
||||
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED') return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
|
||||
if (!video_data.streamingData) return stream.emit('error', { message: 'Streaming data not available.', error_type: 'NO_STREAMING_DATA', playability_status: video_data.playabilityStatus.status });
|
||||
|
||||
formats = formats.concat(video_data.streamingData.formats || []).concat(video_data.streamingData.adaptiveFormats || []);
|
||||
formats.forEach((format) => {
|
||||
format.url = format.url || format.signatureCipher || format.cipher;
|
||||
|
||||
if (format.signatureCipher || format.cipher) {
|
||||
format.url = new SigDecipher(format.url, this.context.client.clientVersion, this.player).decipher();
|
||||
} else {
|
||||
const url_components = new URL(format.url);
|
||||
url_components.searchParams.set('cver', this.context.client.clientVersion);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
format.url = url_components.toString();
|
||||
}
|
||||
|
||||
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
|
||||
format.has_video = !!format.qualityLabel;
|
||||
|
||||
delete format.cipher;
|
||||
delete format.signatureCipher;
|
||||
});
|
||||
|
||||
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
|
||||
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
|
||||
|
||||
const video_details = Constants.formatVideoData(video_data, this, true);
|
||||
|
||||
let url;
|
||||
let bitrates;
|
||||
let filtered_streams;
|
||||
|
||||
switch (options.type) {
|
||||
case 'video':
|
||||
filtered_streams = formats.filter((format) => format.has_video && !format.has_audio);
|
||||
break;
|
||||
case 'audio':
|
||||
filtered_streams = formats.filter((format) => format.has_audio && !format.has_video);
|
||||
break;
|
||||
case 'videoandaudio':
|
||||
filtered_streams = formats.filter((format) => format.has_video && format.has_audio);
|
||||
break;
|
||||
default:
|
||||
filtered_streams = formats.filter((format) => format.has_video && format.has_audio);
|
||||
break;
|
||||
}
|
||||
|
||||
if (options.type != 'videoandaudio') {
|
||||
let streams;
|
||||
|
||||
if (options.type != 'audio') {
|
||||
streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality);
|
||||
} else {
|
||||
streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4'));
|
||||
}
|
||||
|
||||
if (streams == undefined || streams.length == 0) {
|
||||
streams = filtered_streams.filter((format) => format.quality == 'medium');
|
||||
}
|
||||
|
||||
bitrates = streams.map((format) => format.bitrate);
|
||||
url = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
|
||||
}
|
||||
|
||||
const selected_format = options.type == 'videoandaudio' ? filtered_streams[0] : url;
|
||||
if (!selected_format) {
|
||||
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMATS_UNAVAILABLE' });
|
||||
} else {
|
||||
stream.emit('info', { video_details, selected_format, formats });
|
||||
}
|
||||
|
||||
if (options.type == 'videoandaudio') {
|
||||
const response = await Axios.get(selected_format.url, {
|
||||
responseType: 'stream',
|
||||
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
|
||||
headers: Constants.stream_headers()
|
||||
}).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) {
|
||||
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
|
||||
return stream;
|
||||
} else {
|
||||
stream.emit('start');
|
||||
}
|
||||
|
||||
let downloaded_size = 0;
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded_size += chunk.length;
|
||||
let size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / response.headers['content-length']) * 100);
|
||||
stream.emit('progress', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-length'] } });
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
if (cancelled) {
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' });
|
||||
} else {
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
}
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end: true });
|
||||
} else {
|
||||
const chunk_size = 1048576 * 10; // 10MB
|
||||
|
||||
let chunk_start = 0;
|
||||
let chunk_end = chunk_size;
|
||||
let downloaded_size = 0;
|
||||
let end = false;
|
||||
|
||||
stream.emit('start');
|
||||
|
||||
const downloadChunk = async () => {
|
||||
if (chunk_end >= selected_format.contentLength) end = true;
|
||||
|
||||
const response = await Axios.get(`${selected_format.url}&range=${chunk_start}-${chunk_end || ''}`, {
|
||||
responseType: 'stream',
|
||||
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
|
||||
headers: Constants.stream_headers()
|
||||
}).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) {
|
||||
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
|
||||
return stream;
|
||||
}
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded_size += chunk.length;
|
||||
let size = (selected_format.contentLength / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / selected_format.contentLength) * 100);
|
||||
stream.emit('progress', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-length'] } });
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
if (cancelled) {
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' });
|
||||
} else {
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!end) {
|
||||
chunk_start = chunk_end + 1;
|
||||
chunk_end += chunk_size;
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end });
|
||||
};
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
|
||||
stream.cancel = () => {
|
||||
cancelled = true;
|
||||
cancel();
|
||||
};
|
||||
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Innertube;
|
||||
132
lib/Livechat.js
132
lib/Livechat.js
@@ -1,132 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Actions = require('./Actions');
|
||||
const Constants = require('./Constants');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class Livechat extends EventEmitter {
|
||||
constructor(session, token, channel_id, video_id) {
|
||||
super(session);
|
||||
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();
|
||||
}
|
||||
|
||||
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 poll() {
|
||||
if (!this.running) return;
|
||||
|
||||
let data;
|
||||
|
||||
data = { context: this.session.context, continuation: this.ctoken };
|
||||
const livechat = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/live_chat/get_live_chat${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session: this.session, data, desktop: true }));
|
||||
if (livechat instanceof Error) throw new Error(`Error polling livechat: ${livechat.message}`);
|
||||
|
||||
const continuation_contents = livechat.data.continuationContents;
|
||||
const action_group = continuation_contents.liveChatContinuation.actions;
|
||||
this.enqueueActionGroup(action_group);
|
||||
|
||||
// Why don't we just emit the message directly? Well, enqueueing the messages is necessary so they are not emitted in a “messy” way, funny enough that's exactly how YouTube does it in its livechat js player.
|
||||
this.message_queue.forEach((message, index) => {
|
||||
if (this.id_cache.includes(message.id)) return;
|
||||
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
|
||||
this.id_cache.push(message.id);
|
||||
});
|
||||
|
||||
this.message_queue = [];
|
||||
|
||||
data = { context: this.session.context, videoId: this.video_id };
|
||||
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
|
||||
|
||||
const updated_metadata = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/updated_metadata${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session: this.session, data, desktop: true }));
|
||||
if (updated_metadata instanceof Error) throw new Error(`Error polling updated metadata: ${updated_metadata.message}`);
|
||||
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
|
||||
|
||||
const metadata = updated_metadata.data.actions;
|
||||
this.emit('update-metadata', {
|
||||
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
dislikes: metadata[2].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
view_count: {
|
||||
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
|
||||
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
|
||||
}
|
||||
});
|
||||
|
||||
this.livechat_poller = setTimeout(async () => await this.poll(), this.poll_intervals_ms);
|
||||
}
|
||||
|
||||
async sendMessage(text) {
|
||||
const message = await Actions.livechat(this.session, 'live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id });
|
||||
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,
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async blockUser(msg_params) {
|
||||
/* TODO: Implement this */
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
clearTimeout(this.livechat_poller);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Livechat;
|
||||
129
lib/NToken.js
129
lib/NToken.js
@@ -1,129 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
class NToken {
|
||||
constructor(raw_code) {
|
||||
this.raw_code = raw_code;
|
||||
this.null_placeholder_regex = /c\[(.*?)\]=c/g;
|
||||
this.transformation_args_regex = /c\[(.*?)\]\((.+?)\)/g;
|
||||
}
|
||||
|
||||
transform(n) {
|
||||
let n_token = n.split('');
|
||||
let transformations = this.getTransformationData(this.raw_code);
|
||||
|
||||
// Identifies the necessary transformation functions and emulates them accordingly.
|
||||
transformations = transformations.map((el) => {
|
||||
if (el != null && typeof el != 'number') {
|
||||
const is_reverse_base64 = el.includes('case 65:');
|
||||
if (el.includes('function(d){for(var')) {
|
||||
el = (arr) => this.pushSplice(arr);
|
||||
} else if (el.includes('d.push(e)')) {
|
||||
el = (arr, item) => this.push(arr, item);
|
||||
} else if (el.includes('d.reverse()')) {
|
||||
el = (arr) => this.reverse(arr);
|
||||
} else if (el.includes('d.length;d.splice(e,1)')) {
|
||||
el = (arr, index) => this.spliceOnce(arr, index);
|
||||
} else if (el.includes('d[0])[0])')) {
|
||||
el = (arr, index) => this.spliceTwice(arr, index);
|
||||
} else if (el.includes('reverse().forEach')) {
|
||||
el = (arr, index) => this.spliceReverseUnshift(arr, index);
|
||||
} else if (el.includes('f=d[0];d[0]')) {
|
||||
el = (arr, index) => this.swapFirstItem(arr, index);
|
||||
} else if (el.includes('unshift(d.pop())')) {
|
||||
el = (arr, index) => this.unshiftPop(arr, index);
|
||||
} else if (el.includes('switch')) {
|
||||
el = (arr, e) => this.translateAB(arr, e, is_reverse_base64);
|
||||
} else if (el === 'b') {
|
||||
el = n_token;
|
||||
}
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
// Fills the null placeholders with a copy of the transformations array.
|
||||
let null_placeholder_positions = [...this.raw_code.matchAll(this.null_placeholder_regex)].map((item) => parseInt(item[1]));
|
||||
null_placeholder_positions.forEach((pos) => transformations[pos] = transformations);
|
||||
|
||||
// Parses and emulates calls to functions of the transformations array.
|
||||
let transformation_args = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch').matchAll(this.transformation_args_regex)].map((params) => ({ index: params[1], params: params[2] }));
|
||||
transformation_args.forEach((data) => {
|
||||
const index = data.index;
|
||||
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
|
||||
transformations[index](transformations[param_index[0]], transformations[param_index[1]]);
|
||||
});
|
||||
|
||||
return n_token.join('');
|
||||
}
|
||||
|
||||
getTransformationData() {
|
||||
let transformation_data = '[' + Utils.getStringBetweenStrings(this.raw_code, 'c=[', '];c') + ']';
|
||||
transformation_data = transformation_data
|
||||
.replace(/function\(d,e\)/g, '"function(d,e)')
|
||||
.replace(/function\(d\)/g, '"function(d)')
|
||||
.replace(/,b,/g, ',"b",')
|
||||
.replace(/,b/g, ',"b"')
|
||||
.replace(/b,/g, '"b",')
|
||||
.replace(/b]/g, '"b"]')
|
||||
.replace(/},/g, '}",')
|
||||
.replace(/""/g, '')
|
||||
.replace(/length]\)}"/g, 'length])}');
|
||||
|
||||
return JSON.parse(transformation_data);
|
||||
}
|
||||
|
||||
translateAB(arr, e, is_reverse_base64) {
|
||||
let characters = is_reverse_base64 && Constants.base64_alphabet.reverse || Constants.base64_alphabet.normal;
|
||||
arr.forEach(function(char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
|
||||
}, e.split(''));
|
||||
}
|
||||
|
||||
unshiftPop(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
for (; index--;) {
|
||||
arr.unshift(arr.pop());
|
||||
}
|
||||
}
|
||||
|
||||
swapFirstItem(arr, index) {
|
||||
let oldValue = arr[0];
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr[0] = arr[index];
|
||||
arr[index] = oldValue;
|
||||
}
|
||||
|
||||
spliceReverseUnshift(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(-index).reverse().forEach(function(f) {
|
||||
arr.unshift(f);
|
||||
});
|
||||
}
|
||||
|
||||
spliceOnce(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(index, 1);
|
||||
}
|
||||
|
||||
spliceTwice(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(0, 1, arr.splice(index, 1, arr[0])[0]);
|
||||
}
|
||||
|
||||
pushSplice(arr) {
|
||||
for (let index = arr.length; index;)
|
||||
arr.push(arr.splice(--index, 1)[0]);
|
||||
}
|
||||
|
||||
push(arr, item) {
|
||||
arr.push(item);
|
||||
}
|
||||
|
||||
reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NToken;
|
||||
171
lib/OAuth.js
171
lib/OAuth.js
@@ -1,171 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
const EventEmitter = require('events');
|
||||
const Uuid = require("uuid");
|
||||
|
||||
class OAuth extends EventEmitter {
|
||||
constructor(creds) {
|
||||
super();
|
||||
// Default interval between requests when waiting for authorization.
|
||||
this.refresh_interval = 5;
|
||||
|
||||
// OAuth URLs:
|
||||
this.oauth_code_url = `${Constants.urls.YT_BASE_URL}/o/oauth2/device/code`;
|
||||
this.oauth_token_url = `${Constants.urls.YT_BASE_URL}/o/oauth2/token`;
|
||||
|
||||
// Used to check whether an access token is valid or not.
|
||||
this.guide_url = `${Constants.urls.YT_BASE_URL}/youtubei/v1/guide`;
|
||||
|
||||
// These are always the same, so we shouldn't have any problems for now.
|
||||
this.model_name = Constants.oauth.model_name;
|
||||
this.grant_type = Constants.oauth.grant_type;
|
||||
this.scope = Constants.oauth.scope;
|
||||
|
||||
// Script that contains important information such as client id and client secret.
|
||||
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
|
||||
|
||||
// Used to find the credentials inside the script.
|
||||
this.identity_regex = /var .+?=\"(?<id>.+?)\",[.|\s].?=\"(?<secret>.+?)\"/;
|
||||
|
||||
if (creds.access_token != undefined && creds.refresh_token != undefined) return;
|
||||
this.requestAuthCode();
|
||||
}
|
||||
|
||||
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_reqopts).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get auth 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: 'The 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 {
|
||||
this.emit('auth', {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
token_type: response.data.token_type,
|
||||
expires: response.data.expires_in,
|
||||
scope: response.data.scope,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
}, 1000 * this.refresh_interval);
|
||||
}
|
||||
|
||||
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_reqopts).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get auth code.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('auth', {
|
||||
code: response.data.user_code,
|
||||
status: 'AUTHORIZATION_PENDING',
|
||||
expires_in: response.data.expires_in,
|
||||
verification_url: response.data.verification_url
|
||||
});
|
||||
|
||||
this.refresh_interval = response.data.interval;
|
||||
|
||||
// Keeps requesting at a specific rate until the authorization is granted or denied.
|
||||
this.waitForAuth(response.data.device_code);
|
||||
}
|
||||
|
||||
async getClientIdentity() {
|
||||
// The first request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
|
||||
const yttv_response = await Axios.get(`${Constants.urls.YT_BASE_URL}/tv`, Constants.oauth_reqopts).catch((error) => error);
|
||||
if (yttv_response instanceof Error) throw new Error(`Could not get identify: ${yttv_response.message}`);
|
||||
|
||||
// Here we get the script and extract the necessary data to proceed with the auth flow.
|
||||
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
|
||||
const script_url = `${Constants.urls.YT_BASE_URL}/${url_body}`;
|
||||
const response = await Axios.get(script_url, Constants.default_headers).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not fetch data from auth script: ${response.message}`);
|
||||
|
||||
const identity_function = Utils.getStringBetweenStrings(response.data, '=function(){var a=window.environment', '(function()');
|
||||
const client_identity = identity_function.match(this.identity_regex).groups;
|
||||
return client_identity;
|
||||
}
|
||||
|
||||
async refreshAccessToken(refresh_token) {
|
||||
const identity = await this.getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: identity.id,
|
||||
client_secret: identity.secret,
|
||||
refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.oauth_reqopts).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('refresh-token', {
|
||||
error: 'Could not refresh token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('refresh-token', {
|
||||
access_token: response.data.access_token,
|
||||
token_type: response.data.token_type,
|
||||
expires: response.data.expires_in,
|
||||
scope: response.data.scope,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
|
||||
async checkTokenValidity(access_token, session) {
|
||||
let headers = Constants.innertube_request_opts({ session }).headers;
|
||||
headers.authorization = `Bearer ${access_token}`;
|
||||
|
||||
const response = await Axios.post(this.guide_url, JSON.stringify({ context: session.context }), { headers }).catch((error) => error);
|
||||
if (response instanceof Error) return 'INVALID';
|
||||
return 'VALID';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth;
|
||||
@@ -1,43 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
class Player {
|
||||
constructor(innertube_session) {
|
||||
this.session = innertube_session;
|
||||
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
|
||||
this.tmp_cache_dir = __dirname.slice(0, -3) + 'cache';
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (fs.existsSync(`${this.tmp_cache_dir}/${this.player_name}.js`)) {
|
||||
const player_data = fs.readFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`).toString();
|
||||
this.getSigDecipherCode(player_data);
|
||||
this.getNEncoder(player_data);
|
||||
} else {
|
||||
const response = await Axios.get(`${Constants.urls.YT_BASE_URL}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error('Could not get player data: ' + response.message);
|
||||
|
||||
fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
|
||||
fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data);
|
||||
|
||||
this.getSigDecipherCode(response.data);
|
||||
this.getNEncoder(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
getSigDecipherCode(data) {
|
||||
const manipulation_algorithm_code = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const manipulation_sequence_code = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
this.sig_decipher_sc = manipulation_algorithm_code + manipulation_sequence_code;
|
||||
}
|
||||
|
||||
getNEncoder(data) {
|
||||
this.ntoken_sc = 'var b=a.split("")' + Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}') + '} return b.join("");';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Player;
|
||||
@@ -1,78 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const NToken = require('./NToken');
|
||||
const QueryString = require('querystring');
|
||||
|
||||
class SigDecipher {
|
||||
constructor(url, cver, player) {
|
||||
this.url = url;
|
||||
this.cver = cver;
|
||||
this.player = player;
|
||||
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
|
||||
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
|
||||
}
|
||||
|
||||
decipher() {
|
||||
const args = QueryString.parse(this.url);
|
||||
const functions = this.getFunctions();
|
||||
|
||||
function splice(arr, end) {
|
||||
arr.splice(0, end);
|
||||
}
|
||||
|
||||
function swap(arr, position) {
|
||||
let origArrI = arr[0];
|
||||
arr[0] = arr[position % arr.length];
|
||||
arr[position % arr.length] = origArrI;
|
||||
}
|
||||
|
||||
function reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
|
||||
let actions;
|
||||
let signature = args.s.split('');
|
||||
|
||||
while ((actions = this.actions_regex.exec(this.player.sig_decipher_sc)) !== null) {
|
||||
switch (actions[1]) {
|
||||
case functions[0]:
|
||||
reverse(signature, actions[2]);
|
||||
break;
|
||||
case functions[1]:
|
||||
splice(signature, actions[2]);
|
||||
break;
|
||||
case functions[2]:
|
||||
swap(signature, actions[2]);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
const url_components = new URL(args.url);
|
||||
|
||||
args.sp !== undefined ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
|
||||
url_components.searchParams.set('cver', this.cver);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
return url_components.toString();
|
||||
}
|
||||
|
||||
getFunctions() {
|
||||
let func;
|
||||
let func_name = [];
|
||||
|
||||
while ((func = this.func_regex.exec(this.player.sig_decipher_sc)) !== null) {
|
||||
if (func[0].includes('reverse()')) {
|
||||
func_name[0] = func[1];
|
||||
} else if (func[0].includes('splice')) {
|
||||
func_name[1] = func[1];
|
||||
} else {
|
||||
func_name[2] = func[1];
|
||||
}
|
||||
}
|
||||
|
||||
return func_name;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SigDecipher;
|
||||
86
lib/Utils.js
86
lib/Utils.js
@@ -1,86 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Proto = require('protons');
|
||||
const Crypto = require('crypto');
|
||||
const UserAgent = require('user-agents');
|
||||
|
||||
function getRandomUserAgent(type) {
|
||||
switch (type) {
|
||||
case 'mobile':
|
||||
return new UserAgent(/Android/).data;
|
||||
case 'desktop':
|
||||
return new UserAgent({ deviceCategory: 'desktop' }).data;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
function generateSidAuth(sid) {
|
||||
const youtube = 'https://www.youtube.com';
|
||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||
const input = [timestamp, sid, youtube].join(' ');
|
||||
|
||||
let hash = Crypto.createHash('sha1');
|
||||
let data = hash.update(input, 'utf-8');
|
||||
let gen_hash = data.digest('hex');
|
||||
|
||||
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
|
||||
}
|
||||
|
||||
function getStringBetweenStrings(data, start_string, end_string) {
|
||||
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, "s");
|
||||
const match = data.match(regex);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
function escapeStringRegexp(string) {
|
||||
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
|
||||
}
|
||||
|
||||
function encodeNotificationPref(channel_id, index) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.NotificationPreferences.encode({
|
||||
channel_id,
|
||||
pref_id: {
|
||||
index
|
||||
},
|
||||
number_0: 0,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
function generateMessageParams(channel_id, video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.LiveMessageParams.encode({
|
||||
params: {
|
||||
ids: {
|
||||
channel_id,
|
||||
video_id
|
||||
}
|
||||
},
|
||||
number_0: 1,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
|
||||
}
|
||||
|
||||
function generateCommentParams(video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.CreateCommentParams.encode({
|
||||
video_id,
|
||||
params: {
|
||||
index: 0
|
||||
},
|
||||
number: 7
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, generateMessageParams, generateCommentParams, encodeNotificationPref };
|
||||
@@ -1,34 +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;
|
||||
}
|
||||
9645
package-lock.json
generated
9645
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
112
package.json
112
package.json
@@ -1,46 +1,88 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.2.1",
|
||||
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "LuanRT",
|
||||
"license": "MIT",
|
||||
"version": "2.8.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://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": {
|
||||
"example": "examples",
|
||||
"lib": "lib"
|
||||
"test": "./test",
|
||||
"examples": "./examples",
|
||||
"dist": "./dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"protons": "^2.0.3",
|
||||
"time-to-seconds": "^1.1.5",
|
||||
"user-agents": "^1.0.778",
|
||||
"uuid": "^8.3.2"
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"keywords": [
|
||||
"youtube",
|
||||
"youtube-dl",
|
||||
"innertube",
|
||||
"innertubeapi",
|
||||
"livechat",
|
||||
"api",
|
||||
"search",
|
||||
"like",
|
||||
"dislike",
|
||||
"comment",
|
||||
"automation",
|
||||
"downloader",
|
||||
"comments-section",
|
||||
"youtube-downloader"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/LuanRT/YouTube.js/issues"
|
||||
},
|
||||
"homepage": "https://github.com/LuanRT/YouTube.js#readme"
|
||||
}
|
||||
"homepage": "https://github.com/LuanRT/YouTube.js#readme",
|
||||
"keywords": [
|
||||
"yt",
|
||||
"dl",
|
||||
"ytdl",
|
||||
"youtube",
|
||||
"youtubedl",
|
||||
"youtube-dl",
|
||||
"youtube-downloader",
|
||||
"youtube-music",
|
||||
"youtube-studio",
|
||||
"innertubeapi",
|
||||
"innertube",
|
||||
"unofficial",
|
||||
"downloader",
|
||||
"livechat",
|
||||
"studio",
|
||||
"upload",
|
||||
"ytmusic",
|
||||
"search",
|
||||
"comment",
|
||||
"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;
|
||||
}
|
||||
}
|
||||
211
src/core/Studio.ts
Normal file
211
src/core/Studio.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import Proto from '../proto';
|
||||
import { Constants } from '../utils';
|
||||
import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
|
||||
|
||||
import type { ApiResponse } from './Actions';
|
||||
import type Session from './Session';
|
||||
|
||||
interface UploadResult {
|
||||
status: string;
|
||||
scottyResourceId: string;
|
||||
}
|
||||
|
||||
interface InitialUploadData {
|
||||
frontend_upload_id: string;
|
||||
upload_id: string;
|
||||
upload_url: string;
|
||||
scotty_resource_id: string;
|
||||
chunk_granularity: string;
|
||||
}
|
||||
|
||||
export interface VideoMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
category?: number;
|
||||
license?: string;
|
||||
age_restricted?: boolean;
|
||||
made_for_kids?: boolean;
|
||||
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
|
||||
}
|
||||
|
||||
export interface UploadedVideoMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
|
||||
is_draft?: boolean;
|
||||
}
|
||||
|
||||
class Studio {
|
||||
#session: Session;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a custom thumbnail and sets it for a video.
|
||||
* @example
|
||||
* ```ts
|
||||
* const buffer = fs.readFileSync('./my_awesome_thumbnail.jpg');
|
||||
* const response = await yt.studio.setThumbnail(video_id, buffer);
|
||||
* ```
|
||||
*/
|
||||
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
if (!video_id || !buffer)
|
||||
throw new MissingParamError('One or more parameters are missing.');
|
||||
|
||||
const payload = Proto.encodeCustomThumbnailPayload(video_id, buffer);
|
||||
|
||||
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
|
||||
protobuf: true,
|
||||
serialized_data: payload
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates given video's metadata.
|
||||
* @example
|
||||
* ```ts
|
||||
* const response = await yt.studio.updateVideoMetadata('videoid', {
|
||||
* tags: [ 'astronomy', 'NASA', 'APOD' ],
|
||||
* title: 'Artemis Mission',
|
||||
* description: 'A nicely written description...',
|
||||
* category: 27,
|
||||
* license: 'creative_commons'
|
||||
* // ...
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async updateVideoMetadata(video_id: string, metadata: VideoMetadata): Promise<ApiResponse> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);
|
||||
|
||||
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
|
||||
protobuf: true,
|
||||
serialized_data: payload
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a video to YouTube.
|
||||
* @example
|
||||
* ```ts
|
||||
* const file = fs.readFileSync('./my_awesome_video.mp4');
|
||||
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
|
||||
* ```
|
||||
*/
|
||||
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<ApiResponse> {
|
||||
if (!this.#session.logged_in)
|
||||
throw new InnertubeError('You must be signed in to perform this operation.');
|
||||
|
||||
const initial_data = await this.#getInitialUploadData();
|
||||
const upload_result = await this.#uploadVideo(initial_data.upload_url, file);
|
||||
|
||||
if (upload_result.status !== 'STATUS_SUCCESS')
|
||||
throw new InnertubeError('Could not process video.');
|
||||
|
||||
const response = await this.#setVideoMetadata(initial_data, upload_result, metadata);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async #getInitialUploadData(): Promise<InitialUploadData> {
|
||||
const frontend_upload_id = `innertube_android:${uuidv4()}:0:v=3,api=1,cf=3`;
|
||||
|
||||
const payload = {
|
||||
frontendUploadId: frontend_upload_id,
|
||||
deviceDisplayName: 'Pixel 6 Pro',
|
||||
fileId: `goog-edited-video://generated?videoFileUri=content://media/external/video/media/${uuidv4()}`,
|
||||
mp4MoovAtomRelocationStatus: 'UNSUPPORTED',
|
||||
transcodeResult: 'DISABLED',
|
||||
connectionType: 'WIFI'
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch('/upload/youtubei', {
|
||||
baseURL: Constants.URLS.YT_UPLOAD,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'x-goog-upload-command': 'start',
|
||||
'x-goog-upload-protocol': 'resumable'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw new InnertubeError('Could not get initial upload data');
|
||||
|
||||
return {
|
||||
frontend_upload_id,
|
||||
upload_id: response.headers.get('x-guploader-uploadid') as string,
|
||||
upload_url: response.headers.get('x-goog-upload-url') as string,
|
||||
scotty_resource_id: response.headers.get('x-goog-upload-header-scotty-resource-id') as string,
|
||||
chunk_granularity: response.headers.get('x-goog-upload-chunk-granularity') as string
|
||||
};
|
||||
}
|
||||
|
||||
async #uploadVideo(upload_url: string, file: BodyInit): Promise<UploadResult> {
|
||||
const response = await this.#session.http.fetch_function(upload_url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'x-goog-upload-command': 'upload, finalize',
|
||||
'x-goog-upload-file-name': `file-${Date.now()}`,
|
||||
'x-goog-upload-offset': '0'
|
||||
},
|
||||
body: file
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw new InnertubeError('Could not upload video');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadata) {
|
||||
const metadata_payload = {
|
||||
resourceId: {
|
||||
scottyResourceId: {
|
||||
id: upload_result.scottyResourceId
|
||||
}
|
||||
},
|
||||
frontendUploadId: initial_data.frontend_upload_id,
|
||||
initialMetadata: {
|
||||
title: {
|
||||
newTitle: metadata.title || new Date().toDateString()
|
||||
},
|
||||
description: {
|
||||
newDescription: metadata.description || '',
|
||||
shouldSegment: true
|
||||
},
|
||||
privacy: {
|
||||
newPrivacy: metadata.privacy || 'PRIVATE'
|
||||
},
|
||||
draftState: {
|
||||
isDraft: metadata.is_draft || false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const response = await this.#session.actions.execute('/upload/createvideo', {
|
||||
client: 'ANDROID',
|
||||
...metadata_payload
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export default Studio;
|
||||
55
src/core/TabbedFeed.ts
Normal file
55
src/core/TabbedFeed.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import Feed from './Feed';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
|
||||
import type Actions from './Actions';
|
||||
import type { ObservedArray } from '../parser/helpers';
|
||||
|
||||
class TabbedFeed extends Feed {
|
||||
#tabs: ObservedArray<Tab>;
|
||||
#actions: Actions;
|
||||
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
this.#actions = actions;
|
||||
this.#tabs = this.page.contents_memo.getType(Tab);
|
||||
}
|
||||
|
||||
get tabs(): string[] {
|
||||
return this.#tabs.map((tab) => tab.title.toString());
|
||||
}
|
||||
|
||||
async getTabByName(title: string): Promise<TabbedFeed> {
|
||||
const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError(`Tab "${title}" not found`);
|
||||
|
||||
if (tab.selected)
|
||||
return this;
|
||||
|
||||
const response = await tab.endpoint.call(this.#actions);
|
||||
|
||||
return new TabbedFeed(this.#actions, response.data, false);
|
||||
}
|
||||
|
||||
async getTabByURL(url: string): Promise<TabbedFeed> {
|
||||
const tab = this.#tabs.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url);
|
||||
|
||||
if (!tab)
|
||||
throw new InnertubeError(`Tab "${url}" not found`);
|
||||
|
||||
if (tab.selected)
|
||||
return this;
|
||||
|
||||
const response = await tab.endpoint.call(this.#actions);
|
||||
|
||||
return new TabbedFeed(this.#actions, response.data, false);
|
||||
}
|
||||
|
||||
get title(): string | undefined {
|
||||
return this.page.contents_memo.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export default TabbedFeed;
|
||||
331
src/parser/README.md
Normal file
331
src/parser/README.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Parser
|
||||
|
||||
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data. Also [drastically improves](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube/Library.ts#L69) how API calls are made and handled.
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#api">API</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#usage">Usage</a>
|
||||
<ul>
|
||||
<li><a href="#observedarray">ObservedArray</a></li>
|
||||
<li><a href="#superparsedresponse">SuperParsedResponse</a></li>
|
||||
<li><a href="#ytnode">YTNode</a></li>
|
||||
<li><a href="#memo">Memo</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#adding-new-nodes">Adding new nodes</a></li>
|
||||
<li><a href="#how-it-works">How it works</a></li>
|
||||
</ol>
|
||||
|
||||
___
|
||||
|
||||
## API
|
||||
|
||||
* Parser
|
||||
* [.parse](#parse)
|
||||
* [.parseItem](#parse)
|
||||
* [.parseArray](#parse)
|
||||
* [.parseResponse](#parseresponse)
|
||||
|
||||
<a name="parse"></a>
|
||||
|
||||
#### parse(data, requireArray, validTypes)
|
||||
|
||||
Responsible for parsing individual nodes.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| data | `any` | The data |
|
||||
| requireArray | `?boolean` | Whether the response should be an array |
|
||||
| validTypes | `YTNodeConstructor<T> \| YTNodeConstructor<T>[] \| undefined` | Types of `YTNode` allowed |
|
||||
|
||||
When `requireArray` is `true`, the response will be an `ObservedArray<YTNodes>`.
|
||||
|
||||
When `validTypes` is `undefined`, the response will be an array of YTNodes.
|
||||
|
||||
When `validTypes` is an array, the response will be an array of YTNodes that are of the types specified in the array.
|
||||
|
||||
When `validTypes` is a single type, the response will be an array of YTNodes that are of the type specified.
|
||||
|
||||
If you do not specify `requireArray`, the return type of the function will not be known at runtime, and therefore we return the response wrapped in a helper, `SuperParsedResponse`, to gain access to the response.
|
||||
|
||||
You may use the `Parser#parseArray` and `Parser#parseItem` methods to parse the response in a deterministic way.
|
||||
|
||||
<a name="parseresponse"></a>
|
||||
#### parseResponse(data)
|
||||
|
||||
Unlike `parse`, this can be used to parse the entire response object.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| data | `object` | Raw InnerTube response |
|
||||
|
||||
## Usage
|
||||
|
||||
## ObservedArray
|
||||
You may use `ObservedArray<T extends YTNode>` as a normal array, but it provides additional methods for typesafe access and casting.
|
||||
|
||||
```ts
|
||||
// For example, we have a feed, and want all the videos:
|
||||
const feed = new ObservedArray<YTNode>([...feed.contents]);
|
||||
const videos = feed.filterType(GridVideo);
|
||||
// This is now a GridVideo[]
|
||||
|
||||
// Or we want only the first video:
|
||||
const firstVideo = feed.firstOfType(GridVideo);
|
||||
|
||||
// We may cast the whole array to a GridVideo[] and throw if we have any non-GridVideo elements:
|
||||
const allVideos = feed.as(GridVideo);
|
||||
|
||||
// There are some extra methods for ObservedArray<T extends YTNode>
|
||||
// which we use internally but not documented here (yet).
|
||||
// see the source code for more details.
|
||||
```
|
||||
|
||||
## SuperParsedResponse
|
||||
Represents a parsed response in an unknown state. Either a `YTNode` or an `ObservedArray<YTNode>` or `null`.
|
||||
|
||||
You will need to assert the type and unwrap the response to get the actual value.
|
||||
|
||||
```ts
|
||||
// We can assert we have a YTNode:
|
||||
const response = Parser.parse(data);
|
||||
if (response.is_item) {
|
||||
const node = response.item();
|
||||
}
|
||||
|
||||
// We can assert we have an ObservedArray<YTNode>:
|
||||
const response = Parser.parse(data);
|
||||
if (response.is_array) {
|
||||
const nodes = response.array();
|
||||
}
|
||||
|
||||
// Or lastly a null response:
|
||||
const response = Parser.parse(data);
|
||||
const is_null = response.is_null;
|
||||
```
|
||||
|
||||
## YTNode
|
||||
All renderers returned by InnerTube are converted to this generic class and then extended for the specific renderers.
|
||||
|
||||
This class is what allows us a typesafe way to use data returned by the InnerTube API.
|
||||
|
||||
Here's how to use this class to access returned data:
|
||||
|
||||
### Type Casting
|
||||
```ts
|
||||
// We can cast a YTNode to a child class of YTNode
|
||||
const results = node.as(TwoColumnSearchResults);
|
||||
// This will throw if the node is not a TwoColumnSearchResults
|
||||
// We thus may want to check for the type of the node before casting
|
||||
if (node.is(TwoColumnSearchResults)) {
|
||||
// We do not need to recast the node, it is already a TwoColumnSearchResults after calling is() and using it in the branch where is() returns true
|
||||
const results = node;
|
||||
}
|
||||
|
||||
// Sometimes we can expect multiple types of nodes, we can just pass all possible types as params.
|
||||
const results = node.as(TwoColumnSearchResults, VideoList);
|
||||
// The type of `results` will now be `TwoColumnSearchResults | VideoList`
|
||||
|
||||
// Similarly, we can check if the node is of a certain type.
|
||||
if (node.is(TwoColumnSearchResults, VideoList)) {
|
||||
// Again no casting is needed, the node is already of the correct type.
|
||||
const results = node;
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing properties without casting
|
||||
Sometimes multiple nodes have the same properties and we don't want to check the type of the node before accessing the property, for example, the property "contents" is used by many node types, and we may add more in the future, as such we want to only assert the property instead of casting to a specific type.
|
||||
|
||||
```ts
|
||||
// Accessing a property on a node which you aren't sure if it exists.
|
||||
const prop = node.key("contents");
|
||||
// This returns the value wrapped into a `Maybe` type
|
||||
// which you can use to find the type of the value
|
||||
// note, however, this throws an error if the key doesn't exist
|
||||
// we may want to check for the key before accessing it.
|
||||
if (node.hasKey("contents")) {
|
||||
const prop = node.key("contents");
|
||||
}
|
||||
|
||||
// We can assert the type of the value.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isString()) {
|
||||
const value = prop.string();
|
||||
}
|
||||
|
||||
// We can do more complex assertions too,
|
||||
// like checking for instanceof.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isInstanceof(Text)) {
|
||||
const text = prop.instanceof(Text);
|
||||
// and then use the value as the given type
|
||||
text.runs.forEach(run => {
|
||||
console.log(run.text);
|
||||
});
|
||||
}
|
||||
|
||||
// There are some special methods for using with the parser —
|
||||
// such as getting the value as a YTNode.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isNode()) {
|
||||
const node = prop.node();
|
||||
}
|
||||
|
||||
// Like with YTNode, keys can also be checked for YTNode child class types.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isNodeOfType(TwoColumnSearchResults)) {
|
||||
const results = prop.nodeOfType(TwoColumnSearchResults);
|
||||
}
|
||||
|
||||
// Or we can check for multiple types of nodes.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isNodeOfType([TwoColumnSearchResults, VideoList])) {
|
||||
const results = prop.nodeOfType<TwoColumnSearchResults | VideoList>([TwoColumnSearchResults, VideoList]);
|
||||
}
|
||||
|
||||
// Sometimes an ObservedArray is returned when working with parsed data.
|
||||
// We've got a helper for that too;
|
||||
const prop = node.key("contents");
|
||||
if (prop.isObserved()) {
|
||||
const array = prop.observed();
|
||||
|
||||
// Now we may use all the ObservedArray methods as normal,
|
||||
// like finding nodes of a certain type for example.
|
||||
const results = array.filterType(GridVideo);
|
||||
}
|
||||
|
||||
// Other times a SuperParsedResult is returned, like when using the `Parser#parse` method.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isParsed()) {
|
||||
const result = prop.parsed();
|
||||
|
||||
// SuperParsedResult is another helper for typesafe access to the parsed data,
|
||||
// it is explained above with the `Parser#parse` method.
|
||||
const results = results.array();
|
||||
const videos = results.filterType(Video);
|
||||
}
|
||||
|
||||
// Sometimes we just want to debug something and are not interested in finding the type.
|
||||
// This will, however, warn you when being used.
|
||||
const prop = node.key("contents");
|
||||
const value = prop.any();
|
||||
|
||||
// Arrays are also a special case as every element may be of a different type,
|
||||
// the `arrayOfMaybe` method will return an array of `Maybe`s.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isArray()) {
|
||||
const array = prop.arrayOfMaybe();
|
||||
// This will return Maybe[]
|
||||
}
|
||||
|
||||
// Or if you want zero type safety you can use the `array` method.
|
||||
const prop = node.key("contents");
|
||||
if (prop.isArray()) {
|
||||
const array = prop.array();
|
||||
// This will return any[]
|
||||
}
|
||||
```
|
||||
|
||||
## Memo
|
||||
The `Memo` class is a helper class for memoizing values in the `Parser#parseResponse` method. It is useful for finding nodes after parsing the response.
|
||||
|
||||
Say we want all of the videos in a search result. We can use the `Memo` to find all of them quickly without recursing through the response.
|
||||
|
||||
```ts
|
||||
const response = Parser.parseResponse(data);
|
||||
const videos = response.contents_memo.getType(Video);
|
||||
// This returns the nodes as a ObservedArray<Video>.
|
||||
```
|
||||
|
||||
`Memo` extends `Map<string, YTNode[]>` and can be used as a normal `Map` too if you want.
|
||||
|
||||
## Adding new nodes
|
||||
Instructions can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md).
|
||||
|
||||
## How it works
|
||||
|
||||
If you decompile a YouTube client and analyze it for a while you will notice that it has classes named `protos/youtube/api/innertube/MusicItemRenderer`, `protos/youtube/api/innertube/SectionListRenderer`, etc.
|
||||
|
||||
These classes are used to parse objects from the response, map them into models and generate the UI. The website works similarly, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
|
||||
|
||||
Here we're taking a similar approach, the parser goes through all the renderers and parses their inner element(s). The final result is a nicely structured JSON, and on top of that, it also parses navigation endpoints which allow us to make an API call with all required parameters in one line and even emulate client actions (eg; clicking a button).
|
||||
|
||||
Here is your average, arguably ugly InnerTube response:
|
||||
<details>
|
||||
<summary>Click to see</summary>
|
||||
<p>
|
||||
|
||||
```js
|
||||
{
|
||||
sidebar: {
|
||||
playlistSidebarRenderer: {
|
||||
items: [
|
||||
{
|
||||
playlistSidebarPrimaryInfoRenderer: {
|
||||
title: {
|
||||
simpleText: '..'
|
||||
},
|
||||
description: {
|
||||
runs: [
|
||||
{
|
||||
text: '..'
|
||||
},
|
||||
//....
|
||||
]
|
||||
},
|
||||
stats: [
|
||||
{
|
||||
simpleText: '..'
|
||||
},
|
||||
{
|
||||
runs: [
|
||||
{
|
||||
text: '..'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
And what we get after parsing it:
|
||||
<details>
|
||||
<summary>Click to see</summary>
|
||||
<p>
|
||||
|
||||
```js
|
||||
{
|
||||
sidebar: {
|
||||
type: 'PlaylistSidebar',
|
||||
contents: [
|
||||
{
|
||||
type: 'PlaylistSidebarPrimaryInfo',
|
||||
title: { text: '..' },
|
||||
description: { text: '..' },
|
||||
stats: [
|
||||
{
|
||||
text: '..'
|
||||
},
|
||||
{
|
||||
text: '..'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</p>
|
||||
</details>
|
||||
18
src/parser/classes/AccountChannel.ts
Normal file
18
src/parser/classes/AccountChannel.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Text from './misc/Text';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class AccountChannel extends YTNode {
|
||||
static type = 'AccountChannel';
|
||||
|
||||
title: Text;
|
||||
endpoint: NavigationEndpoint;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
export default AccountChannel;
|
||||
45
src/parser/classes/AccountItemSection.ts
Normal file
45
src/parser/classes/AccountItemSection.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import Parser from '..';
|
||||
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
import AccountItemSectionHeader from './AccountItemSectionHeader';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class AccountItem {
|
||||
static type = 'AccountItem';
|
||||
|
||||
account_name: Text;
|
||||
account_photo: Thumbnail[];
|
||||
is_selected: boolean;
|
||||
is_disabled: boolean;
|
||||
has_channel: boolean;
|
||||
endpoint: NavigationEndpoint;
|
||||
account_byline: Text;
|
||||
|
||||
constructor(data: any) {
|
||||
this.account_name = new Text(data.accountName);
|
||||
this.account_photo = Thumbnail.fromResponse(data.accountPhoto);
|
||||
this.is_selected = data.isSelected;
|
||||
this.is_disabled = data.isDisabled;
|
||||
this.has_channel = data.hasChannel;
|
||||
this.endpoint = new NavigationEndpoint(data.serviceEndpoint);
|
||||
this.account_byline = new Text(data.accountByline);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountItemSection extends YTNode {
|
||||
static type = 'AccountItemSection';
|
||||
|
||||
contents;
|
||||
header;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.contents = data.contents.map((ac: any) => new AccountItem(ac.accountItem));
|
||||
this.header = Parser.parseItem<AccountItemSectionHeader>(data.header, AccountItemSectionHeader);
|
||||
}
|
||||
}
|
||||
|
||||
export default AccountItemSection;
|
||||
15
src/parser/classes/AccountItemSectionHeader.ts
Normal file
15
src/parser/classes/AccountItemSectionHeader.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Text from './misc/Text';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class AccountItemSectionHeader extends YTNode {
|
||||
static type = 'AccountItemSectionHeader';
|
||||
|
||||
title: Text;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
}
|
||||
}
|
||||
|
||||
export default AccountItemSectionHeader;
|
||||
20
src/parser/classes/AccountSectionList.ts
Normal file
20
src/parser/classes/AccountSectionList.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import Parser from '..';
|
||||
import AccountChannel from './AccountChannel';
|
||||
import AccountItemSection from './AccountItemSection';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class AccountSectionList extends YTNode {
|
||||
static type = 'AccountSectionList';
|
||||
|
||||
contents;
|
||||
footers;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.contents = Parser.parseItem<AccountItemSection>(data.contents[0], AccountItemSection);
|
||||
this.footers = Parser.parseItem<AccountChannel>(data.footers[0], AccountChannel);
|
||||
}
|
||||
}
|
||||
|
||||
export default AccountSectionList;
|
||||
14
src/parser/classes/AudioOnlyPlayability.ts
Normal file
14
src/parser/classes/AudioOnlyPlayability.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class AudioOnlyPlayability extends YTNode {
|
||||
static type = 'AudioOnlyPlayability';
|
||||
|
||||
audio_only_availability: string;
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.audio_only_availability = data.audioOnlyAvailability;
|
||||
}
|
||||
}
|
||||
|
||||
export default AudioOnlyPlayability;
|
||||
19
src/parser/classes/AutomixPreviewVideo.ts
Normal file
19
src/parser/classes/AutomixPreviewVideo.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { YTNode } from '../helpers';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
|
||||
class AutomixPreviewVideo extends YTNode {
|
||||
static type = 'AutomixPreviewVideo';
|
||||
|
||||
playlist_video?: { endpoint: NavigationEndpoint };
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
if (data?.content?.automixPlaylistVideoRenderer?.navigationEndpoint) {
|
||||
this.playlist_video = {
|
||||
endpoint: new NavigationEndpoint(data.content.automixPlaylistVideoRenderer.navigationEndpoint)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AutomixPreviewVideo;
|
||||
15
src/parser/classes/BackstageImage.ts
Normal file
15
src/parser/classes/BackstageImage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class BackstageImage extends YTNode {
|
||||
static type = 'BackstageImage';
|
||||
|
||||
image: Thumbnail[];
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.image = Thumbnail.fromResponse(data.image);
|
||||
}
|
||||
}
|
||||
|
||||
export default BackstageImage;
|
||||
49
src/parser/classes/BackstagePost.ts
Normal file
49
src/parser/classes/BackstagePost.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import Parser from '../index';
|
||||
import Author from './misc/Author';
|
||||
import Text from './misc/Text';
|
||||
import NavigationEndpoint from './NavigationEndpoint';
|
||||
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class BackstagePost extends YTNode {
|
||||
static type = 'BackstagePost';
|
||||
|
||||
id: string;
|
||||
author: Author;
|
||||
content: Text;
|
||||
published: Text;
|
||||
poll_status: string;
|
||||
vote_status: string;
|
||||
likes: Text;
|
||||
menu;
|
||||
actions;
|
||||
vote_button;
|
||||
surface: string;
|
||||
endpoint: NavigationEndpoint;
|
||||
attachment;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
|
||||
this.id = data.postId;
|
||||
|
||||
this.author = new Author({
|
||||
...data.authorText,
|
||||
navigationEndpoint: data.authorEndpoint
|
||||
}, null, data.authorThumbnail);
|
||||
|
||||
this.content = new Text(data.contentText);
|
||||
this.published = new Text(data.publishedTimeText);
|
||||
this.poll_status = data.pollStatus;
|
||||
this.vote_status = data.voteStatus;
|
||||
this.likes = new Text(data.voteCount);
|
||||
this.menu = Parser.parse(data.actionMenu) || null;
|
||||
this.actions = Parser.parse(data.actionButtons);
|
||||
this.vote_button = Parser.parse(data.voteButton);
|
||||
this.surface = data.surface;
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.attachment = Parser.parse(data.backstageAttachment) || null;
|
||||
}
|
||||
}
|
||||
|
||||
export default BackstagePost;
|
||||
15
src/parser/classes/BackstagePostThread.ts
Normal file
15
src/parser/classes/BackstagePostThread.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Parser from '../index';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class BackstagePostThread extends YTNode {
|
||||
static type = 'BackstagePostThread';
|
||||
|
||||
post;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.post = Parser.parse(data.post);
|
||||
}
|
||||
}
|
||||
|
||||
export default BackstagePostThread;
|
||||
15
src/parser/classes/BrowseFeedActions.ts
Normal file
15
src/parser/classes/BrowseFeedActions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Parser from '../index';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class BrowseFeedActions extends YTNode {
|
||||
static type = 'BrowseFeedActions';
|
||||
|
||||
contents;
|
||||
|
||||
constructor(data: any) {
|
||||
super();
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
}
|
||||
}
|
||||
|
||||
export default BrowseFeedActions;
|
||||
18
src/parser/classes/BrowserMediaSession.ts
Normal file
18
src/parser/classes/BrowserMediaSession.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Text from './misc/Text';
|
||||
import Thumbnail from './misc/Thumbnail';
|
||||
import { YTNode } from '../helpers';
|
||||
|
||||
class BrowserMediaSession extends YTNode {
|
||||
static type = 'BrowserMediaSession';
|
||||
|
||||
album;
|
||||
thumbnails;
|
||||
|
||||
constructor (data: any) {
|
||||
super();
|
||||
this.album = new Text(data.album);
|
||||
this.thumbnails = Thumbnail.fromResponse(data.thumbnailDetails);
|
||||
}
|
||||
}
|
||||
|
||||
export default BrowserMediaSession;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user