mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 17:42:18 +00:00
Compare commits
755 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312c636ec4 | ||
|
|
4c0de199e8 | ||
|
|
9ab528ec82 | ||
|
|
24ffb01aef | ||
|
|
eaac38c919 | ||
|
|
e627887fe0 | ||
|
|
beaa28f4c6 | ||
|
|
a45273fec4 | ||
|
|
bc97e07ac6 | ||
|
|
f35b4c2c8c | ||
|
|
c934325648 | ||
|
|
cd27acd25b | ||
|
|
83b42d2585 | ||
|
|
e54c0c4bf1 | ||
|
|
8e372d5c67 | ||
|
|
987f50604a | ||
|
|
69702085c6 | ||
|
|
d2959b3a55 | ||
|
|
68df321858 | ||
|
|
f4bc8508d0 | ||
|
|
e216124bb0 | ||
|
|
6d98abbd53 | ||
|
|
fba3fc9714 | ||
|
|
f94ea6cf91 | ||
|
|
86fb33ed03 | ||
|
|
bff4210349 | ||
|
|
91de6e5c0e | ||
|
|
c26972c42a | ||
|
|
8bc2aaa358 | ||
|
|
2e5f076fd7 | ||
|
|
0412fa05ff | ||
|
|
10c15bfb9f | ||
|
|
4862c35cee | ||
|
|
2eed1726d5 | ||
|
|
8b69587787 | ||
|
|
ed7be2a675 | ||
|
|
361fb4a9f1 | ||
|
|
1c3ea2acd3 | ||
|
|
859c4585d9 | ||
|
|
751f2b90fd | ||
|
|
90be877d28 | ||
|
|
052632314b | ||
|
|
22a38c0762 | ||
|
|
f7614634b6 | ||
|
|
bf1510b235 | ||
|
|
815e54b854 | ||
|
|
f7666051f6 | ||
|
|
494ee8776a | ||
|
|
87ed3960ff | ||
|
|
eb3cca1e2e | ||
|
|
9971ffe021 | ||
|
|
7949b3df66 | ||
|
|
aa385142e4 | ||
|
|
6c8a916f0f | ||
|
|
31d27b1bca | ||
|
|
cb37c6a17b | ||
|
|
1ff3e1a440 | ||
|
|
46fe18b763 | ||
|
|
0dda97e0b0 | ||
|
|
e370116092 | ||
|
|
3bc53a8c12 | ||
|
|
74e1a5e068 | ||
|
|
0fa5a859ae | ||
|
|
02a111250a | ||
|
|
c1886f9a83 | ||
|
|
5f4cbdb904 | ||
|
|
d91695a9ec | ||
|
|
137464ca66 | ||
|
|
6997982cf2 | ||
|
|
18cbc8c038 | ||
|
|
30ff087587 | ||
|
|
1a034733f6 | ||
|
|
c477b824c0 | ||
|
|
7e5c3648c1 | ||
|
|
bdd98a3b9b | ||
|
|
06750aaa74 | ||
|
|
708c5f7394 | ||
|
|
a9cdbf7010 | ||
|
|
b50d1ef67d | ||
|
|
555d257459 | ||
|
|
2aef67876e | ||
|
|
ae2557d15c | ||
|
|
8c688efb4a | ||
|
|
cffa868c6e | ||
|
|
f267fcd8be | ||
|
|
23c22a93c4 | ||
|
|
1ca20836bf | ||
|
|
5f058e69ae | ||
|
|
3500e92632 | ||
|
|
3f57c2fa5c | ||
|
|
7528ebdb60 | ||
|
|
5e3846259f | ||
|
|
222dfce6bb | ||
|
|
83cbfd631b | ||
|
|
4f9427d752 | ||
|
|
07c1b3e0e5 | ||
|
|
89548ad48a | ||
|
|
519be72445 | ||
|
|
e434bb2632 | ||
|
|
a11e5962c6 | ||
|
|
77b39c79ee | ||
|
|
7c530d30ee | ||
|
|
1e07a184ff | ||
|
|
5de7b24dc5 | ||
|
|
01fd1ee72a | ||
|
|
84b4f1efd1 | ||
|
|
046103a4d8 | ||
|
|
beb4733e84 | ||
|
|
66b026bf49 | ||
|
|
26734194ab | ||
|
|
38a83c3c2a | ||
|
|
b1f19f16ac | ||
|
|
891d889408 | ||
|
|
d4adb9eb6b | ||
|
|
3b0498b68b | ||
|
|
154a5d2868 | ||
|
|
7c0abfccd7 | ||
|
|
8f50c668aa | ||
|
|
4f7ec07c3f | ||
|
|
ab3d5ab16c | ||
|
|
dd21f8c75a | ||
|
|
3a7e58d2b9 | ||
|
|
75ea09dde8 | ||
|
|
95e0294eab | ||
|
|
22ae6c93ee | ||
|
|
257bd475a0 | ||
|
|
f66f0bd656 | ||
|
|
05de3ec97a | ||
|
|
a0566969ba | ||
|
|
a9cad49333 | ||
|
|
096bf362c9 | ||
|
|
ec9c0979f5 | ||
|
|
342d1d95e9 | ||
|
|
dbfc569602 | ||
|
|
c16a967987 | ||
|
|
a07375eb20 | ||
|
|
ce9d9c56b4 | ||
|
|
f50ce1a06b | ||
|
|
3b6ccfa3d8 | ||
|
|
878488d1b3 | ||
|
|
3c94c9da4b | ||
|
|
0b301de6a1 | ||
|
|
c9135e66d3 | ||
|
|
e82c843928 | ||
|
|
be71d7c937 | ||
|
|
470d8d9406 | ||
|
|
2c5907f80f | ||
|
|
ade5feb31c | ||
|
|
13ebf0a039 | ||
|
|
cb8fafe94b | ||
|
|
bd35faa597 | ||
|
|
a8b507ee65 | ||
|
|
e7eacd9742 | ||
|
|
1c72a41675 | ||
|
|
62a68b207c | ||
|
|
1d9587e8c1 | ||
|
|
a90e5e0d07 | ||
|
|
955c8010a6 | ||
|
|
b2269deb79 | ||
|
|
573c8643aa | ||
|
|
e21542c227 | ||
|
|
9d912e5938 | ||
|
|
7ca0607004 | ||
|
|
20d84265b5 | ||
|
|
b13bf6e992 | ||
|
|
3d3436472f | ||
|
|
1a2fc3abd7 | ||
|
|
8ef4b42d44 | ||
|
|
b71f03caf2 | ||
|
|
dae7d6e40c | ||
|
|
2cee59024c | ||
|
|
ffd7d79308 | ||
|
|
9b005d62d6 | ||
|
|
a8e7e644ec | ||
|
|
ad1d3dbf91 | ||
|
|
3df3261488 | ||
|
|
1b1ce41c00 | ||
|
|
b82b720e4b | ||
|
|
4784dfa563 | ||
|
|
3e4d41bf06 | ||
|
|
9f1c31d7a0 | ||
|
|
9cb4530299 | ||
|
|
cb9a0c5410 | ||
|
|
427db5bbc2 | ||
|
|
2b29244b41 | ||
|
|
f9754f5ac6 | ||
|
|
b2253df802 | ||
|
|
f3517708ff | ||
|
|
0d35fe0ca5 | ||
|
|
3e3dc351bb | ||
|
|
197bb759cd | ||
|
|
c76b24b3f4 | ||
|
|
574b67a1f7 | ||
|
|
9b2738f128 | ||
|
|
95f1d4077f | ||
|
|
a511608f18 | ||
|
|
cf8a33c79f | ||
|
|
cfc1a183e0 | ||
|
|
95033e723e | ||
|
|
2cc7b8bcd6 | ||
|
|
2d774e26aa | ||
|
|
214aa147ce | ||
|
|
ce53ac1843 | ||
|
|
0ad26f28d9 | ||
|
|
4c7b8a3403 | ||
|
|
33a6e740d7 | ||
|
|
0b1840a62c | ||
|
|
f4e0f30e6e | ||
|
|
200632f374 | ||
|
|
f933cb45bc | ||
|
|
a0e6cef00f | ||
|
|
a0bfe16427 | ||
|
|
9d352b58eb | ||
|
|
6b6c80ddf1 | ||
|
|
58a6c84121 | ||
|
|
63b1261b7c | ||
|
|
d2eff3bfb8 | ||
|
|
b668ba8cfb | ||
|
|
0b88575614 | ||
|
|
bed0ff4154 | ||
|
|
27a50a2a7e | ||
|
|
d4f2d704bb | ||
|
|
97f181b212 | ||
|
|
251ed74bba | ||
|
|
1cdf701c84 | ||
|
|
bf12740333 | ||
|
|
0d77b59945 | ||
|
|
6e30309f56 | ||
|
|
e37cf62732 | ||
|
|
567fdbaf52 | ||
|
|
0a22319d9e | ||
|
|
eb72c2f6ef | ||
|
|
2ccbe2ce62 | ||
|
|
a69e43bf3a | ||
|
|
b2900f48a7 | ||
|
|
d612590530 | ||
|
|
e82e23dfbb | ||
|
|
f62c66db39 | ||
|
|
de61782f1a | ||
|
|
ceefbed98c | ||
|
|
315d89f84a | ||
|
|
2ea3602b61 | ||
|
|
b7df3d6df4 | ||
|
|
2acb7da019 | ||
|
|
0b991800a5 | ||
|
|
50ef71284d | ||
|
|
d6c5a9b971 | ||
|
|
0fc29f0bbf | ||
|
|
2bbefefbb7 | ||
|
|
13ad3774c9 | ||
|
|
8051a7dee6 | ||
|
|
2842b1d917 | ||
|
|
870b2811d9 | ||
|
|
1aedbd3ea6 | ||
|
|
e8af2a603d | ||
|
|
8e37efa575 | ||
|
|
5a362a0bd5 | ||
|
|
89ee68b084 | ||
|
|
dca61c3a22 | ||
|
|
56e6e23453 | ||
|
|
00fa514b03 | ||
|
|
d36389c865 | ||
|
|
55ca986888 | ||
|
|
b04df7e119 | ||
|
|
d8d92866d1 | ||
|
|
b4b0731589 | ||
|
|
d69d701869 | ||
|
|
cd4d28c951 | ||
|
|
22b9c174bb | ||
|
|
b704c8e78c | ||
|
|
bbfeb99f55 | ||
|
|
f2adeeeab4 | ||
|
|
3756e63996 | ||
|
|
a27807b6c1 | ||
|
|
5cfb969e33 | ||
|
|
1163125f5c | ||
|
|
9ac5043309 | ||
|
|
6a4b4f3359 | ||
|
|
2b3642ba63 | ||
|
|
fb2e237284 | ||
|
|
6f3deaf16a | ||
|
|
d4382e81c3 | ||
|
|
89956cab46 | ||
|
|
ac9341c769 | ||
|
|
cac762569a | ||
|
|
9978ebf085 | ||
|
|
b036e2fcdc | ||
|
|
e37f42f41b | ||
|
|
883a023624 | ||
|
|
506834b253 | ||
|
|
87e7ef77eb | ||
|
|
27fdd8268a | ||
|
|
d4ea87b8b0 | ||
|
|
ec87eea20d | ||
|
|
e43ad202f4 | ||
|
|
104c36b450 | ||
|
|
f5d61d70f2 | ||
|
|
c76f5f478d | ||
|
|
49d1432b5a | ||
|
|
be157ef016 | ||
|
|
9f703203b6 | ||
|
|
516eeeff45 | ||
|
|
6caa679df6 | ||
|
|
2a87f42b32 | ||
|
|
f7c1e0f249 | ||
|
|
fe4c5433cf | ||
|
|
0e5e0c0fab | ||
|
|
f0fd6146c7 | ||
|
|
43061970c6 | ||
|
|
746023d9bb | ||
|
|
3102479dd9 | ||
|
|
c7a13c948c | ||
|
|
ec875ba321 | ||
|
|
db77bba802 | ||
|
|
5ea0a0ebf8 | ||
|
|
0130229236 | ||
|
|
da517fe6d1 | ||
|
|
95ff1e6c5e | ||
|
|
0f8adfd9b8 | ||
|
|
b514765354 | ||
|
|
3cbcd71a3a | ||
|
|
4c00f15f55 | ||
|
|
ea1d206b26 | ||
|
|
aa334aacbd | ||
|
|
1eda93ee08 | ||
|
|
fe0ac0a961 | ||
|
|
8740deb1f2 | ||
|
|
d71b762df5 | ||
|
|
dc14d3785f | ||
|
|
088f909515 | ||
|
|
2a78d77aa3 | ||
|
|
1b2862c00f | ||
|
|
477c030084 | ||
|
|
19d579df13 | ||
|
|
5313c57783 | ||
|
|
190f7681be | ||
|
|
6e027bcc85 | ||
|
|
6b531dd0ea | ||
|
|
92f24076db | ||
|
|
a9eba7ca62 | ||
|
|
2f56c15ecc | ||
|
|
95e0479745 | ||
|
|
556c7cd6e8 | ||
|
|
a4a88419ef | ||
|
|
aefecd061e | ||
|
|
7485726f1e | ||
|
|
9e703abe3a | ||
|
|
affbe84284 | ||
|
|
fcbdae3e34 | ||
|
|
059c858021 | ||
|
|
4ecd3360e0 | ||
|
|
08e9527931 | ||
|
|
a9f03a1523 | ||
|
|
c8980c7985 | ||
|
|
2e5688f235 | ||
|
|
dcf2b720a0 | ||
|
|
a90f5eb853 | ||
|
|
c6482e07b9 | ||
|
|
2de77c8f2c | ||
|
|
2aaa209906 | ||
|
|
ab028ba1ec | ||
|
|
f2f48af1bc | ||
|
|
3a7da21fd1 | ||
|
|
89794d65da | ||
|
|
91847ae3cc | ||
|
|
eb44b71939 | ||
|
|
88ebb5e2ae | ||
|
|
b237b6af4e | ||
|
|
9e618cc576 | ||
|
|
daf95cfe87 | ||
|
|
bc03c91df9 | ||
|
|
e00be25bf4 | ||
|
|
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 |
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
|
||||
9
.eslintignore
Normal file
9
.eslintignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.github
|
||||
test/
|
||||
cache/
|
||||
src/proto/youtube.ts
|
||||
coverage/
|
||||
node_modules/
|
||||
dist/
|
||||
src/proto/generated/
|
||||
91
.eslintrc.yml
Normal file
91
.eslintrc.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
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
|
||||
project:
|
||||
- tsconfig.json
|
||||
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'
|
||||
'@typescript-eslint/consistent-type-imports': 'error'
|
||||
'@typescript-eslint/consistent-type-exports': 'error'
|
||||
|
||||
no-template-curly-in-string: error
|
||||
no-unreachable-loop: error
|
||||
no-unused-private-class-members: 'off'
|
||||
no-prototype-builtins: 'off'
|
||||
no-async-promise-executor: 'off'
|
||||
no-case-declarations: 'off'
|
||||
no-return-assign: 'off'
|
||||
no-floating-decimal: error
|
||||
no-implied-eval: error
|
||||
arrow-spacing: error
|
||||
no-invalid-this: error
|
||||
no-lone-blocks: 'off'
|
||||
no-new-func: error
|
||||
no-new-wrappers: error
|
||||
no-new: error
|
||||
no-void: error
|
||||
no-octal-escape: error
|
||||
no-self-compare: error
|
||||
no-sequences: error
|
||||
no-throw-literal: error
|
||||
no-unmodified-loop-condition: error
|
||||
no-useless-call: error
|
||||
no-useless-concat: error
|
||||
no-useless-escape: error
|
||||
no-useless-return: error
|
||||
no-else-return: error
|
||||
no-lonely-if: error
|
||||
no-undef-init: error
|
||||
no-unneeded-ternary: error
|
||||
no-var: error
|
||||
no-multi-spaces: error
|
||||
no-multiple-empty-lines: ["error", { "max": 2, "maxEOF": 0 }]
|
||||
no-tabs: error
|
||||
no-trailing-spaces: error
|
||||
|
||||
brace-style: error
|
||||
new-parens: error
|
||||
space-infix-ops: error
|
||||
template-curly-spacing: error
|
||||
wrap-regex: error
|
||||
capitalized-comments: error
|
||||
prefer-template: error
|
||||
|
||||
keyword-spacing: ["error", { "before": true } ]
|
||||
array-bracket-spacing: ["error", "always"]
|
||||
arrow-parens: ["error", "always"]
|
||||
comma-dangle: ["error", "never"]
|
||||
comma-spacing: ["error", { "before": false, "after": true }]
|
||||
computed-property-spacing: ["error", "never"]
|
||||
func-call-spacing: ["error", "never"]
|
||||
indent: ["error", 2, { "SwitchCase": 1 }]
|
||||
key-spacing: ["error", { "beforeColon": false }]
|
||||
semi: ["error", "always"]
|
||||
operator-assignment: ["error", "always"]
|
||||
12
.github/FUNDING.yml
vendored
12
.github/FUNDING.yml
vendored
@@ -1,12 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: luanrt
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Use this template for requesting new features
|
||||
title: "[FEATURE NAME]"
|
||||
labels: enhancement
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Please describe the behavior you are expecting
|
||||
|
||||
## Current Behavior
|
||||
|
||||
What is the current behavior?
|
||||
|
||||
## Sample Code
|
||||
|
||||
If applicable, provide a sample code snippet that demonstrates the gist of the feature you're proposing. This can be either from a usage standpoint, or an implementation standpoint.
|
||||
38
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
38
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Issue Report
|
||||
about: Use this template to report a problem
|
||||
title: "[VERSION] [PROBLEM SUMMARY]"
|
||||
labels: bug
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Please describe the behavior you are expecting
|
||||
|
||||
## Current Behavior
|
||||
|
||||
What is the current behavior?
|
||||
|
||||
## Failure Information (for bugs)
|
||||
|
||||
Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
Please provide detailed steps for reproducing the issue.
|
||||
|
||||
1. step 1
|
||||
2. step 2
|
||||
3. you get it...
|
||||
|
||||
### Failure Logs
|
||||
|
||||
Please include any relevant log snippets or files here.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I am running the latest version
|
||||
- [ ] I checked the documentation and found no answer
|
||||
- [ ] I checked to make sure that this issue has not already been filed
|
||||
- [ ] I have provided sufficient information
|
||||
15
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
15
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: Question
|
||||
about: Use this template to ask a question about the project
|
||||
title: "[QUESTION SUMMARY]"
|
||||
labels: question
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Question
|
||||
|
||||
State your question
|
||||
|
||||
## Sample Code
|
||||
|
||||
Please include relevant code snippets or files that provide context for your question.
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +1 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
|
||||
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Feature request
|
||||
description: Use this template to suggest new features
|
||||
labels: [enhancement]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Describe your suggestion
|
||||
placeholder: How would it work?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I am running the latest version.
|
||||
required: true
|
||||
- label: I checked the documentation and found no answer.
|
||||
required: true
|
||||
- label: I have searched the existing issues and made sure this is not a duplicate.
|
||||
required: true
|
||||
- label: I have provided sufficient information.
|
||||
required: true
|
||||
77
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
77
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Issue Report
|
||||
title: "<version> <title>"
|
||||
description: Use this template to report a problem
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Please provide detailed steps for reproducing the issue.
|
||||
placeholder: |
|
||||
Example:
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
3. You get it..
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: failure-logs
|
||||
attributes:
|
||||
label: Failure Logs
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: current-behavior
|
||||
attributes:
|
||||
label: Current behavior
|
||||
description: What is the current behavior?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of the library are you running?
|
||||
options:
|
||||
- Default
|
||||
- Edge
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I am running the latest version.
|
||||
required: false
|
||||
- label: I checked the documentation and found no answer.
|
||||
required: true
|
||||
- label: I have searched the existing issues and made sure this is not a duplicate.
|
||||
required: true
|
||||
- label: I have provided sufficient information.
|
||||
required: true
|
||||
33
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Question
|
||||
description: Use this template to ask a question about the project
|
||||
labels: [question]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
placeholder: What do you want to know?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I am running the latest version.
|
||||
required: true
|
||||
- label: I checked the documentation and found no answer.
|
||||
required: true
|
||||
- label: I have searched the existing issues and made sure this is not a duplicate.
|
||||
required: true
|
||||
- label: I have provided sufficient information.
|
||||
required: true
|
||||
53
.github/labeler_config.yml
vendored
Normal file
53
.github/labeler_config.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
version: 1
|
||||
labels:
|
||||
- label: "breaking-change"
|
||||
title: "^refactor!:.*"
|
||||
|
||||
- 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
|
||||
33
.github/pull_request_template.md
vendored
33
.github/pull_request_template.md
vendored
@@ -1,27 +1,6 @@
|
||||
# Pull Request Template
|
||||
|
||||
## Description
|
||||
|
||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] I have checked my code and corrected any misspellings
|
||||
<!-- Thank you for submitting a Pull Request! Please:
|
||||
* Read our contributing guidelines: https://github.com/LuanRT/YouTube.js/blob/main/CONTRIBUTING.md
|
||||
* Add "Fixes #<issue_number>" to the PR description if you are fixing an issue.
|
||||
* Ensure that the code is up-to-date with the `main` branch.
|
||||
* Include a description of the proposed changes and how to test them.
|
||||
-->
|
||||
14
.github/workflows/labeler.yml
vendored
Normal file
14
.github/workflows/labeler.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
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 }}"
|
||||
18
.github/workflows/lint.yml
vendored
Normal file
18
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
- run: npm ci --legacy-peer-deps
|
||||
- run: npm run lint
|
||||
29
.github/workflows/node.js.yml
vendored
29
.github/workflows/node.js.yml
vendored
@@ -1,29 +0,0 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [ 14.x, 15.x, 16.x ]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
62
.github/workflows/release-please.yml
vendored
Normal file
62
.github/workflows/release-please.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: release-please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: google-github-actions/release-please-action@v3
|
||||
id: release
|
||||
with:
|
||||
release-type: node
|
||||
package-name: youtubei.js
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: "16.x"
|
||||
- name: Build for Deno
|
||||
run: |
|
||||
npm ci --legacy-peer-deps
|
||||
npm run build:deno
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- name: Move Deno files
|
||||
run: |
|
||||
mkdir build
|
||||
mv deno build/deno
|
||||
cp deno.ts build/deno.ts
|
||||
cp {LICENSE,README.md} build
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- name: Push to the Deno branch
|
||||
uses: s0/git-publish-subdir-action@develop
|
||||
env:
|
||||
REPO: self
|
||||
BRANCH: deno
|
||||
FOLDER: ./build
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SKIP_EMPTY_COMMITS: true
|
||||
MESSAGE: "chore: ${{ steps.release.outputs.tag_name }} release"
|
||||
TAG: ${{ steps.release.outputs.tag_name }}-deno
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- name: Remove Deno folder
|
||||
run: rm -rf build
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "16.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
- name: Publish package to npmjs
|
||||
run: |
|
||||
npm ci --legacy-peer-deps
|
||||
npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
8
.github/workflows/stale.yml
vendored
8
.github/workflows/stale.yml
vendored
@@ -11,9 +11,7 @@ jobs:
|
||||
- uses: actions/stale@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.'
|
||||
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'
|
||||
stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. Remove the stale label or comment or this will be closed in 2 days'
|
||||
days-before-stale: 6
|
||||
days-before-close: 2
|
||||
days-before-stale: 60
|
||||
days-before-close: 4
|
||||
18
.github/workflows/test.yml
vendored
Normal file
18
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
- run: npm ci --legacy-peer-deps
|
||||
- run: npm run test
|
||||
77
.gitignore
vendored
Normal file
77
.gitignore
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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
|
||||
bundle/*.cjs
|
||||
bundle/*.cjs.*
|
||||
deno/
|
||||
|
||||
# VSCode files
|
||||
.vscode/
|
||||
|
||||
# MacOS
|
||||
.DS_Store
|
||||
6
.npmignore
Normal file
6
.npmignore
Normal file
@@ -0,0 +1,6 @@
|
||||
**
|
||||
|
||||
src/
|
||||
!dist/**
|
||||
!README.md
|
||||
!bundle/**
|
||||
459
CHANGELOG.md
Normal file
459
CHANGELOG.md
Normal file
@@ -0,0 +1,459 @@
|
||||
# Changelog
|
||||
|
||||
## [7.0.0](https://github.com/LuanRT/YouTube.js/compare/v6.4.1...v7.0.0) (2023-10-28)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **music#getSearchSuggestions:** Return array of `SearchSuggestionsSection` instead of a single node
|
||||
|
||||
### Features
|
||||
|
||||
* **Kids:** Add `blockChannel` command to easily block channels ([#503](https://github.com/LuanRT/YouTube.js/issues/503)) ([9ab528e](https://github.com/LuanRT/YouTube.js/commit/9ab528ec823dcd527a97150009eed632c6d3eb6a))
|
||||
* **music#getSearchSuggestions:** Return array of `SearchSuggestionsSection` instead of a single node ([beaa28f](https://github.com/LuanRT/YouTube.js/commit/beaa28f4c68de8366caa84ce5a026bf9e12e1b9d))
|
||||
* **parser:** Add `PlayerOverflow` and `PlayerControlsOverlay` ([a45273f](https://github.com/LuanRT/YouTube.js/commit/a45273fec498df87eecd364ffb708c9f787793d5))
|
||||
* **UpdateViewerShipAction:** Add `original_view_count` and `unlabeled_view_count_value` ([#527](https://github.com/LuanRT/YouTube.js/issues/527)) ([bc97e07](https://github.com/LuanRT/YouTube.js/commit/bc97e07ac6d1cdc45194e214c6001cf92190e1d5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** Inline package.json import to avoid runtime erros ([#509](https://github.com/LuanRT/YouTube.js/issues/509)) ([4c0de19](https://github.com/LuanRT/YouTube.js/commit/4c0de199e85dd5cc8b3719920b24dec9613acaab))
|
||||
|
||||
## [6.4.1](https://github.com/LuanRT/YouTube.js/compare/v6.4.0...v6.4.1) (2023-10-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Feed:** Do not throw when multiple continuations are present ([8e372d5](https://github.com/LuanRT/YouTube.js/commit/8e372d5c67f148be288bb0485f2c70ec43fbecd0))
|
||||
* **Playlist:** Throw a more helpful error when parsing empty responses ([987f506](https://github.com/LuanRT/YouTube.js/commit/987f50604a0163f9a07091ce787995c6f6fddb75))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* Cache deciphered n-params by info response ([#505](https://github.com/LuanRT/YouTube.js/issues/505)) ([d2959b3](https://github.com/LuanRT/YouTube.js/commit/d2959b3a55a5081295da4754627913933bbaf1e7))
|
||||
* **generator:** Remove duplicate checks in `isMiscType` ([#506](https://github.com/LuanRT/YouTube.js/issues/506)) ([68df321](https://github.com/LuanRT/YouTube.js/commit/68df3218580db10c9a0932c93ff2ce487526ff1e))
|
||||
|
||||
## [6.4.0](https://github.com/LuanRT/YouTube.js/compare/v6.3.0...v6.4.0) (2023-09-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for retrieving transcripts ([#500](https://github.com/LuanRT/YouTube.js/issues/500)) ([f94ea6c](https://github.com/LuanRT/YouTube.js/commit/f94ea6cf917f63f30dd66514b22a4cf43b948f07))
|
||||
* **PlaylistManager:** add .setName() and .setDescription() functions for editing playlists ([#498](https://github.com/LuanRT/YouTube.js/issues/498)) ([86fb33e](https://github.com/LuanRT/YouTube.js/commit/86fb33ed03a127d9fd4caa695ca97642bffe61bd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **BackstagePost:** `vote_button` type mismatch ([fba3fc9](https://github.com/LuanRT/YouTube.js/commit/fba3fc971454d66d80d4920fbd60889a221de381))
|
||||
|
||||
## [6.3.0](https://github.com/LuanRT/YouTube.js/compare/v6.2.0...v6.3.0) (2023-08-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ChannelMetadata:** Add `music_artist_name` ([#497](https://github.com/LuanRT/YouTube.js/issues/497)) ([91de6e5](https://github.com/LuanRT/YouTube.js/commit/91de6e5c0e5b27e6d12ce5db2f500c5ff78b9830))
|
||||
* **Session:** Add on_behalf_of_user session option. ([#494](https://github.com/LuanRT/YouTube.js/issues/494)) ([8bc2aaa](https://github.com/LuanRT/YouTube.js/commit/8bc2aaa3587fcf79f69eedbc2bf422a4c6fa7eb1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **CompactMovie:** Add missing import and remove unnecessary console.log ([#496](https://github.com/LuanRT/YouTube.js/issues/496)) ([c26972c](https://github.com/LuanRT/YouTube.js/commit/c26972c42a6368822ac254c00f1bbee5a1542486))
|
||||
|
||||
## [6.2.0](https://github.com/LuanRT/YouTube.js/compare/v6.1.0...v6.2.0) (2023-08-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Session:** Add fallback for session data retrieval ([#490](https://github.com/LuanRT/YouTube.js/issues/490)) ([10c15bf](https://github.com/LuanRT/YouTube.js/commit/10c15bfb9f131a2acea2f26ff3328993d8d8f4aa))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Format:** Fix `is_original` always being `true` ([#492](https://github.com/LuanRT/YouTube.js/issues/492)) ([0412fa0](https://github.com/LuanRT/YouTube.js/commit/0412fa05ff1f00960b398c2f18d5ce39ce0cb864))
|
||||
|
||||
## [6.1.0](https://github.com/LuanRT/YouTube.js/compare/v6.0.2...v6.1.0) (2023-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **parser:** Add `AlertWithButton` ([#486](https://github.com/LuanRT/YouTube.js/issues/486)) ([8b69587](https://github.com/LuanRT/YouTube.js/commit/8b6958778721ba274283f641779fb60bc6f42cd2))
|
||||
* **parser:** Add `ChannelHeaderLinksView` ([#484](https://github.com/LuanRT/YouTube.js/issues/484)) ([ed7be2a](https://github.com/LuanRT/YouTube.js/commit/ed7be2a675cf1ec663e743e90db6260c97546739))
|
||||
* **parser:** Add `CompactMovie` ([#487](https://github.com/LuanRT/YouTube.js/issues/487)) ([2eed172](https://github.com/LuanRT/YouTube.js/commit/2eed1726d5bde7648af09273cc14ab4a315cb23e))
|
||||
|
||||
## [6.0.2](https://github.com/LuanRT/YouTube.js/compare/v6.0.1...v6.0.2) (2023-08-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid set ids in dash manifest ([#480](https://github.com/LuanRT/YouTube.js/issues/480)) ([1c3ea2a](https://github.com/LuanRT/YouTube.js/commit/1c3ea2acd38652c6b40a0817a7836c672a776c4e))
|
||||
|
||||
## [6.0.1](https://github.com/LuanRT/YouTube.js/compare/v6.0.0...v6.0.1) (2023-08-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **SearchSubMenu:** Groups not being parsed due to a typo ([90be877](https://github.com/LuanRT/YouTube.js/commit/90be877d28e0ef013056eaeaa4f2765c91addd61))
|
||||
|
||||
## [6.0.0](https://github.com/LuanRT/YouTube.js/compare/v5.8.0...v6.0.0) (2023-08-18)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* replace unnecessary classes with pure functions ([#468](https://github.com/LuanRT/YouTube.js/issues/468))
|
||||
|
||||
### Features
|
||||
|
||||
* **MusicResponsiveListItem:** Detect non music tracks properly ([815e54b](https://github.com/LuanRT/YouTube.js/commit/815e54b854fcda3f5423231c8495ce1fb69d8237))
|
||||
* **parser:** add `MusicMultiRowListItem` ([494ee87](https://github.com/LuanRT/YouTube.js/commit/494ee8776af0839d3ee2cca3d2fd836680cfdb9e))
|
||||
* **Session:** Add `IOS` to `ClientType` enum ([22a38c0](https://github.com/LuanRT/YouTube.js/commit/22a38c0762499de74f0aeb3ef01332f893518b08))
|
||||
* **VideoInfo:** support iOS client ([#467](https://github.com/LuanRT/YouTube.js/issues/467)) ([46fe18b](https://github.com/LuanRT/YouTube.js/commit/46fe18b763e0c943b24ea10fdf25456ab9ade709))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Format:** Extracting audio language from captions ([#470](https://github.com/LuanRT/YouTube.js/issues/470)) ([31d27b1](https://github.com/LuanRT/YouTube.js/commit/31d27b1bca489ee0053d2783f1a956609845a901))
|
||||
* **parser:** Allow any property in the `RawResponse` interface ([3bc53a8](https://github.com/LuanRT/YouTube.js/commit/3bc53a8c12e65b22f19a3e337641196b692a94db))
|
||||
* **parser:** Logger logging `classdata` as `[Object object]` ([bf1510b](https://github.com/LuanRT/YouTube.js/commit/bf1510b235e3ee7d13d51f092babd1105c3d6b9f))
|
||||
* **Playlist:** Only try extracting the subtitle for the first page ([#465](https://github.com/LuanRT/YouTube.js/issues/465)) ([e370116](https://github.com/LuanRT/YouTube.js/commit/e3701160928e9e959b88ca215c6b0a44c70ca6e6))
|
||||
* **toDash:** Format grouping into AdaptationSets ([#462](https://github.com/LuanRT/YouTube.js/issues/462)) ([1ff3e1a](https://github.com/LuanRT/YouTube.js/commit/1ff3e1a440389e71055d4b201c29021ca5b39254))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* Cleanup some unnecessary uses of `YTNode#key` and `Maybe` ([#463](https://github.com/LuanRT/YouTube.js/issues/463)) ([0dda97e](https://github.com/LuanRT/YouTube.js/commit/0dda97e0b03171de52d7f11a5abf78911e74cead))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* replace unnecessary classes with pure functions ([#468](https://github.com/LuanRT/YouTube.js/issues/468)) ([87ed396](https://github.com/LuanRT/YouTube.js/commit/87ed3960ffa1c738b6f3b5acaf423647db4d367e))
|
||||
|
||||
## [5.8.0](https://github.com/LuanRT/YouTube.js/compare/v5.7.1...v5.8.0) (2023-07-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **YouTube Playlist:** Add subtitle and fix author optionality ([#458](https://github.com/LuanRT/YouTube.js/issues/458)) ([0fa5a85](https://github.com/LuanRT/YouTube.js/commit/0fa5a859ae15a35266297079e3e34fd9f3a5ebf4))
|
||||
|
||||
## [5.7.1](https://github.com/LuanRT/YouTube.js/compare/v5.7.0...v5.7.1) (2023-07-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **SearchHeader:** remove console.log ([d91695a](https://github.com/LuanRT/YouTube.js/commit/d91695a9ec6c55445cbeedba4ace4ac1e0a72eee))
|
||||
|
||||
## [5.7.0](https://github.com/LuanRT/YouTube.js/compare/v5.6.0...v5.7.0) (2023-07-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **parser:** Add `PageHeader` ([#450](https://github.com/LuanRT/YouTube.js/issues/450)) ([18cbc8c](https://github.com/LuanRT/YouTube.js/commit/18cbc8c038ddddffa1ba1519e56a8054b2996e42))
|
||||
* **parser:** Add `SearchHeader` ([6997982](https://github.com/LuanRT/YouTube.js/commit/6997982cf2db87edf4929e9a77e2690e7b630d3d)), closes [#452](https://github.com/LuanRT/YouTube.js/issues/452)
|
||||
|
||||
## [5.6.0](https://github.com/LuanRT/YouTube.js/compare/v5.5.0...v5.6.0) (2023-07-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **parser:** Add `IncludingResultsFor` ([#447](https://github.com/LuanRT/YouTube.js/issues/447)) ([c477b82](https://github.com/LuanRT/YouTube.js/commit/c477b824c084552169062f72cde8890e77b31f59))
|
||||
* **toDash:** Add option to include thumbnails in the manifest ([#446](https://github.com/LuanRT/YouTube.js/issues/446)) ([1a03473](https://github.com/LuanRT/YouTube.js/commit/1a034733f6bb641e2d97df12de81ae3516c1f703))
|
||||
|
||||
## [5.5.0](https://github.com/LuanRT/YouTube.js/compare/v5.4.0...v5.5.0) (2023-07-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Format:** Populate audio language from captions when available ([#445](https://github.com/LuanRT/YouTube.js/issues/445)) ([bdd98a3](https://github.com/LuanRT/YouTube.js/commit/bdd98a3b9be39c11942043a300a6ebce9a15efc6))
|
||||
* **parser:** Add `CommentsSimplebox` parser ([#442](https://github.com/LuanRT/YouTube.js/issues/442)) ([555d257](https://github.com/LuanRT/YouTube.js/commit/555d257459b76d7c0158e9c6b189a75a82b10faf))
|
||||
* **parser:** Add `HashtagTile` ([#440](https://github.com/LuanRT/YouTube.js/issues/440)) ([ae2557d](https://github.com/LuanRT/YouTube.js/commit/ae2557d15c9df09bb92e0dc6191670d72b36631a))
|
||||
* **parser:** add `MacroMarkersList` ([#444](https://github.com/LuanRT/YouTube.js/issues/444)) ([708c5f7](https://github.com/LuanRT/YouTube.js/commit/708c5f7394b4ea140836b9483848cb61b97ea1af))
|
||||
* **parser:** Add `ShowMiniplayerCommand` ([#443](https://github.com/LuanRT/YouTube.js/issues/443)) ([a9cdbf7](https://github.com/LuanRT/YouTube.js/commit/a9cdbf7010e7b9b9cfde5db645d51bdad51006c5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **package:** Bump Jinter to fix bad export order ([#439](https://github.com/LuanRT/YouTube.js/issues/439)) ([2aef678](https://github.com/LuanRT/YouTube.js/commit/2aef67876ec19118b37d3cecd429ccf8239989e0))
|
||||
* **StructuredDescriptionContent:** `items` can also be a `HorizontalCardList` ([b50d1ef](https://github.com/LuanRT/YouTube.js/commit/b50d1ef67d81276864818de10c61b5a7980cbc1a))
|
||||
|
||||
## [5.4.0](https://github.com/LuanRT/YouTube.js/compare/v5.3.0...v5.4.0) (2023-07-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Channel:** Add `getPodcasts()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
|
||||
* **Channel:** Add `getReleases()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
|
||||
* **parser:** Add `Quiz` ([#437](https://github.com/LuanRT/YouTube.js/issues/437)) ([cffa868](https://github.com/LuanRT/YouTube.js/commit/cffa868c6eeb579047653fac65da8e913fb3c621))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Playlist:** Parse `PlaylistCustomThumbnail` for `thumbnail_renderer` ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
|
||||
|
||||
## [5.3.0](https://github.com/LuanRT/YouTube.js/compare/v5.2.1...v5.3.0) (2023-07-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **toDash:** Add color information ([#430](https://github.com/LuanRT/YouTube.js/issues/430)) ([3500e92](https://github.com/LuanRT/YouTube.js/commit/3500e926327d560b1db036bfe503c276b91922ac))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **Format:** Cleanup the xtags parsing ([#434](https://github.com/LuanRT/YouTube.js/issues/434)) ([1ca2083](https://github.com/LuanRT/YouTube.js/commit/1ca20836bf343c78461fab7ad3b71db2b96e65c3))
|
||||
* **toDash:** Hoist duplicates from Representation to AdaptationSet ([#431](https://github.com/LuanRT/YouTube.js/issues/431)) ([5f058e6](https://github.com/LuanRT/YouTube.js/commit/5f058e69ae8594491133f7f96287bea4137f7822))
|
||||
|
||||
## [5.2.1](https://github.com/LuanRT/YouTube.js/compare/v5.2.0...v5.2.1) (2023-07-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incorrect node parser implementations ([#428](https://github.com/LuanRT/YouTube.js/issues/428)) ([222dfce](https://github.com/LuanRT/YouTube.js/commit/222dfce6bbd13b2cd80ae11540cbc0edd9053fc5))
|
||||
|
||||
## [5.2.0](https://github.com/LuanRT/YouTube.js/compare/v5.1.0...v5.2.0) (2023-06-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **VideoDetails:** Add is_post_live_dvr property ([#411](https://github.com/LuanRT/YouTube.js/issues/411)) ([a11e596](https://github.com/LuanRT/YouTube.js/commit/a11e5962c6eb73b14623a9de1e6c8c2534146b1e))
|
||||
* **ytmusic:** Add support for YouTube Music mood filters ([#404](https://github.com/LuanRT/YouTube.js/issues/404)) ([77b39c7](https://github.com/LuanRT/YouTube.js/commit/77b39c79ee0768eb203b7d47ea81286d470c21f2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **OAuth:** client identity matching ([#421](https://github.com/LuanRT/YouTube.js/issues/421)) ([07c1b3e](https://github.com/LuanRT/YouTube.js/commit/07c1b3e0e57cb1fa42e4772775bfd1437bbc731f))
|
||||
* **PlayerEndpoint:** Use different player params ([#419](https://github.com/LuanRT/YouTube.js/issues/419)) ([519be72](https://github.com/LuanRT/YouTube.js/commit/519be72445b7ff392b396e16bcb1dc05c7df8976))
|
||||
* **Playlist:** Add thumbnail_renderer on Playlist when response includes it ([#424](https://github.com/LuanRT/YouTube.js/issues/424)) ([4f9427d](https://github.com/LuanRT/YouTube.js/commit/4f9427d752e89faec8dd1c4fd7a9607dca998c7a))
|
||||
* **VideoInfo.ts:** reimplement `get music_tracks` ([#409](https://github.com/LuanRT/YouTube.js/issues/409)) ([e434bb2](https://github.com/LuanRT/YouTube.js/commit/e434bb2632fe2b20aab6f1e707a93ca76f9d5c91))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **Search:** Speed up results parsing ([#408](https://github.com/LuanRT/YouTube.js/issues/408)) ([1e07a18](https://github.com/LuanRT/YouTube.js/commit/1e07a184ffaff508ad5ba869cb5e7dc9f095f744))
|
||||
* **toDash:** Speed up format filtering ([#405](https://github.com/LuanRT/YouTube.js/issues/405)) ([5de7b24](https://github.com/LuanRT/YouTube.js/commit/5de7b24dc55fca3eb8fccc6fa30d3c2cd60b8184))
|
||||
|
||||
## [5.1.0](https://github.com/LuanRT/YouTube.js/compare/v5.0.4...v5.1.0) (2023-05-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ReelItem:** Add accessibility label ([#401](https://github.com/LuanRT/YouTube.js/issues/401)) ([046103a](https://github.com/LuanRT/YouTube.js/commit/046103a4d8af09fafefab6e9f971184eeca75c2e))
|
||||
* **toDash:** Add audio track labels to the manifest when available ([#402](https://github.com/LuanRT/YouTube.js/issues/402)) ([84b4f1e](https://github.com/LuanRT/YouTube.js/commit/84b4f1efd111321e4f3e5a87844790c4ec9b0b52))
|
||||
|
||||
## [5.0.4](https://github.com/LuanRT/YouTube.js/compare/v5.0.3...v5.0.4) (2023-05-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **bundles:** Use ESM tslib build for the browser bundles ([#397](https://github.com/LuanRT/YouTube.js/issues/397)) ([2673419](https://github.com/LuanRT/YouTube.js/commit/26734194ab0bc5a9f57e1c509d7646ce8903d0c6))
|
||||
* **Utils:** Circular dependency introduced in 38a83c3c2aa814150d1d9b8ed99fca915c1d67fe ([#400](https://github.com/LuanRT/YouTube.js/issues/400)) ([66b026b](https://github.com/LuanRT/YouTube.js/commit/66b026bf493d71a39e12825938fe54dc63aefd16))
|
||||
* **Utils:** Use instanceof in deepCompare instead of the constructor name ([#398](https://github.com/LuanRT/YouTube.js/issues/398)) ([38a83c3](https://github.com/LuanRT/YouTube.js/commit/38a83c3c2aa814150d1d9b8ed99fca915c1d67fe))
|
||||
|
||||
## [5.0.3](https://github.com/LuanRT/YouTube.js/compare/v5.0.2...v5.0.3) (2023-05-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Video:** typo causing node parsing to fail ([3b0498b](https://github.com/LuanRT/YouTube.js/commit/3b0498b68b5378e63283e792bd45571c0b919e0b))
|
||||
|
||||
## [5.0.2](https://github.com/LuanRT/YouTube.js/compare/v5.0.1...v5.0.2) (2023-04-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **VideoInfo:** Use microformat view_count when videoDetails view_count is NaN ([#393](https://github.com/LuanRT/YouTube.js/issues/393)) ([7c0abfc](https://github.com/LuanRT/YouTube.js/commit/7c0abfccd78a6c291d898f898d73a4f16170e2a9))
|
||||
|
||||
## [5.0.1](https://github.com/LuanRT/YouTube.js/compare/v5.0.0...v5.0.1) (2023-04-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** slow downloads due to visitor data ([#391](https://github.com/LuanRT/YouTube.js/issues/391)) ([4f7ec07](https://github.com/LuanRT/YouTube.js/commit/4f7ec07c3f689219b07e8291877c23b6fbf45fb1))
|
||||
|
||||
## [5.0.0](https://github.com/LuanRT/YouTube.js/compare/v4.3.0...v5.0.0) (2023-04-29)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* overhaul core classes and remove redundant code ([#388](https://github.com/LuanRT/YouTube.js/issues/388))
|
||||
|
||||
### Features
|
||||
|
||||
* **NavigationEndpoint:** parse `content` prop ([dd21f8c](https://github.com/LuanRT/YouTube.js/commit/dd21f8c75ae1d76180faab4f0ef9ee40920966e3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **android:** workaround streaming URLs returning 403 ([#390](https://github.com/LuanRT/YouTube.js/issues/390)) ([75ea09d](https://github.com/LuanRT/YouTube.js/commit/75ea09dde86b1bdf13b197d6e02701899300a371))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* overhaul core classes and remove redundant code ([#388](https://github.com/LuanRT/YouTube.js/issues/388)) ([95e0294](https://github.com/LuanRT/YouTube.js/commit/95e0294eabfdb20bbee2a4bfb751fd101402c5d6))
|
||||
|
||||
## [4.3.0](https://github.com/LuanRT/YouTube.js/compare/v4.2.0...v4.3.0) (2023-04-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **GridVideo:** add `upcoming`, `upcoming_text`, `is_reminder_set` and `buttons` ([05de3ec](https://github.com/LuanRT/YouTube.js/commit/05de3ec97a1fea92543b5e5f84933b86a07ab830)), closes [#380](https://github.com/LuanRT/YouTube.js/issues/380)
|
||||
* **MusicResponsiveListItem:** make flex/fixed cols public ([#382](https://github.com/LuanRT/YouTube.js/issues/382)) ([096bf36](https://github.com/LuanRT/YouTube.js/commit/096bf362c9bd46a510ecb0d01623c70841e26e26))
|
||||
* **ToggleMenuServiceItem:** parse default nav endpoint ([a056696](https://github.com/LuanRT/YouTube.js/commit/a0566969ba436f31ca3722d09442a0c6302235d7))
|
||||
* **ytmusic:** add taste builder nodes ([#383](https://github.com/LuanRT/YouTube.js/issues/383)) ([a9cad49](https://github.com/LuanRT/YouTube.js/commit/a9cad49333aa85c98bbb96e5f2d5b57d9beeb0c7))
|
||||
|
||||
## [4.2.0](https://github.com/LuanRT/YouTube.js/compare/v4.1.1...v4.2.0) (2023-04-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Enable importHelpers in tsconfig to reduce output size ([#378](https://github.com/LuanRT/YouTube.js/issues/378)) ([0b301de](https://github.com/LuanRT/YouTube.js/commit/0b301de6a1e1352a64881c1751a84360922a77cd))
|
||||
* **parser:** ignore PrimetimePromo node ([ce9d9c5](https://github.com/LuanRT/YouTube.js/commit/ce9d9c56b4f45c0139d74edc95c295ecfd1ee4b1))
|
||||
* **PlaylistVideo:** Extract video_info and accessibility_label texts ([#376](https://github.com/LuanRT/YouTube.js/issues/376)) ([c9135e6](https://github.com/LuanRT/YouTube.js/commit/c9135e66d3c9c72b8d063eedcf3cc2123800946d))
|
||||
|
||||
## [4.1.1](https://github.com/LuanRT/YouTube.js/compare/v4.1.0...v4.1.1) (2023-03-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **PlayerCaptionsTracklist:** parse props only if they exist in the node ([470d8d9](https://github.com/LuanRT/YouTube.js/commit/470d8d94063f0159cd005c9eb15fd1a4a175bea0)), closes [#372](https://github.com/LuanRT/YouTube.js/issues/372)
|
||||
* **Search:** Return search results even if there are ads ([#373](https://github.com/LuanRT/YouTube.js/issues/373)) ([2c5907f](https://github.com/LuanRT/YouTube.js/commit/2c5907f80fd76452afe87d1722fe35a4f45a22e0))
|
||||
|
||||
## [4.1.0](https://github.com/LuanRT/YouTube.js/compare/v4.0.1...v4.1.0) (2023-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Session:** allow setting a custom visitor data token ([#371](https://github.com/LuanRT/YouTube.js/issues/371)) ([13ebf0a](https://github.com/LuanRT/YouTube.js/commit/13ebf0a03973e7ba7b65e9f72c4333927e4254f6))
|
||||
* **ShowingResultsFor:** parse all props ([1d9587e](https://github.com/LuanRT/YouTube.js/commit/1d9587e8c1ee0b11bb0e444c3d1e98162e9e1059))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **http:** android tv http client missing `clientName` ([#370](https://github.com/LuanRT/YouTube.js/issues/370)) ([cb8fafe](https://github.com/LuanRT/YouTube.js/commit/cb8fafe94b8ab330ae58211a892923321d65d890))
|
||||
* **node:** Electron apps crashing ([#367](https://github.com/LuanRT/YouTube.js/issues/367)) ([e7eacd9](https://github.com/LuanRT/YouTube.js/commit/e7eacd974211c90e7fbddfbf8019388cda3dfa5a))
|
||||
* **parser:** Make Video.is_live work on channel pages ([#368](https://github.com/LuanRT/YouTube.js/issues/368)) ([bd35faa](https://github.com/LuanRT/YouTube.js/commit/bd35faa5978f0b822e98d019523be1303374ddc0))
|
||||
* **toDash:** Generate unique Representation ids ([#366](https://github.com/LuanRT/YouTube.js/issues/366)) ([a8b507e](https://github.com/LuanRT/YouTube.js/commit/a8b507ee65ccd8edc9aea2aef8a908fa272bb23c))
|
||||
* **Utils:** Properly parse timestamps with thousands separators ([#363](https://github.com/LuanRT/YouTube.js/issues/363)) ([1c72a41](https://github.com/LuanRT/YouTube.js/commit/1c72a41675e47f711bd61ebb898bca5527406a79))
|
||||
|
||||
## [4.0.1](https://github.com/LuanRT/YouTube.js/compare/v4.0.0...v4.0.1) (2023-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Channel:** type mismatch in `subscribe_button` prop ([573c864](https://github.com/LuanRT/YouTube.js/commit/573c8643aae16ec7b6be5b333619a5d8c91ca5c1))
|
||||
|
||||
## [4.0.0](https://github.com/LuanRT/YouTube.js/compare/v3.3.0...v4.0.0) (2023-03-15)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **Parser:** general refactoring of parsers ([#344](https://github.com/LuanRT/YouTube.js/issues/344))
|
||||
* The `toDash` functions are now asynchronous, they now return a `Promise<string>` instead of a `string`, as we need to fetch the first sequence of the OTF format streams while building the manifest.
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for OTF format streams ([3e4d41b](https://github.com/LuanRT/YouTube.js/commit/3e4d41bf06ba16232979977c705444f2032bcde6))
|
||||
* **parser:** add `GridMix` ([#356](https://github.com/LuanRT/YouTube.js/issues/356)) ([a8e7e64](https://github.com/LuanRT/YouTube.js/commit/a8e7e644ec6df3b3c98a313f0321da27b4ca456e))
|
||||
* **parser:** add `GridShow` and `ShowCustomThumbnail` ([8ef4b42](https://github.com/LuanRT/YouTube.js/commit/8ef4b42d444c4fbe5cd65a55c0e0e7aa31738755)), closes [#459](https://github.com/LuanRT/YouTube.js/issues/459)
|
||||
* **parser:** add `MusicCardShelf` ([#358](https://github.com/LuanRT/YouTube.js/issues/358)) ([9b005d6](https://github.com/LuanRT/YouTube.js/commit/9b005d62d6590a2ddf6848dabfa33fce36e8df9c))
|
||||
* **parser:** Add `play_all_button` to `Shelf` ([#345](https://github.com/LuanRT/YouTube.js/issues/345)) ([427db5b](https://github.com/LuanRT/YouTube.js/commit/427db5bbc2bf3e8ec60371d504c2ab1cdae6e918))
|
||||
* **parser:** add `view_playlist` to `Playlist` ([#348](https://github.com/LuanRT/YouTube.js/issues/348)) ([9cb4530](https://github.com/LuanRT/YouTube.js/commit/9cb45302997771d909487b1ecba6f38655abef48))
|
||||
* **parser:** add InfoPanelContent and InfoPanelContainer nodes ([4784dfa](https://github.com/LuanRT/YouTube.js/commit/4784dfa563a4dbeaee31811824d5aa37a67f5557)), closes [#326](https://github.com/LuanRT/YouTube.js/issues/326)
|
||||
* **Parser:** just-in-time YTNode generation ([#310](https://github.com/LuanRT/YouTube.js/issues/310)) ([2cee590](https://github.com/LuanRT/YouTube.js/commit/2cee59024c730c34aa06052849ed6fb3f862ef33))
|
||||
* **yt:** add support for movie items and trailers ([#349](https://github.com/LuanRT/YouTube.js/issues/349)) ([9f1c31d](https://github.com/LuanRT/YouTube.js/commit/9f1c31d7a09532e80a187b14acceff31c22579bf))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **Parser:** general refactoring of parsers ([#344](https://github.com/LuanRT/YouTube.js/issues/344)) ([b13bf6e](https://github.com/LuanRT/YouTube.js/commit/b13bf6e9926c19a1939e0f4b69cbd53d1af0f7c8))
|
||||
|
||||
## [3.3.0](https://github.com/LuanRT/YouTube.js/compare/v3.2.0...v3.3.0) (2023-03-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **parser:** add `ConversationBar` node ([b2253df](https://github.com/LuanRT/YouTube.js/commit/b2253df8022217dc486155d8cacbf22db04dd9c2))
|
||||
* **VideoInfo:** support get by endpoint + more info ([#342](https://github.com/LuanRT/YouTube.js/issues/342)) ([0d35fe0](https://github.com/LuanRT/YouTube.js/commit/0d35fe0ca5e87a877b76cbb6cf3c92843eac5a99))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **MultiMarkersPlayerBar:** avoid observing undefined objects ([f351770](https://github.com/LuanRT/YouTube.js/commit/f3517708ff34093a544c09d6f5f1ec806130d5cc))
|
||||
* **SharedPost:** import `Menu` node directly (oops) ([3e3dc35](https://github.com/LuanRT/YouTube.js/commit/3e3dc351bb44faec87616d9b922924d14a95f29f))
|
||||
* **ytmusic:** use static visitor id to avoid empty API responses ([f9754f5](https://github.com/LuanRT/YouTube.js/commit/f9754f5ac61d0f11b025f37f93783f971560268b)), closes [#279](https://github.com/LuanRT/YouTube.js/issues/279)
|
||||
|
||||
## [3.2.0](https://github.com/LuanRT/YouTube.js/compare/v3.1.1...v3.2.0) (2023-03-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for descriptive audio tracks ([#338](https://github.com/LuanRT/YouTube.js/issues/338)) ([574b67a](https://github.com/LuanRT/YouTube.js/commit/574b67a1f707a32378586dd2fe7b2f36f4ab6ddb))
|
||||
* export `FormatUtils`' types ([2d774e2](https://github.com/LuanRT/YouTube.js/commit/2d774e26aae79f3d1b115e0e85c148ae80985529))
|
||||
* **parser:** add `banner` to `PlaylistHeader` ([#337](https://github.com/LuanRT/YouTube.js/issues/337)) ([95033e7](https://github.com/LuanRT/YouTube.js/commit/95033e723ef912706e4d176de6b2760f017184e1))
|
||||
* **parser:** SharedPost ([#332](https://github.com/LuanRT/YouTube.js/issues/332)) ([ce53ac1](https://github.com/LuanRT/YouTube.js/commit/ce53ac18435cbcb20d6d4c4ab52fd156091e7592))
|
||||
* **VideoInfo:** add `game_info` and `category` ([#333](https://github.com/LuanRT/YouTube.js/issues/333)) ([214aa14](https://github.com/LuanRT/YouTube.js/commit/214aa147ce6306e37a6bf860a7bed5635db4797e))
|
||||
* **YouTube/Search:** add `SearchSubMenu` node ([#340](https://github.com/LuanRT/YouTube.js/issues/340)) ([a511608](https://github.com/LuanRT/YouTube.js/commit/a511608f18b37b0d9f2c7958ed5128330fabcfa0))
|
||||
* **yt:** add `getGuide()` ([#335](https://github.com/LuanRT/YouTube.js/issues/335)) ([2cc7b8b](https://github.com/LuanRT/YouTube.js/commit/2cc7b8bcd6938c7fb3af4f854a1d78b86d153873))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **SegmentedLikeDislikeButton:** like/dislike buttons can also be a simple `Button` ([9b2738f](https://github.com/LuanRT/YouTube.js/commit/9b2738f1285b278c3e83541857651be9a6248288))
|
||||
* **YouTube:** fix warnings when retrieving members-only content ([#341](https://github.com/LuanRT/YouTube.js/issues/341)) ([95f1d40](https://github.com/LuanRT/YouTube.js/commit/95f1d4077ff3775f36967dca786139a09e2830a2))
|
||||
* **ytmusic:** export search filters type ([cf8a33c](https://github.com/LuanRT/YouTube.js/commit/cf8a33c79f5432136b865d535fd0ecedc2393382))
|
||||
|
||||
## [3.1.1](https://github.com/LuanRT/YouTube.js/compare/v3.1.0...v3.1.1) (2023-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Channel:** getting community continuations ([#329](https://github.com/LuanRT/YouTube.js/issues/329)) ([4c7b8a3](https://github.com/LuanRT/YouTube.js/commit/4c7b8a34030effa26c4ea186d3e9509128aec31c))
|
||||
|
||||
## [3.1.0](https://github.com/LuanRT/YouTube.js/compare/v3.0.0...v3.1.0) (2023-02-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add upcoming and live info to playlist videos ([#317](https://github.com/LuanRT/YouTube.js/issues/317)) ([a0bfe16](https://github.com/LuanRT/YouTube.js/commit/a0bfe164279ec27b0c49c6b0c32222c1a92df5c3))
|
||||
* **VideoSecondaryInfo:** add support for attributed descriptions ([#325](https://github.com/LuanRT/YouTube.js/issues/325)) ([f933cb4](https://github.com/LuanRT/YouTube.js/commit/f933cb45bcb92c07b3bc063d63869a51cbff4eb0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **parser:** export YTNodes individually so they can be used as types ([200632f](https://github.com/LuanRT/YouTube.js/commit/200632f374d5e0e105b600d579a2665a6fb36e38)), closes [#321](https://github.com/LuanRT/YouTube.js/issues/321)
|
||||
* **PlayerMicroformat:** Make the embed field optional ([#320](https://github.com/LuanRT/YouTube.js/issues/320)) ([a0e6cef](https://github.com/LuanRT/YouTube.js/commit/a0e6cef00fb9e3f52593cec22704f7ddc1f7553e))
|
||||
* send correct UA for Android requests ([f4e0f30](https://github.com/LuanRT/YouTube.js/commit/f4e0f30e6e94b347b28d67d9a86284ea2d23ee15)), closes [#322](https://github.com/LuanRT/YouTube.js/issues/322)
|
||||
|
||||
## [3.0.0](https://github.com/LuanRT/YouTube.js/compare/v2.9.0...v3.0.0) (2023-02-17)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* cleanup platform support ([#306](https://github.com/LuanRT/YouTube.js/issues/306))
|
||||
|
||||
### Features
|
||||
|
||||
* add parser support for MultiImage community posts ([#298](https://github.com/LuanRT/YouTube.js/issues/298)) ([de61782](https://github.com/LuanRT/YouTube.js/commit/de61782f1a673cbe66ae9b410341e39b7501ba84))
|
||||
* add support for hashtag feeds ([#312](https://github.com/LuanRT/YouTube.js/issues/312)) ([bf12740](https://github.com/LuanRT/YouTube.js/commit/bf12740333a82c26fe84e7c702c2fbb8859814fc))
|
||||
* add support for YouTube Kids ([#291](https://github.com/LuanRT/YouTube.js/issues/291)) ([2bbefef](https://github.com/LuanRT/YouTube.js/commit/2bbefefbb7cb061f3e7b686158b7568c32f0da5d))
|
||||
* allow checking whether a channel has optional tabs ([#296](https://github.com/LuanRT/YouTube.js/issues/296)) ([ceefbed](https://github.com/LuanRT/YouTube.js/commit/ceefbed98c70bb936e2d2df58c02834842acfdfc))
|
||||
* **Channel:** Add getters for all optional tabs ([#303](https://github.com/LuanRT/YouTube.js/issues/303)) ([b2900f4](https://github.com/LuanRT/YouTube.js/commit/b2900f48a7aa4c22635e1819ba9f636e81964f2c))
|
||||
* **Channel:** add support for sorting the playlist tab ([#295](https://github.com/LuanRT/YouTube.js/issues/295)) ([50ef712](https://github.com/LuanRT/YouTube.js/commit/50ef71284db41e5f94bb511892651d22a1d363a0))
|
||||
* extract channel error alert ([0b99180](https://github.com/LuanRT/YouTube.js/commit/0b991800a5c67f0e702251982b52eb8531f36f19))
|
||||
* **FormatUtils:** support multiple audio tracks in the DASH manifest ([#308](https://github.com/LuanRT/YouTube.js/issues/308)) ([a69e43b](https://github.com/LuanRT/YouTube.js/commit/a69e43bf3ae02f2428c4aa86f647e3e5e0db5ba6))
|
||||
* improve support for dubbed content ([#293](https://github.com/LuanRT/YouTube.js/issues/293)) ([d6c5a9b](https://github.com/LuanRT/YouTube.js/commit/d6c5a9b971444d0cd746aaf5310d3389793680ea))
|
||||
* parse isLive in CompactVideo ([#294](https://github.com/LuanRT/YouTube.js/issues/294)) ([2acb7da](https://github.com/LuanRT/YouTube.js/commit/2acb7da0198bfeca6ff911cf95cf06a220fccaa5))
|
||||
* **parser:** add `ChannelAgeGate` node ([1cdf701](https://github.com/LuanRT/YouTube.js/commit/1cdf701c8403db6b681a26ecb1df2daa51add454))
|
||||
* **parser:** Text#toHTML ([#300](https://github.com/LuanRT/YouTube.js/issues/300)) ([e82e23d](https://github.com/LuanRT/YouTube.js/commit/e82e23dfbb24dff3ddf45754c7319d783990e254))
|
||||
* **ytkids:** add `getChannel()` ([#292](https://github.com/LuanRT/YouTube.js/issues/292)) ([0fc29f0](https://github.com/LuanRT/YouTube.js/commit/0fc29f0bbf965215146a6ae192494c74e6cefcbb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* assign MetadataBadge's label ([#311](https://github.com/LuanRT/YouTube.js/issues/311)) ([e37cf62](https://github.com/LuanRT/YouTube.js/commit/e37cf627322f688fcef18d41345f77cbccd58829))
|
||||
* **ChannelAboutFullMetadata:** fix error when there are no primary links ([#299](https://github.com/LuanRT/YouTube.js/issues/299)) ([f62c66d](https://github.com/LuanRT/YouTube.js/commit/f62c66db396ba7d2f93007414101112b49d8375f))
|
||||
* **TopicChannelDetails:** avatar and subtitle parsing ([#302](https://github.com/LuanRT/YouTube.js/issues/302)) ([d612590](https://github.com/LuanRT/YouTube.js/commit/d612590530f5fe590fee969810b1dd44c37f0457))
|
||||
* **VideoInfo:** Gracefully handle missing watch next continuation ([#288](https://github.com/LuanRT/YouTube.js/issues/288)) ([13ad377](https://github.com/LuanRT/YouTube.js/commit/13ad3774c9783ed2a9f286aeee88110bd43b3a73))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* cleanup platform support ([#306](https://github.com/LuanRT/YouTube.js/issues/306)) ([2ccbe2c](https://github.com/LuanRT/YouTube.js/commit/2ccbe2ce6260ace3bfac8b4b391e583fbcc4e286))
|
||||
20
COLLABORATORS.md
Normal file
20
COLLABORATORS.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Collaborators
|
||||
|
||||
This page lists the collaborators who have contributed to the development and success of the project.
|
||||
|
||||
## [LuanRT](https://github.com/LuanRT)
|
||||
[](https://github.com/sponsors/LuanRT)
|
||||
|
||||
Owner and maintainer.
|
||||
|
||||
## [Wykerd](https://github.com/wykerd/)
|
||||
Initial parser implementation, several bug fixes, major refactorings and general maintenance.
|
||||
|
||||
## [MasterOfBob777](https://github.com/MasterOfBob777)
|
||||
Bug fixes and TypeScript support.
|
||||
|
||||
## [patrickkfkan](https://github.com/patrickkfkan)
|
||||
Major refactorings, improved YouTube Music support, and bug fixes.
|
||||
|
||||
## [Absidue](https://github.com/absidue)
|
||||
Several bug fixes, new features & improved MPD support.
|
||||
66
CONTRIBUTING.md
Normal file
66
CONTRIBUTING.md
Normal file
@@ -0,0 +1,66 @@
|
||||
Welcome to YouTube.js! We're thrilled to have you interested in contributing to our project. As a community-driven project, we believe in the power of collaboration and look forward to working with you. To get started, please follow our guidelines:
|
||||
|
||||
## Issues
|
||||
|
||||
### Creating a new issue
|
||||
Before creating a new issue, we recommend searching for similar or related issues to avoid duplication efforts. However, if you can't find one, you're more than welcome to create a new issue using a relevant issue form. Please make sure to describe the issue as clearly and concisely as possible.
|
||||
|
||||
### Solving an issue
|
||||
If you want to lend a hand by solving an issue, it's always good to browse existing issues to find one that grabs your attention. You can narrow down the search using tags as filters. If you find an issue you'd like to help with, please feel free to open a Pull Request with a fix. We appreciate documentation updates and grammar fixes too!
|
||||
|
||||
## Making Changes
|
||||
|
||||
1. Fork the repository on GitHub.
|
||||
2. Ensure that you have the latest Node.js v16 version installed.
|
||||
3. Create a working branch and start making your changes and improvements!
|
||||
|
||||
### Committing updates
|
||||
When you're done with the changes, make sure to commit them. Don't forget to write a clear, descriptive commit message. We recommend following the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
||||
|
||||
### Creating a Pull Request
|
||||
Once you're happy with your updates, create a pull request on GitHub. This is the most efficient way to get your contribution reviewed and eventually merged into our codebase.
|
||||
|
||||
- Use the pull request template to fill in the necessary details.
|
||||
- If you're solving an issue, link the pull request to that issue.
|
||||
- Enable the checkbox to allow maintainers to edit the branch and update it for merging.
|
||||
- Changes may be required before we can merge your changes, and we'll let you know what needs to be done.
|
||||
|
||||
### Testing, Linting, and Building
|
||||
We have some automated processes set up for testing, linting, and building. Please run the following commands to test, lint, and build your code before submitting it:
|
||||
|
||||
Testing:
|
||||
```sh
|
||||
npm run test
|
||||
```
|
||||
|
||||
Linting:
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Building:
|
||||
```sh
|
||||
# Build all
|
||||
npm run build
|
||||
|
||||
# Protobuf
|
||||
npm run build:proto
|
||||
|
||||
# Parser map
|
||||
npm run build:parser-map
|
||||
|
||||
# Deno
|
||||
npm run build:deno
|
||||
|
||||
# ES Module
|
||||
npm run build:esm
|
||||
|
||||
# Node
|
||||
npm run bundle:node
|
||||
|
||||
# Browser
|
||||
npm run bundle:browser
|
||||
npm run bundle:browser:prod
|
||||
```
|
||||
|
||||
We appreciate your efforts and contributions to YouTube.js! Together, we can make this project even better.
|
||||
1
bundle/browser.d.ts
vendored
Normal file
1
bundle/browser.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../dist/src/platform/lib.js';
|
||||
1
bundle/node.d.cts
Normal file
1
bundle/node.d.cts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../dist/src/platform/lib.js';
|
||||
3
deno.ts
Normal file
3
deno.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './deno/src/platform/deno.ts';
|
||||
import Innertube from './deno/src/platform/deno.ts';
|
||||
export default Innertube;
|
||||
115
docs/API/account.md
Normal file
115
docs/API/account.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 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 the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="gettimewatched"></a>
|
||||
### getTimeWatched()
|
||||
|
||||
Retrieves time watched statistics.
|
||||
|
||||
**Returns:** `Promise.<TimeWatched>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<timewatched>#page`
|
||||
- Returns the original InnerTube response(s), parsed and 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.
|
||||
|
||||
- `<settings>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getanalytics"></a>
|
||||
### getAnalytics()
|
||||
|
||||
Retrieves basic channel analytics.
|
||||
|
||||
**Returns:** `Promise.<Analytics>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<analytics>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
115
docs/API/feed.md
Normal file
115
docs/API/feed.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Feed
|
||||
|
||||
Represents a YouTube feed. This class provides a set of utility methods for parsing and interacting with feeds.
|
||||
|
||||
## API
|
||||
|
||||
* Feed
|
||||
* [.videos](#videos)
|
||||
* [.posts](#posts)
|
||||
* [.channels](#channels)
|
||||
* [.playlists](#playlists)
|
||||
* [.shelves](#shelves)
|
||||
* [.memo](#memo)
|
||||
* [.page_contents](#page_contents)
|
||||
* [.secondary_contents](#secondary_contents)
|
||||
* [.page](#page)
|
||||
* [.has_continuation](#has_continuation)
|
||||
* [.getContinuationData()](#getcontinuationdata)
|
||||
* [.getContinuation()](#getcontinuation)
|
||||
* [.getShelf(title)](#getshelf)
|
||||
|
||||
<a name="videos"></a>
|
||||
### videos
|
||||
|
||||
Returns all videos in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>`
|
||||
|
||||
<a name="posts"></a>
|
||||
### posts
|
||||
|
||||
Returns all posts in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Post | BackstagePost>`
|
||||
|
||||
<a name="channels"></a>
|
||||
### channels
|
||||
|
||||
Returns all channels in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Channel | GridChannel>`
|
||||
|
||||
<a name="playlists"></a>
|
||||
### playlists
|
||||
|
||||
Returns all playlists in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Playlist | GridPlaylist>`
|
||||
|
||||
<a name="shelves"></a>
|
||||
### shelves
|
||||
|
||||
Returns all shelves in the feed.
|
||||
|
||||
**Returns:** `ObservedArray<Shelf | RichShelf | ReelShelf>`
|
||||
|
||||
<a name="memo"></a>
|
||||
### memo
|
||||
|
||||
Returns the memoized feed contents.
|
||||
|
||||
**Returns:** `Memo`
|
||||
|
||||
<a name="page_contents"></a>
|
||||
### page_contents
|
||||
|
||||
Returns the page contents.
|
||||
|
||||
**Returns:** `SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand`
|
||||
|
||||
<a name="secondary_contents"></a>
|
||||
### secondary_contents
|
||||
|
||||
Returns the secondary contents node.
|
||||
|
||||
**Returns:** `SuperParsedResult<YTNode> | undefined `
|
||||
|
||||
<a name="page"></a>
|
||||
### page
|
||||
|
||||
Returns the original InnerTube response, parsed and sanitized.
|
||||
|
||||
**Returns:** `T extends IParsedResponse = IParsedResponse`
|
||||
|
||||
<a name="has_continuation"></a>
|
||||
### has_continuation
|
||||
|
||||
Returns whether the feed has a continuation.
|
||||
|
||||
**Returns:** `boolean`
|
||||
|
||||
<a name="getcontinuationdata"></a>
|
||||
### getContinuationData()
|
||||
|
||||
Returns the continuation data.
|
||||
|
||||
**Returns:** `Promise<T | undefined>`
|
||||
|
||||
<a name="getcontinuation"></a>
|
||||
### getContinuation()
|
||||
|
||||
Retrieves the feed's continuation.
|
||||
|
||||
**Returns:** `Promise<Feed<T>>`
|
||||
|
||||
<a name="getshelf"></a>
|
||||
### getShelf(title)
|
||||
|
||||
Gets a shelf by its title.
|
||||
|
||||
**Returns:** `Shelf | RichShelf | ReelShelf | undefined`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| title | `string` | The title of the shelf to get |
|
||||
38
docs/API/filterable-feed.md
Normal file
38
docs/API/filterable-feed.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# FilterableFeed
|
||||
|
||||
Represents a feed that can be filtered.
|
||||
|
||||
> **Note**
|
||||
> This class extends the [Feed](feed.md) class.
|
||||
|
||||
## API
|
||||
|
||||
* FilterableFeed
|
||||
* [.filter_chips](#filter_chips)
|
||||
* [.filters](#filters)
|
||||
* [.getFilteredFeed(filter: string | ChipCloudChip)](#getfilteredfeed)
|
||||
|
||||
<a name="filter_chips"></a>
|
||||
### filter_chips
|
||||
|
||||
Returns the feed's filter chips.
|
||||
|
||||
**Returns:** `ObservedArray<ChipCloudChip>`
|
||||
|
||||
<a name="filters"></a>
|
||||
### filters
|
||||
|
||||
Returns the feed's filter chips as an array of strings.
|
||||
|
||||
**Returns:** `string[]`
|
||||
|
||||
<a name="getfilteredfeed"></a>
|
||||
### getFilteredFeed(filter: string | ChipCloudChip)
|
||||
|
||||
Returns a new [Feed](feed.md) with the given filter applied.
|
||||
|
||||
**Returns:** `Promise<Feed<T>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| filter | `string` \| `ChipCloudChip` | The filter to apply |
|
||||
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.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="dislike"></a>
|
||||
### dislike(video_id)
|
||||
|
||||
Dislikes given video.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="removerating"></a>
|
||||
### removeLike(video_id)
|
||||
|
||||
Remover like/dislike.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
|
||||
<a name="subscribe"></a>
|
||||
### subscribe(channel_id)
|
||||
|
||||
Subscribes to given channel.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | Channel id |
|
||||
|
||||
<a name="unsubscribe"></a>
|
||||
### unsubscribe(channel_id)
|
||||
|
||||
Unsubscribes from given channel.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | Channel id |
|
||||
|
||||
<a name="comment"></a>
|
||||
### comment(video_id, text)
|
||||
|
||||
Posts a comment on given video.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| 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.<ApiResponse>`
|
||||
|
||||
| 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.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | Channel id |
|
||||
| type | `string` | `PERSONALIZED`, `ALL` or `NONE` |
|
||||
127
docs/API/kids.md
Normal file
127
docs/API/kids.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# YouTube Kids
|
||||
|
||||
YouTube Kids is a modified version of the YouTube app, with a simplified interface and curated content. This class allows you to interact with its API.
|
||||
|
||||
## API
|
||||
|
||||
* Kids
|
||||
* [.search(query)](#search)
|
||||
* [.getInfo(video_id)](#getinfo)
|
||||
* [.getChannel(channel_id)](#getchannel)
|
||||
* [.getHomeFeed()](#gethomefeed)
|
||||
* [.blockChannel(channel_id)](#blockchannel)
|
||||
|
||||
<a name="search"></a>
|
||||
### search(query)
|
||||
|
||||
Searches the given query on YouTube Kids.
|
||||
|
||||
**Returns:** `Promise.<Search>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| query | `string` | The query to search |
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<search>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getinfo"></a>
|
||||
### getInfo(video_id)
|
||||
|
||||
Retrieves video info.
|
||||
|
||||
**Returns:** `Promise.<VideoInfo>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | The video id |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<info>#toDash(url_transformer?, format_filter?)`
|
||||
- Generates a DASH manifest from the streaming data.
|
||||
|
||||
- `<info>#chooseFormat(options)`
|
||||
- Selects the format that best matches the given options. This method is used internally by `#download`.
|
||||
|
||||
- `<info>#download(options?)`
|
||||
- Downloads the video.
|
||||
|
||||
- `<info>#addToWatchHistory()`
|
||||
- Adds the video to the watch history.
|
||||
|
||||
- `<info>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getchannel"></a>
|
||||
### getChannel(channel_id)
|
||||
|
||||
Retrieves channel info.
|
||||
|
||||
**Returns:** `Promise.<Channel>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | The channel id |
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<channel>#getContinuation()`
|
||||
- Retrieves next batch of videos.
|
||||
|
||||
- `<channel>#has_continuation`
|
||||
- Returns whether there are more videos to retrieve.
|
||||
|
||||
- `<channel>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="gethomefeed"></a>
|
||||
### getHomeFeed()
|
||||
|
||||
Retrieves the home feed.
|
||||
|
||||
**Returns:** `Promise.<HomeFeed>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<feed>#selectCategoryTab(tab: string | KidsCategoryTab)`
|
||||
- Selects the given category tab.
|
||||
|
||||
- `<feed>#categories`
|
||||
- Returns available categories.
|
||||
|
||||
- `<feed>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</details>
|
||||
|
||||
<a name="blockChannel"></a>
|
||||
### blockChannel(channel_id)
|
||||
|
||||
Retrieves the list of supervised accounts that the signed-in user has access to and blocks the given channel for each of them.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse[]>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channel_id | `string` | Channel id |
|
||||
347
docs/API/music.md
Normal file
347
docs/API/music.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# YouTube Music
|
||||
|
||||
YouTube Music is a music streaming service developed by YouTube, a subsidiary of Google. It provides a tailored interface for the service oriented towards music streaming, with a greater emphasis on browsing and discovery compared to its main service. This class allows you to interact with its API.
|
||||
|
||||
## API
|
||||
|
||||
* Music
|
||||
* [.getInfo(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.
|
||||
|
||||
- `<info>#toDash(url_transformer?, format_filter?)`
|
||||
- Generates a DASH manifest from the streaming data.
|
||||
|
||||
- `<info>#chooseFormat(options)`
|
||||
- Selects the format that best matches the given options. This method is used internally by `#download`.
|
||||
|
||||
- `<info>#download(options?)`
|
||||
- Downloads the track.
|
||||
|
||||
- `<info>#addToWatchHistory()`
|
||||
- Adds the song to the watch history.
|
||||
|
||||
- `<info>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="search"></a>
|
||||
### search(query, filters?)
|
||||
|
||||
Searches on YouTube Music.
|
||||
|
||||
**Returns:** `Promise.<Search>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| query | `string` | Search query |
|
||||
| filters? | `MusicSearchFilters` | Search filters |
|
||||
|
||||
<details>
|
||||
<summary>Search Filters</summary>
|
||||
|
||||
| Filter | Type | Value | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| type | `string` | `all`, `song`, `video`, `album`, `playlist`, `artist` | Search type |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<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 the original InnerTube response(s), parsed and 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).
|
||||
|
||||
- `<homefeed>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getexplore"></a>
|
||||
### getExplore()
|
||||
|
||||
Retrieves “Explore” feed.
|
||||
|
||||
**Returns:** `Promise.<Explore>`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<explore>#page`
|
||||
- Returns the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getlibrary"></a>
|
||||
### getLibrary()
|
||||
|
||||
Retrieves library.
|
||||
|
||||
**Returns:** `Library`
|
||||
|
||||
<details>
|
||||
<summary>Methods & Getters</summary>
|
||||
<p>
|
||||
|
||||
- `<library>#applyFilter(filter)`
|
||||
- Applies given filter to the library.
|
||||
|
||||
- `<library>#applySort(sort_by)`
|
||||
- Applies given sort option to the library items.
|
||||
|
||||
- `<library>#getContinuation()`
|
||||
- Retrieves continuation of the library items.
|
||||
|
||||
- `<library>#has_continuation`
|
||||
- Checks if continuation is available.
|
||||
|
||||
- `<library>#filters`
|
||||
- Returns available filters.
|
||||
|
||||
- `<library>#sort_options`
|
||||
- Returns available sort options.
|
||||
|
||||
- `<library>#page`
|
||||
- Returns the original InnerTube response(s), parsed and 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 the original InnerTube response(s), parsed and 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 the original InnerTube response(s), parsed and 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 the original InnerTube response(s), parsed and 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 the original InnerTube response(s), parsed and sanitized.
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<a name="getsearchsuggestions"></a>
|
||||
### getSearchSuggestions(query)
|
||||
|
||||
Retrieves search suggestions.
|
||||
|
||||
**Returns:** `Promise.<Array.<SearchSuggestion | HistorySuggestion>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| query | `string` | Search query |
|
||||
99
docs/API/playlist.md
Normal file
99
docs/API/playlist.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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)
|
||||
* [.setName(playlist_id, name)](#setname)
|
||||
* [.setDescription(playlist_id, description)](#setdescription)
|
||||
|
||||
<a name="create"></a>
|
||||
### create(title, video_ids)
|
||||
|
||||
Creates a playlist.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| 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.<ApiResponse>`
|
||||
|
||||
| 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 |
|
||||
|
||||
<a name="setname"></a>
|
||||
### setName(playlist_id, name)
|
||||
|
||||
Sets the name / title for the given playlist.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| playlist_id | `string` | Playlist id |
|
||||
| name | `string` | Name / title |
|
||||
|
||||
|
||||
<a name="setdescription"></a>
|
||||
### setDescription(playlist_id, description)
|
||||
|
||||
Sets the description for the given playlist.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| playlist_id | `string` | Playlist id |
|
||||
| description | `string` | Description |
|
||||
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.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| buffer | `Uint8Array` | Thumbnail buffer |
|
||||
|
||||
<a name="updatemetadata"></a>
|
||||
### updateVideoMetadata(video_id, metadata)
|
||||
|
||||
Updates given video's metadata.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| video_id | `string` | Video id |
|
||||
| metadata | `VideoMetadata` | Video metadata |
|
||||
|
||||
<a name="upload"></a>
|
||||
### upload(file, metadata)
|
||||
|
||||
Uploads a video to YouTube.
|
||||
|
||||
**Returns:** `Promise.<ApiResponse>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| file | `BodyInit` | Video file |
|
||||
| metadata | `UploadedVideoMetadata` | Video metadata |
|
||||
62
docs/API/tabbed-feed.md
Normal file
62
docs/API/tabbed-feed.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# TabbedFeed
|
||||
|
||||
Represents a feed with tabs.
|
||||
|
||||
> **Note**
|
||||
> This class extends the [Feed](feed.md) class.
|
||||
|
||||
## API
|
||||
|
||||
* TabbedFeed
|
||||
* [.tabs](#tabs)
|
||||
* [.getTabByName(title: string)](#gettabbyname)
|
||||
* [.getTabByURL(url: string)](#gettabbyurl)
|
||||
* [.hasTabWithURL(url: string)](#hastabwithurl)
|
||||
* [.title](#title)
|
||||
|
||||
<a name="tabs"></a>
|
||||
### tabs
|
||||
|
||||
Returns the feed's tabs as an array of strings.
|
||||
|
||||
**Returns:** `string[]`
|
||||
|
||||
<a name="gettabbyname"></a>
|
||||
### getTabByName(title: string)
|
||||
|
||||
Fetches a tab by its title.
|
||||
|
||||
**Returns:** `Promise<TabbedFeed<T>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| title | `string` | The title of the tab to get |
|
||||
|
||||
<a name="gettabbyurl"></a>
|
||||
### getTabByURL(url: string)
|
||||
|
||||
Fetches a tab by its URL.
|
||||
|
||||
**Returns:** `Promise<TabbedFeed<T>>`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| url | `string` | The URL of the tab to get |
|
||||
|
||||
<a name="hastabwithurl"></a>
|
||||
### hasTabWithURL(url: string)
|
||||
|
||||
Returns whether the feed has a tab with the given URL.
|
||||
|
||||
**Returns:** `boolean`
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| url | `string` | The URL to check |
|
||||
|
||||
<a name="title"></a>
|
||||
### title
|
||||
|
||||
Returns the currently selected tab's title.
|
||||
|
||||
**Returns:** `string | undefined`
|
||||
60
docs/updating-the-parser.md
Normal file
60
docs/updating-the-parser.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Updating the Parser
|
||||
|
||||
YouTube is constantly changing, so it is not uncommon to see YouTube crawlers/scrapers breaking every now and then.
|
||||
|
||||
Our parser, on the other hand, was written so that it behaves similarly to an official client, parsing and mapping renderers (also known as YTNodes) dynamically without hard-coding their path in the response. This way, whenever a new renderer pops up (e.g., when YouTube adds a new feature or makes a minor UI change), the library will print a warning similar to this:
|
||||
|
||||
```
|
||||
SomeRenderer not found!
|
||||
This is a bug, want to help us fix it? Follow the instructions at https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md or report it at https://github.com/LuanRT/YouTube.js/issues!
|
||||
Introspected and JIT generated this class in the meantime:
|
||||
class SomeRenderer extends YTNode {
|
||||
static type = 'SomeRenderer';
|
||||
|
||||
// ...
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This warning **does not** throw an error. The parser itself will continue working normally, even if a parsing error occurs in an existing renderer parser.
|
||||
|
||||
## Adding a New Renderer Parser
|
||||
|
||||
Thanks to the modularity of the parser, a renderer can be implemented by simply adding a new file anywhere in the [classes directory](../src/parser/classes)!
|
||||
|
||||
For example, suppose we have found a new renderer named `verticalListRenderer`. In that case, to let the parser know it exists at compile-time, we would have to create a file with the following structure:
|
||||
|
||||
> `../classes/VerticalList.ts`
|
||||
|
||||
```ts
|
||||
import Parser, { RawNode } from '../index.js';
|
||||
import { YTNode } from '../helpers.js';
|
||||
|
||||
export default class VerticalList extends YTNode {
|
||||
static type = 'VerticalList';
|
||||
|
||||
header;
|
||||
contents;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
// parse the data here, ex;
|
||||
this.header = Parser.parseItem(data.header);
|
||||
this.contents = Parser.parseArray(data.contents);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You may use the parser's generated class for the new renderer as a starting point for your own implementation.
|
||||
|
||||
Then update the parser map:
|
||||
|
||||
```bash
|
||||
npm run build:parser-map
|
||||
```
|
||||
|
||||
And that's it!
|
||||
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();
|
||||
```
|
||||
38
examples/auth/index.js
Normal file
38
examples/auth/index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({
|
||||
// required if you wish to use OAuth#cacheCredentials
|
||||
cache: new UniversalCache(false)
|
||||
});
|
||||
|
||||
// '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', async ({ credentials }) => {
|
||||
console.log('Credentials updated:', credentials);
|
||||
await yt.session.oauth.cacheCredentials();
|
||||
});
|
||||
|
||||
// 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();
|
||||
})();
|
||||
23
examples/blockchannel/index.js
Normal file
23
examples/blockchannel/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(true, './credcache') });
|
||||
|
||||
yt.session.on('auth-pending', (data) => {
|
||||
console.log(`Go to ${data.verification_url} in your browser and enter code ${data.user_code} to authenticate.`);
|
||||
});
|
||||
yt.session.on('auth', async () => {
|
||||
console.log('Sign in successful');
|
||||
await yt.session.oauth.cacheCredentials();
|
||||
});
|
||||
yt.session.on('update-credentials', async () => {
|
||||
await yt.session.oauth.cacheCredentials();
|
||||
});
|
||||
|
||||
// Attempt to sign in
|
||||
await yt.session.signIn();
|
||||
|
||||
// Block Channel for all kids / profiles on the signed-in account.
|
||||
const resp = await yt.kids.blockChannel('UCpbpfcZfo-hoDAx2m1blFhg');
|
||||
console.info('Blocked channel for ', resp.length, ' profiles.');
|
||||
})();
|
||||
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!
|
||||
|
||||
## Usage
|
||||
|
||||
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in `examples/browser/proxy/deno.ts`.
|
||||
|
||||
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/web.bundle.min";
|
||||
|
||||
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 |
31
examples/browser/web/index.html
Normal file
31
examples/browser/web/index.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="stylesheet" href="/src/assets/style.css" />
|
||||
<link rel="stylesheet" href="/src/assets/player.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YouTube.js Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<form>
|
||||
<input type="text" name="id" placeholder="Video ID or URL" />
|
||||
<input type="submit" value="Play" />
|
||||
</form>
|
||||
<div class="loader" id="loader"></div>
|
||||
<div id="video-container">
|
||||
<div class="shaka-container" id="shaka-container" data-shaka-player-container>
|
||||
<video class="videoel" id="videoel" data-shaka-player autoplay></video>
|
||||
</div>
|
||||
<h2 id="title"></h2>
|
||||
<div id="metadata"></div>
|
||||
<hr />
|
||||
<div id="description"></div>
|
||||
</div>
|
||||
<footer>
|
||||
<p>Powered by <a href="https://github.com/LuanRT/YouTube.js">YouTube.js</a></p>
|
||||
</footer>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
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": {
|
||||
"shaka-player": "^4.3.8"
|
||||
}
|
||||
}
|
||||
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 |
423
examples/browser/web/src/assets/player.css
Normal file
423
examples/browser/web/src/assets/player.css
Normal file
@@ -0,0 +1,423 @@
|
||||
@import url(https://fonts.googleapis.com/css?family=Material+Icons+Sharp);
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/roboto/v27/KFOmCnqEu92Fr1Me5Q.ttf) format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/roboto/v27/KFOlCnqEu92Fr1MmEU9vAw.ttf) format('truetype');
|
||||
}
|
||||
|
||||
.shaka-container {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-bottom-controls {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
padding-bottom: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-bottom-controls {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-box-direction: normal;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-ad-controls {
|
||||
-webkit-box-ordinal-group: 2;
|
||||
-ms-flex-order: 1;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-spinner .shaka-spinner-path {
|
||||
stroke: #ffffff;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-scrim-container {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-shrink: 1;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
transition: opacity cubic-bezier(.4, 0, .6, 1) .6s;
|
||||
background: linear-gradient(to top, hsla(0, 0%, 0%, 0.61), transparent 15%);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-play-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
filter: invert();
|
||||
box-shadow: none;
|
||||
-webkit-box-ordinal-group: -3;
|
||||
-ms-flex-order: -4;
|
||||
order: -4;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-small-play-button {
|
||||
-webkit-box-ordinal-group: -2;
|
||||
-ms-flex-order: -3;
|
||||
order: -3;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-mute-button {
|
||||
-webkit-box-ordinal-group: -1;
|
||||
-ms-flex-order: -2;
|
||||
order: -2;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-controls-button-panel>* {
|
||||
margin: 0;
|
||||
padding: 3px 8px;
|
||||
color: #EEE;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-controls-button-panel>*:hover {
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-controls-button-panel .shaka-volume-bar-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
left: -1px;
|
||||
-webkit-box-ordinal-group: 0;
|
||||
-ms-flex-order: -1;
|
||||
order: -1;
|
||||
opacity: 0;
|
||||
width: 0px;
|
||||
-webkit-transition: width 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
height: 3px;
|
||||
transition: width 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-controls-button-panel .shaka-volume-bar-container:hover,
|
||||
.shaka-container .shaka-controls-button-panel .shaka-volume-bar-container:focus {
|
||||
display: block;
|
||||
width: 50px;
|
||||
opacity: 1;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-mute-button:hover+div {
|
||||
opacity: 1;
|
||||
width: 50px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-current-time {
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container {
|
||||
height: 3px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container .shaka-range-element {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container:hover {
|
||||
height: 5px;
|
||||
top: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container:hover .shaka-range-element {
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container input[type=range]::-webkit-slider-thumb {
|
||||
background: #FF0000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container input[type=range]::-moz-range-thumb {
|
||||
background: #FF0000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-seek-bar-container input[type=range]::-ms-thumb {
|
||||
background: #FF0000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-video-container * {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-video-container .material-icons-round {
|
||||
font-family: 'Material Icons Sharp';
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu,
|
||||
.shaka-container .shaka-settings-menu {
|
||||
border-radius: 2px;
|
||||
background: rgba(37, 37, 37, 0.9);
|
||||
text-shadow: 0 0 2px rgb(0 0 0%);
|
||||
-webkit-transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
animation: fade 0.3s;
|
||||
-webkit-user-select: none;
|
||||
right: 10px;
|
||||
bottom: 50px;
|
||||
padding: 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu {
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button span {
|
||||
margin-left: 33px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button[aria-selected="true"] {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button[aria-selected="true"] span {
|
||||
-webkit-box-ordinal-group: 3;
|
||||
-ms-flex-order: 2;
|
||||
order: 2;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-settings-menu button[aria-selected="true"] i {
|
||||
-webkit-box-ordinal-group: 2;
|
||||
-ms-flex-order: 1;
|
||||
order: 1;
|
||||
font-size: 18px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button i {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button .shaka-overflow-button-label {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-pack: justify;
|
||||
-ms-flex-pack: justify;
|
||||
justify-content: space-between;
|
||||
-webkit-box-orient: horizontal;
|
||||
-webkit-box-direction: normal;
|
||||
-ms-flex-direction: row;
|
||||
flex-direction: row;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
height: 40px;
|
||||
-webkit-box-flex: 0;
|
||||
-ms-flex: 0 0 100%;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button .shaka-overflow-button-label span {
|
||||
-ms-flex-negative: initial;
|
||||
flex-shrink: initial;
|
||||
padding-left: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu span+span {
|
||||
color: #FFF;
|
||||
font-weight: 400 !important;
|
||||
font-size: 12px !important;
|
||||
padding-right: 8px;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu span+span:after {
|
||||
content: "navigate_next";
|
||||
font-family: 'Material Icons Sharp';
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu .shaka-pip-button span+span {
|
||||
padding-right: 15px !important;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu .shaka-pip-button span+span:after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.shaka-container .shaka-back-to-overflow-button {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
font-size: 12px;
|
||||
color: #eee;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-back-to-overflow-button .material-icons-round {
|
||||
font-size: 15px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-back-to-overflow-button span {
|
||||
margin-left: 3px !important;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button:hover,
|
||||
.shaka-container .shaka-settings-menu button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button:hover label,
|
||||
.shaka-container .shaka-settings-menu button:hover label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button,
|
||||
.shaka-container .shaka-settings-menu button {
|
||||
color: #EEE;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-captions-off {
|
||||
color: #BFBFBF;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu-button {
|
||||
font-size: 18px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-fullscreen-button:hover {
|
||||
font-size: 25px;
|
||||
-webkit-transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu,
|
||||
.shaka-container .shaka-settings-menu {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
||||
.shaka-container .shaka-overflow-menu,
|
||||
.shaka-container .shaka-settings-menu {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu span+span,
|
||||
.shaka-container .shaka-overflow-menu button,
|
||||
.shaka-container .shaka-settings-menu button {
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.shaka-container .shaka-controls-button-panel {
|
||||
-webkit-box-ordinal-group: 3;
|
||||
-ms-flex-order: 2;
|
||||
order: 2;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.shaka-container .shaka-scrim-container {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-range-container {
|
||||
margin: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-mute-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu,
|
||||
.shaka-container .shaka-settings-menu {
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 80%;
|
||||
margin: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button,
|
||||
.shaka-container .shaka-settings-menu button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.shaka-container .shaka-overflow-menu button span,
|
||||
.shaka-container .shaka-settings-menu button span {
|
||||
margin-left: 0;
|
||||
padding-left: 15px;
|
||||
}
|
||||
}
|
||||
135
examples/browser/web/src/assets/style.css
Normal file
135
examples/browser/web/src/assets/style.css
Normal file
@@ -0,0 +1,135 @@
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: #202020;
|
||||
color: rgb(255, 255, 255);
|
||||
line-height: 1.6;
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
background-color: rgb(68, 68, 68);
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0.5rem 0;
|
||||
display: none;
|
||||
border-radius: 0.3rem;
|
||||
background-color: rgb(68, 68, 68);
|
||||
}
|
||||
|
||||
form input {
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
form input[type="text"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
form input[type="text"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
form input[type="submit"] {
|
||||
color: rgb(255, 255, 255);
|
||||
background-color: rgba(0, 0, 0, 0.244);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: #ffffff;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
#loader {
|
||||
display: block;
|
||||
border: 10px solid rgb(68, 68, 68);
|
||||
border-top: 10px solid rgb(255, 255, 255);
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
align-self: center;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#shaka-container {
|
||||
height: 40vw;
|
||||
}
|
||||
|
||||
#video-container {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 70vw !important;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
#metadata {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-self: left;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
#metadata>#metadata-item {
|
||||
margin: 0 0.3rem;
|
||||
background-color: #ffffff;
|
||||
color: rgba(0, 0, 0, 0.757);
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
#video-container>#description {
|
||||
align-self: left;
|
||||
margin-left: 0.5rem;
|
||||
font-size: medium;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
video {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#shaka-container {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#video-container {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
281
examples/browser/web/src/main.ts
Normal file
281
examples/browser/web/src/main.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { Innertube, UniversalCache } from '../../../../bundle/browser';
|
||||
|
||||
// @ts-ignore - Shaka's TS support is not the best.
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui.js';
|
||||
|
||||
import "shaka-player/dist/controls.css";
|
||||
|
||||
const title = document.getElementById('title') as HTMLHeadingElement;
|
||||
const description = document.getElementById('description') as HTMLDivElement;
|
||||
const metadata = document.getElementById('metadata') as HTMLDivElement;
|
||||
const loader = document.getElementById('loader') as HTMLDivElement;
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
|
||||
async function main() {
|
||||
const yt = await Innertube.create({
|
||||
generate_session_locally: true,
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
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]));
|
||||
|
||||
if (input instanceof Request) {
|
||||
// @ts-ignore
|
||||
input.duplex = 'half';
|
||||
}
|
||||
|
||||
// Copy over the request.
|
||||
const request = new Request(
|
||||
url,
|
||||
input instanceof Request ? input : undefined,
|
||||
);
|
||||
|
||||
headers.delete('user-agent');
|
||||
|
||||
return fetch(request, init ? {
|
||||
...init,
|
||||
headers
|
||||
} : {
|
||||
headers
|
||||
});
|
||||
},
|
||||
cache: new UniversalCache(false),
|
||||
});
|
||||
|
||||
form.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
|
||||
form.style.display = 'block';
|
||||
|
||||
showUI({ hidePlayer: true });
|
||||
|
||||
let player: shaka.Player | undefined;
|
||||
let ui: shaka.ui.Overlay | undefined;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (player) {
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
hideUI();
|
||||
|
||||
let videoId;
|
||||
|
||||
const videoIdOrURL = document.querySelector<HTMLInputElement>('input[type=text]')?.value;
|
||||
|
||||
if (!videoIdOrURL) {
|
||||
title.textContent = 'No video id or URL provided';
|
||||
showUI({ hidePlayer: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (videoIdOrURL.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/)) {
|
||||
const endpoint = await yt.resolveURL(videoIdOrURL);
|
||||
|
||||
if (!endpoint.payload.videoId) {
|
||||
title.textContent = 'Could not resolve URL';
|
||||
showUI({ hidePlayer: true });
|
||||
return;
|
||||
}
|
||||
|
||||
videoId = endpoint.payload.videoId;
|
||||
} else {
|
||||
videoId = videoIdOrURL;
|
||||
}
|
||||
|
||||
const info = await yt.getInfo(videoId);
|
||||
|
||||
title.textContent = info.basic_info.title || null;
|
||||
description.innerHTML = info.secondary_info?.description.toHTML() || '';
|
||||
title.textContent = info.basic_info.title || null;
|
||||
|
||||
document.title = info.basic_info.title || '';
|
||||
|
||||
metadata.innerHTML = '';
|
||||
metadata.innerHTML += `<div id="metadata-item">${info.primary_info?.published.toHTML()}</div>`;
|
||||
metadata.innerHTML += `<div id="metadata-item">${info.primary_info?.view_count.toHTML()}</div>`;
|
||||
metadata.innerHTML += `<div id="metadata-item">${info.basic_info.like_count} likes</div>`;
|
||||
|
||||
showUI({ hidePlayer: false });
|
||||
|
||||
const dash = await info.toDash();
|
||||
|
||||
const uri = 'data:application/dash+xml;charset=utf-8;base64,' + btoa(dash);
|
||||
|
||||
if (player) {
|
||||
await player.destroy();
|
||||
player = undefined;
|
||||
}
|
||||
|
||||
if (ui) {
|
||||
ui.destroy();
|
||||
ui = undefined;
|
||||
}
|
||||
|
||||
const videoEl = document.getElementById('videoel') as HTMLVideoElement;
|
||||
const shakaContainer = document.getElementById('shaka-container') as HTMLDivElement;
|
||||
|
||||
shakaContainer
|
||||
.querySelectorAll("div")
|
||||
.forEach(node => node.remove());
|
||||
|
||||
shaka.polyfill.installAll();
|
||||
|
||||
if (shaka.Player.isBrowserSupported()) {
|
||||
videoEl.poster = info.basic_info.thumbnail![0].url;
|
||||
|
||||
player = new shaka.Player(videoEl);
|
||||
ui = new shaka.ui.Overlay(player, shakaContainer, videoEl);
|
||||
|
||||
const config = {
|
||||
seekBarColors: {
|
||||
base: 'rgba(255,255,255,.2)',
|
||||
buffered: 'rgba(255,255,255,.4)',
|
||||
played: 'rgb(255,0,0)',
|
||||
},
|
||||
fadeDelay: 0,
|
||||
};
|
||||
|
||||
ui.configure(config);
|
||||
|
||||
const overflowMenuButton = document.querySelector('.shaka-overflow-menu-button');
|
||||
if (overflowMenuButton) {
|
||||
overflowMenuButton.innerHTML = 'settings';
|
||||
}
|
||||
|
||||
const backToOverflowButton = document.querySelector('.shaka-back-to-overflow-button .material-icons-round');
|
||||
if (backToOverflowButton) {
|
||||
backToOverflowButton.innerHTML = 'arrow_back_ios_new';
|
||||
}
|
||||
|
||||
player.configure({
|
||||
streaming: {
|
||||
bufferingGoal: 180,
|
||||
rebufferingGoal: 0.02,
|
||||
bufferBehind: 300
|
||||
}
|
||||
});
|
||||
|
||||
player.getNetworkingEngine()?.registerRequestFilter((_type: any, request: any) => {
|
||||
const uri = request.uris[0];
|
||||
const url = new URL(uri);
|
||||
const headers = request.headers;
|
||||
|
||||
if (url.host.endsWith(".googlevideo.com") || headers.Range) {
|
||||
url.searchParams.set('__host', url.host);
|
||||
url.host = 'localhost:8080';
|
||||
url.protocol = 'http';
|
||||
}
|
||||
|
||||
request.method = 'POST';
|
||||
|
||||
// protobuf - { 15: 0 }
|
||||
request.body = new Uint8Array([120, 0]);
|
||||
|
||||
if (url.pathname === "/videoplayback") {
|
||||
if (headers.Range) {
|
||||
request.headers = {};
|
||||
url.searchParams.set("range", headers.Range.split("=")[1]);
|
||||
url.searchParams.set("alr", "yes");
|
||||
}
|
||||
}
|
||||
|
||||
request.uris[0] = url.toString();
|
||||
});
|
||||
|
||||
// The UTF-8 characters "h", "t", "t", and "p".
|
||||
const HTTP_IN_HEX = 0x68747470;
|
||||
|
||||
const RequestType = shaka.net.NetworkingEngine.RequestType;
|
||||
|
||||
player.getNetworkingEngine()?.registerResponseFilter(async (type: any, response: any) => {
|
||||
const dataView = new DataView(response.data);
|
||||
|
||||
if (response.data.byteLength < 4 ||
|
||||
dataView.getUint32(0) != HTTP_IN_HEX) {
|
||||
// This doesn't start with "http", so it is not an ALR.
|
||||
return;
|
||||
}
|
||||
|
||||
// Interpret the response data as a URL string.
|
||||
const response_as_string = shaka.util.StringUtils.fromUTF8(response.data);
|
||||
|
||||
let retry_parameters;
|
||||
|
||||
if (type == RequestType.MANIFEST) {
|
||||
retry_parameters = player!.getConfiguration().manifest.retryParameters;
|
||||
} else if (type == RequestType.SEGMENT) {
|
||||
retry_parameters = player!.getConfiguration().streaming.retryParameters;
|
||||
} else if (type == RequestType.LICENSE) {
|
||||
retry_parameters = player!.getConfiguration().drm.retryParameters;
|
||||
} else {
|
||||
retry_parameters = shaka.net.NetworkingEngine.defaultRetryParameters();
|
||||
}
|
||||
|
||||
// Make another request for the redirect URL.
|
||||
const uris = [response_as_string];
|
||||
const redirect_request = shaka.net.NetworkingEngine.makeRequest(uris, retry_parameters);
|
||||
const request_operation = player!.getNetworkingEngine()!.request(type, redirect_request);
|
||||
const redirect_response = await request_operation.promise;
|
||||
|
||||
// Modify the original response to contain the results of the redirect
|
||||
// response.
|
||||
response.data = redirect_response.data;
|
||||
response.headers = redirect_response.headers;
|
||||
response.uri = redirect_response.uri;
|
||||
});
|
||||
|
||||
try {
|
||||
await player.load(uri);
|
||||
} catch (e) {
|
||||
console.error('Could not load manifest', e);
|
||||
}
|
||||
} else {
|
||||
console.error('Browser not supported!');
|
||||
}
|
||||
} catch (error) {
|
||||
title.textContent = 'An error occurred (see console)';
|
||||
showUI({ hidePlayer: true });
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showUI(args: { hidePlayer?: boolean } = {
|
||||
hidePlayer: true,
|
||||
}) {
|
||||
const ytplayer = document.getElementById('shaka-container') as HTMLDivElement;
|
||||
|
||||
ytplayer.style.display = args.hidePlayer ? 'none' : 'block';
|
||||
|
||||
const video_container = document.getElementById('video-container') as HTMLDivElement;
|
||||
video_container.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
|
||||
video_container.style.display = 'block';
|
||||
|
||||
loader.style.display = 'none';
|
||||
}
|
||||
|
||||
function hideUI() {
|
||||
const video_container = document.getElementById('video-container') as HTMLDivElement;
|
||||
video_container.style.display = 'none';
|
||||
loader.style.display = 'block';
|
||||
}
|
||||
|
||||
main();
|
||||
1
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(false), generate_session_locally: true });
|
||||
|
||||
const channel = await yt.getChannel('UCX6OQ3DkcsbYNE6H8uQQuVA');
|
||||
|
||||
if (channel.header?.is(YTNodes.C4TabbedHeader)) {
|
||||
console.info('Viewing channel:', channel?.header?.author.name);
|
||||
console.info('Family Safe:', channel.metadata.is_family_safe);
|
||||
}
|
||||
|
||||
const about = await channel.getAbout();
|
||||
|
||||
console.info('Country:', about.country.toString());
|
||||
|
||||
console.info('\nVideos:');
|
||||
const videos = await channel.getVideos();
|
||||
|
||||
for (const video of videos.videos) {
|
||||
console.info('Video:', video.title.toString());
|
||||
}
|
||||
|
||||
console.info('\nPopular videos:');
|
||||
const popular_videos = await videos.applyFilter('Popular');
|
||||
for (const video of popular_videos.videos) {
|
||||
console.info('Video:', video.title.toString());
|
||||
}
|
||||
|
||||
console.info('\nPlaylists:');
|
||||
const playlists = await channel.getPlaylists();
|
||||
|
||||
for (const playlist of playlists.playlists) {
|
||||
console.info('Playlist:', playlist.title.toString());
|
||||
}
|
||||
|
||||
console.info('\nChannels:');
|
||||
const channels = await channel.getChannels();
|
||||
|
||||
for (const channel of channels.channels) {
|
||||
console.info('Channel:', channel.author.name);
|
||||
}
|
||||
|
||||
console.info('\nCommunity posts:');
|
||||
const posts = await channel.getCommunity();
|
||||
|
||||
for (const post of posts.posts) {
|
||||
console.info('Post:', post.content.toString().substring(0, 20) + '...');
|
||||
}
|
||||
})();
|
||||
34
examples/comments/Comment.md
Normal file
34
examples/comments/Comment.md
Normal file
@@ -0,0 +1,34 @@
|
||||
## Comment
|
||||
Contains information about a single comment. A [`Comment`](../../src/parser/classes/comments/Comment.ts) can be a top-level comment or a reply to a top-level comment.
|
||||
|
||||
## API
|
||||
|
||||
* Comment
|
||||
* [.like](#like) ⇒ `function`
|
||||
* [.dislike](#dislike) ⇒ `function`
|
||||
* [.reply](#comment) ⇒ `function`
|
||||
* [.translate](#translate) ⇒ `function`
|
||||
|
||||
<a name="like"></a>
|
||||
### like()
|
||||
Likes the comment.
|
||||
|
||||
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object }>`
|
||||
|
||||
<a name="dislike"></a>
|
||||
### dislike()
|
||||
Dislikes the comment.
|
||||
|
||||
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object }>`
|
||||
|
||||
<a name="reply"></a>
|
||||
### reply(text)
|
||||
Creates a reply to the comment. **Note:** To create a top-level comment, use the [`Comments#comment(text)`](./README.md#comment) method.
|
||||
|
||||
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object }>`
|
||||
|
||||
<a name="translate"></a>
|
||||
### translate(target_language)
|
||||
Translates the comment to the given language.
|
||||
|
||||
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object, content: string }>`
|
||||
50
examples/comments/CommentThread.md
Normal file
50
examples/comments/CommentThread.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# CommentThread
|
||||
|
||||
A `CommentThread` represents a top-level comment and its replies.
|
||||
|
||||
## API
|
||||
|
||||
* CommentThread
|
||||
* [.comment](#comment) ⇒ `Comment`
|
||||
* [.replies](#replies) ⇒ `Comment[]`
|
||||
* [.getReplies](#getreplies) ⇒ `function`
|
||||
* [.getContinuation](#getcontinuation) ⇒ `function`
|
||||
* [.has_continuation](#hascontinuation) ⇒ `boolean`
|
||||
* [.has_replies](#hasreplies) ⇒ `boolean`
|
||||
|
||||
<a name="comment"></a>
|
||||
### comment
|
||||
The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
|
||||
|
||||
**Type:** [`Comment`](../../src/parser/classes/comments/Comment.ts)
|
||||
|
||||
<a name="replies"></a>
|
||||
### replies
|
||||
An array of replies to the top-level comment. (not populated until [`getReplies()`](#getreplies) is called).
|
||||
|
||||
**Type:** [`Comment[]`](../../src/parser/classes/comments/Comment.ts)
|
||||
|
||||
<a name="getreplies"></a>
|
||||
### getReplies()
|
||||
Retrieves replies to the top-level comment and attaches a [`replies`](#replies) array to the original `CommentThread` object and returns it.
|
||||
|
||||
**Returns:** [`Promise.<CommentThread>`](../../src/parser/classes/comments/CommentThread.ts)
|
||||
|
||||
<a name="getcontinuation"></a>
|
||||
### getContinuation()
|
||||
Retrieves next batch of replies and adds them to the [`replies`](#replies) array. **Note:** [`getReplies()`](#getreplies) must be called before using this.
|
||||
|
||||
**Returns:** [`Promise.<CommentThread>`](../../src/parser/classes/comments/CommentThread.ts)
|
||||
|
||||
<a name="hascontinuation"></a>
|
||||
### has_continuation
|
||||
Whether there are more replies to be retrieved.
|
||||
|
||||
**Type:** `boolean`
|
||||
|
||||
<a name="hasreplies"></a>
|
||||
### has_replies
|
||||
|
||||
Whether there are replies to the top-level comment.
|
||||
|
||||
**Type:** `boolean`
|
||||
65
examples/comments/README.md
Normal file
65
examples/comments/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
## Comments
|
||||
YouTube.js has full support for comments, including comment actions such as translating, liking, disliking and replying.
|
||||
|
||||
## Usage
|
||||
Get a [`Comments`](../../src/parser/youtube/Comments.ts) instance:
|
||||
|
||||
```js
|
||||
const comments = await yt.getComments(VIDEO_ID);
|
||||
```
|
||||
|
||||
## API
|
||||
* Comments
|
||||
* [.contents](#commentthread) ⇒ `CommentThread[]`
|
||||
* [.applySort](#applysort) ⇒ `function`
|
||||
* [.createComment](#createComment) ⇒ `function`
|
||||
* [.getContinuation](#getc) ⇒ `function`
|
||||
* [.has_continuation](#has_continuation) ⇒ `getter`
|
||||
* [.page](#page) ⇒ `getter`
|
||||
|
||||
<a name="commentthread"></a>
|
||||
### contents
|
||||
A list of comment threads. **Note:** More about comment threads [**here**](./CommentThread.md).
|
||||
|
||||
**Type:** [`CommentThread[]`](../../src/parser/classes/comments/CommentThread.ts)
|
||||
|
||||
<a name="applysort"></a>
|
||||
### applySort(sort)
|
||||
Applies given sort option to the comments.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| sort | `string` | Sort option. Can be `TOP_COMMENTS`, `NEWEST_FIRST` |
|
||||
|
||||
**Returns:** [`Promise.<Comments>`](../../src/parser/youtube/Comments.ts)
|
||||
|
||||
<a name="createComment"></a>
|
||||
### createComment(text)
|
||||
Creates a top-level comment.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| text | `string` | Comment content |
|
||||
|
||||
**Returns:** `Promise<ActionsResponse>`
|
||||
|
||||
<a name="getc"></a>
|
||||
### getContinuation()
|
||||
Retrieves next batch of comment threads.
|
||||
|
||||
**Returns:** [`Promise.<Comments>`](../../src/parser/youtube/Comments.ts)
|
||||
|
||||
<a name="has_continuation"></a>
|
||||
### has_continuation
|
||||
Returns whether there are more comments to be fetched.
|
||||
|
||||
**Type:** `boolean`
|
||||
|
||||
<a name="page"></a>
|
||||
### page
|
||||
Returns original InnerTube response (sanitized).
|
||||
|
||||
**Returns:** `ParsedResponse`
|
||||
|
||||
## Example
|
||||
See [`index.ts`]('./index.ts').
|
||||
45
examples/comments/index.ts
Normal file
45
examples/comments/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Innertube, UniversalCache } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
|
||||
|
||||
const comment_section = await yt.getComments('a-rqu-hjobc');
|
||||
|
||||
console.info(`This video has ${comment_section.header?.comments_count.toString() || 'N/A'} comments.\n`);
|
||||
|
||||
for (const thread of comment_section.contents) {
|
||||
const comment = thread.comment;
|
||||
|
||||
if (comment) {
|
||||
console.info(
|
||||
`${comment.is_pinned ? '[Pinned]' : ''}`,
|
||||
`${comment.is_member ? `${comment.sponsor_comment_badge?.tooltip}` : ''}`,
|
||||
`${comment.author.name} • ${comment.published}\n`,
|
||||
`${comment.content.toString()}`, '\n',
|
||||
`Likes: ${comment.vote_count}`, '\n'
|
||||
);
|
||||
|
||||
if (thread.has_replies) {
|
||||
console.info('Replies:', '\n');
|
||||
|
||||
let comment_thread = await thread.getReplies();
|
||||
|
||||
while (true) {
|
||||
for (const reply of comment_thread?.replies || []) {
|
||||
console.info(
|
||||
`> ${reply.author.name} • ${reply.published}\n`,
|
||||
`${reply.content.toString()}`, '\n',
|
||||
`Likes: ${reply.vote_count}`, '\n'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
comment_thread = await comment_thread.getContinuation();
|
||||
} catch { break; };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
}
|
||||
})();
|
||||
7
examples/deno/README.md
Normal file
7
examples/deno/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Deno example
|
||||
|
||||
Run this example with:
|
||||
|
||||
```
|
||||
deno run --allow-net --allow-write index.ts
|
||||
```
|
||||
16
examples/deno/index.ts
Normal file
16
examples/deno/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Innertube } from 'https://deno.land/x/youtubei/deno.ts';
|
||||
|
||||
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);
|
||||
44
examples/download/index.ts
Normal file
44
examples/download/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Innertube, UniversalCache, Utils } from 'youtubei.js';
|
||||
import { existsSync, mkdirSync, createWriteStream } from 'fs';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
|
||||
|
||||
const search = await yt.music.search('No Copyright Background Music', { type: 'album' });
|
||||
|
||||
if (!search.results)
|
||||
throw new Error('Filter "type" must be used');
|
||||
|
||||
const album = await yt.music.getAlbum(search.results[0].id as string);
|
||||
|
||||
if (!album.contents)
|
||||
throw new Error('Album appears to be empty');
|
||||
|
||||
console.info(`Album "${album.header?.title.toString()}" by ${album.header?.author?.name}`, '\n');
|
||||
|
||||
for (const song of album.contents) {
|
||||
const stream = await yt.download(song.id as string, {
|
||||
type: 'audio', // audio, video or video+audio
|
||||
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
|
||||
format: 'mp4' // media container format
|
||||
});
|
||||
|
||||
console.info(`Downloading ${song.title} (${song.id})`);
|
||||
|
||||
const dir = `./${album.header?.title.toString()}`;
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir);
|
||||
}
|
||||
|
||||
const file = createWriteStream(`${dir}/${song.title?.replace(/\//g, '')}.m4a`);
|
||||
|
||||
for await (const chunk of Utils.streamToIterable(stream)) {
|
||||
file.write(chunk);
|
||||
}
|
||||
|
||||
console.info(`${song.id} - Done!`, '\n');
|
||||
}
|
||||
|
||||
console.info(`Downloaded ${album.header?.song_count}!`);
|
||||
})();
|
||||
@@ -1,92 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
const creds_path = './yt_oauth_creds.json';
|
||||
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
|
||||
youtube.ev.on('auth', (data) => {
|
||||
if (data.status === 'AUTHORIZATION_PENDING') {
|
||||
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
|
||||
} else if (data.status === 'SUCCESS') {
|
||||
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Successfully signed-in, enjoy!');
|
||||
}
|
||||
});
|
||||
|
||||
youtube.ev.on('update-credentials', (data) => {
|
||||
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Credentials updated!', data);
|
||||
});
|
||||
|
||||
await youtube.signIn(creds);
|
||||
|
||||
const search = await youtube.search('Looking for life on Mars - documentary');
|
||||
console.info('Search results:', search);
|
||||
|
||||
const video = await youtube.getDetails(search.videos[0].id);
|
||||
console.info('Video details:', video);
|
||||
|
||||
if (youtube.logged_in) {
|
||||
const myNotifications = await youtube.getNotifications();
|
||||
console.info('My notifications:', myNotifications);
|
||||
|
||||
const like = await video.like();
|
||||
if (like.success) {
|
||||
console.info('Video marked as liked!');
|
||||
}
|
||||
|
||||
const dislike = await video.dislike();
|
||||
if (dislike.success) {
|
||||
console.info('Video marked as disliked!');
|
||||
}
|
||||
|
||||
const removeDislikeOrLike = await video.removeLike();
|
||||
if (removeDislikeOrLike.success) {
|
||||
console.info('Removed the dislike/like!')
|
||||
}
|
||||
|
||||
const myComment = await video.comment('Haha, nice!');
|
||||
if (myComment.success) {
|
||||
console.info('Comment successfully posted!')
|
||||
}
|
||||
|
||||
const subscribe = await video.subscribe();
|
||||
if (subscribe.success) {
|
||||
console.info('Just subscribed to', video.metadata.channel_name + '!');
|
||||
}
|
||||
|
||||
const unsubscribe = await video.unsubscribe();
|
||||
if (unsubscribe.success) {
|
||||
console.info('Just unsubscribed from', video.metadata.channel_name + ' :(');
|
||||
}
|
||||
}
|
||||
|
||||
// Downloading videos:
|
||||
const stream = youtube.download(search.videos[0].id, {
|
||||
format: 'mp4', // Optional, ignored when type is set to audio and defaults to mp4, and I recommend to leave it as it is
|
||||
quality: '360p', // if a video doesn't have a specific quality it'll fall back to 360p, this is ignored when type is set to audio
|
||||
type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
|
||||
});
|
||||
|
||||
stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
|
||||
|
||||
stream.on('start', () => {
|
||||
console.info('[DOWNLOADER]', 'Starting download now!');
|
||||
});
|
||||
|
||||
stream.on('info', (info) => {
|
||||
console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
console.info('[DOWNLOADER]', 'Done!');
|
||||
});
|
||||
|
||||
stream.on('error', (err) => console.error('[ERROR]', err));
|
||||
}
|
||||
|
||||
start();
|
||||
112
examples/livechat/README.md
Normal file
112
examples/livechat/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
## Live Chat
|
||||
|
||||
Represents a livestream chat.
|
||||
|
||||
## Usage
|
||||
|
||||
Before fetching a live chat, you have to retrieve the target livestream's info:
|
||||
|
||||
```js
|
||||
const info = await yt.getInfo('video_id');
|
||||
```
|
||||
|
||||
Then you may request a live chat instance:
|
||||
```js
|
||||
const livechat = await info.getLiveChat();
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
* LiveChat
|
||||
* [.ev](#ev) ⇒ `EventEmitter`
|
||||
* [.start](#start) ⇒ `function`
|
||||
* [.stop](#stop) ⇒ `function`
|
||||
* [.applyFilter](#applyfilter) ⇒ `function`
|
||||
* [.getItemMenu](#getitemmenu) ⇒ `function`
|
||||
* [.sendMessage](#sendmessage) ⇒ `function`
|
||||
|
||||
<a name="ev"></a>
|
||||
### ev
|
||||
Live Chat's EventEmitter.
|
||||
|
||||
**Events:**
|
||||
|
||||
- `start`
|
||||
|
||||
Fired when the live chat is started.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `LiveChatContinuation` | Initial chat data, actions, info, etc. |
|
||||
|
||||
- `chat-update`
|
||||
|
||||
Fired when a new chat action is received.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `ChatAction` | Chat action |
|
||||
|
||||
- `metadata-update`
|
||||
|
||||
Fired when the livestream's metadata is updated.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `LiveMetadata` | Livestream metadata |
|
||||
|
||||
- `error`
|
||||
|
||||
Fired when an error occurs.
|
||||
|
||||
Arguments:
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `Error` | Details about the error |
|
||||
|
||||
- `end`
|
||||
|
||||
Fired when the livestream ends.
|
||||
|
||||
<a name="start"></a>
|
||||
### start()
|
||||
Starts the Live Chat.
|
||||
|
||||
<a name="stop"></a>
|
||||
### stop()
|
||||
Stops the Live Chat.
|
||||
|
||||
<a name="applyfilter"></a>
|
||||
### applyFilter(filter)
|
||||
|
||||
Applies given filter to the live chat.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| filter | `string` | Can be `TOP_CHAT` or `LIVE_CHAT` |
|
||||
|
||||
<a name="getitemmenu"></a>
|
||||
### getItemMenu(item)
|
||||
Retrieves given chat item's menu.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| item | `object` | Chat item |
|
||||
|
||||
**Returns:** `Promise<ItemMenu>`
|
||||
|
||||
<a name="sendmessage"></a>
|
||||
### sendMessage(text)
|
||||
Sends a message.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| text | `string` | Message content |
|
||||
|
||||
**Returns:** `Promise<ObservedArray<AddChatItemAction>>`
|
||||
|
||||
## Example
|
||||
See [`index.ts`]('./index.ts').
|
||||
106
examples/livechat/index.ts
Normal file
106
examples/livechat/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Innertube, UniversalCache, YTNodes, LiveChatContinuation } from 'youtubei.js';
|
||||
import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
|
||||
|
||||
const search = await yt.search('lofi hip hop radio - beats to relax/study to');
|
||||
const info = await yt.getInfo(search.videos[0].as(YTNodes.Video).id);
|
||||
|
||||
const livechat = info.getLiveChat();
|
||||
|
||||
livechat.on('start', (initial_data: LiveChatContinuation) => {
|
||||
/**
|
||||
* Initial info is what you see when you first open a a live chat — this is; initial actions (pinned messages, top donations..), account's info and so forth.
|
||||
*/
|
||||
console.info(`Hey ${initial_data.viewer_name || 'Guest'}, welcome to Live Chat!`);
|
||||
|
||||
const pinned_action = initial_data.actions.firstOfType(YTNodes.AddBannerToLiveChatCommand);
|
||||
|
||||
if (pinned_action) {
|
||||
if (pinned_action.banner?.contents?.is(YTNodes.LiveChatTextMessage)) {
|
||||
console.info(
|
||||
'\n', 'Pinned message:\n',
|
||||
pinned_action.banner.contents.author?.name.toString(), '-', pinned_action?.banner.contents.message.toString(),
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
livechat.on('error', (error: Error) => console.info('Live chat error:', error));
|
||||
|
||||
livechat.on('end', () => console.info('This live stream has ended.'));
|
||||
|
||||
livechat.on('chat-update', (action: ChatAction) => {
|
||||
/**
|
||||
* An action represents what is being added to
|
||||
* the live chat. All actions have a `type` property,
|
||||
* including their item (if the action has an item).
|
||||
*
|
||||
* Below are a few examples of how this can be used.
|
||||
*/
|
||||
|
||||
if (action.is(YTNodes.AddChatItemAction)) {
|
||||
const item = action.as(YTNodes.AddChatItemAction).item;
|
||||
|
||||
if (!item)
|
||||
return console.info('Action did not have an item.', action);
|
||||
|
||||
const hours = new Date(item.hasKey('timestamp') ? item.timestamp : Date.now()).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
switch (item.type) {
|
||||
case 'LiveChatTextMessage':
|
||||
console.info(
|
||||
`${item.as(YTNodes.LiveChatTextMessage).author?.is_moderator ? '[MOD]' : ''}`,
|
||||
`${hours} - ${item.as(YTNodes.LiveChatTextMessage).author?.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatTextMessage).message.toString()}\n`
|
||||
);
|
||||
break;
|
||||
case 'LiveChatPaidMessage':
|
||||
console.info(
|
||||
`${item.as(YTNodes.LiveChatPaidMessage).author?.is_moderator ? '[MOD]' : ''}`,
|
||||
`${hours} - ${item.as(YTNodes.LiveChatPaidMessage).author.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatPaidMessage).message.toString()}\n`,
|
||||
`${item.as(YTNodes.LiveChatPaidMessage).purchase_amount}\n`
|
||||
);
|
||||
break;
|
||||
case 'LiveChatPaidSticker':
|
||||
console.info(
|
||||
`${item.as(YTNodes.LiveChatPaidSticker).author?.is_moderator ? '[MOD]' : ''}`,
|
||||
`${hours} - ${item.as(YTNodes.LiveChatPaidSticker).author.name.toString()}:\n` +
|
||||
`${item.as(YTNodes.LiveChatPaidSticker).purchase_amount}\n`
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.debug(action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.is(YTNodes.AddBannerToLiveChatCommand)) {
|
||||
console.info('Message pinned:', action.banner?.contents);
|
||||
}
|
||||
|
||||
if (action.is(YTNodes.RemoveBannerForLiveChatCommand)) {
|
||||
console.info(`Message with action id ${action.target_action_id} was unpinned.`);
|
||||
}
|
||||
|
||||
if (action.is(YTNodes.RemoveChatItemAction)) {
|
||||
console.warn(`Message with action id ${action.target_item_id} just got deleted!`, '\n');
|
||||
}
|
||||
});
|
||||
|
||||
livechat.on('metadata-update', (metadata: LiveMetadata) => {
|
||||
console.info(`
|
||||
VIEWS: ${metadata.views?.view_count.toString()}
|
||||
LIKES: ${metadata.likes?.default_text}
|
||||
DATE: ${metadata.date?.date_text}
|
||||
`);
|
||||
});
|
||||
|
||||
livechat.start();
|
||||
})();
|
||||
1
examples/parser/artist.json
Normal file
1
examples/parser/artist.json
Normal file
File diff suppressed because one or more lines are too long
26
examples/parser/index.ts
Normal file
26
examples/parser/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Parser, YTNodes } from 'youtubei.js';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Artist page response from YouTube Music
|
||||
const data = readFileSync('./artist.json').toString();
|
||||
const page = Parser.parseResponse(JSON.parse(data));
|
||||
|
||||
const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);
|
||||
|
||||
console.info('Header:', header);
|
||||
|
||||
// The parser encapsulates all arrays in a proxy object.
|
||||
// A proxy intercepts access to the actual data, allowing
|
||||
// the parser to add type safety and many utility methods
|
||||
// that make working with InnerTube much easier.
|
||||
const tab = page.contents?.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
|
||||
|
||||
if (!tab)
|
||||
throw new Error('Target tab not found');
|
||||
|
||||
if (!tab.content)
|
||||
throw new Error('Target tab appears to be empty');
|
||||
|
||||
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
|
||||
|
||||
console.info('Sections:', sections);
|
||||
16
examples/transcript/index.ts
Normal file
16
examples/transcript/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Innertube } from 'youtubei.js';
|
||||
|
||||
(async () => {
|
||||
const yt = await Innertube.create({ generate_session_locally: true });
|
||||
|
||||
const info = await yt.getInfo('hePb00CqvP0');
|
||||
|
||||
const defaultTranscriptInfo = await info.getTranscript();
|
||||
|
||||
console.log(`Got ${defaultTranscriptInfo.selectedLanguage} transcript with ${defaultTranscriptInfo.transcript.content.body.initial_segments.length} lines.`);
|
||||
|
||||
console.log("Fetching Hebrew transcript...");
|
||||
|
||||
const heTranscriptInfo = await defaultTranscriptInfo.selectLanguage('Hebrew');
|
||||
console.log(`Got ${heTranscriptInfo.selectedLanguage} transcript with ${heTranscriptInfo.transcript.content.body.initial_segments.length} lines.`);
|
||||
})();
|
||||
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(false) });
|
||||
|
||||
yt.session.on('auth-pending', (data: any) => {
|
||||
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.user_code}`);
|
||||
});
|
||||
|
||||
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!', upload);
|
||||
})();
|
||||
17
jest.config.js
Normal file
17
jest.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
|
||||
export default {
|
||||
projects: [
|
||||
{
|
||||
displayName: 'node',
|
||||
roots: [ '<rootDir>/test' ],
|
||||
testTimeout: 10000,
|
||||
transform: {
|
||||
"^.+\\.(ts|tsx)$": "ts-jest",
|
||||
},
|
||||
moduleFileExtensions: ["ts", "tsx", "js"],
|
||||
testMatch: [ '**/*.test.ts' ],
|
||||
setupFiles: []
|
||||
}
|
||||
]
|
||||
};
|
||||
839
lib/Innertube.js
839
lib/Innertube.js
@@ -1,839 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Stream = require('stream');
|
||||
const Parser = require('./parser');
|
||||
const CancelToken = Axios.CancelToken;
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const OAuth = require('./core/OAuth');
|
||||
const Player = require('./core/Player');
|
||||
const Actions = require('./core/Actions');
|
||||
const Livechat = require('./core/Livechat');
|
||||
|
||||
const Utils = require('./utils/Utils');
|
||||
const Request = require('./utils/Request');
|
||||
const Constants = require('./utils/Constants');
|
||||
|
||||
const Proto = require('./proto');
|
||||
const NToken = require('./deciphers/NToken');
|
||||
const Signature = require('./deciphers/Signature');
|
||||
|
||||
class Innertube {
|
||||
#oauth;
|
||||
#player;
|
||||
#retry_count;
|
||||
|
||||
/**
|
||||
* ```js
|
||||
* const Innertube = require('youtubei.js');
|
||||
* const youtube = await new Innertube();
|
||||
* ```
|
||||
* @param {object} [config]
|
||||
* @param {string} [config.gl]
|
||||
* @param {string} [config.cookie]
|
||||
* @returns {Innertube}
|
||||
* @constructor
|
||||
*/
|
||||
constructor(config) {
|
||||
this.config = config || {};
|
||||
this.#retry_count = 0;
|
||||
return this.#init();
|
||||
}
|
||||
|
||||
async #init() {
|
||||
const response = await Axios.get(Constants.URLS.YT_BASE, Constants.DEFAULT_HEADERS(this.config)).catch((error) => error);
|
||||
if (response instanceof Error) throw new Utils.InnertubeError('Could not retrieve Innertube session', { message: response.message, status_code: response.status || 0 });
|
||||
|
||||
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') || ''}}`);
|
||||
if (data.INNERTUBE_CONTEXT) {
|
||||
this.key = data.INNERTUBE_API_KEY;
|
||||
this.version = data.INNERTUBE_API_VERSION;
|
||||
this.context = data.INNERTUBE_CONTEXT;
|
||||
|
||||
this.player_url = data.PLAYER_JS_URL;
|
||||
this.logged_in = data.LOGGED_IN;
|
||||
this.sts = data.STS;
|
||||
|
||||
this.context.client.hl = 'en';
|
||||
this.context.client.gl = this.config.gl || 'US';
|
||||
|
||||
/**
|
||||
* @event Innertube#auth - Fired when signing in to an account.
|
||||
* @event Innertube#update-credentials - Fired when the access token is no longer valid.
|
||||
* @type {EventEmitter}
|
||||
*/
|
||||
this.ev = new EventEmitter();
|
||||
this.#oauth = new OAuth(this.ev);
|
||||
|
||||
this.#player = new Player(this);
|
||||
await this.#player.init();
|
||||
|
||||
if (this.logged_in && this.config.cookie) {
|
||||
this.auth_apisid = Utils.getStringBetweenStrings(this.config.cookie, 'PAPISID=', ';');
|
||||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
|
||||
}
|
||||
|
||||
this.request = new Request(this);
|
||||
|
||||
this.#initMethods();
|
||||
} else {
|
||||
this.#retry_count += 1;
|
||||
if (this.#retry_count >= 10)
|
||||
throw new Utils.ParsingError('No InnerTubeContext shell provided in ytconfig.', {
|
||||
data_snippet: response.data.slice(0, 300),
|
||||
status_code: response.status || 0
|
||||
});
|
||||
return this.#init();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
#initMethods() {
|
||||
this.account = {
|
||||
info: () => this.getAccountInfo(),
|
||||
settings: {
|
||||
notifications: {
|
||||
/**
|
||||
* Notify about activity from the channels you're subscribed to.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setSubscriptions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Recommended content notifications.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setRecommendedVideos: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Notify about activity on your channel.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setChannelActivity: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Notify about replies to your comments.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setCommentReplies: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Notify when others mention your channel.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setMentions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Notify when others share your content on their channels.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setSharedContent: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'account_notifications', new_value)
|
||||
},
|
||||
privacy: {
|
||||
/**
|
||||
* If set to true, your subscriptions won't be visible to others.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setSubscriptionsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'account_privacy', new_value),
|
||||
|
||||
/**
|
||||
* If set to true, saved playlists won't appear on your channel.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setSavedPlaylistsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'account_privacy', new_value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.interact = {
|
||||
/**
|
||||
* Likes a given video.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
like: (video_id) => Actions.engage(this, 'like/like', { video_id }),
|
||||
|
||||
/**
|
||||
* Diskes a given video.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
dislike: (video_id) => Actions.engage(this, 'like/dislike', { video_id }),
|
||||
|
||||
/**
|
||||
* Removes a like/dislike.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
removeLike: (video_id) => Actions.engage(this, 'like/removelike', { video_id }),
|
||||
|
||||
/**
|
||||
* Posts a comment on a given video.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @param {string} text
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
comment: (video_id, text) => Actions.engage(this, 'comment/create_comment', { video_id, text }),
|
||||
|
||||
/**
|
||||
* Subscribes to a given channel.
|
||||
*
|
||||
* @param {string} channel_id
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
subscribe: (channel_id) => Actions.engage(this, 'subscription/subscribe', { channel_id }),
|
||||
|
||||
/**
|
||||
* Unsubscribes from a given channel.
|
||||
*
|
||||
* @param {string} channel_id
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
unsubscribe: (channel_id) => Actions.engage(this, 'subscription/unsubscribe', { channel_id }),
|
||||
|
||||
/**
|
||||
* Changes notification preferences for a given channel.
|
||||
* Only works with channels you are subscribed to.
|
||||
*
|
||||
* @param {string} channel_id
|
||||
* @param {string} type PERSONALIZED | ALL | NONE
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }),
|
||||
};
|
||||
|
||||
this.playlist = {
|
||||
/**
|
||||
* Creates a playlist.
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} video_id - Note that a video must be supplied, empty playlists cannot be created.
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
create: (title, video_id) => Actions.engage(this, 'playlist/create', { title, video_id }),
|
||||
|
||||
/**
|
||||
* Deletes a given playlist.
|
||||
*
|
||||
* @param {string} playlist_id
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
delete: (playlist_id) => Actions.engage(this, 'playlist/delete', { playlist_id }),
|
||||
|
||||
/**
|
||||
* Adds an array of videos to a given playlist.
|
||||
*
|
||||
* @param {string} playlist_id
|
||||
* @param {Array.<string>} video_ids
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
addVideos: (playlist_id, video_ids) => Actions.engage(this, 'browse/edit_playlist', { action: 'ACTION_ADD_VIDEO', playlist_id, video_ids })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to perform changes on an account's settings.
|
||||
*
|
||||
* @param {string} setting_id
|
||||
* @param {string} type
|
||||
* @param {string} new_value
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
*/
|
||||
async #setSetting(setting_id, type, new_value) {
|
||||
const response = await Actions.browse(this, type);
|
||||
if (!response.success) return response;
|
||||
|
||||
const contents = ({
|
||||
account_notifications: () => Utils.findNode(response.data, 'contents', 'Your preferences', 13, false).options,
|
||||
account_privacy: () => Utils.findNode(response.data, 'contents', 'settingsSwitchRenderer', 13, false).options
|
||||
})[type.trim()]();
|
||||
|
||||
const option = contents.find((option) => option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemIdForClient == setting_id);
|
||||
|
||||
const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId;
|
||||
const set_setting = await Actions.account(this, 'account/set_setting', { new_value: type == 'account_privacy' ? !new_value : new_value, setting_item_id });
|
||||
|
||||
return {
|
||||
success: set_setting.success,
|
||||
status_code: set_setting.status_code,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs-in to a google account.
|
||||
*
|
||||
* @param {object} auth_info
|
||||
* @param {string} auth_info.access_token - Token used to sign in.
|
||||
* @param {string} auth_info.refresh_token - Token used to get a new access token.
|
||||
* @param {Date} auth_info.expires - Access token's expiration date, which is usually 24hrs-ish
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
signIn(auth_info = {}) {
|
||||
return new Promise(async (resolve) => {
|
||||
this.#oauth.init(auth_info);
|
||||
|
||||
if (this.#oauth.isValidAuthInfo()) {
|
||||
await this.#oauth.checkTokenValidity();
|
||||
this.#updateCredentials();
|
||||
return resolve();
|
||||
}
|
||||
|
||||
this.ev.on('auth', (data) => {
|
||||
if (data.status === 'SUCCESS') {
|
||||
this.#updateCredentials();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#updateCredentials() {
|
||||
this.access_token = this.#oauth.getAccessToken();
|
||||
this.refresh_token = this.#oauth.getRefreshToken();
|
||||
this.logged_in = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs out of your account.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number }>}
|
||||
*/
|
||||
async signOut() {
|
||||
if (!this.logged_in) throw new Utils.InnertubeError('You are not signed in');
|
||||
const response = await this.#oauth.revokeAccessToken();
|
||||
response.success && (this.logged_in = false);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves account details.
|
||||
* @returns {Promise.<{ name: string; photo: Array<object>; country: string; language: string; }>}
|
||||
*/
|
||||
async getAccountInfo() {
|
||||
const response = await Actions.account(this, 'account/account_menu');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get account info', response);
|
||||
|
||||
const menu = Utils.findNode(response, 'actions', 'multiPageMenuRenderer', 6, false);
|
||||
|
||||
return {
|
||||
name: menu.header.activeAccountHeaderRenderer.accountName.simpleText,
|
||||
photo: menu.header.activeAccountHeaderRenderer.accountPhoto.thumbnails,
|
||||
country: menu.sections[1].multiPageMenuSectionRenderer.items[2].compactLinkRenderer.subtitle.simpleText,
|
||||
language: menu.sections[1].multiPageMenuSectionRenderer.items[1].compactLinkRenderer.subtitle.simpleText
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches on YouTube.
|
||||
*
|
||||
* @param {string} query - Search query.
|
||||
* @param {object} options - Search options.
|
||||
* @param {string} options.client - Client used to perform the search, can be: `YTMUSIC` or `YOUTUBE`.
|
||||
* @param {string} options.period - Filter videos uploaded within a period, can be: any | hour | day | week | month | year
|
||||
* @param {string} options.order - Filter results by order, can be: relevance | rating | age | views
|
||||
* @param {string} options.duration - Filter video results by duration, can be: any | short | long
|
||||
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: [] } |
|
||||
* { results: { songs: []; videos: []; albums: []; community_playlists: [] } }>}
|
||||
*/
|
||||
async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) {
|
||||
const response = await Actions.search(this, options.client, { query, options });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not search on YouTube', response);
|
||||
|
||||
const results = new Parser(this, response.data, {
|
||||
query, client: options.client,
|
||||
data_type: 'SEARCH'
|
||||
}).parse();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves search suggestions.
|
||||
*
|
||||
* @param {string} input - The search query.
|
||||
* @param {object} [options] - Search options.
|
||||
* @param {string} [options.client='YOUTUBE'] - Client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
|
||||
* @returns {Promise.<[{ text: string; bold_text: string }]>}
|
||||
*/
|
||||
async getSearchSuggestions(input, options = { client: 'YOUTUBE' }) {
|
||||
const response = await Actions.getSearchSuggestions(this, options.client, input);
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get search suggestions', response);
|
||||
if (options.client === 'YTMUSIC' && !response.data.contents) return [];
|
||||
|
||||
const suggestions = new Parser(this, response.data, {
|
||||
input, client: options.client,
|
||||
data_type: 'SEARCH_SUGGESTIONS'
|
||||
}).parse();
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves video info.
|
||||
*
|
||||
* @param {string} video_id - Video id
|
||||
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>}
|
||||
*/
|
||||
async getDetails(video_id) {
|
||||
if (!video_id) throw new Utils.MissingParamError('Video id is missing');
|
||||
|
||||
const response = await Actions.getVideoInfo(this, { id: video_id });
|
||||
const continuation = await Actions.next(this, { video_id });
|
||||
continuation.success && (response.continuation = continuation.data);
|
||||
|
||||
const details = new Parser(this, response, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'VIDEO_INFO'
|
||||
}).parse();
|
||||
|
||||
details.like = () => Actions.engage(this, 'like/like', { video_id });
|
||||
details.dislike = () => Actions.engage(this, 'like/dislike', { video_id });
|
||||
details.removeLike = () => Actions.engage(this, 'like/removelike', { video_id });
|
||||
details.subscribe = () => Actions.engage(this, 'subscription/subscribe', { channel_id: details.metadata.channel_id });
|
||||
details.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { channel_id: details.metadata.channel_id });
|
||||
details.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text });
|
||||
details.getComments = (sort_by) => this.getComments(video_id, sort_by);
|
||||
details.getLivechat = () => new Livechat(this, continuation.data.contents?.twoColumnWatchNextResults?.conversationBar?.liveChatRenderer?.continuations?.find((continuation) => continuation.reloadContinuationData).reloadContinuationData.continuation, details.metadata.channel_id, video_id);
|
||||
details.setNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' });
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves comments for a video.
|
||||
*
|
||||
* @param {string} video_id - Video id
|
||||
* @param {string} [sort_by] - Can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
|
||||
* @return {Promise.<{ page_count: number; comment_count: number; items: []; }>}
|
||||
*/
|
||||
async getComments(video_id, sort_by) {
|
||||
const payload = Proto.encodeCommentsSectionParams(video_id, {
|
||||
sort_by: sort_by || 'TOP_COMMENTS'
|
||||
});
|
||||
|
||||
const response = await Actions.next(this, { continuation_token: payload });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve comments', response);
|
||||
|
||||
const comments = new Parser(this, response.data, {
|
||||
video_id,
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'COMMENTS'
|
||||
}).parse();
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves contents for a given channel. (WIP)
|
||||
*
|
||||
* @param {string} id - The id of the channel.
|
||||
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>}
|
||||
*/
|
||||
async getChannel(id) {
|
||||
const response = await Actions.browse(this, 'channel', { browse_id: id });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve channel info.', response);
|
||||
|
||||
const channel_info = new Parser(this, response.data, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'CHANNEL'
|
||||
}).parse();
|
||||
|
||||
return channel_info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves your watch history.
|
||||
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
|
||||
*/
|
||||
async getHistory() {
|
||||
const response = await Actions.browse(this, 'history');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve watch history', response);
|
||||
|
||||
const history = new Parser(this, response, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'HISTORY'
|
||||
}).parse();
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves YouTube's home feed (aka recommendations).
|
||||
* @returns {Promise.<{ videos: [{ id: string; title: string; description: string; channel: string; metadata: object }] }>}
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await Actions.browse(this, 'home_feed');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve home feed', response);
|
||||
|
||||
const homefeed = new Parser(this, response, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'HOMEFEED'
|
||||
}).parse();
|
||||
|
||||
return homefeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves trending content.
|
||||
* @returns {Promise.<{ now: { content: [{ title: string; videos: []; }] };
|
||||
* music: { getVideos: Promise.<Array>; }; gaming: { getVideos: Promise.<Array>; };
|
||||
* gaming: { getVideos: Promise.<Array>; }; }>}
|
||||
*/
|
||||
async getTrending() {
|
||||
const response = await Actions.browse(this, 'trending');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve trending content', response);
|
||||
|
||||
const trending = new Parser(this, response, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'TRENDING'
|
||||
}).parse();
|
||||
|
||||
return trending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves your subscriptions feed.
|
||||
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await Actions.browse(this, 'subscriptions_feed');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve subscriptions feed', response);
|
||||
|
||||
const subsfeed = new Parser(this, response, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'SUBSFEED'
|
||||
}).parse();
|
||||
|
||||
return subsfeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves your notifications.
|
||||
* @returns {Promise.<{ items: [{ title: string; sent_time: string; channel_name: string; channel_thumbnail: {}; video_thumbnail: {}; video_url: string; read: boolean; notification_id: string }] }>}
|
||||
*/
|
||||
async getNotifications() {
|
||||
const response = await Actions.notifications(this, 'get_notification_menu');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not fetch notifications', response);
|
||||
|
||||
const notifications = new Parser(this, response.data, {
|
||||
client: 'YOUTUBE',
|
||||
data_type: 'NOTIFICATIONS'
|
||||
}).parse();
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves unseen notifications count.
|
||||
* @returns {Promise.<number>} unseen notifications count.
|
||||
*/
|
||||
async getUnseenNotificationsCount() {
|
||||
const response = await Actions.notifications(this, 'get_unseen_count');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get unseen notifications count', response);
|
||||
return response.data.unseenCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves lyrics for a given song if available.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {Promise.<string>} Song lyrics
|
||||
*/
|
||||
async getLyrics(video_id) {
|
||||
const continuation = await Actions.next(this, { video_id: video_id, ytmusic: true });
|
||||
if (!continuation.success) throw new Utils.InnertubeError('Could not retrieve lyrics', continuation);
|
||||
|
||||
const lyrics_tab = Utils.findNode(continuation, 'contents', 'Lyrics', 8, false);
|
||||
|
||||
const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.endpoint?.browseEndpoint.browseId });
|
||||
if (!response.success || !response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id });
|
||||
|
||||
const lyrics = Utils.findNode(response.data, 'contents', 'runs', 6, false);
|
||||
return lyrics.runs[0].text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a given playlist.
|
||||
*
|
||||
* @param {string} playlist_id - The id of the playlist.
|
||||
* @param {object} options - { client: YOUTUBE | YTMUSIC }
|
||||
* @param {string} options.client - Client used to parse the playlist, can be: `YTMUSIC` | `YOUTUBE`
|
||||
* @returns {Promise.<
|
||||
* { title: string; description: string; total_items: string; last_updated: string; views: string; items: [] } |
|
||||
* { title: string; description: string; total_items: number; duration: string; year: string; items: [] }>}
|
||||
*/
|
||||
async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) {
|
||||
const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { ytmusic: options.client == 'YTMUSIC', browse_id: `VL${playlist_id}` });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get playlist', response);
|
||||
|
||||
const playlist = new Parser(this, response.data, {
|
||||
client: options.client,
|
||||
data_type: 'PLAYLIST'
|
||||
}).parse();
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to process and filter formats.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} video_data
|
||||
* @returns {object.<{ selected_format: {}; formats: [] }>}
|
||||
*/
|
||||
#chooseFormat(options, video_data) {
|
||||
let formats = [];
|
||||
|
||||
formats = formats
|
||||
.concat(video_data.streamingData.formats || [])
|
||||
.concat(video_data.streamingData.adaptiveFormats || []);
|
||||
|
||||
formats.forEach((format) => {
|
||||
format.url = format.url || format.signatureCipher || format.cipher;
|
||||
|
||||
if (format.signatureCipher || format.cipher) {
|
||||
format.url = new Signature(format.url, this.#player).decipher();
|
||||
}
|
||||
|
||||
const url_components = new URL(format.url);
|
||||
url_components.searchParams.set('cver', this.context.client.clientVersion);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
|
||||
if (url_components.searchParams.get('n')) {
|
||||
url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc, url_components.searchParams.get('n')).transform());
|
||||
}
|
||||
|
||||
format.url = url_components.toString();
|
||||
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
|
||||
format.has_video = !!format.qualityLabel;
|
||||
|
||||
delete format.cipher;
|
||||
delete format.signatureCipher;
|
||||
});
|
||||
|
||||
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
|
||||
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
|
||||
|
||||
let format;
|
||||
let bitrates;
|
||||
let filtered_formats;
|
||||
|
||||
filtered_formats = ({
|
||||
'video': formats.filter((format) => format.has_video && !format.has_audio),
|
||||
'audio': formats.filter((format) => format.has_audio && !format.has_video),
|
||||
'videoandaudio': formats.filter((format) => format.has_video && format.has_audio)
|
||||
})[options.type] || formats.filter((format) => format.has_video && format.has_audio);
|
||||
|
||||
if (options.type != 'videoandaudio') {
|
||||
let streams;
|
||||
|
||||
options.type != 'audio' &&
|
||||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
|
||||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4')));
|
||||
|
||||
streams == undefined || streams.length == 0 &&
|
||||
(streams = filtered_formats.filter((format) => format.quality == 'medium'));
|
||||
|
||||
bitrates = streams.map((format) => format.bitrate);
|
||||
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
|
||||
} else {
|
||||
format = filtered_formats[0];
|
||||
}
|
||||
|
||||
return { selected_format: format, formats };
|
||||
}
|
||||
|
||||
/**
|
||||
* An alternative to {@link download}.
|
||||
* Returns deciphered streaming data.
|
||||
*
|
||||
* @param {string} id - Video id
|
||||
* @param {object} options - Download options.
|
||||
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
|
||||
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
|
||||
* @param {string} options.format - File format
|
||||
* @returns {Promise.<{ selected_format: {}; formats: [] }>}
|
||||
*/
|
||||
async getStreamingData(id, options = {}) {
|
||||
options.quality = options.quality || '360p';
|
||||
options.type = options.type || 'videoandaudio';
|
||||
options.format = options.format || 'mp4';
|
||||
|
||||
const data = await Actions.getVideoInfo(this, { id });
|
||||
const streaming_data = this.#chooseFormat(options, data);
|
||||
if (!streaming_data.selected_format) throw new Utils.NoStreamingDataError('Could not find any suitable format.', { id, options });
|
||||
|
||||
return streaming_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}.
|
||||
*
|
||||
* @param {string} id - Video id
|
||||
* @param {object} options - Download options.
|
||||
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
|
||||
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
|
||||
* @param {string} options.format - File format
|
||||
* @return {ReadableStream}
|
||||
*/
|
||||
download(id, options = {}) {
|
||||
if (!id) throw new Utils.MissingParamError('Video id is missing');
|
||||
|
||||
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();
|
||||
Actions.getVideoInfo(this, { id }).then(async (video_data) => {
|
||||
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED')
|
||||
return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
|
||||
if (!video_data.streamingData)
|
||||
return stream.emit('error', { message: 'Streaming data not available.', error_type: 'NO_STREAMING_DATA', playability_status: video_data.playabilityStatus.status });
|
||||
|
||||
const { selected_format: format, formats } = this.#chooseFormat(options, video_data);
|
||||
|
||||
if (!format)
|
||||
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
|
||||
|
||||
const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse();
|
||||
stream.emit('info', { video_details, selected_format: format, formats });
|
||||
|
||||
if (options.type == 'videoandaudio' && !options.range) {
|
||||
const response = await Axios.get(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', {
|
||||
size,
|
||||
percentage,
|
||||
chunk_size: chunk.length,
|
||||
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
|
||||
raw_data: {
|
||||
chunk_size: chunk.length,
|
||||
downloaded: downloaded_size,
|
||||
size: response.headers['content-length']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
cancelled &&
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
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 = (options.range && options.range.start || 0);
|
||||
let chunk_end = (options.range && options.range.end || chunk_size);
|
||||
let downloaded_size = 0;
|
||||
let must_end = false;
|
||||
|
||||
stream.emit('start');
|
||||
|
||||
const downloadChunk = async () => {
|
||||
(chunk_end >= format.contentLength || options.range) && (must_end = true);
|
||||
options.range && (format.contentLength = options.range.end);
|
||||
|
||||
const response = await Axios.get(`${format.url}&range=${chunk_start}-${chunk_end || ''}`, {
|
||||
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 = (format.contentLength / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / format.contentLength) * 100);
|
||||
|
||||
stream.emit('progress', {
|
||||
size,
|
||||
percentage,
|
||||
chunk_size: chunk.length,
|
||||
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
|
||||
raw_data: {
|
||||
chunk_size: chunk.length,
|
||||
downloaded: downloaded_size,
|
||||
size: response.headers['content-length']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
cancelled &&
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
});
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!must_end && !options.range) {
|
||||
chunk_start = chunk_end + 1;
|
||||
chunk_end += chunk_size;
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end: must_end });
|
||||
};
|
||||
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
|
||||
stream.cancel = () => {
|
||||
cancelled = true;
|
||||
cancel();
|
||||
};
|
||||
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Innertube;
|
||||
@@ -1,461 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Uuid = require('uuid');
|
||||
const Axios = require('axios');
|
||||
const Proto = require('../proto');
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
/**
|
||||
* Performs direct interactions on YouTube.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {string} engagement_type
|
||||
* @param {object} args
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function engage(session, engagement_type, args = {}) {
|
||||
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = { context: session.context };
|
||||
switch (engagement_type) {
|
||||
case 'like/like':
|
||||
case 'like/dislike':
|
||||
case 'like/removelike':
|
||||
data.target = {
|
||||
videoId: args.video_id
|
||||
}
|
||||
break;
|
||||
case 'subscription/subscribe':
|
||||
case 'subscription/unsubscribe':
|
||||
data.channelIds = [args.channel_id];
|
||||
data.params = engagement_type == 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data.commentText = args.text;
|
||||
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
|
||||
break;
|
||||
case 'comment/create_comment_reply':
|
||||
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
|
||||
data.commentText = args.text;
|
||||
break;
|
||||
case 'comment/perform_comment_action':
|
||||
const action = ({
|
||||
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id),
|
||||
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id),
|
||||
})[args.comment_action]();
|
||||
data.actions = [ action ];
|
||||
break;
|
||||
case 'playlist/create':
|
||||
data.title = args.title;
|
||||
data.videoIds = [args.video_id];
|
||||
break;
|
||||
case 'playlist/delete':
|
||||
data.playlistId = args.playlist_id;
|
||||
break;
|
||||
case 'browse/edit_playlist':
|
||||
data.playlistId = args.playlist_id;
|
||||
data.actions = args.video_ids.map((id) => ({
|
||||
action: args.action,
|
||||
addedVideoId: id
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', engagement_type);
|
||||
}
|
||||
|
||||
const response = await session.request.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesses YouTube's various sections.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {string} action
|
||||
* @param {object} args
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function browse(session, action, args = {}) {
|
||||
if (!session.logged_in && ![ 'home_feed', 'lyrics',
|
||||
'music_playlist', 'playlist', 'trending' ].includes(action))
|
||||
throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = { context: session.context };
|
||||
switch (action) {
|
||||
case 'account_notifications':
|
||||
data.browseId = 'SPaccount_notifications';
|
||||
break;
|
||||
case 'account_privacy':
|
||||
data.browseId = 'SPaccount_privacy';
|
||||
break;
|
||||
case 'history':
|
||||
data.browseId = 'FEhistory';
|
||||
break;
|
||||
case 'home_feed':
|
||||
data.browseId = 'FEwhat_to_watch';
|
||||
break;
|
||||
case 'trending':
|
||||
data.browseId = 'FEtrending';
|
||||
args.params && (data.params = args.params);
|
||||
break;
|
||||
case 'subscriptions_feed':
|
||||
data.browseId = 'FEsubscriptions';
|
||||
break;
|
||||
case 'lyrics':
|
||||
case 'music_playlist':
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data.context = context;
|
||||
data.browseId = args.browse_id;
|
||||
break;
|
||||
case 'channel':
|
||||
case 'playlist':
|
||||
data.browseId = args.browse_id;
|
||||
break;
|
||||
case 'continuation':
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.request.post('/browse', JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints used to report content.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {string} action
|
||||
* @param {object} args
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function flag(session, action, args = {}) {
|
||||
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in');
|
||||
const data = { context: session.context };
|
||||
|
||||
switch (action) {
|
||||
case 'flag/flag':
|
||||
data.action = args.action;
|
||||
break;
|
||||
case 'flag/get_form':
|
||||
data.params = args.params;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.request.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Account settings endpoints.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {string} action
|
||||
* @param {object} args
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function account(session, action, args = {}) {
|
||||
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
switch (action) {
|
||||
case 'account/account_menu':
|
||||
data.context = session.context;
|
||||
break;
|
||||
case 'account/set_setting':
|
||||
data.context = session.context;
|
||||
data.newValue = { boolValue: args.new_value };
|
||||
data.settingItemId = args.setting_item_id;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.request.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesses YouTube Music endpoints (/youtubei/v1/music/).
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {string} action
|
||||
* @param {object} args
|
||||
* @todo Implement more endpoints.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function music(session, action, args) {
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
let data = {};
|
||||
switch (action) {
|
||||
case 'get_search_suggestions':
|
||||
data.context = context;
|
||||
data.input = args.input || '';
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.request.post(`/music/${action}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches a given query on YouTube/YTMusic.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {string} client - YouTube client: YOUTUBE | YTMUSIC
|
||||
* @param {object} args - Search arguments.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function search(session, client, args = {}) {
|
||||
const data = { context: session.context };
|
||||
switch (client) {
|
||||
case 'YOUTUBE':
|
||||
if (args.query) {
|
||||
data.params = Proto.encodeSearchFilter(args.options.period, args.options.duration, args.options.order);
|
||||
data.query = args.query;
|
||||
} else {
|
||||
data.continuation = args.ctoken;
|
||||
}
|
||||
break;
|
||||
case 'YTMUSIC':
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data.context = context;
|
||||
data.query = args.query;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid client', client);
|
||||
}
|
||||
|
||||
const response = await session.request.post('/search', JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interacts with YouTube's notification system.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {string} action
|
||||
* @param {object} args
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function notifications(session, action, args = {}) {
|
||||
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
switch (action) {
|
||||
case 'modify_channel_preference':
|
||||
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
|
||||
data.context = session.context;
|
||||
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
|
||||
break;
|
||||
case 'get_notification_menu':
|
||||
data.context = session.context;
|
||||
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
|
||||
args.ctoken && (data.ctoken = args.ctoken);
|
||||
break;
|
||||
case 'get_unseen_count':
|
||||
data.context = session.context;
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.request.post(`/notification/${action}`, JSON.stringify(data)).catch((err) => err);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
if (action === 'modify_channel_preference') return { success: true, status_code: response.status };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interacts with YouTube's livechat system.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {string} action
|
||||
* @param {object} args
|
||||
* @returns {Promise.<{ success: boolean; data: object; message?: string }>}
|
||||
*/
|
||||
async function livechat(session, action, args = {}) {
|
||||
const data = {};
|
||||
switch (action) {
|
||||
case 'live_chat/get_live_chat':
|
||||
data.context = session.context;
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
case 'live_chat/send_message':
|
||||
data.context = session.context;
|
||||
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
|
||||
data.clientMessageId = Uuid.v4();
|
||||
data.richMessage = {
|
||||
textSegments: [{ text: args.text }]
|
||||
}
|
||||
break;
|
||||
case 'live_chat/get_item_context_menu':
|
||||
data.context = session.context;
|
||||
break;
|
||||
case 'live_chat/moderate':
|
||||
data.context = session.context;
|
||||
data.params = args.cmd_params;
|
||||
break;
|
||||
case 'updated_metadata':
|
||||
data.context = session.context;
|
||||
data.videoId = args.video_id;
|
||||
args.continuation && (data.continuation = args.continuation);
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.request.post(`/${action}`, JSON.stringify(data)).catch((err) => err);
|
||||
if (response instanceof Error) return { success: false, message: response.message };
|
||||
|
||||
return { success: true, data: response.data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests continuation for previously performed actions.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {object} args
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function next(session, args = {}) {
|
||||
let data = { context: session.context };
|
||||
args.continuation_token && (data.continuation = args.continuation_token);
|
||||
|
||||
if (args.video_id) {
|
||||
data.videoId = args.video_id;
|
||||
if (args.ytmusic) {
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data.context = context;
|
||||
data.isAudioOnly = true;
|
||||
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL';
|
||||
} else {
|
||||
data.racyCheckOk = true;
|
||||
data.contentCheckOk = false;
|
||||
data.autonavState = 'STATE_NONE';
|
||||
data.playbackContext = { vis: 0, lactMilliseconds: '-1' };
|
||||
data.captionsRequested = false;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await session.request.post('/next', JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves video data.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {object} args
|
||||
* @returns {Promise.<object>} - Video data.
|
||||
*/
|
||||
async function getVideoInfo(session, args = {}) {
|
||||
const response = await session.request.post(`/player`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context))).catch((err) => err);
|
||||
if (response instanceof Error) throw new Utils.InnertubeError(`Could not get video info: ${response.message}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets search suggestions.
|
||||
*
|
||||
* @param {Innertube} session
|
||||
* @param {string} query
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function getSearchSuggestions(session, client, input) {
|
||||
if (!['YOUTUBE', 'YTMUSIC'].includes(client))
|
||||
throw new Utils.InnertubeError('Invalid client', client);
|
||||
|
||||
const response = await ({
|
||||
'YOUTUBE': async () => {
|
||||
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(input)}`,
|
||||
Constants.DEFAULT_HEADERS(session.config)).catch((error) => error);
|
||||
|
||||
return {
|
||||
success: !(response instanceof Error),
|
||||
status_code: response.status,
|
||||
data: response?.data
|
||||
};
|
||||
},
|
||||
'YTMUSIC': async () => {
|
||||
const response = await music(session, 'get_search_suggestions', { input });
|
||||
return response;
|
||||
}
|
||||
}[client])();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
module.exports = { engage, browse, account, flag, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions };
|
||||
@@ -1,139 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Actions = require('./Actions');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class Livechat extends EventEmitter {
|
||||
constructor(session, token, channel_id, video_id) {
|
||||
super(session);
|
||||
|
||||
if (!token)
|
||||
throw new Error('Could not retrieve livechat data');
|
||||
|
||||
this.ctoken = token;
|
||||
this.session = session;
|
||||
this.video_id = video_id;
|
||||
this.channel_id = channel_id;
|
||||
|
||||
this.message_queue = [];
|
||||
this.id_cache = [];
|
||||
|
||||
this.poll_intervals_ms = 1000;
|
||||
this.running = true;
|
||||
|
||||
this.#poll();
|
||||
}
|
||||
|
||||
async #poll() {
|
||||
if (!this.running) return;
|
||||
|
||||
const livechat = await Actions.livechat(this.session, 'live_chat/get_live_chat', { ctoken: this.ctoken });
|
||||
if (!livechat.success) {
|
||||
this.emit('error', { message: `Failed polling livechat: ${livechat.message}. Retrying...` });
|
||||
return await this.#poll();
|
||||
}
|
||||
|
||||
const continuation_contents = livechat.data.continuationContents;
|
||||
const action_group = continuation_contents.liveChatContinuation.actions;
|
||||
this.#enqueueActionGroup(action_group);
|
||||
|
||||
this.message_queue.forEach((message) => {
|
||||
if (this.id_cache.includes(message.id)) return;
|
||||
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
|
||||
this.id_cache.push(message.id);
|
||||
});
|
||||
|
||||
this.message_queue = [];
|
||||
|
||||
const data = { video_id: this.video_id };
|
||||
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
|
||||
|
||||
const updated_metadata = await Actions.livechat(this.session, 'updated_metadata', data);
|
||||
if (!updated_metadata.success) {
|
||||
this.emit('error', { message: `Failed polling livechat metadata: ${livechat.message}.` });
|
||||
}
|
||||
|
||||
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
|
||||
|
||||
const metadata = updated_metadata.data.actions;
|
||||
this.emit('update-metadata', {
|
||||
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
view_count: {
|
||||
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
|
||||
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
|
||||
}
|
||||
});
|
||||
|
||||
this.livechat_poller = setTimeout(async () => await this.#poll(), this.poll_intervals_ms);
|
||||
}
|
||||
|
||||
#enqueueActionGroup(group) {
|
||||
group.forEach((action) => {
|
||||
if (!action.addChatItemAction) return; //TODO: handle different action types
|
||||
const message_content = action.addChatItemAction.item.liveChatTextMessageRenderer;
|
||||
if (!message_content) return;
|
||||
|
||||
const message = {
|
||||
text: message_content.message.runs.map((item) => item.text).join(' '),
|
||||
author: {
|
||||
name: message_content.authorName && message_content.authorName.simpleText || 'N/',
|
||||
channel_id: message_content.authorExternalChannelId,
|
||||
profile_picture: message_content.authorPhoto.thumbnails
|
||||
},
|
||||
timestamp: message_content.timestampUsec,
|
||||
id: message_content.id
|
||||
};
|
||||
|
||||
this.message_queue.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(text) {
|
||||
const message = await Actions.livechat(this.session, 'live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id });
|
||||
if (!message.success) return message;
|
||||
|
||||
const deleteMessage = async () => {
|
||||
const menu = await Actions.livechat(this.session, 'live_chat/get_item_context_menu', { params: { params: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params, pbj: 1 } });
|
||||
if (!menu.success) return menu;
|
||||
|
||||
const chat_item_menu = menu.data.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0];
|
||||
|
||||
const cmd = await Actions.livechat(this.session, 'live_chat/moderate', { cmd_params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params });
|
||||
if (!cmd.success) return cmd;
|
||||
|
||||
return { success: true, status_code: cmd.status_code };
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: message.status_code,
|
||||
deleteMessage: deleteMessage,
|
||||
message_data: {
|
||||
text: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message.runs.map((item) => item.text).join(' '),
|
||||
author: {
|
||||
name: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName && message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName.simpleText || 'N/',
|
||||
channel_id: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorExternalChannelId,
|
||||
profile_picture: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorPhoto.thumbnails
|
||||
},
|
||||
timestamp: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.timestampUsec,
|
||||
id: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks a user.
|
||||
* @todo Implement this method.
|
||||
* @param {object} msg_params
|
||||
*/
|
||||
async blockUser(msg_params) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
clearTimeout(this.livechat_poller);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Livechat;
|
||||
@@ -1,235 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Constants = require('../utils/Constants');
|
||||
const Uuid = require('uuid');
|
||||
|
||||
class OAuth {
|
||||
#scope = Constants.OAUTH.SCOPE;
|
||||
#model_name = Constants.OAUTH.MODEL_NAME;
|
||||
#grant_type = Constants.OAUTH.GRANT_TYPE;
|
||||
|
||||
#oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
|
||||
#oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
|
||||
#oauth_revoke_url = `${Constants.URLS.YT_BASE}/o/oauth2/revoke`;
|
||||
|
||||
#auth_info = {};
|
||||
#refresh_interval = 5;
|
||||
#ev = null;
|
||||
|
||||
constructor(ev) {
|
||||
this.#ev = ev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the auth flow in case no valid credentials are available.
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async init(auth_info) {
|
||||
this.#auth_info = auth_info;
|
||||
if (!auth_info.access_token) {
|
||||
this.#requestUserCode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the OAuth server for a user code
|
||||
* and verification URL.
|
||||
*
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async #requestUserCode() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
this.client_id = identity.id;
|
||||
this.client_secret = identity.secret;
|
||||
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
scope: this.#scope,
|
||||
device_id: Uuid.v4(),
|
||||
model_name: this.#model_name
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.#oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not obtain user code.', status: 'FAILED' });
|
||||
|
||||
this.#ev.emit('auth', {
|
||||
code: response.data.user_code,
|
||||
status: 'AUTHORIZATION_PENDING',
|
||||
expires_in: response.data.expires_in,
|
||||
verification_url: response.data.verification_url
|
||||
});
|
||||
|
||||
this.refresh_interval = response.data.interval;
|
||||
|
||||
this.#waitForAuth(response.data.device_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for sign-in authorization.
|
||||
*
|
||||
* @param {string} device_code - Client's device code.
|
||||
* @returns
|
||||
*/
|
||||
#waitForAuth(device_code) {
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
client_secret: this.client_secret,
|
||||
code: device_code,
|
||||
grant_type: this.#grant_type
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not get authentication token.', status: 'FAILED' });
|
||||
|
||||
if (response.data.error) {
|
||||
switch (response.data.error) {
|
||||
case 'slow_down':
|
||||
case 'authorization_pending':
|
||||
this.#waitForAuth(device_code);
|
||||
break;
|
||||
case 'access_denied':
|
||||
this.#ev.emit('auth', {
|
||||
error: 'Access was denied.',
|
||||
status: 'ACCESS_DENIED'
|
||||
});
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.#ev.emit('auth', {
|
||||
error: 'The user code has expired, requesting a new one.',
|
||||
status: 'DEVICE_CODE_EXPIRED'
|
||||
});
|
||||
this.#requestUserCode();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
const credentials = {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
expires: expiration_date,
|
||||
};
|
||||
|
||||
this.#auth_info = credentials;
|
||||
|
||||
this.#ev.emit('auth', {
|
||||
credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
}, 1000 * this.#refresh_interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the access token if necessary.
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async checkTokenValidity() {
|
||||
if (this.shouldRefreshToken()) {
|
||||
await this.#refreshAccessToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new access token using a refresh token.
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async #refreshAccessToken() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: identity.id,
|
||||
client_secret: identity.secret,
|
||||
refresh_token: this.#auth_info.refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
|
||||
if (response instanceof Error)
|
||||
return this.#ev.emit('update-credentials', {
|
||||
error: 'Could not refresh access token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
const credentials = {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token || this.#auth_info.refresh_token,
|
||||
expires: expiration_date,
|
||||
};
|
||||
|
||||
this.#auth_info = credentials;
|
||||
|
||||
this.#ev.emit('update-credentials', {
|
||||
credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes access token (note that the refresh token will also be revoked).
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async revokeAccessToken() {
|
||||
const response = await Axios.post(`${this.#oauth_revoke_url}?token=${this.getAccessToken()}`, Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
return {
|
||||
success: !(response instanceof Error),
|
||||
status_code: response.status || 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets client identity data.
|
||||
* @returns {Promise.<{ id: string; secret: string }>}
|
||||
*/
|
||||
async #getClientIdentity() {
|
||||
// This request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
|
||||
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
|
||||
|
||||
// Here we download the script and extract the necessary data to proceed with the auth flow.
|
||||
const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(yttv_response.data)[1];
|
||||
const script_url = `${Constants.URLS.YT_BASE}/${url_body}`;
|
||||
|
||||
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS()).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
|
||||
|
||||
const client_identity = response.data.replace(/\n/g, '').match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
|
||||
return client_identity.groups;
|
||||
}
|
||||
|
||||
getAccessToken() {
|
||||
return this.#auth_info.access_token;
|
||||
}
|
||||
|
||||
getRefreshToken() {
|
||||
return this.#auth_info.refresh_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the auth info is valid.
|
||||
* @returns {boolean} true | false
|
||||
*/
|
||||
isValidAuthInfo() {
|
||||
return this.#auth_info.hasOwnProperty('access_token')
|
||||
&& this.#auth_info.hasOwnProperty('refresh_token')
|
||||
&& this.#auth_info.hasOwnProperty('expires');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks access token validity.
|
||||
* @returns {boolean} true | false
|
||||
*/
|
||||
shouldRefreshToken() {
|
||||
const timestamp = new Date(this.#auth_info.expires).getTime();
|
||||
return new Date().getTime() > timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth;
|
||||
@@ -1,49 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
class Player {
|
||||
constructor(session) {
|
||||
this.session = session;
|
||||
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
|
||||
this.tmp_cache_dir = __dirname.slice(0, -8) + 'cache';
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (Fs.existsSync(`${this.tmp_cache_dir}/${this.player_name}.js`)) {
|
||||
const player_data = Fs.readFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`).toString();
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(player_data);
|
||||
this.ntoken_sc = this.#getNEncoder(player_data);
|
||||
} else {
|
||||
const response = await Axios.get(`${Constants.URLS.YT_BASE}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message);
|
||||
|
||||
try {
|
||||
// Deletes old players
|
||||
Fs.existsSync(this.tmp_cache_dir) && Fs.rmSync(this.tmp_cache_dir, { recursive: true });
|
||||
|
||||
// Caches the current player so we don't have to download it all the time.
|
||||
Fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
|
||||
Fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data);
|
||||
} catch (err) {}
|
||||
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
|
||||
this.ntoken_sc = this.#getNEncoder(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
#getSigDecipherCode(data) {
|
||||
const sig_alg_sc = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const sig_data = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
return sig_alg_sc + sig_data;
|
||||
}
|
||||
|
||||
#getNEncoder(data) {
|
||||
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Player;
|
||||
@@ -1,139 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
class NToken {
|
||||
constructor(raw_code, n) {
|
||||
this.n = n;
|
||||
this.raw_code = raw_code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solves throttling challange by transforming the n token.
|
||||
* @returns {string} transformed token.
|
||||
*/
|
||||
transform() {
|
||||
let n_token = this.n.split('');
|
||||
|
||||
try {
|
||||
let transformations = this.#getTransformationData();
|
||||
transformations = transformations.map((el) => {
|
||||
if (el != null && typeof el != 'number') {
|
||||
const is_reverse_base64 = el.includes('case 65:');
|
||||
(({ // Identifies the transformation functions
|
||||
[Constants.FUNCS.PUSH]: () => el = (arr, i) => this.#push(arr, i),
|
||||
[Constants.FUNCS.SPLICE]: () => el = (arr, i) => this.#splice(arr, i),
|
||||
[Constants.FUNCS.SWAP0_1]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.SWAP0_2]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.ROTATE_1]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.ROTATE_2]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.REVERSE_1]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.REVERSE_2]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.BASE64_DIA]: () => el = () => this.#getBase64Dia(is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_1]: () => el = (arr, token) => this.#translate1(arr, token, is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_2]: () => el = (arr, token, base64_dic) => this.#translate2(arr, token, base64_dic)
|
||||
})[this.#getFunc(el)] || (() => el === 'b' && (el = n_token)))();
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
// Fills all placeholders with the transformations array
|
||||
const placeholder_indexes = [...this.raw_code.matchAll(Constants.NTOKEN_REGEX.PLACEHOLDERS)].map((item) => parseInt(item[1]));
|
||||
placeholder_indexes.forEach((i) => transformations[i] = transformations);
|
||||
|
||||
// Parses and emulates calls to the functions of the transformations array
|
||||
const function_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch')
|
||||
.matchAll(Constants.NTOKEN_REGEX.CALLS)].map((params) => ({ index: params[1], params: params[2] }));
|
||||
|
||||
function_calls.forEach((data) => {
|
||||
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
|
||||
const base64_dia = (param_index[2] && transformations[param_index[2]]());
|
||||
transformations[data.index](transformations[param_index[0]], transformations[param_index[1]], base64_dia);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Could not transform n-token (${this.n}), download may be throttled:`, err.message);
|
||||
return this.n;
|
||||
}
|
||||
return n_token.join('');
|
||||
}
|
||||
|
||||
#getFunc(el) {
|
||||
return el.match(Constants.FUNCS_REGEX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the n-transform data, refines it, and then returns a readable json array.
|
||||
* @returns {object}
|
||||
*/
|
||||
#getTransformationData() {
|
||||
const data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
|
||||
return JSON.parse(Utils.refineNTokenData(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a base64 alphabet and uses it as a lookup table to modify n.
|
||||
* @returns
|
||||
*/
|
||||
#translate1(arr, token, is_reverse_base64) {
|
||||
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
|
||||
arr.forEach(function(char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
|
||||
}, token.split(''));
|
||||
}
|
||||
|
||||
#translate2(arr, token, characters) {
|
||||
let chars_length = characters.length;
|
||||
arr.forEach(function(char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + index + chars_length--) % characters.length]);
|
||||
}, token.split(''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the requested base64 dialect, currently this is only used by 'translate2'.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
#getBase64Dia(is_reverse_base64) {
|
||||
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
|
||||
return characters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the first element with the one at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#swap0(arr, index) {
|
||||
const old_elem = arr[0];
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr[0] = arr[index];
|
||||
arr[index] = old_elem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates elements of the array.
|
||||
* @returns
|
||||
*/
|
||||
#rotate(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(-index).reverse().forEach((el) => arr.unshift(el));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one element at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#splice(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(index, 1);
|
||||
}
|
||||
|
||||
#reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
|
||||
#push(arr, item) {
|
||||
arr.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NToken;
|
||||
@@ -1,75 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const QueryString = require('querystring');
|
||||
|
||||
class Signature {
|
||||
constructor(url, player) {
|
||||
this.url = url;
|
||||
this.player = player;
|
||||
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
|
||||
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deciphers signature.
|
||||
*/
|
||||
decipher() {
|
||||
const args = QueryString.parse(this.url);
|
||||
const functions = this.#getFunctions();
|
||||
|
||||
function splice(arr, end) {
|
||||
arr.splice(0, end);
|
||||
}
|
||||
|
||||
function swap(arr, index) {
|
||||
let origArrI = arr[0];
|
||||
arr[0] = arr[index % arr.length];
|
||||
arr[index % arr.length] = origArrI;
|
||||
}
|
||||
|
||||
function reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
|
||||
let actions;
|
||||
let signature = args.s.split('');
|
||||
|
||||
while ((actions = this.actions_regex.exec(this.player.sig_decipher_sc)) !== null) {
|
||||
switch (actions[1]) {
|
||||
case functions[0]:
|
||||
reverse(signature, actions[2]);
|
||||
break;
|
||||
case functions[1]:
|
||||
splice(signature, actions[2]);
|
||||
break;
|
||||
case functions[2]:
|
||||
swap(signature, actions[2]);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
const url_components = new URL(args.url);
|
||||
args.sp ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
|
||||
return url_components.toString();
|
||||
}
|
||||
|
||||
#getFunctions() {
|
||||
let func;
|
||||
let func_name = [];
|
||||
|
||||
while ((func = this.func_regex.exec(this.player.sig_decipher_sc)) !== null) {
|
||||
if (func[0].includes('reverse()')) {
|
||||
func_name[0] = func[1];
|
||||
} else if (func[0].includes('splice')) {
|
||||
func_name[1] = func[1];
|
||||
} else {
|
||||
func_name[2] = func[1];
|
||||
}
|
||||
}
|
||||
|
||||
return func_name;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Signature;
|
||||
@@ -1,548 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../utils/Utils');
|
||||
const Actions = require('../core/Actions');
|
||||
const Constants = require('../utils/Constants');
|
||||
const YTDataItems = require('./youtube');
|
||||
const YTMusicDataItems = require('./ytmusic');
|
||||
const Proto = require('../proto');
|
||||
|
||||
class Parser {
|
||||
constructor(session, data, args = {}) {
|
||||
this.data = data;
|
||||
this.session = session;
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
parse() {
|
||||
const client = this.args.client;
|
||||
const data_type = this.args.data_type
|
||||
|
||||
let processed_data;
|
||||
|
||||
switch (client) {
|
||||
case 'YOUTUBE':
|
||||
processed_data = ({
|
||||
SEARCH: () => this.#processSearch(),
|
||||
CHANNEL: () => this.#processChannel(),
|
||||
PLAYLIST: () => this.#processPlaylist(),
|
||||
SUBSFEED: () => this.#processSubscriptionFeed(),
|
||||
HOMEFEED: () => this.#processHomeFeed(),
|
||||
TRENDING: () => this.#processTrending(),
|
||||
HISTORY: () => this.#processHistory(),
|
||||
COMMENTS: () => this.#processComments(),
|
||||
VIDEO_INFO: () => this.#processVideoInfo(),
|
||||
NOTIFICATIONS: () => this.#processNotifications(),
|
||||
SEARCH_SUGGESTIONS: () => this.#processSearchSuggestions(),
|
||||
})[data_type]()
|
||||
break;
|
||||
case 'YTMUSIC':
|
||||
processed_data = ({
|
||||
SEARCH: () => this.#processMusicSearch(),
|
||||
PLAYLIST: () => this.#processMusicPlaylist(),
|
||||
SEARCH_SUGGESTIONS: () => this.#processMusicSearchSuggestions(),
|
||||
})[data_type]();
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid client');
|
||||
}
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processSearch() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'contents', 5);
|
||||
|
||||
const processed_data = {};
|
||||
|
||||
const parseItems = (contents) => {
|
||||
const content = contents[0].itemSectionRenderer.contents;
|
||||
|
||||
processed_data.query = content[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
|
||||
processed_data.corrected_query = content[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || 'N/A';
|
||||
processed_data.estimated_results = parseInt(this.data.estimatedResults);
|
||||
|
||||
processed_data.videos = YTDataItems.VideoResultItem.parse(content);
|
||||
|
||||
processed_data.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await Actions.search(this.session, 'YOUTUBE', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get continuation', response);
|
||||
|
||||
const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
|
||||
return parseItems(continuation_items);
|
||||
};
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
#processMusicSearch() {
|
||||
const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
|
||||
const contents = Utils.findNode(tabs, '0', 'contents', 5);
|
||||
|
||||
const did_you_mean_item = contents.find((content) => content.itemSectionRenderer);
|
||||
const did_you_mean_renderer = did_you_mean_item?.itemSectionRenderer.contents[0].didYouMeanRenderer;
|
||||
|
||||
const processed_data = {
|
||||
query: '',
|
||||
corrected_query: '',
|
||||
results: {}
|
||||
};
|
||||
|
||||
processed_data.query = this.args.query;
|
||||
processed_data.corrected_query = did_you_mean_renderer?.correctedQuery.runs.map((run) => run.text).join('') || 'N/A';
|
||||
|
||||
contents.forEach((content) => {
|
||||
const section = content?.musicShelfRenderer;
|
||||
if (section) {
|
||||
const section_title = section.title.runs[0].text;
|
||||
|
||||
const section_items = ({
|
||||
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents),
|
||||
['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents),
|
||||
['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents),
|
||||
['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
|
||||
['Community playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
|
||||
['Artists']: () => YTMusicDataItems.ArtistResultItem.parse(section.contents),
|
||||
['Albums']: () => YTMusicDataItems.AlbumResultItem.parse(section.contents)
|
||||
})[section_title]();
|
||||
|
||||
processed_data.results[section_title.replace(/ /g, '_').toLowerCase()] = section_items;
|
||||
}
|
||||
});
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processSearchSuggestions() {
|
||||
return YTDataItems.SearchSuggestionItem.parse(this.data[1], this.data[0]);
|
||||
}
|
||||
|
||||
#processMusicSearchSuggestions() {
|
||||
const contents = this.data.contents[0].searchSuggestionsSectionRenderer.contents;
|
||||
return YTMusicDataItems.MusicSearchSuggestionItem.parse(contents);
|
||||
}
|
||||
|
||||
#processPlaylist() {
|
||||
const details = this.data.sidebar.playlistSidebarRenderer.items[0];
|
||||
|
||||
const metadata = {
|
||||
title: this.data.metadata.playlistMetadataRenderer.title,
|
||||
description: details.playlistSidebarPrimaryInfoRenderer.description.simpleText || 'N/A',
|
||||
total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0].text,
|
||||
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1].text,
|
||||
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
|
||||
}
|
||||
|
||||
const list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
|
||||
const items = YTDataItems.PlaylistItem.parse(list.contents);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#processMusicPlaylist() {
|
||||
const details = this.data.header.musicDetailHeaderRenderer;
|
||||
|
||||
const metadata = {
|
||||
title: details?.title?.runs[0].text,
|
||||
description: details?.description?.runs?.map((run) => run.text).join('') || 'N/A',
|
||||
total_items: parseInt(details?.secondSubtitle?.runs[0].text.match(/\d+/g)),
|
||||
duration: details?.secondSubtitle?.runs[2].text,
|
||||
year: details?.subtitle?.runs[4].text
|
||||
};
|
||||
|
||||
const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
|
||||
|
||||
const items = YTMusicDataItems.PlaylistItem.parse(playlist_content);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
|
||||
*/
|
||||
#processVideoInfo() {
|
||||
const playability_status = this.data.playabilityStatus;
|
||||
|
||||
if (playability_status.status == 'ERROR')
|
||||
throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
|
||||
|
||||
const details = this.data.videoDetails;
|
||||
const microformat = this.data.microformat.playerMicroformatRenderer;
|
||||
const streaming_data = this.data.streamingData;
|
||||
|
||||
const mf_raw_data = Object.entries(microformat);
|
||||
const dt_raw_data = Object.entries(details);
|
||||
|
||||
const processed_data = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
thumbnail: [],
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
// Extracts most of the metadata
|
||||
mf_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
|
||||
key == 'owner_profile_url' && (processed_data.metadata.channel_url = entry[1]) ||
|
||||
key == 'owner_channel_name' && (processed_data.metadata.channel_name = entry[1]) ||
|
||||
(processed_data.metadata[key] = entry[1]);
|
||||
} else {
|
||||
processed_data[key] = entry[1];
|
||||
}
|
||||
});
|
||||
|
||||
// Extracts extra details
|
||||
dt_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
|
||||
(processed_data.metadata[key] = entry[1]);
|
||||
} else {
|
||||
key == 'short_description' && (processed_data.description = entry[1]) ||
|
||||
key == 'thumbnail' && (processed_data.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
|
||||
key == 'video_id' && (processed_data.id = entry[1]) ||
|
||||
(processed_data[key] = entry[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Data continuation is only required for getDetails()
|
||||
if (this.data.continuation) {
|
||||
const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
|
||||
|
||||
const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
|
||||
|
||||
const like_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
|
||||
|
||||
const dislike_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
|
||||
|
||||
const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
|
||||
?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
|
||||
|
||||
// These will always be false if logged out.
|
||||
processed_data.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
|
||||
processed_data.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
|
||||
processed_data.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
|
||||
|
||||
processed_data.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
|
||||
processed_data.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
|
||||
.state.buttonRenderer.icon.iconType || 'N/A';
|
||||
|
||||
// Simpler version of publish_date
|
||||
processed_data.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
|
||||
|
||||
// Only parse like count if it's enabled
|
||||
if (processed_data.metadata.allow_ratings) {
|
||||
processed_data.metadata.likes = {
|
||||
count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
|
||||
short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
|
||||
};
|
||||
}
|
||||
|
||||
processed_data.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
|
||||
}
|
||||
|
||||
streaming_data && streaming_data.adaptiveFormats &&
|
||||
(processed_data.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
|
||||
.map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]) ||
|
||||
(processed_data.metadata.available_qualities = []);
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processComments() {
|
||||
if (!this.data.onResponseReceivedEndpoints)
|
||||
throw new Utils.UnavailableContentError('Comments section not available', this.args);
|
||||
|
||||
const header = Utils.findNode(this.data, 'onResponseReceivedEndpoints', 'commentsHeaderRenderer', 5, false);
|
||||
const comment_count = parseInt(header.commentsHeaderRenderer.countText.runs[0].text.replace(/,/g, ''));
|
||||
const page_count = parseInt(comment_count / 20);
|
||||
|
||||
const parseComments = (data) => {
|
||||
const items = Utils.findNode(data, 'onResponseReceivedEndpoints', 'commentRenderer', 4, false);
|
||||
|
||||
const response = {
|
||||
page_count,
|
||||
comment_count,
|
||||
items: []
|
||||
};
|
||||
|
||||
response.items = items.map((item) => {
|
||||
const comment = YTDataItems.CommentThread.parseItem(item);
|
||||
if (comment) {
|
||||
comment.like = () => Actions.engage(this.session, 'comment/perform_comment_action', { comment_action: 'like', comment_id: comment.metadata.id, video_id: this.args.video_id }),
|
||||
comment.dislike = () => Actions.engage(this.session, 'comment/perform_comment_action', { comment_action: 'dislike', comment_id: comment.metadata.id, video_id: this.args.video_id }),
|
||||
comment.reply = (text) => Actions.engage(this.session, 'comment/create_comment_reply', { text, comment_id: comment.metadata.id, video_id: this.args.video_id });
|
||||
|
||||
comment.report = async () => {
|
||||
const payload = Utils.findNode(item, 'commentThreadRenderer', 'params', 10, false);
|
||||
const form = await Actions.flag(this.session, 'flag/get_form', { params: payload.params });
|
||||
|
||||
const action = Utils.findNode(form, 'actions', 'flagAction', 13, false);
|
||||
const flag = await Actions.flag(this.session, 'flag/flag', { action: action.flagAction });
|
||||
|
||||
return flag;
|
||||
};
|
||||
|
||||
comment.getReplies = async () => {
|
||||
if (comment.metadata.reply_count === 0) throw new Utils.InnertubeError('This comment has no replies', comment);
|
||||
const payload = Proto.encodeCommentRepliesParams(this.args.video_id, comment.metadata.id);
|
||||
const next = await Actions.next(this.session, { continuation_token: payload });
|
||||
return parseComments(next.data);
|
||||
};
|
||||
|
||||
return comment;
|
||||
}
|
||||
}).filter((c) => c);
|
||||
|
||||
response.getContinuation = async () => {
|
||||
const continuation_item = items.find((item) => item.continuationItemRenderer);
|
||||
if (!continuation_item) throw new Utils.InnertubeError('You\'ve reached the end');
|
||||
|
||||
const is_reply = !!continuation_item.continuationItemRenderer.button;
|
||||
const payload = Utils.findNode(continuation_item, 'continuationItemRenderer', 'token', is_reply && 5 || 3);
|
||||
const next = await Actions.next(this.session, { continuation_token: payload.token });
|
||||
|
||||
return parseComments(next.data);
|
||||
};
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return parseComments(this.data);
|
||||
}
|
||||
|
||||
#processHomeFeed() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
|
||||
|
||||
const parseItems = (contents) => {
|
||||
const videos = YTDataItems.VideoItem.parse(contents);
|
||||
|
||||
const getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await Actions.browse(this.session, 'continuation', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
|
||||
|
||||
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
|
||||
}
|
||||
|
||||
return { videos, getContinuation };
|
||||
}
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
#processSubscriptionFeed() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false);
|
||||
|
||||
const subsfeed = { items: [] };
|
||||
|
||||
const parseItems = (contents) => {
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
|
||||
const section_contents = section.itemSectionRenderer.contents[0];
|
||||
const section_title = section_contents.shelfRenderer.title.runs[0].text;
|
||||
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
|
||||
|
||||
const items = YTDataItems.GridVideoItem.parse(section_items);
|
||||
|
||||
subsfeed.items.push({
|
||||
date: section_title,
|
||||
videos: items
|
||||
});
|
||||
});
|
||||
|
||||
subsfeed.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await Actions.browse(this.session, 'continuation', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
|
||||
|
||||
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false);
|
||||
subsfeed.items = [];
|
||||
|
||||
return parseItems(ccontents);
|
||||
}
|
||||
|
||||
return subsfeed;
|
||||
};
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
#processChannel() {
|
||||
const tabs = this.data.contents.twoColumnBrowseResultsRenderer.tabs;
|
||||
const metadata = this.data.metadata;
|
||||
|
||||
const home_tab = tabs.find((tab) => tab.tabRenderer.title == 'Home');
|
||||
const home_contents = home_tab.tabRenderer.content.sectionListRenderer.contents;
|
||||
const home_shelves = [];
|
||||
|
||||
home_contents.forEach((content) => {
|
||||
if (content.itemSectionRenderer) {
|
||||
const contents = content.itemSectionRenderer.contents[0];
|
||||
const list = contents?.shelfRenderer?.content.horizontalListRenderer;
|
||||
if (!list) return; // Ignores featured channels (for now only videos & playlists are supported)
|
||||
|
||||
const shelf = {
|
||||
title: contents.shelfRenderer.title.runs[0].text,
|
||||
content: []
|
||||
};
|
||||
|
||||
shelf.content = list.items.map((item) => {
|
||||
if (item.gridVideoRenderer) {
|
||||
return YTDataItems.GridVideoItem.parseItem(item);
|
||||
} else if (item.gridPlaylistRenderer) {
|
||||
return YTDataItems.GridPlaylistItem.parseItem(item);
|
||||
}
|
||||
});
|
||||
|
||||
home_shelves.push(shelf);
|
||||
}
|
||||
});
|
||||
|
||||
const ch_info = YTDataItems.ChannelMetadata.parse(metadata);
|
||||
|
||||
return {
|
||||
...ch_info,
|
||||
content: {
|
||||
// Home page of the channel, always available in the first request.
|
||||
home_page: home_shelves,
|
||||
|
||||
// TODO: Implement these (note: they require additional requests)
|
||||
getVideos: () => {},
|
||||
getPlaylists: () => {},
|
||||
getCommunity: () => {},
|
||||
getChannels: () => {},
|
||||
getAbout: () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#processNotifications() {
|
||||
const contents = this.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
|
||||
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications', response);
|
||||
|
||||
const parseItems = (items) => {
|
||||
const parsed_items = YTDataItems.NotificationItem.parse(items);
|
||||
|
||||
const getContinuation = async () => {
|
||||
const citem = items.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken;
|
||||
|
||||
const response = await Actions.notifications(this.session, 'get_notification_menu', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
|
||||
|
||||
return parseItems(response.data.actions[0].appendContinuationItemsAction.continuationItems);
|
||||
}
|
||||
|
||||
return { items: parsed_items, getContinuation };
|
||||
}
|
||||
|
||||
return parseItems(contents.multiPageMenuNotificationSectionRenderer.items);
|
||||
}
|
||||
|
||||
#processTrending() {
|
||||
const tabs = Utils.findNode(this.data, 'contents', 'tabRenderer', 4, false);
|
||||
const categories = {};
|
||||
|
||||
const trending = tabs.map((tab) => {
|
||||
const tab_renderer = tab.tabRenderer;
|
||||
const tab_content = tab_renderer?.content;
|
||||
const category_title = tab_renderer.title.toLowerCase();
|
||||
|
||||
categories[category_title] = {};
|
||||
|
||||
if (tab_content) { // The “Now” category is always available
|
||||
const contents = tab_content.sectionListRenderer.contents;
|
||||
|
||||
categories[category_title].content = contents.map((content) => {
|
||||
const shelf = content.itemSectionRenderer.contents[0].shelfRenderer;
|
||||
const parsed_shelf = YTDataItems.ShelfRenderer.parse(shelf);
|
||||
return parsed_shelf;
|
||||
});
|
||||
} else { // The rest can only be fetched with additional calls
|
||||
const params = tab_renderer.endpoint.browseEndpoint.params;
|
||||
categories[category_title].getVideos = async () => {
|
||||
const response = await Actions.browse(this.session, 'trending', { params });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve category videos', response);
|
||||
|
||||
const tabs = Utils.findNode(response, 'contents', 'tabRenderer', 4, false);
|
||||
const tab = tabs.find((tab) => tab.tabRenderer.title === tab_renderer.title);
|
||||
|
||||
const contents = tab.tabRenderer.content.sectionListRenderer.contents;
|
||||
const items = Utils.findNode(contents, 'itemSectionRenderer', 'items', 8, false);
|
||||
|
||||
return YTDataItems.VideoItem.parse(items);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
#processHistory() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
|
||||
|
||||
const history = { items: [] };
|
||||
|
||||
const parseItems = (contents) => {
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
|
||||
const header = section.itemSectionRenderer.header.itemSectionHeaderRenderer.title;
|
||||
const section_title = header?.simpleText || header?.runs.map((run) => run.text).join('');
|
||||
const contents = section.itemSectionRenderer.contents;
|
||||
|
||||
const section_items = YTDataItems.VideoItem.parse(contents);
|
||||
|
||||
history.items.push({
|
||||
date: section_title,
|
||||
videos: section_items
|
||||
});
|
||||
});
|
||||
|
||||
history.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await Actions.browse(this.session, 'continuation', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
|
||||
|
||||
history.items = [];
|
||||
|
||||
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Parser;
|
||||
@@ -1,14 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const VideoResultItem = require('./search/VideoResultItem');
|
||||
const SearchSuggestionItem = require('./search/SearchSuggestionItem');
|
||||
const PlaylistItem = require('./others/PlaylistItem');
|
||||
const NotificationItem = require('./others/NotificationItem');
|
||||
const VideoItem = require('./others/VideoItem');
|
||||
const GridVideoItem = require('./others/GridVideoItem');
|
||||
const GridPlaylistItem = require('./others/GridPlaylistItem');
|
||||
const ChannelMetadata = require('./others/ChannelMetadata');
|
||||
const ShelfRenderer = require('./others/ShelfRenderer');
|
||||
const CommentThread = require('./others/CommentThread');
|
||||
|
||||
module.exports = { VideoResultItem, SearchSuggestionItem, PlaylistItem, NotificationItem, VideoItem, GridVideoItem, GridPlaylistItem, ChannelMetadata, ShelfRenderer, CommentThread };
|
||||
@@ -1,20 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class ChannelMetadata {
|
||||
static parse(data) {
|
||||
return {
|
||||
title: data.channelMetadataRenderer.title,
|
||||
description: data.channelMetadataRenderer.description,
|
||||
metadata: {
|
||||
url: data.channelMetadataRenderer?.channelUrl,
|
||||
rss_urls: data.channelMetadataRenderer?.rssUrl,
|
||||
vanity_channel_url: data.channelMetadataRenderer?.vanityChannelUrl,
|
||||
external_id: data.channelMetadataRenderer?.externalId,
|
||||
is_family_safe: data.channelMetadataRenderer?.isFamilySafe,
|
||||
keywords: data.channelMetadataRenderer?.keywords
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChannelMetadata;
|
||||
@@ -1,37 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Constants = require('../../../utils/Constants');
|
||||
|
||||
class CommentThread {
|
||||
static parseItem(item) {
|
||||
if (item.commentThreadRenderer || item.commentRenderer) {
|
||||
const comment = item?.commentThreadRenderer?.comment || item;
|
||||
const replies = item?.commentThreadRenderer?.replies;
|
||||
|
||||
const like_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.likeButton;
|
||||
const dislike_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.dislikeButton;
|
||||
|
||||
return {
|
||||
text: comment.commentRenderer.contentText.runs.map((run) => run.text).join(''),
|
||||
author: {
|
||||
name: comment.commentRenderer.authorText.simpleText,
|
||||
thumbnails: comment.commentRenderer.authorThumbnail.thumbnails,
|
||||
channel_id: comment.commentRenderer.authorEndpoint.browseEndpoint.browseId,
|
||||
channel_url: Constants.URLS.YT_BASE + comment.commentRenderer.authorEndpoint.browseEndpoint.canonicalBaseUrl
|
||||
},
|
||||
metadata: {
|
||||
published: comment.commentRenderer.publishedTimeText.runs[0].text,
|
||||
is_reply: !!item.commentRenderer,
|
||||
is_liked: like_btn.toggleButtonRenderer.isToggled,
|
||||
is_disliked: dislike_btn.toggleButtonRenderer.isToggled,
|
||||
is_pinned: comment.commentRenderer.pinnedCommentBadge && true || false,
|
||||
is_channel_owner: comment.commentRenderer.authorIsChannelOwner,
|
||||
like_count: parseInt(like_btn?.toggleButtonRenderer?.accessibilityData?.accessibilityData.label.replace(/\D/g, '')),
|
||||
reply_count: comment.commentRenderer.replyCount || 0,
|
||||
id: comment.commentRenderer.commentId,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = CommentThread;
|
||||
@@ -1,20 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class GridPlaylistItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
return {
|
||||
id: item?.gridPlaylistRenderer.playlistId,
|
||||
title: item?.gridPlaylistRenderer.title?.runs?.map((run) => run.text).join(''),
|
||||
metadata: {
|
||||
thumbnail: item?.gridPlaylistRenderer.thumbnail?.thumbnails?.slice(-1)[0] || {},
|
||||
video_count: item?.gridPlaylistRenderer.videoCountShortText?.simpleText || 'N/A',
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GridPlaylistItem;
|
||||
@@ -1,35 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Constants = require('../../../utils/Constants');
|
||||
|
||||
class GridVideoItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
return {
|
||||
id: item.gridVideoRenderer.videoId,
|
||||
title: item?.gridVideoRenderer?.title?.runs?.map((run) => run.text).join(' '),
|
||||
channel: {
|
||||
id: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
|
||||
name: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
|
||||
url: `${Constants.URLS.YT_BASE}${item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
|
||||
},
|
||||
metadata: {
|
||||
view_count: item?.gridVideoRenderer?.viewCountText?.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: item?.gridVideoRenderer?.shortViewCountText?.simpleText || 'N/A',
|
||||
accessibility_label: item?.gridVideoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
|
||||
},
|
||||
thumbnail: item?.gridVideoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || [],
|
||||
moving_thumbnail: item?.gridVideoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
|
||||
published: item?.gridVideoRenderer?.publishedTimeText?.simpleText || 'N/A',
|
||||
badges: item?.gridVideoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: item?.gridVideoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GridVideoItem;
|
||||
@@ -1,25 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class NotificationItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
if (item.notificationRenderer) {
|
||||
const notification = item.notificationRenderer;
|
||||
return {
|
||||
title: notification?.shortMessage?.simpleText,
|
||||
sent_time: notification?.sentTimeText?.simpleText,
|
||||
channel_name: notification?.contextualMenu?.menuRenderer?.items[1]?.menuServiceItemRenderer?.text?.runs[1]?.text || 'N/A',
|
||||
channel_thumbnail: notification?.thumbnail?.thumbnails[0],
|
||||
video_thumbnail: notification?.videoThumbnail?.thumbnails[0],
|
||||
video_url: notification.navigationEndpoint.watchEndpoint && `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}` || 'N/A',
|
||||
read: notification.read,
|
||||
notification_id: notification.notificationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NotificationItem;
|
||||
@@ -1,26 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
|
||||
class PlaylistItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
if (item.playlistVideoRenderer)
|
||||
return {
|
||||
id: item?.playlistVideoRenderer?.videoId,
|
||||
title: item?.playlistVideoRenderer?.title?.runs[0]?.text,
|
||||
author: item?.playlistVideoRenderer?.shortBylineText?.runs[0]?.text,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(item?.playlistVideoRenderer?.lengthText?.simpleText || '0'),
|
||||
simple_text: item?.playlistVideoRenderer?.lengthText?.simpleText || 'N/A',
|
||||
accessibility_label: item?.playlistVideoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
|
||||
},
|
||||
thumbnails: item?.playlistVideoRenderer?.thumbnail?.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistItem;
|
||||
@@ -1,41 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const VideoItem = require('./VideoItem');
|
||||
const GridVideoItem = require('./GridVideoItem');
|
||||
|
||||
class ShelfRenderer {
|
||||
static parse(data) {
|
||||
return {
|
||||
title: this.getTitle(data.title),
|
||||
videos: this.parseItems(data.content)
|
||||
}
|
||||
}
|
||||
|
||||
static getTitle(data) {
|
||||
if ('runs' in (data || {})) {
|
||||
return data.runs.map((run) => run.text).join('');
|
||||
} else if ('simpleText' in (data || {})) {
|
||||
return data.simpleText;
|
||||
} else {
|
||||
return 'Others';
|
||||
}
|
||||
}
|
||||
|
||||
static parseItems(data) {
|
||||
let items;
|
||||
|
||||
if ('expandedShelfContentsRenderer' in data) {
|
||||
items = data.expandedShelfContentsRenderer.items;
|
||||
} else if ('horizontalListRenderer' in data) {
|
||||
items = data.horizontalListRenderer.items;
|
||||
}
|
||||
|
||||
const videos = ('gridVideoRenderer' in items[0])
|
||||
&& GridVideoItem.parse(items)
|
||||
|| VideoItem.parse(items);
|
||||
|
||||
return videos;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ShelfRenderer;
|
||||
@@ -1,46 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
const Constants = require('../../../utils/Constants');
|
||||
|
||||
class VideoItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
item = (item.richItemRenderer && item.richItemRenderer.content.videoRenderer)
|
||||
&& item.richItemRenderer.content
|
||||
|| item;
|
||||
|
||||
if (item.videoRenderer) return {
|
||||
id: item.videoRenderer.videoId,
|
||||
title: item.videoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
description: item?.videoRenderer?.descriptionSnippet?.runs[0]?.text || 'N/A',
|
||||
channel: {
|
||||
id: item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
|
||||
name: item?.videoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
|
||||
url: `${Constants.URLS.YT_BASE}${item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
|
||||
},
|
||||
metadata: {
|
||||
view_count: item?.videoRenderer?.viewCountText?.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: item?.videoRenderer?.shortViewCountText?.simpleText || 'N/A',
|
||||
accessibility_label: item?.videoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
|
||||
},
|
||||
thumbnail: item?.videoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || {},
|
||||
moving_thumbnail: item?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
|
||||
published: item?.videoRenderer?.publishedTimeText?.simpleText || 'N/A',
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(item?.videoRenderer?.lengthText?.simpleText || '0'),
|
||||
simple_text: item?.videoRenderer?.lengthText?.simpleText || 'N/A',
|
||||
accessibility_label: item?.videoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
|
||||
},
|
||||
badges: item?.videoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: item?.videoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VideoItem;
|
||||
@@ -1,12 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class SearchSuggestionItem {
|
||||
static parse(data, bold_text) {
|
||||
return data.map((item) => ({
|
||||
text: item.trim(),
|
||||
bold_text: bold_text.trim().toLowerCase()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SearchSuggestionItem;
|
||||
@@ -1,43 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
const Constants = require('../../../utils/Constants');
|
||||
|
||||
class VideoResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const renderer = item.videoRenderer || item.compactVideoRenderer;
|
||||
if (renderer) return {
|
||||
id: renderer.videoId,
|
||||
url: `https://youtu.be/${renderer.videoId}`,
|
||||
title: renderer.title.runs[0].text,
|
||||
description: renderer?.detailedMetadataSnippets && renderer?.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
|
||||
channel: {
|
||||
id: renderer?.ownerText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
|
||||
name: renderer?.ownerText?.runs[0]?.text,
|
||||
url: `${Constants.URLS.YT_BASE}${renderer.ownerText.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
|
||||
},
|
||||
metadata: {
|
||||
view_count: renderer?.viewCountText?.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: renderer?.shortViewCountText?.simpleText || 'N/A',
|
||||
accessibility_label: renderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
|
||||
},
|
||||
thumbnails: renderer?.thumbnail.thumbnails,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(renderer?.lengthText?.simpleText || '0'),
|
||||
simple_text: renderer?.lengthText?.simpleText || 'N/A',
|
||||
accessibility_label: renderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
|
||||
},
|
||||
published: renderer?.publishedTimeText?.simpleText || 'N/A',
|
||||
badges: renderer?.badges?.map((item) => item.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: renderer?.ownerBadges?.map((item) => item.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VideoResultItem;
|
||||
@@ -1,12 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const SongResultItem = require('./search/SongResultItem');
|
||||
const VideoResultItem = require('./search/VideoResultItem');
|
||||
const AlbumResultItem = require('./search/AlbumResultItem');
|
||||
const ArtistResultItem = require('./search/ArtistResultItem');
|
||||
const PlaylistResultItem = require('./search/PlaylistResultItem');
|
||||
const MusicSearchSuggestionItem = require('./search/MusicSearchSuggestionItem');
|
||||
const TopResultItem = require('./search/TopResultItem');
|
||||
const PlaylistItem = require('./others/PlaylistItem');
|
||||
|
||||
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, MusicSearchSuggestionItem, TopResultItem, PlaylistItem };
|
||||
@@ -1,28 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../../../utils/Utils');
|
||||
|
||||
class PlaylistItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item.id);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const item_renderer = item.musicResponsiveListItemRenderer;
|
||||
const fixed_columns = item_renderer.fixedColumns;
|
||||
const flex_columns = item_renderer.flexColumns;
|
||||
|
||||
return {
|
||||
id: item_renderer.playlistItemData && item_renderer.playlistItemData.videoId,
|
||||
title: flex_columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
author: flex_columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text || '0'),
|
||||
simple_text: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
|
||||
},
|
||||
thumbnails: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistItem;
|
||||
@@ -1,21 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class AlbumResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
return {
|
||||
id: list_item.navigationEndpoint.browseEndpoint.browseId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
year: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
|
||||
.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
|
||||
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AlbumResultItem;
|
||||
@@ -1,19 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class ArtistResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
return {
|
||||
id: list_item.navigationEndpoint.browseEndpoint.browseId,
|
||||
name: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
subscribers: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ArtistResultItem;
|
||||
@@ -1,22 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class MusicSearchSuggestionItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
let suggestion;
|
||||
|
||||
item.historySuggestionRenderer &&
|
||||
(suggestion = item.historySuggestionRenderer.suggestion) ||
|
||||
(suggestion = item.searchSuggestionRenderer.suggestion);
|
||||
|
||||
return {
|
||||
text: suggestion.runs.map((run) => run.text).join('').trim(),
|
||||
bold_text: suggestion.runs[0].text.trim()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MusicSearchSuggestionItem;
|
||||
@@ -1,23 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class PlaylistResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item));
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
const watch_playlist_endpoint = list_item?.overlay?.musicItemThumbnailOverlayRenderer
|
||||
?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchPlaylistEndpoint;
|
||||
|
||||
return {
|
||||
id: watch_playlist_endpoint?.playlistId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
channel_id: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.navigationEndpoint?.browseEndpoint.browseId || '0',
|
||||
total_items: parseInt(list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text.match(/\d+/g)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlaylistResultItem;
|
||||
@@ -1,22 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
class SongResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => this.parseItem(item)).filter((item) => item);
|
||||
}
|
||||
|
||||
static parseItem(item) {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
if (list_item.playlistItemData) return {
|
||||
id: list_item.playlistItemData.videoId,
|
||||
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
|
||||
artist: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
|
||||
album: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
|
||||
duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
|
||||
.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
|
||||
thumbnails: list_item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SongResultItem;
|
||||
@@ -1,33 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const SongResultItem = require('./SongResultItem');
|
||||
const VideoResultItem = require('./VideoResultItem');
|
||||
const AlbumResultItem = require('./AlbumResultItem');
|
||||
const ArtistResultItem = require('./ArtistResultItem');
|
||||
const PlaylistResultItem = require('./PlaylistResultItem');
|
||||
|
||||
class TopResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
|
||||
const runs = list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs;
|
||||
const type = runs[0].text.toLowerCase();
|
||||
|
||||
const parsed_item = ({
|
||||
playlist: () => PlaylistResultItem.parseItem(item),
|
||||
song: () => SongResultItem.parseItem(item),
|
||||
video: () => VideoResultItem.parseItem(item),
|
||||
artist: () => ArtistResultItem.parseItem(item),
|
||||
album: () => AlbumResultItem.parseItem(item),
|
||||
single: () => AlbumResultItem.parseItem(item)
|
||||
}[type])();
|
||||
|
||||
parsed_item && (parsed_item.type = type);
|
||||
|
||||
return parsed_item;
|
||||
}).filter((item) => item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TopResultItem;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user