Compare commits
649 commits
dood-refac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
720c9bb651 | ||
|
|
f05cf5040c | ||
|
|
65191cfc8d | ||
|
|
bd2dec0392 | ||
|
|
5f9fbbb30d | ||
|
|
43f04455f1 | ||
|
|
43637a2ff4 | ||
|
|
b87bf62362 | ||
|
|
89e57d48d9 | ||
|
|
ad47aecea3 | ||
|
|
f192fd0b75 | ||
|
|
557efd2079 | ||
|
|
373c6c1d4c | ||
|
|
4c7da5dc20 | ||
|
|
bed5b00300 | ||
|
|
e36d460d57 | ||
|
|
5cffa112ba | ||
|
|
66ae60ca44 | ||
|
|
b60496bf22 | ||
|
|
ff39c59475 | ||
|
|
b6b67568b5 | ||
|
|
eecf9c42c5 | ||
|
|
fc3d61b05a | ||
|
|
b2b9760445 | ||
|
|
7afff18629 | ||
|
|
ee3effaf00 | ||
|
|
296f0478b0 | ||
|
|
693a77e73e | ||
|
|
b769cd0aa1 | ||
|
|
9eca05efc5 | ||
|
|
ca0110cdbc | ||
|
|
66ee72baf0 | ||
|
|
3c08d69bc3 | ||
|
|
0f004dc878 | ||
|
|
b7093ed4ae | ||
|
|
20289eaeaf | ||
|
|
3b16fe0390 | ||
|
|
c78c1ad628 | ||
|
|
8fe4cc5a9b | ||
|
|
930e8191be | ||
|
|
2734fb4d0d | ||
|
|
b5223148c7 | ||
|
|
ad3d0b317e | ||
|
|
e90b5583a7 | ||
|
|
394dc91bfa | ||
|
|
7de046838d | ||
|
|
955872a825 | ||
|
|
d17780f0a7 | ||
|
|
737a8063a8 | ||
|
|
423abf2479 | ||
|
|
4e436509bf | ||
|
|
f58e0e1f4f | ||
|
|
d2e72b0756 | ||
|
|
edebd860aa | ||
|
|
6b8363bd9c | ||
|
|
d0c40ffa00 | ||
|
|
b36e1484eb | ||
|
|
a5e429fea3 | ||
|
|
6d5870ccb2 | ||
|
|
6cc82520a0 | ||
|
|
7dfc599de9 | ||
|
|
aa820e1f64 | ||
|
|
38d0ac5305 | ||
|
|
438387faee | ||
|
|
5a1786d3e5 | ||
|
|
bbb80f3cf0 | ||
|
|
8ea6d4fe37 | ||
|
|
282b3d05b3 | ||
|
|
168d0280dc | ||
|
|
50e024a3ea | ||
|
|
e1d582036a | ||
|
|
354c2731d8 | ||
|
|
ce4637ea80 | ||
|
|
73ce5f372d | ||
|
|
221fa989f4 | ||
|
|
a4dfad6235 | ||
|
|
ae10810caa | ||
|
|
964e979469 | ||
|
|
556b20e669 | ||
|
|
6c102362fd | ||
|
|
49faa5d116 | ||
|
|
cf8fb4cfa4 | ||
|
|
3bb7a800e3 | ||
|
|
26b6143152 | ||
|
|
728ed7ec06 | ||
|
|
5bfbd94e51 | ||
|
|
23db19d073 | ||
|
|
bd160eb32c | ||
|
|
fb354b4746 | ||
|
|
245dcf6007 | ||
|
|
94f5c7438d | ||
|
|
d0f3e0f931 | ||
|
|
524630d671 | ||
|
|
65a0e57ab9 | ||
|
|
eccea520b7 | ||
|
|
7ec9c5b147 | ||
|
|
1ae12fa0f7 | ||
|
|
ccdd4600bb | ||
|
|
3f35ecf6f3 | ||
|
|
de13209709 | ||
|
|
34500ea3fc | ||
|
|
72c191fbc1 | ||
|
|
8c2897a0b1 | ||
|
|
31da8aa92c | ||
|
|
92c45d553e | ||
|
|
ae5829dcb0 | ||
|
|
3f5a82d663 | ||
|
|
2612860f15 | ||
|
|
e6adf6549a | ||
|
|
c23ffa382d | ||
|
|
1f4d81bfc9 | ||
|
|
455d84d5b1 | ||
|
|
72c19e7f59 | ||
|
|
e52cee2125 | ||
|
|
5c87c5d9e2 | ||
|
|
8f8ecb868e | ||
|
|
a876c9b144 | ||
|
|
2ba6e4fa35 | ||
|
|
50a7e5559b | ||
|
|
db860c25c6 | ||
|
|
074311107a | ||
|
|
19a73b3866 | ||
|
|
75c9509793 | ||
|
|
bc98d64969 | ||
|
|
a90d7b1cad | ||
|
|
43e16f5bfa | ||
|
|
622b1b3bcc | ||
|
|
12ea135feb | ||
|
|
e5197fdb8b | ||
|
|
a5539bcf59 | ||
|
|
d3db3fc512 | ||
|
|
9945ed4615 | ||
|
|
28a83e2f29 | ||
|
|
93fe1ae871 | ||
|
|
1ddc8d99d6 | ||
|
|
3c979e77cc | ||
|
|
a00fa2c3d6 | ||
|
|
7c886583c9 | ||
|
|
92a4ed5578 | ||
|
|
e8ed29eb9d | ||
|
|
ef2cc7f16b | ||
|
|
646e75d5f8 | ||
|
|
d259587491 | ||
|
|
7c99e4d1c1 | ||
|
|
aeb15d8640 | ||
|
|
771d664e5a | ||
|
|
7b708e2385 | ||
|
|
a9c281c8c4 | ||
|
|
8aa1344fa5 | ||
|
|
5fd62028ce | ||
|
|
5b49dd0735 | ||
|
|
7bb38fda3e | ||
|
|
507b96014c | ||
|
|
986db2cc67 | ||
|
|
546dee9799 | ||
|
|
4cfbf731b8 | ||
|
|
231a12f685 | ||
|
|
e54b54bfb3 | ||
|
|
58966ccff1 | ||
|
|
59c84952ed | ||
|
|
90c56debfe | ||
|
|
9cf9c4498c | ||
|
|
31ca9597e5 | ||
|
|
17e70c169b | ||
|
|
79c8e7fc32 | ||
|
|
26278aadba | ||
|
|
5ef6119294 | ||
|
|
96c4b1f16b | ||
|
|
4cf08fc6d5 | ||
|
|
bad1170421 | ||
|
|
7593d3d62e | ||
|
|
ce11a80ba9 | ||
|
|
5103be82a4 | ||
|
|
9ee91dff31 | ||
|
|
381fb89cfb | ||
|
|
9354c0ee9b | ||
|
|
51aa127a33 | ||
|
|
9d6d138fff | ||
|
|
a189cddd37 | ||
|
|
f7cf9da686 | ||
|
|
d00cc8a94a | ||
|
|
20ce2ac831 | ||
|
|
c6a6a70eaf | ||
|
|
9e256186aa | ||
|
|
f047287fe9 | ||
|
|
816123ab83 | ||
|
|
815d3b6b1e | ||
|
|
05b80a2d63 | ||
|
|
de79dc014f | ||
|
|
1a99a9e99b | ||
|
|
f49769f3c4 | ||
|
|
77cf6767b1 | ||
|
|
d1fcd671c9 | ||
|
|
5cb4944413 | ||
|
|
559ee6ed4e | ||
|
|
cc1e96f3cf | ||
|
|
b273e3a71d | ||
|
|
66fb0e2e6d | ||
|
|
baf5c9143d | ||
|
|
28a8a1554d | ||
|
|
515ca0a98c | ||
|
|
dcad7db29c | ||
|
|
4bec9e556f | ||
|
|
da7397fe75 | ||
|
|
86eb3d498c | ||
|
|
ca8caa7795 | ||
|
|
f6f71b043b | ||
|
|
281fe00a35 | ||
|
|
e5d68c5214 | ||
|
|
04f3fc42e0 | ||
|
|
ed50cb2f6a | ||
|
|
e1e8f37862 | ||
|
|
7146c6cf11 | ||
|
|
41bc53b9da | ||
|
|
bfe8c36e87 | ||
|
|
430e5b7fce | ||
|
|
8d1991ed84 | ||
|
|
c075fe9481 | ||
|
|
7e4891a93f | ||
|
|
f8f62db174 | ||
|
|
93a098e438 | ||
|
|
1f4d7c752d | ||
|
|
9b3c7c76cf | ||
|
|
ac4cc8a346 | ||
|
|
e7b1e2a4fc | ||
|
|
f8b63f4cce | ||
|
|
30c60261af | ||
|
|
9079feebbd | ||
|
|
a7d93590c6 | ||
|
|
d20c568e85 | ||
|
|
c8e38d73ff | ||
|
|
135d90a233 | ||
|
|
9f5ac02ec6 | ||
|
|
6f24b7928d | ||
|
|
b5fc29f150 | ||
|
|
6cc24c0332 | ||
|
|
924265b964 | ||
|
|
7264fd2ed4 | ||
|
|
0610b8556a | ||
|
|
9fd563450e | ||
|
|
ddf4f227b3 | ||
|
|
36a15f0091 | ||
|
|
58bd6250c9 | ||
|
|
10081a2276 | ||
|
|
fa6bee4c83 | ||
|
|
40f9f9ec8c | ||
|
|
01649955b1 | ||
|
|
5d412b759f | ||
|
|
4f7a9d725e | ||
|
|
e3ea65d7f4 | ||
|
|
d71aa240e7 | ||
|
|
e438f3cbbd | ||
|
|
d44e0a2588 | ||
|
|
258e35c94d | ||
|
|
d607b7fb6b | ||
|
|
42b9cbe38f | ||
|
|
d7edb51698 | ||
|
|
1bd85fb4ad | ||
|
|
580845ae77 | ||
|
|
32b49d49a9 | ||
|
|
5ff8a49be5 | ||
|
|
3955820eb9 | ||
|
|
609bd23cd8 | ||
|
|
0f7bc7c07f | ||
|
|
cf0be4712e | ||
|
|
50c5d3a1cd | ||
|
|
8dae887eca | ||
|
|
3be37008b7 | ||
|
|
ae00c43106 | ||
|
|
d7392c09d2 | ||
|
|
f549a32dd2 | ||
|
|
e092d7247d | ||
|
|
d3dbedf18a | ||
|
|
90ab3d0a31 | ||
|
|
cba3c8bbae | ||
|
|
4a14ad7fb0 | ||
|
|
5c04104ad2 | ||
|
|
ef2b6721c7 | ||
|
|
d7e82ceff3 | ||
|
|
973eb06584 | ||
|
|
825d97adc4 | ||
|
|
8835219c26 | ||
|
|
1dd9d324e6 | ||
|
|
afbc16a3ec | ||
|
|
98b9d5a81d | ||
|
|
6f6a3ae2fd | ||
|
|
5300e6c6e7 | ||
|
|
9f7155f42e | ||
|
|
362bef9f57 | ||
|
|
4cd1e09466 | ||
|
|
1765311586 | ||
|
|
68cfa65135 | ||
|
|
b7930014ab | ||
|
|
67d0508fd7 | ||
|
|
280abc4dea | ||
|
|
e1c727b7de | ||
|
|
d07d31d94a | ||
|
|
09f795b862 | ||
|
|
f5f47403cc | ||
|
|
31e46c5c56 | ||
|
|
219cb4b5f6 | ||
|
|
683e26e32a | ||
|
|
9bec5b5f3b | ||
|
|
4815054fed | ||
|
|
c50e095781 | ||
|
|
b4ba098158 | ||
|
|
836021c57e | ||
|
|
0a85b0dc1e | ||
|
|
3b78186702 | ||
|
|
dbbf310661 | ||
|
|
7eb760b5d6 | ||
|
|
e2268a325a | ||
|
|
3f0c661dae | ||
|
|
7dca36fa83 | ||
|
|
159be1cac4 | ||
|
|
e478a45bc0 | ||
|
|
d80ca63dad | ||
|
|
57bc4640e5 | ||
|
|
33dbeb9776 | ||
|
|
3c398d4a1d | ||
|
|
943f4c3453 | ||
|
|
8e1ea10c5a | ||
|
|
a7bc742035 | ||
|
|
82b810ed36 | ||
|
|
6eceaf474d | ||
|
|
88a1fe10a1 | ||
|
|
240504816c | ||
|
|
053dce0729 | ||
|
|
a36d152b61 | ||
|
|
e5a5c62106 | ||
|
|
db25f98251 | ||
|
|
ad7c9cc545 | ||
|
|
331e5bd5d1 | ||
|
|
c4bf32f991 | ||
|
|
20c43a3ea1 | ||
|
|
172edaf0fc | ||
|
|
4bc5e42e50 | ||
|
|
88b6a5ef45 | ||
|
|
5078609c86 | ||
|
|
e6ef4b870a | ||
|
|
68dcab39fb | ||
|
|
79c16d530d | ||
|
|
365c2de4e5 | ||
|
|
7cabb4d4ab | ||
|
|
73476779ae | ||
|
|
bd1e184023 | ||
|
|
cc7b0be150 | ||
|
|
e2eaa9143b | ||
|
|
23da8dc637 | ||
|
|
6203863523 | ||
|
|
e6d8d5076c | ||
|
|
cc3d30be2f | ||
|
|
77164dfaa7 | ||
|
|
56e72b73fe | ||
|
|
c8b6cf2732 | ||
|
|
a9b81d00c0 | ||
|
|
03bb9fea79 | ||
|
|
bb733fca96 | ||
|
|
ae7b784783 | ||
|
|
d59658fb7b | ||
|
|
20a4eb2f94 | ||
|
|
ad61be8626 | ||
|
|
209ec2673f | ||
|
|
6fffb57a8d | ||
|
|
5d6a8145c3 | ||
|
|
00fcda4f12 | ||
|
|
e989fc05d5 | ||
|
|
ea2c128937 | ||
|
|
a93799036d | ||
|
|
ce2fac6c4c | ||
|
|
bcd868850d | ||
|
|
eaa95e8cd9 | ||
|
|
a76e7254ff | ||
|
|
14da572830 | ||
|
|
c7b507ca6b | ||
|
|
6f60c49bd1 | ||
|
|
0018ccc929 | ||
|
|
177739e7cd | ||
|
|
5bf8096158 | ||
|
|
066424e087 | ||
|
|
7e08a8090c | ||
|
|
a4fb495e28 | ||
|
|
9ab69d8461 | ||
|
|
1a56d7ef91 | ||
|
|
c88b3798f0 | ||
|
|
076f853a5b | ||
|
|
405a8efad3 | ||
|
|
6f3233eefd | ||
|
|
3007698b1b | ||
|
|
0830635947 | ||
|
|
c7040e9936 | ||
|
|
da24e96fb4 | ||
|
|
a42edcd438 | ||
|
|
cf1f3e9eed | ||
|
|
c5c4c1a41c | ||
|
|
fbffe3aa67 | ||
|
|
8761eee22f | ||
|
|
068017941b | ||
|
|
a3b90bf19a | ||
|
|
22c6754efc | ||
|
|
5e1c3eea24 | ||
|
|
e11cf8986e | ||
|
|
b8c5c1a4f3 | ||
|
|
06e0ba23d7 | ||
|
|
8fb3320868 | ||
|
|
b3db546e00 | ||
|
|
312f60660a | ||
|
|
bab0da2a8f | ||
|
|
a7360b68f0 | ||
|
|
f1c00e2684 | ||
|
|
298ac96558 | ||
|
|
a54ae2b151 | ||
|
|
d0958e8771 | ||
|
|
8b4aef94be | ||
|
|
c19d46f406 | ||
|
|
5170dde1d6 | ||
|
|
c27c1f9541 | ||
|
|
424c2fd1e0 | ||
|
|
bb21bbc7fa | ||
|
|
d1493cbb57 | ||
|
|
30a709d619 | ||
|
|
c610a995c2 | ||
|
|
78125c7f37 | ||
|
|
7191c59b8a | ||
|
|
eea8cac018 | ||
|
|
5a4f28e50a | ||
|
|
f7bb497432 | ||
|
|
b8360405dd | ||
|
|
753e90bbe2 | ||
|
|
ac7d2480ea | ||
|
|
26120d1662 | ||
|
|
5ac359f1eb | ||
|
|
34b7fddfc7 | ||
|
|
390adea9d4 | ||
|
|
7e4657c6ad | ||
|
|
04069d83ea | ||
|
|
0c0c2987e8 | ||
|
|
012b1648e1 | ||
|
|
04b8987c30 | ||
|
|
9f46b7fffa | ||
|
|
b5d7e95a92 | ||
|
|
c3721c428e | ||
|
|
d6fb4c971b | ||
|
|
5466472b21 | ||
|
|
f0a66e1984 | ||
|
|
4598151034 | ||
|
|
8f2e8e492f | ||
|
|
e327f3c734 | ||
|
|
20648ae4cd | ||
|
|
4bf2cf632b | ||
|
|
0879531478 | ||
|
|
4bb8e132f5 | ||
|
|
fb689bf7da | ||
|
|
6c44fccf66 | ||
|
|
b670fc7109 | ||
|
|
d8a4375783 | ||
|
|
60530343b4 | ||
|
|
fcced2e122 | ||
|
|
af5c3f2ae6 | ||
|
|
be7c19a54f | ||
|
|
708d3d785d | ||
|
|
25b326ee48 | ||
|
|
447153ca3c | ||
|
|
1fb7d0c66b | ||
|
|
ae342845f8 | ||
|
|
0ff0758b74 | ||
|
|
7760c13ea9 | ||
|
|
3757a3514b | ||
|
|
6214df7e8f | ||
|
|
42d46accb6 | ||
|
|
ddc8133c5b | ||
|
|
a96d015a0c | ||
|
|
b1244086cd | ||
|
|
da556d3523 | ||
|
|
6f412d736b | ||
|
|
4697d150cf | ||
|
|
4c5f5a3514 | ||
|
|
0a0bedacb4 | ||
|
|
7e771c2644 | ||
|
|
9709c31bcd | ||
|
|
2e3b6602af | ||
|
|
6d5a511cb4 | ||
|
|
1a2b40d6f2 | ||
|
|
b7714d7d60 | ||
|
|
953a7914c9 | ||
|
|
092d53ef9d | ||
|
|
48b0924bd8 | ||
|
|
a2af1ae21e | ||
|
|
833c4870f8 | ||
|
|
a30b271a57 | ||
|
|
f9d8dc0b86 | ||
|
|
60f44c9603 | ||
|
|
1433d032f3 | ||
|
|
d90cd8f984 | ||
|
|
c10ca29fda | ||
|
|
c819dd6bb7 | ||
|
|
d06c2934a8 | ||
|
|
261897c699 | ||
|
|
6ef80d3166 | ||
|
|
aeb7ce5f54 | ||
|
|
1028c663eb | ||
|
|
e33aa42b19 | ||
|
|
48b6f9efb0 | ||
|
|
f4d905833f | ||
|
|
65c73e8b6e | ||
|
|
737bf6aa94 | ||
|
|
f89d90619a | ||
|
|
a3c25adc7a | ||
|
|
ae32376758 | ||
|
|
aded562304 | ||
|
|
8b479be6c5 | ||
|
|
8acbd37df3 | ||
|
|
cf6946bc4a | ||
|
|
78db179957 | ||
|
|
9a279b53a4 | ||
|
|
2aa9ad270e | ||
|
|
4ca87f0ff1 | ||
|
|
dc610bfa64 | ||
|
|
58b50b49de | ||
|
|
a79b0bdfdd | ||
|
|
b67e1b85d6 | ||
|
|
2421913587 | ||
|
|
4078f0c404 | ||
|
|
731bd33470 | ||
|
|
4351ddc00d | ||
|
|
941a61fb74 | ||
|
|
a729dc85e0 | ||
|
|
491595934f | ||
|
|
d087318286 | ||
|
|
5429b0cb7a | ||
|
|
69da82b139 | ||
|
|
fdb9bff86b | ||
|
|
cb256de9f8 | ||
|
|
b27126fd8e | ||
|
|
b9c13b8a89 | ||
|
|
2a47d97d5c | ||
|
|
0f00aecde5 | ||
|
|
7ee87ceb9e | ||
|
|
81208ab99b | ||
|
|
b513a8926b | ||
|
|
2f370d1b9c | ||
|
|
ffd62a5d16 | ||
|
|
7249c1fd96 | ||
|
|
87893819b0 | ||
|
|
c29ec82d6a | ||
|
|
98c53a15f2 | ||
|
|
d190446187 | ||
|
|
7f264b2503 | ||
|
|
128c144ebf | ||
|
|
531ad7562a | ||
|
|
c9e4dfce68 | ||
|
|
057b2346dc | ||
|
|
7f2cf10021 | ||
|
|
904f5de3e0 | ||
|
|
00bd11495f | ||
|
|
03b3512380 | ||
|
|
c5da7c1997 | ||
|
|
8b5588936e | ||
|
|
3a2178eb55 | ||
|
|
78a83c536e | ||
|
|
17e99101bc | ||
|
|
8430e5d0d1 | ||
|
|
0bfced503c | ||
|
|
39681dc7f6 | ||
|
|
39850dccf7 | ||
|
|
327d297d58 | ||
|
|
68232363ae | ||
|
|
41f78b50e7 | ||
|
|
c2983f0551 | ||
|
|
6577eda289 | ||
|
|
ad4eea68ce | ||
|
|
e1b5e7bce1 | ||
|
|
3b8481ed3f | ||
|
|
9f15a03a5a | ||
|
|
3380185449 | ||
|
|
b60f91a96a | ||
|
|
1ecb088b60 | ||
|
|
8a1645fb81 | ||
|
|
224b241cd6 | ||
|
|
907a150574 | ||
|
|
48c7735165 | ||
|
|
55983990fb | ||
|
|
120f10139c | ||
|
|
b589c25bb5 | ||
|
|
211babec37 | ||
|
|
e846900384 | ||
|
|
ad7444ed97 | ||
|
|
af4d0010fb | ||
|
|
fde6128757 | ||
|
|
cf79473fad | ||
|
|
ff62ea0597 | ||
|
|
ad7e480213 | ||
|
|
139cb64ca5 | ||
|
|
cfcf81b665 | ||
|
|
4002301fa6 | ||
|
|
b0a251c21e | ||
|
|
756cd01ab9 | ||
|
|
c7e87e14b9 | ||
|
|
fd0900e053 | ||
|
|
5ea34f66dd | ||
|
|
5547fd5486 | ||
|
|
12ac42ef5f | ||
|
|
89790609db | ||
|
|
2b7e9c403d | ||
|
|
94abcd3f0a | ||
|
|
83021fb773 | ||
|
|
63ada6099a | ||
|
|
833043143f | ||
|
|
41aacf84db | ||
|
|
1b89cadab5 | ||
|
|
969ab8d0d7 | ||
|
|
896ec74959 | ||
|
|
316fd4621b | ||
|
|
bb95118d2b | ||
|
|
fc17545860 | ||
|
|
a250440670 | ||
|
|
1e100e6d2e | ||
|
|
ccf8006f13 | ||
|
|
7ac71837b3 | ||
|
|
c68570d2bc | ||
|
|
bc06cb5750 | ||
|
|
dc093f7109 | ||
|
|
50764b3602 | ||
|
|
bd38329fd8 | ||
|
|
ceb520d49d | ||
|
|
b47a512587 | ||
|
|
2468fbde4e | ||
|
|
e109637e48 | ||
|
|
4d05ac859f | ||
|
|
285746a4a9 | ||
|
|
8154527533 | ||
|
|
4414324a08 | ||
|
|
c47f48c64b | ||
|
|
0fc7675560 | ||
|
|
3c4507b198 | ||
|
|
c556d9500b | ||
|
|
33ee150915 | ||
|
|
c82a7ab756 | ||
|
|
9da7c07dc6 | ||
|
|
3044e41d9b | ||
|
|
75363c72cf | ||
|
|
18d8ee6b79 | ||
|
|
4a1a3b19aa | ||
|
|
cefc80fa55 | ||
|
|
ada3ff1926 | ||
|
|
b4dca46e5e | ||
|
|
0449150874 | ||
|
|
2381a2c2ad | ||
|
|
c8cd2ec794 |
682 changed files with 126990 additions and 35801 deletions
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Smartphone (please complete the following information):**
|
||||||
|
- Device: [e.g. iPhone6]
|
||||||
|
- OS: [e.g. iOS8.1]
|
||||||
|
- Browser [e.g. stock browser, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
23
.github/ISSUE_TEMPLATE/request-source-extractor.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/request-source-extractor.md
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
name: Request source/extractor
|
||||||
|
about: Request a source or extractor to get URLs from
|
||||||
|
title: Request to add <source/extractor>
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**What is the site you want to get added?**
|
||||||
|
A link to the source or embed hoster
|
||||||
|
|
||||||
|
**What is the benefit of adding this?**
|
||||||
|
More content is not a good reason. If it's already covered via an existing source/extractor, why should we add another one?
|
||||||
|
|
||||||
|
**Are you aware of any protection the site uses?**
|
||||||
|
E.g. rate-limits or Cloudflare captchas and such
|
||||||
|
|
||||||
|
**What languages does this relate to?**
|
||||||
|
E.g. multi, German, ..
|
||||||
|
|
||||||
|
**Example**
|
||||||
|
A movie/series IMDb/TMDB ID + source link or embed URL to test with.
|
||||||
28
.github/workflows/build-tag.yml
vendored
Normal file
28
.github/workflows/build-tag.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
name: Build and push latest
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
-
|
||||||
|
name: Set up QEMU (for cross-arch)
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: webstreamr/webstreamr:latest
|
||||||
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
|
|
@ -1,15 +1,18 @@
|
||||||
name: Build
|
name: Build
|
||||||
on: push
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
push: false
|
push: false
|
||||||
tags: webstreamr/webstreamr:latest
|
tags: webstreamr/webstreamr:latest
|
||||||
|
|
|
||||||
23
.github/workflows/tests.yml
vendored
23
.github/workflows/tests.yml
vendored
|
|
@ -1,26 +1,17 @@
|
||||||
name: Tests
|
name: Tests
|
||||||
on: push
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "24"
|
||||||
|
|
||||||
- name: Cache node modules
|
|
||||||
uses: actions/cache@v4
|
|
||||||
env:
|
|
||||||
cache-name: cache-node-modules
|
|
||||||
with:
|
|
||||||
path: ~/.npm
|
|
||||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
|
||||||
${{ runner.os }}-build-
|
|
||||||
${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
".": "0.44.1"
|
".": "0.69.1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1667
CHANGELOG.md
1667
CHANGELOG.md
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:22.18-alpine AS builder
|
FROM node:24 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
@ -10,7 +10,7 @@ RUN npm run build
|
||||||
|
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
FROM node:22.18-alpine
|
FROM node:24
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
|
||||||
98
README.md
98
README.md
|
|
@ -1,15 +1,47 @@
|
||||||
# WebStreamr
|
# WebStreamr
|
||||||
|
|
||||||
[](https://github.com/webstreamr/webstreamr/actions/workflows/tests.yml)
|
[](https://github.com/webstreamr/webstreamr/actions/workflows/tests.yml)
|
||||||
[](https://github.com/webstreamr/webstreamr/releases)
|
[](https://github.com/webstreamr/webstreamr/releases)
|
||||||

|

|
||||||
|
|
||||||
[Stremio](https://www.stremio.com/) add-on which provides HTTP URLs from streaming websites.
|
[Stremio](https://www.stremio.com/) add-on which provides HTTP URLs from streaming websites.
|
||||||
|
|
||||||
|
HTTP streams have limitations.
|
||||||
|
For a better experience, I'd advise using a Debrid service and WebStreamr as fallback.
|
||||||
|
[TorBox](https://torbox.app/subscription?referral=f22eb00d-27ce-4e20-85fc-68da3d018b99) is working very well.
|
||||||
|
|
||||||
## Public instance
|
## Public instance
|
||||||
|
|
||||||
A public instance is available at https://webstreamr.hayd.uk. Hosting infrastructure for this instance is donated by [ElfHosted](https://elfhosted.com), and independently maintained by [Hayduk](https://hayd.uk).
|
A public instance is available at https://webstreamr.hayd.uk. Hosting infrastructure for this instance is donated by [ElfHosted](https://elfhosted.com), and independently maintained by [Hayduk](https://hayd.uk).
|
||||||
|
|
||||||
|
## Known issues / limitations
|
||||||
|
|
||||||
|
- PixelServer / pixeldrain has a daily limit of 6 GB per IP: https://pixeldrain.dev
|
||||||
|
- Dropload and SuperVideo on Android do not work because Stremio does not use the `Referer` header properly via HLS playlists: https://github.com/Stremio/stremio-bugs/issues/2389, maybe https://github.com/Stremio/stremio-bugs/issues/1579
|
||||||
|
- MediaFlow proxy has to be used in an inefficient way because Stremio on Android or its players cannot deal with HLS playlist with redirects: https://github.com/Stremio/stremio-bugs/issues/1574
|
||||||
|
- FlareSolverr cookies cannot be used because Cloudflare does techniques like TLS fingerprinting most likely. But FlareSolverr uses a session per host and should be quick.
|
||||||
|
- VidSrc works but rate limits heavily and is therefore only queried as fallback if nothing else is found.
|
||||||
|
- RgShows detects shared usage and blocks IPs. It therefore only works on private instances.
|
||||||
|
|
||||||
|
## MediaFlow Proxy
|
||||||
|
|
||||||
|
[MediaFlow Proxy](https://github.com/mhdzumair/mediaflow-proxy/) can be added when configuration the add-on to gain access to a couple of more file hosters.
|
||||||
|
It depends highly on the language / source used if that unlocks more streams or not.
|
||||||
|
|
||||||
|
MediaFlow proxy is needed because some hosters ip-lock streams and the add-on does not run on the same device that will stream the video.
|
||||||
|
|
||||||
|
The following hosters can be used only with MediaFlow Proxy:
|
||||||
|
|
||||||
|
- Fastream
|
||||||
|
- FileLions
|
||||||
|
- FileMoon
|
||||||
|
- LuluStream
|
||||||
|
- Mixdrop
|
||||||
|
- Streamtape
|
||||||
|
- VOE
|
||||||
|
|
||||||
|
MediaFlow proxy can either be self-hosted or acquired via bundle from [ElfHosted](https://docs.elfhosted.com/app/mediaflow-proxy/).
|
||||||
|
|
||||||
## Hosting
|
## Hosting
|
||||||
|
|
||||||
Don't want to use the public instance, or concerned about reliability during periods of high use? It's open-source, you can host it yourself!
|
Don't want to use the public instance, or concerned about reliability during periods of high use? It's open-source, you can host it yourself!
|
||||||
|
|
@ -22,12 +54,64 @@ Self-hosting to stressful? ElfHosted offer [ready-to-go, turn-key WebStreamr ins
|
||||||
|
|
||||||
### Self-Hosting
|
### Self-Hosting
|
||||||
|
|
||||||
You can build an image using the [Dockerfile](./Dockerfile) and run it.
|
You can run the latest WebStreamr via Docker. E.g.
|
||||||
|
|
||||||
Alternatively, you can also start WebStreamr directly via
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm install
|
docker run \
|
||||||
npm run build
|
--detach=true \
|
||||||
npm start
|
--name webstreamr \
|
||||||
|
--rm \
|
||||||
|
--pull always \
|
||||||
|
--publish 51546:51546 \
|
||||||
|
--env TMDB_ACCESS_TOKEN="YOUR_TOKEN" \
|
||||||
|
--volume /tmp:/tmp \
|
||||||
|
webstreamr/webstreamr
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
#### `CACHE_DIR`
|
||||||
|
|
||||||
|
Optional. Directory for persistent caches using SQLite files. Default: OS tmp dir.
|
||||||
|
|
||||||
|
#### `CONFIGURATION_DESCRIPTION`
|
||||||
|
|
||||||
|
Optional. To customize the description shown on the configuration page.
|
||||||
|
|
||||||
|
#### `DISABLED_EXTRACTORS`
|
||||||
|
|
||||||
|
Optional. Comma separated list of extractors which should be disabled. E.g. `doodstream,vidsrc`
|
||||||
|
|
||||||
|
#### `DISABLED_SOURCES`
|
||||||
|
|
||||||
|
Optional. Comma separated list of sources which should be disabled. E.g. `frembed,vidsrc`
|
||||||
|
|
||||||
|
#### `FLARESOLVERR_ENDPOINT`
|
||||||
|
|
||||||
|
Optional. If domains show Cloudflare challenges, FlareSolverr can be used to work around them. E.g. `http://flaresolverr:8191`
|
||||||
|
Proxy configuration is passed-through and only a single session is used to save resources. Byparr is not supported.
|
||||||
|
|
||||||
|
#### `MANIFEST_ID`
|
||||||
|
|
||||||
|
Optional. Add-on manifest ID. Default: `webstreamr`
|
||||||
|
|
||||||
|
#### `MANIFEST_NAME`
|
||||||
|
|
||||||
|
Optional. Add-on manifest name. Default: `WebStreamr`
|
||||||
|
|
||||||
|
#### `PORT`
|
||||||
|
|
||||||
|
Optional. Port of the node web server. Default: `51546`
|
||||||
|
|
||||||
|
#### `PROXY_CONFIG`
|
||||||
|
|
||||||
|
Optional. Proxies which should be used based on domain. Supports minimatch. E.g. `dood.to:http://USERNAME:PASSWORD@IP:PORT,*:socks5://172.17.0.1:1080` would use an http proxy for dood.to and a socks5 proxy for all other domains.
|
||||||
|
|
||||||
|
Some hosters are a bit picky when it comes to IPs. Best case is if you use a residential IP.
|
||||||
|
If you can't do that, then I suggest to use a VPN / proxy like Cloudflare WARP.
|
||||||
|
DoodStream is not working with WARP.
|
||||||
|
Free Webshare proxies seem to work with it.
|
||||||
|
|
||||||
|
#### `TMDB_ACCESS_TOKEN`
|
||||||
|
|
||||||
|
**Required**. TMDB access token to get information like title and year for content. Use the [API Read Access Token](https://www.themoviedb.org/settings/api).
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const config: Config = {
|
||||||
coverageDirectory: '<rootDir>/coverage',
|
coverageDirectory: '<rootDir>/coverage',
|
||||||
coveragePathIgnorePatterns: [
|
coveragePathIgnorePatterns: [
|
||||||
'/src/controller/',
|
'/src/controller/',
|
||||||
|
'/src/utils/dispatcher.ts',
|
||||||
],
|
],
|
||||||
coverageProvider: 'babel',
|
coverageProvider: 'babel',
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { socksDispatcher } from 'fetch-socks';
|
process.env['CACHE_DIR'] = '/dev/null';
|
||||||
import { Agent, Dispatcher, interceptors, ProxyAgent, setGlobalDispatcher } from 'undici';
|
process.env['HOST'] = 'example.test';
|
||||||
|
process.env['PROTOCOL'] = 'https';
|
||||||
|
|
||||||
|
if (!process.env['TMDB_ACCESS_TOKEN']) {
|
||||||
|
process.env['TMDB_ACCESS_TOKEN'] = 'some access token';
|
||||||
|
}
|
||||||
|
|
||||||
jest.mock('randomstring', () => ({
|
jest.mock('randomstring', () => ({
|
||||||
generate: jest.fn(() => 'mocked-random-string'),
|
generate: jest.fn(() => 'mocked-random-string'),
|
||||||
|
|
@ -9,19 +14,4 @@ beforeEach(() => {
|
||||||
jest.spyOn(Date, 'now').mockImplementation(() => 639837296000);
|
jest.spyOn(Date, 'now').mockImplementation(() => 639837296000);
|
||||||
});
|
});
|
||||||
|
|
||||||
let dispatcher: Dispatcher;
|
console.log = console.warn = console.error = console.info = console.debug = () => { /* disable in favor of logger */ };
|
||||||
if (process.env['ALL_PROXY']) {
|
|
||||||
const proxyUrl = new URL(process.env['ALL_PROXY']);
|
|
||||||
if (proxyUrl.protocol === 'socks5:') {
|
|
||||||
dispatcher = socksDispatcher({ type: 5, host: proxyUrl.hostname, port: parseInt(proxyUrl.port) }, { allowH2: true });
|
|
||||||
} else {
|
|
||||||
dispatcher = new ProxyAgent({ uri: proxyUrl.href, allowH2: true });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dispatcher = new Agent({ allowH2: true });
|
|
||||||
}
|
|
||||||
dispatcher.compose(
|
|
||||||
interceptors.dns(),
|
|
||||||
interceptors.retry({ maxRetries: 3 }),
|
|
||||||
);
|
|
||||||
setGlobalDispatcher(dispatcher);
|
|
||||||
|
|
|
||||||
5512
package-lock.json
generated
5512
package-lock.json
generated
File diff suppressed because it is too large
Load diff
46
package.json
46
package.json
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "webstreamr",
|
"name": "webstreamr",
|
||||||
"description": "Provides HTTP URLs from streaming websites.",
|
"description": "Provides HTTP URLs from streaming websites.",
|
||||||
"version": "0.44.1",
|
"version": "0.69.1",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyse": "tsc --noEmit --project ./tsconfig.dev.json",
|
"analyse": "tsc --noEmit --project ./tsconfig.dev.json",
|
||||||
|
|
@ -28,37 +28,55 @@
|
||||||
"url": "https://github.com/webstreamr/webstreamr/issues"
|
"url": "https://github.com/webstreamr/webstreamr/issues"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0"
|
"node": "^22.0.0 || ^24.0.0"
|
||||||
|
},
|
||||||
|
"devEngines": {
|
||||||
|
"runtime": {
|
||||||
|
"name": "node",
|
||||||
|
"onFail": "error"
|
||||||
|
},
|
||||||
|
"packageManager": {
|
||||||
|
"name": "npm",
|
||||||
|
"onFail": "error"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/ttlcache": "^1.4.1",
|
"@keyv/sqlite": "^4.0.5",
|
||||||
"@types/jsdom": "^21.1.7",
|
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"axios-cache-interceptor": "^1.8.3",
|
||||||
|
"axios-mock-adapter": "^2.1.0",
|
||||||
|
"axios-retry": "^4.5.0",
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
|
"cacheable": "^2.0.0",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"fetch-socks": "^1.3.2",
|
"express-rate-limit": "^8.2.1",
|
||||||
"http-cache-semantics": "^4.2.0",
|
"fast-levenshtein": "^3.0.0",
|
||||||
"jsdom": "^26.1.0",
|
"glob": "^13.0.0",
|
||||||
"lru-cache": "^11.1.0",
|
"http-proxy-agent": "^7.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
|
"memoizee": "^0.4.17",
|
||||||
|
"minimatch": "^10.0.3",
|
||||||
"randomstring": "^1.3.1",
|
"randomstring": "^1.3.1",
|
||||||
|
"rot13-cipher": "^1.0.0",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"socks-proxy-agent": "^8.0.5",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
"tough-cookie": "^6.0.0",
|
"tough-cookie": "^6.0.0",
|
||||||
"undici": "^7.10.0",
|
|
||||||
"unpacker": "^1.0.1",
|
"unpacker": "^1.0.1",
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"winston": "^3.17.0"
|
"winston": "^3.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.26.0",
|
"@eslint/js": "^9.26.0",
|
||||||
"@stylistic/eslint-plugin": "^5.0.0",
|
"@stylistic/eslint-plugin": "^5.0.0",
|
||||||
"@tsconfig/node22": "^22.0.2",
|
"@tsconfig/node24": "^24.0.0",
|
||||||
"@types/bytes": "^3.1.5",
|
"@types/bytes": "^3.1.5",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/http-cache-semantics": "^4.0.4",
|
"@types/fast-levenshtein": "^0.0.4",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/make-fetch-happen": "^10.0.4",
|
"@types/memoizee": "^0.4.12",
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^24.0.0",
|
||||||
"@types/randomstring": "^1.3.0",
|
"@types/randomstring": "^1.3.0",
|
||||||
"@types/stremio-addon-sdk": "^1.6.11",
|
"@types/stremio-addon-sdk": "^1.6.11",
|
||||||
"babel-jest": "^30.0.0",
|
"babel-jest": "^30.0.0",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export class ConfigureController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getConfigure(req: Request, res: Response) {
|
private getConfigure(req: Request, res: Response) {
|
||||||
const config: Config = JSON.parse(req.params['config'] || JSON.stringify(getDefaultConfig()));
|
const config: Config = JSON.parse(req.params['config'] as string || JSON.stringify(getDefaultConfig()));
|
||||||
|
|
||||||
// Convenience preset for ElfHosted WebStreamr bundle including Media Flow Proxy
|
// Convenience preset for ElfHosted WebStreamr bundle including Media Flow Proxy
|
||||||
if (!req.params['config'] && isElfHostedInstance(req)) {
|
if (!req.params['config'] && isElfHostedInstance(req)) {
|
||||||
|
|
|
||||||
53
src/controller/ExtractController.ts
Normal file
53
src/controller/ExtractController.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Mutex } from 'async-mutex';
|
||||||
|
import { Request, Response, Router } from 'express';
|
||||||
|
import winston from 'winston';
|
||||||
|
import { ExtractorRegistry } from '../extractor';
|
||||||
|
import { contextFromRequestAndResponse, Fetcher } from '../utils';
|
||||||
|
|
||||||
|
export class ExtractController {
|
||||||
|
public readonly router: Router;
|
||||||
|
|
||||||
|
private readonly logger: winston.Logger;
|
||||||
|
private readonly extractorRegistry: ExtractorRegistry;
|
||||||
|
|
||||||
|
private readonly locks = new Map<string, Mutex>();
|
||||||
|
|
||||||
|
public constructor(logger: winston.Logger, _fetcher: Fetcher, extractorRegistry: ExtractorRegistry) {
|
||||||
|
this.router = Router();
|
||||||
|
|
||||||
|
this.logger = logger;
|
||||||
|
this.extractorRegistry = extractorRegistry;
|
||||||
|
|
||||||
|
this.router.get('/extract', this.extract.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extract(req: Request, res: Response) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.status(405).send('Method Not Allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = contextFromRequestAndResponse(req, res);
|
||||||
|
|
||||||
|
const index = parseInt(req.query['index'] as string);
|
||||||
|
const url = new URL(req.query['url'] as string);
|
||||||
|
|
||||||
|
this.logger.info(`Lazy extract index ${index} of URL ${url} for ip ${ctx.ip}`, ctx);
|
||||||
|
|
||||||
|
let mutex = this.locks.get(url.href);
|
||||||
|
if (!mutex) {
|
||||||
|
mutex = new Mutex();
|
||||||
|
this.locks.set(url.href, mutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutex.runExclusive(async () => {
|
||||||
|
const urlResults = await this.extractorRegistry.handle(ctx, url);
|
||||||
|
|
||||||
|
res.redirect(urlResults[index]?.url.href as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mutex.isLocked()) {
|
||||||
|
this.locks.delete(url.href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ export class ManifestController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getManifest(req: Request, res: Response) {
|
private getManifest(req: Request, res: Response) {
|
||||||
const config: Config = JSON.parse(req.params['config'] || JSON.stringify(getDefaultConfig()));
|
const config: Config = JSON.parse(req.params['config'] as string || JSON.stringify(getDefaultConfig()));
|
||||||
|
|
||||||
const manifest = buildManifest(this.sources, this.extractors, config);
|
const manifest = buildManifest(this.sources, this.extractors, config);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import winston from 'winston';
|
||||||
import { Source } from '../source';
|
import { Source } from '../source';
|
||||||
import { contextFromRequestAndResponse, envIsProd, Id, ImdbId, StreamResolver, TmdbId } from '../utils';
|
import { contextFromRequestAndResponse, envIsProd, Id, ImdbId, StreamResolver, TmdbId } from '../utils';
|
||||||
|
|
||||||
const locks = new Map<string, Mutex>();
|
|
||||||
|
|
||||||
export class StreamController {
|
export class StreamController {
|
||||||
public readonly router: Router;
|
public readonly router: Router;
|
||||||
|
|
||||||
|
|
@ -14,6 +12,8 @@ export class StreamController {
|
||||||
private readonly sources: Source[];
|
private readonly sources: Source[];
|
||||||
private readonly streamResolver: StreamResolver;
|
private readonly streamResolver: StreamResolver;
|
||||||
|
|
||||||
|
private readonly locks = new Map<string, Mutex>();
|
||||||
|
|
||||||
public constructor(logger: winston.Logger, sources: Source[], streams: StreamResolver) {
|
public constructor(logger: winston.Logger, sources: Source[], streams: StreamResolver) {
|
||||||
this.router = Router();
|
this.router = Router();
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ export class StreamController {
|
||||||
|
|
||||||
private async getStream(req: Request, res: Response) {
|
private async getStream(req: Request, res: Response) {
|
||||||
const type: ContentType = (req.params['type'] || '') as ContentType;
|
const type: ContentType = (req.params['type'] || '') as ContentType;
|
||||||
const rawId: string = req.params['id'] || '';
|
const rawId: string = req.params['id'] as string || '';
|
||||||
|
|
||||||
let id: Id;
|
let id: Id;
|
||||||
if (rawId.startsWith('tmdb:')) {
|
if (rawId.startsWith('tmdb:')) {
|
||||||
|
|
@ -35,7 +35,9 @@ export class StreamController {
|
||||||
} else if (rawId.startsWith('tt')) {
|
} else if (rawId.startsWith('tt')) {
|
||||||
id = ImdbId.fromString(rawId);
|
id = ImdbId.fromString(rawId);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported ID: ${rawId}`);
|
res.status(400).send(`Unsupported ID: ${rawId}`);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = contextFromRequestAndResponse(req, res);
|
const ctx = contextFromRequestAndResponse(req, res);
|
||||||
|
|
@ -44,17 +46,17 @@ export class StreamController {
|
||||||
|
|
||||||
const sources = this.sources.filter(source => source.countryCodes.filter(countryCode => countryCode in ctx.config).length);
|
const sources = this.sources.filter(source => source.countryCodes.filter(countryCode => countryCode in ctx.config).length);
|
||||||
|
|
||||||
let mutex = locks.get(rawId);
|
let mutex = this.locks.get(rawId);
|
||||||
if (!mutex) {
|
if (!mutex) {
|
||||||
mutex = new Mutex();
|
mutex = new Mutex();
|
||||||
locks.set(rawId, mutex);
|
this.locks.set(rawId, mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mutex.runExclusive(async () => {
|
await mutex.runExclusive(async () => {
|
||||||
const { streams, ttl } = await this.streamResolver.resolve(ctx, sources, type, id);
|
const { streams, ttl } = await this.streamResolver.resolve(ctx, sources, type, id);
|
||||||
|
|
||||||
if (ttl && envIsProd()) {
|
if (ttl && envIsProd()) {
|
||||||
res.setHeader('Cache-Control', `max-age=${ttl / 1000}, public`);
|
res.setHeader('Cache-Control', `public, max-age=${Math.floor(ttl / 1000)}, immutable`);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
|
@ -62,7 +64,7 @@ export class StreamController {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!mutex.isLocked()) {
|
if (!mutex.isLocked()) {
|
||||||
locks.delete(rawId);
|
this.locks.delete(rawId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from './ExtractController';
|
||||||
export * from './ConfigureController';
|
export * from './ConfigureController';
|
||||||
export * from './ManifestController';
|
export * from './ManifestController';
|
||||||
export * from './StreamController';
|
export * from './StreamController';
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
import { BlockedReason } from '../types';
|
import { BlockedReason } from '../types';
|
||||||
|
|
||||||
export class BlockedError extends Error {
|
export class BlockedError extends Error {
|
||||||
|
public readonly url: URL;
|
||||||
public readonly reason: BlockedReason;
|
public readonly reason: BlockedReason;
|
||||||
public readonly headers: Record<string, string[] | string | undefined>;
|
public readonly headers: AxiosResponse['headers'];
|
||||||
|
|
||||||
public constructor(reason: BlockedReason, headers: Record<string, string[] | string | undefined>) {
|
public constructor(url: URL, reason: BlockedReason, headers: AxiosResponse['headers']) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.url = url;
|
||||||
this.reason = reason;
|
this.reason = reason;
|
||||||
this.headers = headers;
|
this.headers = headers;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
|
public readonly url: URL;
|
||||||
public readonly status: number;
|
public readonly status: number;
|
||||||
public readonly statusText: string;
|
public readonly statusText: string;
|
||||||
public readonly headers: Record<string, string[] | string | undefined>;
|
public readonly headers: AxiosResponse['headers'];
|
||||||
|
|
||||||
public constructor(status: number, statusText: string, headers: Record<string, string[] | string | undefined>) {
|
public constructor(url: URL, status: number, statusText: string, headers: AxiosResponse['headers']) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.url = url;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.statusText = statusText;
|
this.statusText = statusText;
|
||||||
this.headers = headers;
|
this.headers = headers;
|
||||||
|
|
|
||||||
|
|
@ -1 +1,9 @@
|
||||||
export class QueueIsFullError extends Error {}
|
export class QueueIsFullError extends Error {
|
||||||
|
public readonly url: URL;
|
||||||
|
|
||||||
|
public constructor(url: URL) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,9 @@
|
||||||
export class TimeoutError extends Error {}
|
export class TimeoutError extends Error {
|
||||||
|
public readonly url: URL;
|
||||||
|
|
||||||
|
public constructor(url: URL) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
export class TooManyRequestsError extends Error {
|
export class TooManyRequestsError extends Error {
|
||||||
|
public readonly url: URL;
|
||||||
public readonly retryAfter: number;
|
public readonly retryAfter: number;
|
||||||
|
|
||||||
public constructor(retryAfter: number) {
|
public constructor(url: URL, retryAfter: number) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.url = url;
|
||||||
this.retryAfter = retryAfter;
|
this.retryAfter = retryAfter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,9 @@
|
||||||
export class TooManyTimeoutsError extends Error {}
|
export class TooManyTimeoutsError extends Error {
|
||||||
|
public readonly url: URL;
|
||||||
|
|
||||||
|
public constructor(url: URL) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,51 +17,54 @@ export * from './TooManyTimeoutsError';
|
||||||
|
|
||||||
export const logErrorAndReturnNiceString = (ctx: Context, logger: winston.Logger, source: string, error: unknown): string => {
|
export const logErrorAndReturnNiceString = (ctx: Context, logger: winston.Logger, source: string, error: unknown): string => {
|
||||||
if (error instanceof BlockedError) {
|
if (error instanceof BlockedError) {
|
||||||
if (error.reason === BlockedReason.cloudflare_challenge) {
|
if (error.reason === BlockedReason.media_flow_proxy_auth) {
|
||||||
logger.warn(`${source}: Request was blocked via Cloudflare challenge.`, ctx);
|
|
||||||
} else if (error.reason === BlockedReason.cloudflare_censor) {
|
|
||||||
logger.warn(`${source}: Request was censored by Cloudflare.`, ctx);
|
|
||||||
} else if (error.reason === BlockedReason.media_flow_proxy_auth) {
|
|
||||||
return '⚠️ MediaFlow Proxy authentication failed. Please set the correct password.';
|
return '⚠️ MediaFlow Proxy authentication failed. Please set the correct password.';
|
||||||
} else {
|
|
||||||
logger.warn(`${source}: Request was blocked, headers: ${JSON.stringify(error.headers)}.`, ctx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `⚠️ Request was blocked. Reason: ${error.reason}`;
|
logger.warn(`${source}: Request to ${error.url} was blocked, reason: ${error.reason}, headers: ${JSON.stringify(error.headers)}.`, ctx);
|
||||||
|
|
||||||
|
return `⚠️ Request to ${error.url.host} was blocked. Reason: ${error.reason}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof TooManyRequestsError) {
|
if (error instanceof TooManyRequestsError) {
|
||||||
logger.warn(`${source}: Rate limited for ${error.retryAfter} seconds.`, ctx);
|
logger.warn(`${source}: Request to ${error.url} was rate limited for ${error.retryAfter} seconds.`, ctx);
|
||||||
|
|
||||||
return '🚦 Request was rate-limited. Please try again later or consider self-hosting.';
|
return `🚦 Request to ${error.url.host} was rate-limited. Please try again later or consider self-hosting.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof TooManyTimeoutsError) {
|
if (error instanceof TooManyTimeoutsError) {
|
||||||
logger.warn(`${source}: Too many timeouts.`, ctx);
|
logger.warn(`${source}: Too many timeouts when requesting ${error.url}.`, ctx);
|
||||||
|
|
||||||
return '🚦 Too many recent timeouts. Please try again later.';
|
return `🚦 Too many recent timeouts when requesting ${error.url.host}. Please try again later.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof TimeoutError) {
|
if (error instanceof TimeoutError) {
|
||||||
|
logger.warn(`${source}: Request to ${error.url} timed out.`, ctx);
|
||||||
|
|
||||||
|
return `🐢 Request to ${error.url.host} timed out.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof DOMException && ['AbortError', 'TimeoutError'].includes(error.name)) {
|
||||||
|
// sometimes this gets through, no idea why..
|
||||||
logger.warn(`${source}: Request timed out.`, ctx);
|
logger.warn(`${source}: Request timed out.`, ctx);
|
||||||
|
|
||||||
return '🐢 Request timed out.';
|
return '🐢 Request timed out.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof QueueIsFullError) {
|
if (error instanceof QueueIsFullError) {
|
||||||
logger.warn(`${source}: Request queue is full.`, ctx);
|
logger.warn(`${source}: Request queue for ${error.url.host} is full.`, ctx);
|
||||||
|
|
||||||
return '⏳ Request queue is full. Please try again later or consider self-hosting.';
|
return `⏳ Request queue for ${error.url.host} is full. Please try again later or consider self-hosting.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
logger.error(`${source}: HTTP status ${error.status} (${error.statusText}), headers: ${JSON.stringify(error.headers)}, stack: ${error.stack}.`, ctx);
|
logger.error(`${source}: Error when requesting url ${error.url}, HTTP status ${error.status} (${error.statusText}), headers: ${JSON.stringify(error.headers)}, stack: ${error.stack}.`, ctx);
|
||||||
|
|
||||||
if (error.status >= 500) {
|
if (error.status >= 500) {
|
||||||
return `❌ Remote server has issues. We can't fix this, please try later again.`;
|
return `❌ Remote server ${error.url.host} has issues. We can't fix this, please try later again.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `❌ Request failed with status ${error.status} (${error.statusText}). Request-id: ${ctx.id}.`;
|
return `❌ Request to ${error.url.host} failed with status ${error.status} (${error.statusText}). Request-id: ${ctx.id}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cause = (error as Error & { cause?: unknown }).cause;
|
const cause = (error as Error & { cause?: unknown }).cause;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { DoodStream } from './DoodStream';
|
import { DoodStream } from './DoodStream';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
|
@ -8,26 +7,18 @@ import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new DoodStream(new FetcherMock(`${__dirname}/__fixtures__/DoodStream`))]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new DoodStream(new FetcherMock(`${__dirname}/__fixtures__/DoodStream`))]);
|
||||||
|
|
||||||
const ctx = createTestContext();
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
||||||
|
|
||||||
describe('DoodStream', () => {
|
describe('DoodStream', () => {
|
||||||
test('dood.to', async () => {
|
test('dood.to', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('http://dood.to/e/sk1m9eumzyjj'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('http://dood.to/e/sk1m9eumzyjj'))).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('doodster', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dooodster.com/e/1cfcevn6dg8shrfvht22odxw2lty18hr'), CountryCode.de)).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing pass_md5 -> not found', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dood.to/e/gy8l8mb2i311'), CountryCode.mx)).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can guess height from title', async () => {
|
test('can guess height from title', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://do7go.com/e/dfx8me4un4ul'), CountryCode.fr)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://do7go.com/e/dfx8me4un4ul'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cloudflare storage', async () => {
|
test('not found', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://doodstream.com/e/wfpwtsgyr1xi'), CountryCode.mx)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://myvidplay.com/e/cz7cus0bvlzr'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import randomstring from 'randomstring';
|
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { Fetcher } from '../utils';
|
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class DoodStream extends Extractor {
|
export class DoodStream extends Extractor {
|
||||||
|
|
@ -11,102 +10,44 @@ export class DoodStream extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'DoodStream';
|
public readonly label = 'DoodStream';
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
public override readonly ttl: number = 21600000; // 6h
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
public override viaMediaFlowProxy = true;
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/doodstream.py */
|
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/doodstream.py */
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return [
|
return null !== url.host.match(/dood|do[0-9]go|doood|dooood|ds2play|ds2video|dsvplay|d0o0d|do0od|d0000d|d000d|myvidplay|vidply|all3do|doply|vide0|vvide0|d-s/) && supportsMediaFlowProxy(ctx);
|
||||||
'all3do.com',
|
|
||||||
'd-s.io',
|
|
||||||
'd0000d.com',
|
|
||||||
'd000d.com',
|
|
||||||
'd0o0d.com',
|
|
||||||
'do0od.com',
|
|
||||||
'do7go.com',
|
|
||||||
'dood.cx',
|
|
||||||
'dood.la',
|
|
||||||
'dood.li',
|
|
||||||
'dood.pm',
|
|
||||||
'dood.re',
|
|
||||||
'dood.sh',
|
|
||||||
'dood.so',
|
|
||||||
'dood.stream',
|
|
||||||
'dood.to',
|
|
||||||
'dood.watch',
|
|
||||||
'dood.wf',
|
|
||||||
'dood.work',
|
|
||||||
'dood.ws',
|
|
||||||
'dood.yt',
|
|
||||||
'doodcdn.io',
|
|
||||||
'doods.pro',
|
|
||||||
'doodstream.co',
|
|
||||||
'doodstream.com',
|
|
||||||
'dooodster.com',
|
|
||||||
'dooood.com',
|
|
||||||
'doply.net',
|
|
||||||
'ds2play.com',
|
|
||||||
'ds2video.com',
|
|
||||||
'vide0.net',
|
|
||||||
'vidply.com',
|
|
||||||
'vvide0.com',
|
|
||||||
].includes(url.host);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
public override normalize(url: URL): URL {
|
||||||
const supportedHosts = [
|
const videoId = url.pathname.replace(/\/+$/, '').split('/').at(-1) as string;
|
||||||
'all3do.com',
|
|
||||||
'd-s.io',
|
|
||||||
'doodstream.com',
|
|
||||||
'vide0.net',
|
|
||||||
'vidply.com',
|
|
||||||
];
|
|
||||||
const host = supportedHosts.includes(url.host) ? url.host : 'd-s.io';
|
|
||||||
|
|
||||||
const videoId = url.pathname.split('/').slice(-1)[0] as string;
|
return new URL(`http://dood.to/e/${videoId}`);
|
||||||
|
|
||||||
return new URL(`/d/${videoId}`, `${url.protocol}//${host}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
const passMd5Match = html.match(/\/pass_md5\/[\w-]+\/([\w-]+)/);
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
if (!passMd5Match) {
|
|
||||||
|
if (/Video not found/.test(html)) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = passMd5Match[1] as string;
|
|
||||||
|
|
||||||
const baseUrl = await this.fetcher.text(ctx, new URL(passMd5Match[0], url.origin));
|
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('title').text().trim().replace(/ - DoodStream$/, '').trim();
|
const title = $('title').text().trim().replace(/ - DoodStream$/, '').trim();
|
||||||
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/) as string[];
|
|
||||||
|
|
||||||
const mp4Url = baseUrl.includes('cloudflarestorage.')
|
const downloadHtml = await this.fetcher.text(ctx, new URL(url.href.replace('/e/', '/d/')));
|
||||||
? new URL(baseUrl) // TODO: headers needed here? compare with resolveurl
|
const sizeMatch = downloadHtml.match(/([\d.]+ ?[GM]B)/);
|
||||||
: new URL(`${baseUrl}${randomstring.generate(10)}?token=${token}&expiry=${Date.now()}`);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: mp4Url,
|
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Doodstream', url, headers),
|
||||||
format: Format.mp4,
|
format: Format.mp4,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
title,
|
title,
|
||||||
bytes: bytes.parse(sizeMatch[1] as string) as number,
|
...(sizeMatch && { bytes: bytes.parse(sizeMatch[1] as string) as number }),
|
||||||
},
|
|
||||||
requestHeaders: {
|
|
||||||
Referer: url.origin,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { Dropload } from './Dropload';
|
import { Dropload } from './Dropload';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
|
@ -12,18 +11,22 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('Dropload', () => {
|
describe('Dropload', () => {
|
||||||
test('dropload.io', async () => {
|
test('dropload.io', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/embed-lyo2h1snpe5c.html'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/embed-lyo2h1snpe5c.html'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropload unknown height and size', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/gf2apij3ql37'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('file not found', async () => {
|
test('file not found', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('processing / internal problem', async () => {
|
test('processing / internal problem', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/7xtxuac84xyh.html'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/7xtxuac84xyh.html'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('download URL', async () => {
|
test('download URL', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/d/xhcmgcc2txnv'), CountryCode.en)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://dropload.io/d/xhcmgcc2txnv'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { extractUrlFromPacked, Fetcher } from '../utils';
|
import { extractUrlFromPacked, guessHeightFromPlaylist } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Dropload extends Extractor {
|
export class Dropload extends Extractor {
|
||||||
|
|
@ -10,47 +10,48 @@ export class Dropload extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'Dropload';
|
public readonly label = 'Dropload';
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
public override readonly ttl: number = 10800000; // 3h
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/dropload/);
|
return null !== url.host.match(/dropload|dr0pstream/);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override readonly normalize = (url: URL): URL => new URL(url.href.replace('/d/', '/').replace('/e/', '/').replace('/embed-', '/'));
|
public override readonly normalize = (url: URL): URL => new URL(url.href.replace('/d/', '/').replace('/e/', '/').replace('/embed-', '/'));
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
|
||||||
if (html.includes('File Not Found') || html.includes('Pending in queue')) {
|
if (html.includes('File Not Found') || html.includes('Pending in queue')) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const heightMatch = html.match(/\d{3,}x(\d{3,}),/) as string[];
|
const playlistUrl = extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]);
|
||||||
|
const playlistHeaders = { Referer: 'https://dr0pstream.com/' };
|
||||||
|
|
||||||
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/) as string[];
|
const heightMatch = html.match(/\d{3,}x(\d{3,}),/);
|
||||||
|
const height = heightMatch
|
||||||
|
? parseInt(heightMatch[1] as string)
|
||||||
|
: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl, { headers: playlistHeaders });
|
||||||
|
|
||||||
|
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/);
|
||||||
|
const size = sizeMatch ? bytes.parse(sizeMatch[1] as string) as number : undefined;
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('.videoplayer h1').text().trim();
|
const title = $('.videoplayer h1').text().trim();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]),
|
url: playlistUrl,
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
bytes: bytes.parse(sizeMatch[1] as string) as number,
|
...meta,
|
||||||
countryCodes: [countryCode],
|
|
||||||
height: parseInt(heightMatch[1] as string),
|
|
||||||
title,
|
title,
|
||||||
|
...(size && { bytes: size }),
|
||||||
|
...(height && { height }),
|
||||||
},
|
},
|
||||||
|
requestHeaders: playlistHeaders,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExternalUrl } from './ExternalUrl';
|
import { ExternalUrl } from './ExternalUrl';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
|
@ -12,14 +11,10 @@ const ctx = createTestContext({ includeExternalUrls: 'on' });
|
||||||
|
|
||||||
describe('ExternalUrl', () => {
|
describe('ExternalUrl', () => {
|
||||||
test('404 - not found', async () => {
|
test('404 - not found', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://streamtape.com/e/gjA1OQ4klyHxgJ'), CountryCode.fr)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://streamtape.com/e/gjA1OQ4klyHxgJ'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('netu.fanstream.us', async () => {
|
test('netu.fanstream.us', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://netu.fanstream.us/e/0DFgfkcXOsDP'), CountryCode.fr)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://netu.fanstream.us/e/0DFgfkcXOsDP'))).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('johnalwayssame.com with title', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://johnalwayssame.com/e/cqy9oue7sv0g'), CountryCode.fr, 'Black Mirror 4x2')).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { BlockedError } from '../error';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { showExternalUrls } from '../utils';
|
||||||
import { Fetcher, showExternalUrls } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class ExternalUrl extends Extractor {
|
export class ExternalUrl extends Extractor {
|
||||||
|
|
@ -8,43 +7,20 @@ export class ExternalUrl extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'External';
|
public readonly label = 'External';
|
||||||
|
|
||||||
public override readonly ttl = 3600000; // 1h
|
public override readonly ttl = 21600000; // 6h
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return showExternalUrls(ctx.config) && null !== url.host.match(/.*/);
|
return showExternalUrls(ctx.config) && null !== url.host.match(/.*/);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode, title: string | undefined): Promise<UrlResult[]> {
|
protected async extractInternal(_ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
try {
|
|
||||||
// Make sure the URL is accessible, but avoid causing noise and delays doing this
|
|
||||||
await this.fetcher.head(ctx, url, { noFlareSolverr: true, timeout: 1000, headers: { Referer: url.origin } });
|
|
||||||
} catch (error) {
|
|
||||||
/* istanbul ignore if */
|
|
||||||
if (!(error instanceof BlockedError)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: url,
|
url: url,
|
||||||
format: Format.unknown,
|
format: Format.unknown,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
label: `${url.host}`,
|
label: `${url.host}`,
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
meta,
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
|
||||||
countryCodes: [countryCode],
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta, UrlResult } from '../types';
|
||||||
|
import { Fetcher } from '../utils';
|
||||||
|
|
||||||
export abstract class Extractor {
|
export abstract class Extractor {
|
||||||
public abstract readonly id: string;
|
public abstract readonly id: string;
|
||||||
|
|
@ -8,19 +9,34 @@ export abstract class Extractor {
|
||||||
|
|
||||||
public readonly ttl: number = 900000; // 15m
|
public readonly ttl: number = 900000; // 15m
|
||||||
|
|
||||||
|
public readonly cacheVersion: number | undefined = undefined;
|
||||||
|
|
||||||
public readonly viaMediaFlowProxy: boolean = false;
|
public readonly viaMediaFlowProxy: boolean = false;
|
||||||
|
|
||||||
|
protected readonly fetcher: Fetcher;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher) {
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public abstract supports(ctx: Context, url: URL): boolean;
|
public abstract supports(ctx: Context, url: URL): boolean;
|
||||||
|
|
||||||
public normalize(url: URL): URL {
|
public normalize(url: URL): URL {
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
protected abstract extractInternal(ctx: Context, url: URL, countryCode: CountryCode, title?: string | undefined): Promise<UrlResult[]>;
|
protected abstract extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]>;
|
||||||
|
|
||||||
public async extract(ctx: Context, url: URL, countryCode: CountryCode, title?: string | undefined): Promise<UrlResult[]> {
|
public async extract(ctx: Context, url: URL, meta: Meta): Promise<UrlResult[]> {
|
||||||
try {
|
try {
|
||||||
return await this.extractInternal(ctx, url, countryCode, title);
|
return (await this.extractInternal(ctx, url, meta)).map(
|
||||||
|
urlResult => ({
|
||||||
|
...urlResult,
|
||||||
|
label: this.formatLabel(urlResult.label ?? this.label),
|
||||||
|
ttl: this.ttl,
|
||||||
|
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NotFoundError) {
|
if (error instanceof NotFoundError) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -32,14 +48,15 @@ export abstract class Extractor {
|
||||||
format: Format.unknown,
|
format: Format.unknown,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
error,
|
error,
|
||||||
label: this.label,
|
label: this.formatLabel(this.label),
|
||||||
sourceId: `${this.id}`,
|
ttl: 0,
|
||||||
meta: {
|
meta,
|
||||||
countryCodes: [countryCode],
|
|
||||||
...(title && { title }),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private formatLabel(label: string): string {
|
||||||
|
return this.viaMediaFlowProxy ? `${label} (MFP)` : label;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { createExtractors } from './index';
|
import { createExtractors } from './index';
|
||||||
|
|
@ -12,46 +11,53 @@ describe('ExtractorRegistry', () => {
|
||||||
const ctx = createTestContext();
|
const ctx = createTestContext();
|
||||||
|
|
||||||
test('returns error result from extractor', async () => {
|
test('returns error result from extractor', async () => {
|
||||||
const urlResult = await extractorRegistry.handle(ctx, new URL('https://some-url.test'), CountryCode.de);
|
const urlResult = await extractorRegistry.handle(ctx, new URL('https://some-url.test'));
|
||||||
|
|
||||||
expect(urlResult).toMatchSnapshot();
|
expect(urlResult).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns external URLs if enabled by config', async () => {
|
test('returns external URLs if enabled by config', async () => {
|
||||||
const urlResult = await extractorRegistry.handle({ ...ctx, config: { ...ctx.config, includeExternalUrls: 'on' } }, new URL('https://mixdrop.ag/e/3nzwveprim63or6'), CountryCode.de);
|
const urlResult = await extractorRegistry.handle({ ...ctx, config: { ...ctx.config, includeExternalUrls: 'on' } }, new URL('https://mixdrop.ag/e/3nzwveprim63or6'));
|
||||||
|
|
||||||
expect(urlResult).toMatchSnapshot();
|
expect(urlResult).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not return external URLs by default', async () => {
|
test('does not return external URLs by default', async () => {
|
||||||
const urlResult = await extractorRegistry.handle(ctx, new URL('https://mixdrop.ag/e/l7v73zqrfdj19z'), CountryCode.de);
|
const urlResult = await extractorRegistry.handle(ctx, new URL('https://mixdrop.ag/e/l7v73zqrfdj19z'));
|
||||||
|
|
||||||
expect(urlResult).toStrictEqual([]);
|
expect(urlResult).toStrictEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns from memory cache if possible', async () => {
|
test('returns from memory cache if possible', async () => {
|
||||||
const urlResults1 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'), CountryCode.de);
|
const urlResults1 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'));
|
||||||
const urlResults2 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'), CountryCode.de);
|
const urlResults2 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/lyo2h1snpe5c.html'));
|
||||||
|
|
||||||
expect(urlResults1).not.toStrictEqual([]);
|
expect(urlResults1).not.toStrictEqual([]);
|
||||||
expect(urlResults2).not.toStrictEqual([]);
|
expect(urlResults2).not.toStrictEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ignores not found errors but caches them', async () => {
|
test('ignores not found errors but caches them', async () => {
|
||||||
const urlResults1 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'), CountryCode.de);
|
const urlResults1 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'));
|
||||||
const urlResults2 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'), CountryCode.de);
|
const urlResults2 = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'));
|
||||||
|
|
||||||
expect(urlResults1).toStrictEqual([]);
|
expect(urlResults1).toStrictEqual([]);
|
||||||
expect(urlResults2).toStrictEqual([]);
|
expect(urlResults2).toStrictEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns external url for error', async () => {
|
test('returns external url for error', async () => {
|
||||||
const urlResults = await extractorRegistry.handle(ctx, new URL('https://dropload.io/mocked-blocked.html'), CountryCode.de);
|
const urlResults = await extractorRegistry.handle(ctx, new URL('https://dropload.io/mocked-blocked.html'));
|
||||||
expect(urlResults).toMatchSnapshot();
|
expect(urlResults).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns external url for error with title', async () => {
|
test('empty results are cached', async () => {
|
||||||
const urlResults = await extractorRegistry.handle(ctx, new URL('https://dropload.io/mocked-blocked-2.html'), CountryCode.de, 'a title!');
|
const urlResults = await extractorRegistry.handle(ctx, new URL('https://dropload.io/asdfghijklmn.html'), { title: 'title' });
|
||||||
expect(urlResults).toMatchSnapshot();
|
expect(urlResults).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stats returns something', async () => {
|
||||||
|
const stats = extractorRegistry.stats();
|
||||||
|
|
||||||
|
expect(stats).toHaveProperty('urlResultCache');
|
||||||
|
expect(stats.urlResultCache).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,112 @@
|
||||||
import TTLCache from '@isaacs/ttlcache';
|
import { Cacheable, CacheableMemory, Keyv } from 'cacheable';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { Context, CountryCode, UrlResult } from '../types';
|
import { Context, Format, Meta, UrlResult } from '../types';
|
||||||
import { isExtractorDisabled } from '../utils';
|
import { createKeyvSqlite, envGet, isExtractorDisabled } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class ExtractorRegistry {
|
export class ExtractorRegistry {
|
||||||
private readonly logger: winston.Logger;
|
private readonly logger: winston.Logger;
|
||||||
private readonly extractors: Extractor[];
|
private readonly extractors: Extractor[];
|
||||||
private readonly urlResultCache: TTLCache<string, UrlResult[]>;
|
|
||||||
|
private readonly urlResultCache: Cacheable;
|
||||||
|
private readonly lazyUrlResultCache: Cacheable;
|
||||||
|
|
||||||
public constructor(logger: winston.Logger, extractors: Extractor[]) {
|
public constructor(logger: winston.Logger, extractors: Extractor[]) {
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.extractors = extractors;
|
this.extractors = extractors;
|
||||||
this.urlResultCache = new TTLCache({ max: 4096 });
|
|
||||||
|
this.urlResultCache = new Cacheable({
|
||||||
|
nonBlocking: true,
|
||||||
|
primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }),
|
||||||
|
secondary: createKeyvSqlite('extractor-cache'),
|
||||||
|
stats: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.lazyUrlResultCache = new Cacheable({
|
||||||
|
nonBlocking: true,
|
||||||
|
primary: new Keyv({ store: new CacheableMemory({ lruSize: 1024 }) }),
|
||||||
|
secondary: createKeyvSqlite('extractor-lazy-cache'),
|
||||||
|
stats: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle(ctx: Context, url: URL, countryCode: CountryCode, title?: string | undefined): Promise<UrlResult[]> {
|
public stats() {
|
||||||
|
return {
|
||||||
|
urlResultCache: this.urlResultCache.stats,
|
||||||
|
lazyUrlResultCache: this.lazyUrlResultCache.stats,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
public async handle(ctx: Context, url: URL, meta?: Meta, allowLazy?: boolean): Promise<UrlResult[]> {
|
||||||
const extractor = this.extractors.find(extractor => !isExtractorDisabled(ctx.config, extractor) && extractor.supports(ctx, url));
|
const extractor = this.extractors.find(extractor => !isExtractorDisabled(ctx.config, extractor) && extractor.supports(ctx, url));
|
||||||
if (!extractor) {
|
if (!extractor) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedUrl = extractor.normalize(url);
|
const normalizedUrl = extractor.normalize(url);
|
||||||
const cacheKey = `${extractor.id}_${normalizedUrl}`;
|
const cacheKey = this.determineCacheKey(ctx, extractor, normalizedUrl);
|
||||||
|
|
||||||
let urlResults = this.urlResultCache.get(cacheKey) ?? [];
|
const storedDataRaw = await this.urlResultCache.getRaw<UrlResult[]>(cacheKey);
|
||||||
if (this.urlResultCache.has(cacheKey)) {
|
const expires = storedDataRaw?.expires;
|
||||||
return urlResults.map(urlResult => ({ ...urlResult, ttl: this.urlResultCache.getRemainingTTL(cacheKey) }));
|
if (storedDataRaw && expires) {
|
||||||
|
const ttl = expires - Date.now();
|
||||||
|
|
||||||
|
/* istanbul ignore if */
|
||||||
|
if (ttl > 0) {
|
||||||
|
return (storedDataRaw.value as UrlResult[]).map(urlResult => ({ ...urlResult, ttl, url: new URL(urlResult.url) }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Extract stream URL using ${extractor.id} extractor from ${url}`, ctx);
|
const lazyUrlResults = await this.lazyUrlResultCache.get<UrlResult[]>(normalizedUrl.href) ?? [];
|
||||||
|
|
||||||
urlResults = await extractor.extract(ctx, normalizedUrl, countryCode, title);
|
/* istanbul ignore next */
|
||||||
|
if (
|
||||||
|
lazyUrlResults.length && allowLazy && !extractor.viaMediaFlowProxy
|
||||||
|
&& lazyUrlResults.every(urlResult => urlResult.format !== Format.hls) // related to Android issues, e.g. https://github.com/Stremio/stremio-bugs/issues/1574 or https://github.com/Stremio/stremio-bugs/issues/1579
|
||||||
|
) {
|
||||||
|
// generate lazy extract urls
|
||||||
|
return lazyUrlResults.map((urlResult, index) => {
|
||||||
|
const extractUrl = new URL(`${envGet('PROTOCOL')}:${envGet('HOST')}/extract/`);
|
||||||
|
|
||||||
if (!urlResults.some(urlResult => urlResult.error) && extractor.ttl) {
|
extractUrl.searchParams.set('index', `${index}`);
|
||||||
this.urlResultCache.set(cacheKey, urlResults, { ttl: extractor.ttl });
|
extractUrl.searchParams.set('url', url.href);
|
||||||
|
|
||||||
|
return { ...urlResult, url: extractUrl };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`Extract ${url} using ${extractor.id} extractor`, ctx);
|
||||||
|
|
||||||
|
const mergedMeta: Meta = { ...meta, ...lazyUrlResults[0]?.meta };
|
||||||
|
const urlResults = await extractor.extract(ctx, normalizedUrl, { extractorId: extractor.id, ...mergedMeta });
|
||||||
|
|
||||||
|
if (!Object.keys(mergedMeta).length || urlResults.some(urlResult => urlResult.error)) {
|
||||||
|
await this.urlResultCache.delete(cacheKey);
|
||||||
|
await this.lazyUrlResultCache.delete(normalizedUrl.href);
|
||||||
|
|
||||||
|
return urlResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = urlResults.length ? extractor.ttl : 43200000; // 12h
|
||||||
|
|
||||||
|
await this.urlResultCache.set<UrlResult[]>(cacheKey, urlResults, ttl);
|
||||||
|
|
||||||
|
if (extractor.id !== 'external') {
|
||||||
|
await this.lazyUrlResultCache.set<UrlResult[]>(normalizedUrl.href, urlResults, 2629800000); // 1 month
|
||||||
}
|
}
|
||||||
|
|
||||||
return urlResults;
|
return urlResults;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private determineCacheKey(ctx: Context, extractor: Extractor, url: URL): string {
|
||||||
|
let suffix = '';
|
||||||
|
if (extractor.viaMediaFlowProxy) {
|
||||||
|
suffix += `_${ctx.config.mediaFlowProxyUrl}`;
|
||||||
|
}
|
||||||
|
if (extractor.cacheVersion) {
|
||||||
|
suffix += `_${extractor.cacheVersion}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${extractor.id}_${url}${suffix}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Fastream } from './Fastream';
|
import { Fastream } from './Fastream';
|
||||||
|
|
@ -12,6 +11,10 @@ const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test
|
||||||
|
|
||||||
describe('Fastream', () => {
|
describe('Fastream', () => {
|
||||||
test('fastream.to embed', async () => {
|
test('fastream.to embed', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://fastream.to/embed-3aooif4ozt10.html'), CountryCode.es)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://fastream.to/embed-3aooif4ozt10.html'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expired or deleted', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://fastream.to/embed-a9rp1d1pqom7.html'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,16 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { NotFoundError } from '../error';
|
||||||
import { buildMediaFlowProxyExtractorStreamUrl, Fetcher, supportsMediaFlowProxy } from '../utils';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
|
import { buildMediaFlowProxyExtractorStreamUrl, supportsMediaFlowProxy } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Fastream extends Extractor {
|
export class Fastream extends Extractor {
|
||||||
public readonly id = 'fastream';
|
public readonly id = 'fastream';
|
||||||
|
|
||||||
public readonly label = 'Fastream (via MediaFlow Proxy)';
|
public readonly label = 'Fastream';
|
||||||
|
|
||||||
public override readonly ttl = 0;
|
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/fastream/) && supportsMediaFlowProxy(ctx);
|
return null !== url.host.match(/fastream/) && supportsMediaFlowProxy(ctx);
|
||||||
}
|
}
|
||||||
|
|
@ -28,11 +19,17 @@ export class Fastream extends Extractor {
|
||||||
return new URL(url.href.replace('/e/', '/embed-').replace('/d/', '/embed-'));
|
return new URL(url.href.replace('/e/', '/embed-').replace('/d/', '/embed-'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'Fastream', url);
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
const downloadUrl = new URL(url.href.replace('/embed-', '/d/'));
|
const downloadUrl = new URL(url.href.replace('/embed-', '/d/'));
|
||||||
const html = await this.fetcher.text(ctx, downloadUrl);
|
const html = await this.fetcher.text(ctx, downloadUrl, { headers });
|
||||||
|
|
||||||
|
if (/No such file/.test(html)) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'Fastream', url, headers);
|
||||||
|
|
||||||
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/) as string[];
|
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/) as string[];
|
||||||
const titleMatch = html.match(/>Download (.*?)</) as string[];
|
const titleMatch = html.match(/>Download (.*?)</) as string[];
|
||||||
|
|
@ -41,11 +38,8 @@ export class Fastream extends Extractor {
|
||||||
{
|
{
|
||||||
url: playlistUrl,
|
url: playlistUrl,
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
bytes: bytes.parse(heightAndSizeMatch[2] as string) as number,
|
bytes: bytes.parse(heightAndSizeMatch[2] as string) as number,
|
||||||
height: parseInt(heightAndSizeMatch[1] as string),
|
height: parseInt(heightAndSizeMatch[1] as string),
|
||||||
title: titleMatch[1],
|
title: titleMatch[1],
|
||||||
|
|
|
||||||
28
src/extractor/FileLions.test.ts
Normal file
28
src/extractor/FileLions.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { FileLions } from './FileLions';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(logger, [new FileLions(new FetcherMock(`${__dirname}/__fixtures__/FileLions`))]);
|
||||||
|
|
||||||
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
||||||
|
|
||||||
|
describe('FileLions', () => {
|
||||||
|
test('filelions f', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://filelions.to/f/tyn45apubte2'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mivalyo v referer lock', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://mivalyo.com/v/tah5znapz3e5'), { referer: 'https://kinoger.com' })).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file not found', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://filelions.to/v/ylcp2cu5qanb'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleted by administration', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://callistanise.com/f/cy4t5nkerjrt'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
99
src/extractor/FileLions.ts
Normal file
99
src/extractor/FileLions.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import bytes from 'bytes';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { NotFoundError } from '../error';
|
||||||
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
|
import { buildMediaFlowProxyExtractorStreamUrl, supportsMediaFlowProxy, unpackEval } from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
/** @see https://github.com/Gujal00/ResolveURL/commits/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py */
|
||||||
|
export class FileLions extends Extractor {
|
||||||
|
public readonly id = 'filelions';
|
||||||
|
|
||||||
|
public readonly label = 'FileLions';
|
||||||
|
|
||||||
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
|
const supportedDomain = null !== url.host.match(/.*lions?/)
|
||||||
|
|| [
|
||||||
|
'6sfkrspw4u.sbs',
|
||||||
|
'ajmidyadfihayh.sbs',
|
||||||
|
'alhayabambi.sbs',
|
||||||
|
'anime7u.com',
|
||||||
|
'azipcdn.com',
|
||||||
|
'bingezove.com',
|
||||||
|
'callistanise.com',
|
||||||
|
'coolciima.online',
|
||||||
|
'dhtpre.com',
|
||||||
|
'dingtezuni.com',
|
||||||
|
'dintezuvio.com',
|
||||||
|
'e4xb5c2xnz.sbs',
|
||||||
|
'egsyxutd.sbs',
|
||||||
|
'fdewsdc.sbs',
|
||||||
|
'gsfomqu.sbs',
|
||||||
|
'javplaya.com',
|
||||||
|
'katomen.online',
|
||||||
|
'lumiawatch.top',
|
||||||
|
'minochinos.com',
|
||||||
|
'mivalyo.com',
|
||||||
|
'moflix-stream.click',
|
||||||
|
'motvy55.store',
|
||||||
|
'movearnpre.com',
|
||||||
|
'peytonepre.com',
|
||||||
|
'ryderjet.com',
|
||||||
|
'smoothpre.com',
|
||||||
|
'taylorplayer.com',
|
||||||
|
'techradar.ink',
|
||||||
|
'videoland.sbs',
|
||||||
|
'vidhide.com',
|
||||||
|
'vidhide.fun',
|
||||||
|
'vidhidefast.com',
|
||||||
|
'vidhidehub.com',
|
||||||
|
'vidhideplus.com',
|
||||||
|
'vidhidepre.com',
|
||||||
|
'vidhidepro.com',
|
||||||
|
'vidhidevip.com',
|
||||||
|
].includes(url.host);
|
||||||
|
|
||||||
|
return supportedDomain && supportsMediaFlowProxy(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override normalize(url: URL): URL {
|
||||||
|
return new URL(url.href.replace('/v/', '/f/').replace('/download/', '/f/').replace('/file/', '/f/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
|
||||||
|
if (html.includes('This video can be watched as embed only')) {
|
||||||
|
return await this.extractInternal(ctx, new URL(url.href.replace('/f/', '/v/')), meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/File Not Found|deleted by administration/.test(html)) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const unpacked = unpackEval(html);
|
||||||
|
const heightMatch = unpacked.match(/(\d{3,})p/) as string[];
|
||||||
|
|
||||||
|
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/);
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const title = $('meta[name="description"]').attr('content') as string;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'FileLions', url, headers),
|
||||||
|
format: Format.hls,
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
height: parseInt(heightMatch[1] as string),
|
||||||
|
...(sizeMatch && { bytes: bytes.parse(sizeMatch[1] as string) as number }),
|
||||||
|
...(title && { title }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
24
src/extractor/FileMoon.test.ts
Normal file
24
src/extractor/FileMoon.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { FileMoon } from './FileMoon';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(logger, [new FileMoon(new FetcherMock(`${__dirname}/__fixtures__/FileMoon`))]);
|
||||||
|
|
||||||
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
||||||
|
|
||||||
|
describe('FileMoon', () => {
|
||||||
|
test('z1ekv717 d', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://z1ekv717.fun/d/wkhcbggdxf1d'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page not found', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://filemoon.sx/e/n7i8zodwjqr9'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing height', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://filemoon.to/d/ugj7f1kq94p1'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
82
src/extractor/FileMoon.ts
Normal file
82
src/extractor/FileMoon.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { NotFoundError } from '../error';
|
||||||
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
|
import {
|
||||||
|
buildMediaFlowProxyExtractorStreamUrl,
|
||||||
|
supportsMediaFlowProxy,
|
||||||
|
unpackEval,
|
||||||
|
} from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/filemoon.py */
|
||||||
|
export class FileMoon extends Extractor {
|
||||||
|
public readonly id = 'filemoon';
|
||||||
|
|
||||||
|
public readonly label = 'FileMoon';
|
||||||
|
|
||||||
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
|
const supportedDomain = null !== url.host.match(/filemoon/)
|
||||||
|
|| [
|
||||||
|
'1azayf9w.xyz',
|
||||||
|
'222i8x.lol',
|
||||||
|
'81u6xl9d.xyz',
|
||||||
|
'8mhlloqo.fun',
|
||||||
|
'96ar.com',
|
||||||
|
'bf0skv.org',
|
||||||
|
'boosteradx.online',
|
||||||
|
'c1z39.com',
|
||||||
|
'cinegrab.com',
|
||||||
|
'f51rm.com',
|
||||||
|
'furher.in',
|
||||||
|
'kerapoxy.cc',
|
||||||
|
'l1afav.net',
|
||||||
|
'moonmov.pro',
|
||||||
|
'smdfs40r.skin',
|
||||||
|
'xcoic.com',
|
||||||
|
'z1ekv717.fun',
|
||||||
|
].includes(url.host);
|
||||||
|
|
||||||
|
return supportedDomain && supportsMediaFlowProxy(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override normalize(url: URL): URL {
|
||||||
|
return new URL(url.href.replace('/e/', '/d/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta, originalUrl?: URL): Promise<InternalUrlResult[]> {
|
||||||
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
|
||||||
|
if (/Page not found/.test(html)) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const title = $('h3').text().trim();
|
||||||
|
|
||||||
|
const iframeUrlMatches = Array.from(html.matchAll(/iframe.*?src=["'](.*?)["']/g));
|
||||||
|
if (iframeUrlMatches.length) {
|
||||||
|
// Use last match because there can be fake adblock catcher urls before
|
||||||
|
return await this.extractInternal(ctx, new URL((iframeUrlMatches[iframeUrlMatches.length - 1] as RegExpExecArray)[1] as string), { title, ...meta }, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'FileMoon', originalUrl as URL, headers);
|
||||||
|
|
||||||
|
const unpacked = unpackEval(html);
|
||||||
|
const heightMatch = unpacked.match(/(\d{3,})p/);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: playlistUrl,
|
||||||
|
format: Format.hls,
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
...(heightMatch && { height: parseInt(heightMatch[1] as string) }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Fsst } from './Fsst';
|
import { Fsst } from './Fsst';
|
||||||
|
|
@ -11,15 +10,7 @@ const extractorRegistry = new ExtractorRegistry(logger, [new Fsst(new FetcherMoc
|
||||||
const ctx = createTestContext();
|
const ctx = createTestContext();
|
||||||
|
|
||||||
describe('Fsst', () => {
|
describe('Fsst', () => {
|
||||||
test('Blood & Sinners', async () => {
|
test('Wake up Dead Man', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/900576/'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/948429/'))).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('Dead City', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/901994/'), CountryCode.de)).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('How to Train Your Dragon', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://fsst.online/embed/902668/'), CountryCode.de)).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { Fetcher } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Fsst extends Extractor {
|
export class Fsst extends Extractor {
|
||||||
|
|
@ -8,44 +7,35 @@ export class Fsst extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'Fsst';
|
public readonly label = 'Fsst';
|
||||||
|
|
||||||
public override readonly ttl = 0;
|
public override readonly ttl: number = 10800000; // 3h
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/fsst/);
|
return null !== url.host.match(/fsst/);
|
||||||
};
|
};
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const html = await this.fetcher.text(ctx, url, { headers, noProxyHeaders: true });
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('title').text().trim();
|
const title = $('title').text().trim();
|
||||||
|
|
||||||
const filesMatch = html.match(/file:"(.*)"/) as string[];
|
const filesMatch = html.match(/file:"(.*)"/) as string[];
|
||||||
|
|
||||||
return (filesMatch[1] as string).split(',').map((fileString, index) => {
|
const lastFile = (filesMatch[1] as string).split(',').pop() as string;
|
||||||
const heightAndUrlMatch = fileString.match(/\[?([\d]*)p?]?(.*)/) as string[];
|
|
||||||
const fileHref = heightAndUrlMatch[2] as string;
|
|
||||||
const heightFromFileHrefMatch = fileHref.match(/([\d]+)p/) as string[];
|
|
||||||
|
|
||||||
return {
|
const heightAndUrlMatch = lastFile.match(/\[?([\d]*)p?]?(.*)/) as string[];
|
||||||
url: new URL(fileHref),
|
const fileHref = heightAndUrlMatch[2] as string;
|
||||||
|
|
||||||
|
return [{
|
||||||
|
url: await this.fetcher.getFinalRedirectUrl(ctx, new URL(fileHref), { headers, noProxyHeaders: true }, 1),
|
||||||
format: Format.mp4,
|
format: Format.mp4,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}_${index}`,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
height: parseInt(heightAndUrlMatch[1] || heightFromFileHrefMatch[1] as string),
|
height: parseInt(heightAndUrlMatch[1] as string),
|
||||||
title,
|
title,
|
||||||
},
|
},
|
||||||
};
|
}];
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
src/extractor/HubCloud.test.ts
Normal file
28
src/extractor/HubCloud.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { HubCloud } from './HubCloud';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(logger, [new HubCloud(new FetcherMock(`${__dirname}/__fixtures__/HubCloud`))]);
|
||||||
|
|
||||||
|
const ctx = createTestContext();
|
||||||
|
|
||||||
|
describe('HubCloud', () => {
|
||||||
|
test('handle dexter original sin 2024 s01e01', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://hubcloud.one/drive/idt1evqfuviqiei'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle crayon shin-chan 1993', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://hubcloud.one/drive/bffzqlpqfllfcld'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle dark 2017 s03e08', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://hubcloud.one/drive/nknlofk8snfnknh'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle goat 2026', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://hubcloud.foo/drive/p94k4dccjwxjcx4'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/extractor/HubCloud.ts
Normal file
102
src/extractor/HubCloud.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import bytes from 'bytes';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
|
import { findCountryCodes, findHeight } from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
export class HubCloud extends Extractor {
|
||||||
|
public readonly id = 'hubcloud';
|
||||||
|
|
||||||
|
public readonly label = 'HubCloud';
|
||||||
|
|
||||||
|
public override readonly ttl: number = 43200000; // 12h
|
||||||
|
|
||||||
|
public override readonly cacheVersion = 1;
|
||||||
|
|
||||||
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
|
return null !== url.host.match(/hubcloud/);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const redirectHtml = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
const redirectUrlMatch = redirectHtml.match(/var url ?= ?'(.*?)'/) as string[];
|
||||||
|
|
||||||
|
const linksHtml = await this.fetcher.text(ctx, new URL(redirectUrlMatch[1] as string), { headers: { Referer: url.href } });
|
||||||
|
const $ = cheerio.load(linksHtml);
|
||||||
|
|
||||||
|
const title = $('title').text().trim();
|
||||||
|
const countryCodes = [...new Set([...meta.countryCodes ?? [], ...findCountryCodes(title)])];
|
||||||
|
const height = meta.height ?? findHeight(title);
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
...$('a')
|
||||||
|
.filter((_i, el) => {
|
||||||
|
const text = $(el).text();
|
||||||
|
|
||||||
|
return text.includes('FSL') && !text.includes('FSLv2');
|
||||||
|
})
|
||||||
|
.map((_i, el) => {
|
||||||
|
const url = new URL($(el).attr('href') as string);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
format: Format.unknown,
|
||||||
|
label: `${this.label} (FSL)`,
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
bytes: bytes.parse($('#size').text()) as number,
|
||||||
|
extractorId: `${this.id}_fsl`,
|
||||||
|
countryCodes,
|
||||||
|
height,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}).toArray(),
|
||||||
|
...$('a')
|
||||||
|
.filter((_i, el) => {
|
||||||
|
const text = $(el).text();
|
||||||
|
|
||||||
|
return text.includes('FSLv2');
|
||||||
|
})
|
||||||
|
.map((_i, el) => {
|
||||||
|
const url = new URL($(el).attr('href') as string);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
format: Format.unknown,
|
||||||
|
label: `${this.label} (FSLv2)`,
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
bytes: bytes.parse($('#size').text()) as number,
|
||||||
|
extractorId: `${this.id}_fslv2`,
|
||||||
|
countryCodes,
|
||||||
|
height,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}).toArray(),
|
||||||
|
...$('a')
|
||||||
|
.filter((_i, el) => $(el).text().includes('PixelServer'))
|
||||||
|
.map((_i, el) => {
|
||||||
|
const userUrl = new URL(($(el).attr('href') as string).replace('/api/file/', '/u/'));
|
||||||
|
const url = new URL(userUrl.href.replace('/u/', '/api/file/'));
|
||||||
|
url.searchParams.set('download', '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
format: Format.unknown,
|
||||||
|
label: `${this.label} (PixelServer)`,
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
bytes: bytes.parse($('#size').text()) as number,
|
||||||
|
extractorId: `${this.id}_pixelserver`,
|
||||||
|
countryCodes,
|
||||||
|
height,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
requestHeaders: { Referer: userUrl.href },
|
||||||
|
};
|
||||||
|
}).toArray(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
}
|
||||||
29
src/extractor/HubDrive.test.ts
Normal file
29
src/extractor/HubDrive.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { HubCloud } from './HubCloud';
|
||||||
|
import { HubDrive } from './HubDrive';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(
|
||||||
|
logger,
|
||||||
|
[
|
||||||
|
new HubDrive(
|
||||||
|
new FetcherMock(`${__dirname}/__fixtures__/HubDrive`),
|
||||||
|
new HubCloud(new FetcherMock(`${__dirname}/__fixtures__/HubDrive/HubCloud`)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctx = createTestContext();
|
||||||
|
|
||||||
|
describe('HubDrive', () => {
|
||||||
|
test('handle avatar', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://hubdrive.space/file/7283903021'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle missing hubcloud', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://hubdrive.space/file/2243124026'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/extractor/HubDrive.ts
Normal file
40
src/extractor/HubDrive.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { Context, InternalUrlResult, Meta } from '../types';
|
||||||
|
import { Fetcher } from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
import { HubCloud } from './HubCloud';
|
||||||
|
|
||||||
|
export class HubDrive extends Extractor {
|
||||||
|
public readonly id = 'hubdrive';
|
||||||
|
|
||||||
|
public readonly label = 'HubDrive';
|
||||||
|
|
||||||
|
public override readonly ttl: number = 43200000; // 12h
|
||||||
|
|
||||||
|
public override readonly cacheVersion = 1;
|
||||||
|
|
||||||
|
private readonly hubCloud: HubCloud;
|
||||||
|
|
||||||
|
public constructor(fetcher: Fetcher, hubCloud: HubCloud) {
|
||||||
|
super(fetcher);
|
||||||
|
|
||||||
|
this.hubCloud = hubCloud;
|
||||||
|
}
|
||||||
|
|
||||||
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
|
return null !== url.host.match(/hubdrive/);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const hubCloudUrl = $('a:contains("HubCloud")')
|
||||||
|
.map((_i, el) => new URL($(el).attr('href') as string))
|
||||||
|
.get(0);
|
||||||
|
|
||||||
|
return hubCloudUrl ? this.hubCloud.extract(ctx, hubCloudUrl, meta) : [];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { KinoGer } from './KinoGer';
|
import { KinoGer } from './KinoGer';
|
||||||
|
|
@ -12,10 +11,18 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('KinoGer', () => {
|
describe('KinoGer', () => {
|
||||||
test('Blood & Sinners', async () => {
|
test('Blood & Sinners', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.re/#ge5fhb'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.re/#ge5fhb'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Dead City', async () => {
|
test('Dead City', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.re/#x6tsx9'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.re/#x6tsx9'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filma365.strp2p.site', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://filma365.strp2p.site/#vih6bx'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kinoger.p2pplay.pro', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://kinoger.p2pplay.pro/#k53k9'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { Fetcher, guessHeightFromPlaylist } from '../utils';
|
import { guessHeightFromPlaylist } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/kinoger.py */
|
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/kinoger.py */
|
||||||
|
|
@ -9,13 +9,7 @@ export class KinoGer extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'KinoGer';
|
public readonly label = 'KinoGer';
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
public override readonly ttl: number = 21600000; // 6h
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return [
|
return [
|
||||||
|
|
@ -23,11 +17,19 @@ export class KinoGer extends Extractor {
|
||||||
'disneycdn.net',
|
'disneycdn.net',
|
||||||
'dzo.vidplayer.live',
|
'dzo.vidplayer.live',
|
||||||
'filedecrypt.link',
|
'filedecrypt.link',
|
||||||
|
'filma365.strp2p.site',
|
||||||
|
'flimmer.rpmvip.com',
|
||||||
|
'flixfilmesonline.strp2p.site',
|
||||||
|
'kinoger.p2pplay.pro',
|
||||||
'kinoger.re',
|
'kinoger.re',
|
||||||
'moflix.rpmplay.xyz',
|
'moflix.rpmplay.xyz',
|
||||||
'moflix.upns.xyz',
|
'moflix.upns.xyz',
|
||||||
'player.upn.one',
|
'player.upn.one',
|
||||||
|
'securecdn.shop',
|
||||||
'shiid4u.upn.one',
|
'shiid4u.upn.one',
|
||||||
|
'srbe84.vidplayer.live',
|
||||||
|
'strp2p.site',
|
||||||
|
't1.p2pplay.pro',
|
||||||
'tuktuk.rpmvid.com',
|
'tuktuk.rpmvid.com',
|
||||||
'ultrastream.online',
|
'ultrastream.online',
|
||||||
'videoland.cfd',
|
'videoland.cfd',
|
||||||
|
|
@ -41,34 +43,35 @@ export class KinoGer extends Extractor {
|
||||||
return new URL(`${url.origin}/api/v1/video?id=${url.hash.slice(1)}`);
|
return new URL(`${url.origin}/api/v1/video?id=${url.hash.slice(1)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const hexData = await this.fetcher.text(ctx, url, { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36' } });
|
const headers = {
|
||||||
|
'Origin': url.origin,
|
||||||
|
'Referer': url.origin + '/',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||||
|
};
|
||||||
|
|
||||||
const encrypted = Buffer.from(hexData, 'hex');
|
const hexData = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
|
||||||
|
const encrypted = Buffer.from(hexData.slice(0, -1), 'hex');
|
||||||
const key = Buffer.from('6b69656d7469656e6d75613931316361', 'hex');
|
const key = Buffer.from('6b69656d7469656e6d75613931316361', 'hex');
|
||||||
const iv = Buffer.from('313233343536373839306f6975797472', 'hex');
|
const iv = Buffer.from('313233343536373839306f6975797472', 'hex');
|
||||||
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
||||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
||||||
|
|
||||||
const { cf, title } = JSON.parse(decrypted) as { cf: string; title: string };
|
const { source, title } = JSON.parse(decrypted) as { source: string; title: string };
|
||||||
|
|
||||||
const m3u8Url = new URL(cf);
|
const m3u8Url = new URL(source);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: new URL(cf),
|
url: m3u8Url,
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers: { Referer: url.origin } }),
|
height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers }),
|
||||||
title,
|
title,
|
||||||
},
|
},
|
||||||
requestHeaders: {
|
requestHeaders: headers,
|
||||||
Referer: url.origin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
24
src/extractor/LuluStream.test.ts
Normal file
24
src/extractor/LuluStream.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { LuluStream } from './LuluStream';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(logger, [new LuluStream(new FetcherMock(`${__dirname}/__fixtures__/LuluStream`))]);
|
||||||
|
|
||||||
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
||||||
|
|
||||||
|
describe('LuluStream', () => {
|
||||||
|
test('streamhihi d', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://streamhihi.com/d/mk9m58lz8ts6'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streamhihi', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://streamhihi.com/mk9m58lz8ts6'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no such file', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://lulustream.com/e/uthq0o0sljnx'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/extractor/LuluStream.ts
Normal file
67
src/extractor/LuluStream.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import bytes from 'bytes';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { NotFoundError } from '../error';
|
||||||
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
|
import {
|
||||||
|
buildMediaFlowProxyExtractorStreamUrl,
|
||||||
|
supportsMediaFlowProxy,
|
||||||
|
} from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/lulustream.py */
|
||||||
|
export class LuluStream extends Extractor {
|
||||||
|
public readonly id = 'lulustream';
|
||||||
|
|
||||||
|
public readonly label = 'LuluStream';
|
||||||
|
|
||||||
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
|
const supportedDomain = null !== url.host.match(/lulu/)
|
||||||
|
|| [
|
||||||
|
'732eg54de642sa.sbs',
|
||||||
|
'cdn1.site',
|
||||||
|
'd00ds.site',
|
||||||
|
'streamhihi.com',
|
||||||
|
].includes(url.host);
|
||||||
|
|
||||||
|
return supportedDomain && supportsMediaFlowProxy(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override normalize(url: URL): URL {
|
||||||
|
const videoId = url.pathname.replace(/\/+$/, '').split('/').at(-1) as string;
|
||||||
|
|
||||||
|
return new URL(`/e/${videoId}`, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const fileUrl = new URL(url.href.replace('/e/', '/d/'));
|
||||||
|
const html = await this.fetcher.text(ctx, fileUrl, { headers });
|
||||||
|
|
||||||
|
if (/No such file|File Not Found/.test(html)) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const title = $('h1').text().trim();
|
||||||
|
|
||||||
|
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/) as string[];
|
||||||
|
|
||||||
|
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'LuluStream', url, headers);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: playlistUrl,
|
||||||
|
format: Format.hls,
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
title,
|
||||||
|
bytes: bytes.parse(heightAndSizeMatch[2] as string) as number,
|
||||||
|
height: parseInt(heightAndSizeMatch[1] as string),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Mixdrop } from './Mixdrop';
|
import { Mixdrop } from './Mixdrop';
|
||||||
|
|
@ -12,14 +11,10 @@ const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test
|
||||||
|
|
||||||
describe('Mixdrop', () => {
|
describe('Mixdrop', () => {
|
||||||
test('mixdrop.my /e/', async () => {
|
test('mixdrop.my /e/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.my/e/knq0kj8waq44l8'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.my/e/knq0kj8waq44l8'))).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('mixdrop.my /f/', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.my/f/knq0kj8waq44l8'), CountryCode.de)).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleted or expired file', async () => {
|
test('deleted or expired file', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.ag/e/123456789'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://mixdrop.ag/e/123456789'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,24 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { buildMediaFlowProxyExtractorRedirectUrl, Fetcher, supportsMediaFlowProxy } from '../utils';
|
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Mixdrop extends Extractor {
|
export class Mixdrop extends Extractor {
|
||||||
public readonly id = 'mixdrop';
|
public readonly id = 'mixdrop';
|
||||||
|
|
||||||
public readonly label = 'Mixdrop (via MediaFlow Proxy)';
|
public readonly label = 'Mixdrop';
|
||||||
|
|
||||||
public override readonly ttl = 0;
|
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/mixdrop/) && supportsMediaFlowProxy(ctx);
|
return null !== url.host.match(/mixdrop|mixdrp|mixdroop|m1xdrop/) && supportsMediaFlowProxy(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override readonly normalize = (url: URL): URL => new URL(url.href.replace('/f/', '/e/'));
|
public override readonly normalize = (url: URL): URL => new URL(url.href.replace('/f/', '/e/'));
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const fileUrl = new URL(url.href.replace('/e/', '/f/'));
|
const fileUrl = new URL(url.href.replace('/e/', '/f/'));
|
||||||
const html = await this.fetcher.text(ctx, fileUrl);
|
const html = await this.fetcher.text(ctx, fileUrl);
|
||||||
|
|
||||||
|
|
@ -36,7 +26,7 @@ export class Mixdrop extends Extractor {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeMatch = html.match(/([\d.,]+ ?[GM]B)/);
|
const sizeMatch = html.match(/([\d.,]+ ?[GM]B)/) as string[];
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('.title b').text().trim();
|
const title = $('.title b').text().trim();
|
||||||
|
|
@ -45,15 +35,10 @@ export class Mixdrop extends Extractor {
|
||||||
{
|
{
|
||||||
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Mixdrop', url),
|
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Mixdrop', url),
|
||||||
format: Format.mp4,
|
format: Format.mp4,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
title,
|
|
||||||
...(sizeMatch && {
|
|
||||||
bytes: bytes.parse((sizeMatch[1] as string).replace(',', '')) as number,
|
bytes: bytes.parse((sizeMatch[1] as string).replace(',', '')) as number,
|
||||||
}),
|
title,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
24
src/extractor/RgShows.test.ts
Normal file
24
src/extractor/RgShows.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { RgShows } from './RgShows';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(logger, [new RgShows(new FetcherMock(`${__dirname}/__fixtures__/RgShows`))]);
|
||||||
|
|
||||||
|
const ctx = createTestContext();
|
||||||
|
|
||||||
|
describe('RgShows', () => {
|
||||||
|
test('handle one battle after another', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://api.rgshows.ru/main/movie/1054867'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle stranger things s05e01', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://api.rgshows.ru/main/tv/66732/5/1'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle parasite 2019 (mp4)', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://api.rgshows.ru/main/movie/496243'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/extractor/RgShows.ts
Normal file
49
src/extractor/RgShows.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { BlockedError } from '../error';
|
||||||
|
import { BlockedReason, Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
|
import { guessHeightFromPlaylist } from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
interface RgShowsApiData {
|
||||||
|
stream: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RgShows extends Extractor {
|
||||||
|
public readonly id = 'rgshows';
|
||||||
|
|
||||||
|
public readonly label = 'RgShows';
|
||||||
|
|
||||||
|
public override readonly ttl: number = 10800000; // 3h
|
||||||
|
|
||||||
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
|
return null !== url.host.match(/rgshows/);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
|
const headers = { 'Referer': 'https://www.rgshows.ru/', 'Origin': 'https://www.rgshows.ru', 'User-Agent': 'Mozilla' };
|
||||||
|
|
||||||
|
const data = await this.fetcher.json(ctx, url, { headers }) as RgShowsApiData;
|
||||||
|
|
||||||
|
const streamUrl = new URL(data.stream.url);
|
||||||
|
/* istanbul ignore if */
|
||||||
|
if (streamUrl.host.includes('vidzee')) {
|
||||||
|
throw new BlockedError(url, BlockedReason.unknown, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMp4 = streamUrl.href.includes('mp4');
|
||||||
|
const isHls = streamUrl.href.includes('m3u8') || streamUrl.href.includes('txt');
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: streamUrl,
|
||||||
|
format: isMp4 ? Format.mp4 : (isHls ? Format.hls : /* istanbul ignore next */ Format.unknown),
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
...(isHls && { height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, streamUrl, { headers }) }),
|
||||||
|
},
|
||||||
|
requestHeaders: headers,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { SaveFiles } from './SaveFiles';
|
import { SaveFiles } from './SaveFiles';
|
||||||
|
|
@ -12,22 +11,26 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('SafeFiles', () => {
|
describe('SafeFiles', () => {
|
||||||
test('savefiles', async () => {
|
test('savefiles', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/ip0k0dj2g0i3'), CountryCode.en)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/ip0k0dj2g0i3'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('savefiles /e/', async () => {
|
test('savefiles /e/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/e/edptfmhyjr39'), CountryCode.en)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/e/edptfmhyjr39'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('savefiles /d/', async () => {
|
test('savefiles /d/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/d/s9g6zb5kjbqd'), CountryCode.en)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/d/s9g6zb5kjbqd'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('savefiles locked file', async () => {
|
test('savefiles locked file', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/omqq55i59nvv'), CountryCode.en)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/omqq55i59nvv'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('savefiles deleted file', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://savefiles.com/bejqk3vlsv02'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('streamhls /e/', async () => {
|
test('streamhls /e/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://streamhls.to/e/ip0k0dj2g0i3'), CountryCode.en)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://streamhls.to/e/ip0k0dj2g0i3'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { Fetcher } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class SaveFiles extends Extractor {
|
export class SaveFiles extends Extractor {
|
||||||
|
|
@ -9,13 +8,7 @@ export class SaveFiles extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'SaveFiles';
|
public readonly label = 'SaveFiles';
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
public override readonly ttl: number = 21600000; // 6h
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/savefiles|streamhls/);
|
return null !== url.host.match(/savefiles|streamhls/);
|
||||||
|
|
@ -25,10 +18,12 @@ export class SaveFiles extends Extractor {
|
||||||
return new URL(url.href.replace('/e/', '/').replace('/d/', '/'));
|
return new URL(url.href.replace('/e/', '/').replace('/d/', '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
if (/File was locked by administrator/.test(html)) {
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
|
||||||
|
if (/file was locked|file was deleted/i.test(html)) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,11 +37,8 @@ export class SaveFiles extends Extractor {
|
||||||
{
|
{
|
||||||
url: new URL(fileMatch[1] as string),
|
url: new URL(fileMatch[1] as string),
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
title,
|
title,
|
||||||
height: parseInt(sizeMatch[1] as string),
|
height: parseInt(sizeMatch[1] as string),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { Soaper } from './Soaper';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new Soaper(new FetcherMock(`${__dirname}/__fixtures__/Soaper`))]);
|
|
||||||
|
|
||||||
const ctx = createTestContext();
|
|
||||||
|
|
||||||
describe('Soaper', () => {
|
|
||||||
test('Full Metal Jacket', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://soaper.live/movie_d8kdeypDY9.html'), CountryCode.en, 'Full Metal Jacket (1987)')).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Black Mirror', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://soaper.live/episode_5KDq78eGp1.html'), CountryCode.en, 'Black Mirror 4x2')).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('last of us s2e3', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://soaper.live/episode_rYg3vMEDL1.html'), CountryCode.de, 'The Last of Us 2x3')).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
|
||||||
import { Fetcher, guessHeightFromPlaylist } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
|
|
||||||
interface SoaperInfoResponsePartial {
|
|
||||||
val: string;
|
|
||||||
val_bak: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Soaper extends Extractor {
|
|
||||||
public readonly id = 'soaper';
|
|
||||||
|
|
||||||
public readonly label = 'Soaper';
|
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
|
||||||
return null !== url.host.match(/soaper/) && null !== url.pathname.match(/^\/(episode|movie)_/);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode, title: string | undefined): Promise<UrlResult[]> {
|
|
||||||
const movieOrEpisodeId = (url.pathname.match(/\/\w+_(\w+)/) as string[])[1] as string;
|
|
||||||
|
|
||||||
const form = new URLSearchParams();
|
|
||||||
form.append('pass', movieOrEpisodeId);
|
|
||||||
form.append('param', '');
|
|
||||||
form.append('extra', '');
|
|
||||||
form.append('e2', '');
|
|
||||||
form.append('server', '0');
|
|
||||||
|
|
||||||
const response = await this.fetcher.textPost(
|
|
||||||
ctx,
|
|
||||||
new URL(url.pathname.includes('episode') ? '/home/index/GetEInfoAjax' : '/home/index/GetMInfoAjax', url.origin),
|
|
||||||
form.toString(),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Referer': url.origin,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const jsonResponse = JSON.parse(response) as SoaperInfoResponsePartial;
|
|
||||||
|
|
||||||
const m3u8Url = new URL(jsonResponse['val'], url.origin);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
url: m3u8Url,
|
|
||||||
format: Format.hls,
|
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
|
||||||
countryCodes: [countryCode],
|
|
||||||
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url),
|
|
||||||
title: `${title}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { StreamEmbed } from './StreamEmbed';
|
import { StreamEmbed } from './StreamEmbed';
|
||||||
|
|
@ -12,6 +11,10 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('StreamEmbed', () => {
|
describe('StreamEmbed', () => {
|
||||||
test('watch.gxplayer.xyz', async () => {
|
test('watch.gxplayer.xyz', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://watch.gxplayer.xyz/watch?v=MEKI92PU'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://watch.gxplayer.xyz/watch?v=MEKI92PU'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('video is not ready', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://watch.gxplayer.xyz/watch?v=PBO90WAS'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { NotFoundError } from '../error';
|
||||||
import { Fetcher } from '../utils';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class StreamEmbed extends Extractor {
|
export class StreamEmbed extends Extractor {
|
||||||
|
|
@ -7,20 +7,20 @@ export class StreamEmbed extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'StreamEmbed';
|
public readonly label = 'StreamEmbed';
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
public override readonly ttl: number = 259200000; // 3d
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/bullstream|mp4player|watch\.gxplayer/);
|
return null !== url.host.match(/bullstream|mp4player|watch\.gxplayer/);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
|
||||||
|
if (/Video is not ready/.test(html)) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
const video = JSON.parse((html.match(/video ?= ?(.*);/) as string[])[1] as string);
|
const video = JSON.parse((html.match(/video ?= ?(.*);/) as string[])[1] as string);
|
||||||
|
|
||||||
|
|
@ -28,11 +28,8 @@ export class StreamEmbed extends Extractor {
|
||||||
{
|
{
|
||||||
url: new URL(`/m3u8/${video.uid}/${video.md5}/master.txt?s=1&id=${video.id}&cache=${video.status}`, url.origin),
|
url: new URL(`/m3u8/${video.uid}/${video.md5}/master.txt?s=1&id=${video.id}&cache=${video.status}`, url.origin),
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
height: parseInt(JSON.parse(video.quality)[0]),
|
height: parseInt(JSON.parse(video.quality)[0]),
|
||||||
title: decodeURIComponent(video.title),
|
title: decodeURIComponent(video.title),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Streamtape } from './Streamtape';
|
import { Streamtape } from './Streamtape';
|
||||||
|
|
@ -8,10 +7,10 @@ import { Streamtape } from './Streamtape';
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new Streamtape(new FetcherMock(`${__dirname}/__fixtures__/Streamtape`))]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new Streamtape(new FetcherMock(`${__dirname}/__fixtures__/Streamtape`))]);
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test', mediaFlowProxyPassword: 'asdfg' });
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
||||||
|
|
||||||
describe('Streamtape', () => {
|
describe('Streamtape', () => {
|
||||||
test('streamtape.com /e', async () => {
|
test('streamtape.com /e', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://streamtape.com/e/84Kb3mkoYAiokmW'), CountryCode.es)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://streamtape.com/e/DjmZBG0K9gHkJ7z/WALL-E.2008.1080p.BrRip.x264.YIFY.mp4'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,68 @@
|
||||||
|
import bytes from 'bytes';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import {
|
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils';
|
||||||
buildMediaFlowProxyExtractorRedirectUrl,
|
|
||||||
Fetcher,
|
|
||||||
supportsMediaFlowProxy,
|
|
||||||
} from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Streamtape extends Extractor {
|
export class Streamtape extends Extractor {
|
||||||
public readonly id = 'streamtape';
|
public readonly id = 'streamtape';
|
||||||
|
|
||||||
public readonly label = 'Streamtape (via MediaFlow Proxy)';
|
public readonly label = 'Streamtape';
|
||||||
|
|
||||||
public override readonly ttl = 0;
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/streamtape/) && supportsMediaFlowProxy(ctx);
|
const supportedDomain = null !== url.host.match(/streamtape/)
|
||||||
|
|| [
|
||||||
|
'strtape.cloud',
|
||||||
|
'streamta.pe',
|
||||||
|
'strcloud.link',
|
||||||
|
'strcloud.club',
|
||||||
|
'strtpe.link',
|
||||||
|
'scloud.online',
|
||||||
|
'stape.fun',
|
||||||
|
'streamadblockplus.com',
|
||||||
|
'shavetape.cash',
|
||||||
|
'streamta.site',
|
||||||
|
'streamadblocker.xyz',
|
||||||
|
'tapewithadblock.org',
|
||||||
|
'adblocktape.wiki',
|
||||||
|
'antiadtape.com',
|
||||||
|
'tapeblocker.com',
|
||||||
|
'streamnoads.com',
|
||||||
|
'tapeadvertisement.com',
|
||||||
|
'tapeadsenjoyer.com',
|
||||||
|
'watchadsontape.com',
|
||||||
|
].includes(url.host);
|
||||||
|
|
||||||
|
return supportedDomain && supportsMediaFlowProxy(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
public override normalize(url: URL): URL {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
return new URL(url.href.replace('/e/', '/v/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
// Only needed to properly find non-existing files via 404 response
|
||||||
|
await this.fetcher.text(ctx, new URL(url.href.replace('/v/', '/e/')), { headers });
|
||||||
|
|
||||||
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
|
||||||
|
const sizeMatch = html.match(/([\d.]+ ?[GM]B)/) as string[];
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('meta[name="og:title"]').attr('content') as string;
|
const title = $('meta[name="og:title"]').attr('content') as string;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Streamtape', url),
|
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Streamtape', url, headers),
|
||||||
format: Format.mp4,
|
format: Format.mp4,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
title,
|
title,
|
||||||
|
bytes: bytes.parse(sizeMatch[1] as string) as number,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { SuperVideo } from './SuperVideo';
|
import { SuperVideo } from './SuperVideo';
|
||||||
|
|
@ -12,22 +11,22 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('SuperVideo', () => {
|
describe('SuperVideo', () => {
|
||||||
test('supervideo.cc /e/', async () => {
|
test('supervideo.cc /e/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/e/q7i0sw1oytw3'), CountryCode.de)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/e/q7i0sw1oytw3'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('supervideo.tv /embed-/', async () => {
|
test('supervideo.tv /embed-/', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.tv/embed-1p0m1fi9mok8.html'), CountryCode.it)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.tv/embed-1p0m1fi9mok8.html'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('embed only', async () => {
|
test('embed only', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/e/bj6szat1pval'), CountryCode.it)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/e/bj6szat1pval'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleted or expired file', async () => {
|
test('deleted or expired file', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/ndf5shmy9lpt'), CountryCode.it)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/ndf5shmy9lpt'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('processing video', async () => {
|
test('processing video', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/3h1qqoqtldo8'), CountryCode.it)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://supervideo.cc/3h1qqoqtldo8'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { NotFoundError } from '../error';
|
import { NotFoundError } from '../error';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { extractUrlFromPacked, Fetcher } from '../utils';
|
import { extractUrlFromPacked, guessHeightFromPlaylist } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class SuperVideo extends Extractor {
|
export class SuperVideo extends Extractor {
|
||||||
|
|
@ -10,53 +10,52 @@ export class SuperVideo extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'SuperVideo';
|
public readonly label = 'SuperVideo';
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
public override readonly ttl: number = 10800000; // 3h
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/supervideo/);
|
return null !== url.host.match(/supervideo/);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override normalize(url: URL): URL {
|
public override normalize(url: URL): URL {
|
||||||
return new URL(url.href.replace('/e/', '/').replace('/embed-', '/'));
|
return new URL(url.href.replace('/e/', '/').replace('/k/', '/').replace('/embed-', '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
|
||||||
if (html.includes('This video can be watched as embed only')) {
|
if (html.includes('This video can be watched as embed only')) {
|
||||||
return await this.extractInternal(ctx, new URL(`/e${url.pathname}`, url.origin), countryCode);
|
return await this.extractInternal(ctx, new URL(`/e${url.pathname}`, url.origin), meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/'The file was deleted|The file expired|Video is processing/.test(html)) {
|
if (/'The file was deleted|The file expired|Video is processing/.test(html)) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playlistUrl = extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]);
|
||||||
|
const playlistHeaders = { Referer: 'https://supervideo.cc/' };
|
||||||
|
|
||||||
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/);
|
const heightAndSizeMatch = html.match(/\d{3,}x(\d{3,}), ([\d.]+ ?[GM]B)/);
|
||||||
|
const size = heightAndSizeMatch ? bytes.parse(heightAndSizeMatch[2] as string) as number : undefined;
|
||||||
|
const height = heightAndSizeMatch
|
||||||
|
? parseInt(heightAndSizeMatch[1] as string)
|
||||||
|
: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl, { headers: playlistHeaders });
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const title = $('.download__title').text().trim();
|
const title = $('.download__title').text().trim();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: extractUrlFromPacked(html, [/sources:\[{file:"(.*?)"/]),
|
url: playlistUrl,
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
title,
|
title,
|
||||||
...(heightAndSizeMatch && {
|
...(size && { bytes: size }),
|
||||||
bytes: bytes.parse(heightAndSizeMatch[2] as string) as number,
|
...(height && { height }),
|
||||||
height: parseInt(heightAndSizeMatch[1] as string),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
|
requestHeaders: playlistHeaders,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { Uqload } from './Uqload';
|
import { Uqload } from './Uqload';
|
||||||
|
|
@ -8,14 +7,14 @@ import { Uqload } from './Uqload';
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new Uqload(new FetcherMock(`${__dirname}/__fixtures__/Uqload`))]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new Uqload(new FetcherMock(`${__dirname}/__fixtures__/Uqload`))]);
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test', mediaFlowProxyPassword: 'asdfg' });
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
||||||
|
|
||||||
describe('Uqload', () => {
|
describe('Uqload', () => {
|
||||||
test('uqload.net /embed-', async () => {
|
test('uqload.net /embed-', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://uqload.net/embed-z0xbr87oz637.html'), CountryCode.fr)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://uqload.net/embed-z0xbr87oz637.html'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uqload.net /', async () => {
|
test('file not found', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://uqload.net/z0xbr87oz637.html'), CountryCode.fr)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://uqload.cx/2ua47fdfkjgd.html'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,16 @@
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { NotFoundError } from '../error';
|
||||||
import { buildMediaFlowProxyExtractorRedirectUrl, Fetcher, supportsMediaFlowProxy } from '../utils';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
|
import { buildMediaFlowProxyExtractorRedirectUrl, supportsMediaFlowProxy } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class Uqload extends Extractor {
|
export class Uqload extends Extractor {
|
||||||
public readonly id = 'uqload';
|
public readonly id = 'uqload';
|
||||||
|
|
||||||
public readonly label = 'Uqload (via MediaFlow Proxy)';
|
public readonly label = 'Uqload';
|
||||||
|
|
||||||
public override readonly ttl = 0;
|
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/uqload/) && supportsMediaFlowProxy(ctx);
|
return null !== url.host.match(/uqload/) && supportsMediaFlowProxy(ctx);
|
||||||
}
|
}
|
||||||
|
|
@ -28,9 +19,13 @@ export class Uqload extends Extractor {
|
||||||
return new URL(url.href.replace('/embed-', '/'));
|
return new URL(url.href.replace('/embed-', '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
|
if (/File Not Found/.test(html)) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
const heightMatch = html.match(/\d{3,}x(\d{3,})/);
|
const heightMatch = html.match(/\d{3,}x(\d{3,})/);
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
@ -40,11 +35,8 @@ export class Uqload extends Extractor {
|
||||||
{
|
{
|
||||||
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Uqload', url),
|
url: buildMediaFlowProxyExtractorRedirectUrl(ctx, 'Uqload', url),
|
||||||
format: Format.mp4,
|
format: Format.mp4,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
title,
|
title,
|
||||||
...(heightMatch && {
|
...(heightMatch && {
|
||||||
height: parseInt(heightMatch[1] as string),
|
height: parseInt(heightMatch[1] as string),
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,20 @@
|
||||||
import { MockAgent, setGlobalDispatcher } from 'undici';
|
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
import { FetcherMock } from '../utils';
|
||||||
import { Fetcher, FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { VidSrc } from './VidSrc';
|
import { VidSrc } from './VidSrc';
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new VidSrc(new FetcherMock(`${__dirname}/__fixtures__/VidSrc`), ['xyz'])]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new VidSrc(new FetcherMock(`${__dirname}/__fixtures__/VidSrc`), ['vidsrc-embed.ru'])]);
|
||||||
|
|
||||||
const ctx = createTestContext();
|
const ctx = createTestContext();
|
||||||
|
|
||||||
describe('VidSrc', () => {
|
describe('VidSrc', () => {
|
||||||
test('Full Metal Jacket', async () => {
|
test('Full Metal Jacket', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc.xyz/embed/movie/tt0093058'), CountryCode.en, 'Full Metal Jacket (1987)')).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc-embed.ru/embed/movie/tt0093058'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Black Mirror', async () => {
|
test('Black Mirror', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc.xyz/embed/tv/tt2085059/4/2'), CountryCode.en, 'Black Mirror 4x2')).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc-embed.ru/embed/tv/tt2085059/4-2'))).toMatchSnapshot();
|
||||||
});
|
|
||||||
|
|
||||||
test('not found', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vidsrc.xyz/embed/movie/tt35628853'), CountryCode.en, 'Titan: The OceanGate Disaster')).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rate limit issues are retried and fail if no tlds are left', async () => {
|
|
||||||
const mockAgent = new MockAgent({ enableCallHistory: true });
|
|
||||||
mockAgent.disableNetConnect();
|
|
||||||
setGlobalDispatcher(mockAgent);
|
|
||||||
|
|
||||||
mockAgent.get('https://vidsrc.xyz')
|
|
||||||
.intercept({ path: '/embed/movie/tt33043892/1/1' }).reply(429);
|
|
||||||
mockAgent.get('https://vidsrc.net')
|
|
||||||
.intercept({ path: '/embed/movie/tt33043892/1/1' }).reply(429);
|
|
||||||
|
|
||||||
const fetcher = new Fetcher(winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] }));
|
|
||||||
const vidSrc = new VidSrc(fetcher, ['net', 'xyz']);
|
|
||||||
|
|
||||||
expect(await vidSrc.extract(ctx, new URL('https://vidsrc.xyz/embed/movie/tt33043892/1/1'), CountryCode.en, 'Dexter: Resurrection 1x1')).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import slugify from 'slugify';
|
import { BlockedError, TooManyRequestsError } from '../error';
|
||||||
import { NotFoundError, TooManyRequestsError } from '../error';
|
import { Context, Format, InternalUrlResult, Meta, NonEmptyArray } from '../types';
|
||||||
import { Context, CountryCode, Format, NonEmptyArray, UrlResult } from '../types';
|
|
||||||
import { Fetcher, guessHeightFromPlaylist } from '../utils';
|
import { Fetcher, guessHeightFromPlaylist } from '../utils';
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
|
@ -10,45 +9,49 @@ export class VidSrc extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'VidSrc';
|
public readonly label = 'VidSrc';
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
public override readonly ttl: number = 10800000; // 3h
|
||||||
private readonly tlds: NonEmptyArray<string>;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher, tlds: NonEmptyArray<string>) {
|
private readonly domains: NonEmptyArray<string>;
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
public constructor(fetcher: Fetcher, domains: NonEmptyArray<string>) {
|
||||||
this.tlds = tlds;
|
super(fetcher);
|
||||||
|
|
||||||
|
this.domains = domains;
|
||||||
}
|
}
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/vidsrc/);
|
return null !== url.host.match(/vidsrc|vsrc/);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
return this.extractUsingRandomTld(ctx, url, countryCode, [...this.tlds]);
|
// While this is a crappy thing to do, they seem to be blocking overly strict IMO
|
||||||
|
const randomIp = `${Math.floor(Math.random() * 223) + 1}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}`;
|
||||||
|
const newCtx = { ...ctx, ip: randomIp };
|
||||||
|
|
||||||
|
return this.extractUsingRandomDomain(newCtx, url, meta, [...this.domains]);
|
||||||
};
|
};
|
||||||
|
|
||||||
private async extractUsingRandomTld(ctx: Context, url: URL, countryCode: CountryCode, tlds: string[]): Promise<UrlResult[]> {
|
private async extractUsingRandomDomain(ctx: Context, url: URL, meta: Meta, domains: string[]): Promise<InternalUrlResult[]> {
|
||||||
const tldIndex = Math.floor(Math.random() * tlds.length);
|
const domainIndex = Math.floor(Math.random() * domains.length);
|
||||||
const [tld] = tlds.splice(tldIndex, 1) as [string];
|
const [domain] = domains.splice(domainIndex, 1) as [string];
|
||||||
|
|
||||||
const newUrl = new URL(url);
|
const newUrl = new URL(url);
|
||||||
const hostnameParts = newUrl.hostname.split('.');
|
newUrl.hostname = domain;
|
||||||
hostnameParts[hostnameParts.length - 1] = tld;
|
|
||||||
newUrl.hostname = hostnameParts.join('.');
|
|
||||||
|
|
||||||
let html: string;
|
let html: string;
|
||||||
try {
|
try {
|
||||||
html = await this.fetcher.text(ctx, newUrl);
|
html = await this.fetcher.text(ctx, newUrl, { queueLimit: 1 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TooManyRequestsError && tlds.length) {
|
/* istanbul ignore next */
|
||||||
return this.extractUsingRandomTld(ctx, url, countryCode, tlds);
|
if (domains.length && (error instanceof TooManyRequestsError || error instanceof BlockedError)) {
|
||||||
|
return this.extractUsingRandomDomain(ctx, url, meta, domains);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* istanbul ignore next */
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html.replace('<!--', '').replace('-->', '')); // server HTML is commented-out
|
||||||
|
|
||||||
const iframeUrl = new URL(($('#player_iframe').attr('src') as string).replace(/^\/\//, 'https://'));
|
const iframeUrl = new URL(($('#player_iframe').attr('src') as string).replace(/^\/\//, 'https://'));
|
||||||
const title = $('title').text().trim();
|
const title = $('title').text().trim();
|
||||||
|
|
@ -59,26 +62,21 @@ export class VidSrc extends Extractor {
|
||||||
.toArray()
|
.toArray()
|
||||||
.filter(({ serverName }) => serverName === 'CloudStream Pro')
|
.filter(({ serverName }) => serverName === 'CloudStream Pro')
|
||||||
.map(async ({ serverName, dataHash }) => {
|
.map(async ({ serverName, dataHash }) => {
|
||||||
const iframeHtml = await this.fetcher.text(ctx, new URL(`/rcp/${dataHash}`, iframeUrl.origin), { headers: { Referer: iframeUrl.origin } });
|
const rcpUrl = new URL(`/rcp/${dataHash}`, iframeUrl.origin);
|
||||||
|
const iframeHtml = await this.fetcher.text(ctx, rcpUrl, { headers: { Referer: newUrl.origin } });
|
||||||
const srcMatch = iframeHtml.match(`src:\\s?'(.*)'`) as string[];
|
const srcMatch = iframeHtml.match(`src:\\s?'(.*)'`) as string[];
|
||||||
|
|
||||||
const playerHtml = await this.fetcher.text(ctx, new URL(srcMatch[1] as string, iframeUrl.origin), { headers: { Referer: iframeUrl.origin } });
|
const playerHtml = await this.fetcher.text(ctx, new URL(srcMatch[1] as string, iframeUrl.origin), { headers: { Referer: rcpUrl.href } });
|
||||||
const fileMatch = playerHtml.match(`file:\\s?'(.*)'`);
|
const fileMatch = playerHtml.match(`(https:\\/\\/.*?{v\\d}.*?) or`) as string[];
|
||||||
if (!fileMatch) {
|
const m3u8Url = new URL((fileMatch[1] as string).replace(/{v\d}/, iframeUrl.host));
|
||||||
throw new NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const m3u8Url = new URL(fileMatch[1] as string);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: m3u8Url,
|
url: m3u8Url,
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
label: `${this.label} (${serverName})`,
|
label: serverName,
|
||||||
sourceId: `${this.id}_${slugify(serverName)}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url),
|
height: await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers: { Referer: iframeUrl.href } }),
|
||||||
title,
|
title,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
20
src/extractor/Vidora.test.ts
Normal file
20
src/extractor/Vidora.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { Vidora } from './Vidora';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(logger, [new Vidora(new FetcherMock(`${__dirname}/__fixtures__/Vidora`))]);
|
||||||
|
|
||||||
|
const ctx = createTestContext();
|
||||||
|
|
||||||
|
describe('Vidora', () => {
|
||||||
|
test('vidora.stream', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://vidora.stream/5iz6c1elaqgq'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vidora.stream /embed/', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://vidora.stream/embed/5iz6c1elaqgq'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/extractor/Vidora.ts
Normal file
43
src/extractor/Vidora.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
|
import { extractUrlFromPacked, guessHeightFromPlaylist } from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
export class Vidora extends Extractor {
|
||||||
|
public readonly id = 'vidora';
|
||||||
|
|
||||||
|
public readonly label = 'Vidora';
|
||||||
|
|
||||||
|
public override readonly ttl: number = 43200000; // 12h
|
||||||
|
|
||||||
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
|
return null !== url.host.match(/vidora/);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override normalize(url: URL): URL {
|
||||||
|
return new URL(url.href.replace('/embed/', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const title = $('title').text().trim().replace(/^Watch /, '').trim();
|
||||||
|
|
||||||
|
const m3u8Url = extractUrlFromPacked(html, [/file: ?"(.*?)"/]);
|
||||||
|
const headers = { Origin: url.origin };
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: m3u8Url,
|
||||||
|
format: Format.hls,
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, m3u8Url, { headers }),
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
requestHeaders: headers,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { VixSrc } from './VixSrc';
|
import { VixSrc } from './VixSrc';
|
||||||
|
|
@ -8,20 +7,24 @@ import { VixSrc } from './VixSrc';
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new VixSrc(new FetcherMock(`${__dirname}/__fixtures__/VixSrc`))]);
|
const extractorRegistry = new ExtractorRegistry(logger, [new VixSrc(new FetcherMock(`${__dirname}/__fixtures__/VixSrc`))]);
|
||||||
|
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test', mediaFlowProxyPassword: 'asdfg', de: 'on', en: 'on', it: 'on' });
|
const ctx = createTestContext();
|
||||||
|
|
||||||
describe('VixSrc', () => {
|
describe('VixSrc', () => {
|
||||||
test('Full Metal Jacket', async () => {
|
test('Full Metal Jacket', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/movie/600'), CountryCode.it, 'Full Metal Jacket (1987)')).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/movie/600'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Black Mirror', async () => {
|
test('Black Mirror', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/tv/42009/4/2'), CountryCode.it, 'Black Mirror 4x2')).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/tv/42009/4/2'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rental Family', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/movie/1208348'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Black Mirror is excluded if no matching language was found', async () => {
|
test('Black Mirror is excluded if no matching language was found', async () => {
|
||||||
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow-proxy.test', mediaFlowProxyPassword: 'asdfg', de: 'on' });
|
const ctx = createTestContext({ de: 'on' });
|
||||||
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/tv/42009/4/2'), CountryCode.it, 'Black Mirror 4x2')).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://vixsrc.to/tv/42009/4/3'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,34 @@
|
||||||
import bytes from 'bytes';
|
import { Context, CountryCode, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { CustomRequestConfig, guessHeightFromPlaylist, hasMultiEnabled, iso639FromCountryCode } from '../utils';
|
||||||
import {
|
|
||||||
buildMediaFlowProxyExtractorStreamUrl,
|
|
||||||
Fetcher,
|
|
||||||
hasMultiEnabled,
|
|
||||||
iso639FromCountryCode,
|
|
||||||
supportsMediaFlowProxy,
|
|
||||||
} from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class VixSrc extends Extractor {
|
export class VixSrc extends Extractor {
|
||||||
public readonly id = 'vixsrc';
|
public readonly id = 'vixsrc';
|
||||||
|
|
||||||
public readonly label = 'VixSrc (via MediaFlow Proxy)';
|
public readonly label = 'VixSrc';
|
||||||
|
|
||||||
public override readonly ttl = 0;
|
public override readonly ttl: number = 21600000; // 6h
|
||||||
|
|
||||||
public override viaMediaFlowProxy = true;
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
|
return null !== url.host.match(/vixsrc/);
|
||||||
private readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public supports(ctx: Context, url: URL): boolean {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
return null !== url.host.match(/vixsrc/) && supportsMediaFlowProxy(ctx);
|
const headers = { Referer: url.href };
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const html = await this.fetcher.text(ctx, url);
|
||||||
|
|
||||||
const filenameMatch = html.match(/"filename":"(.*?)"/);
|
const tokenMatch = html.match(/['"]token['"]: ?['"](.*?)['"]/) as string[];
|
||||||
const sizeMatch = html.match(/"size":(\d+)/);
|
const expiresMatch = html.match(/['"]expires['"]: ?['"](.*?)['"]/) as string[];
|
||||||
const qualityMatch = html.match(/"quality":(\d+)/);
|
const urlMatch = html.match(/url: ?['"](.*?)['"]/) as string[];
|
||||||
|
|
||||||
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'VixCloud', url);
|
const baseUrl = new URL(`${urlMatch[1]}`);
|
||||||
const countryCodes = await this.determineCountryCodesFromPlaylist(ctx, playlistUrl);
|
const playlistUrl = new URL(`${baseUrl.origin}${baseUrl.pathname}.m3u8?${baseUrl.searchParams}`);
|
||||||
|
playlistUrl.searchParams.append('token', tokenMatch[1] as string);
|
||||||
|
playlistUrl.searchParams.append('expires', expiresMatch[1] as string);
|
||||||
|
playlistUrl.searchParams.append('h', '1');
|
||||||
|
|
||||||
|
const countryCodes = meta.countryCodes ?? [CountryCode.multi, ...(await this.determineCountryCodesFromPlaylist(ctx, playlistUrl, { headers }))];
|
||||||
|
|
||||||
if (!hasMultiEnabled(ctx.config) && !countryCodes.some(countryCode => countryCode in ctx.config)) {
|
if (!hasMultiEnabled(ctx.config) && !countryCodes.some(countryCode => countryCode in ctx.config)) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -48,23 +38,19 @@ export class VixSrc extends Extractor {
|
||||||
{
|
{
|
||||||
url: playlistUrl,
|
url: playlistUrl,
|
||||||
format: Format.hls,
|
format: Format.hls,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
|
...meta,
|
||||||
countryCodes,
|
countryCodes,
|
||||||
...(filenameMatch && { title: filenameMatch[1] }),
|
height: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl, { headers }),
|
||||||
...(sizeMatch && { bytes: bytes.parse(`${sizeMatch[1]} kb`) as number }),
|
|
||||||
...(qualityMatch && { height: parseInt(qualityMatch[1] as string) }),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
private async determineCountryCodesFromPlaylist(ctx: Context, playlistUrl: URL): Promise<CountryCode[]> {
|
private async determineCountryCodesFromPlaylist(ctx: Context, playlistUrl: URL, init?: CustomRequestConfig): Promise<CountryCode[]> {
|
||||||
const playlist = await this.fetcher.text(ctx, playlistUrl);
|
const playlist = await this.fetcher.text(ctx, playlistUrl, init);
|
||||||
|
|
||||||
const countryCodes: CountryCode[] = [CountryCode.it];
|
const countryCodes: CountryCode[] = [];
|
||||||
|
|
||||||
(Object.keys(CountryCode) as CountryCode[]).forEach((countryCode) => {
|
(Object.keys(CountryCode) as CountryCode[]).forEach((countryCode) => {
|
||||||
const iso639 = iso639FromCountryCode(countryCode);
|
const iso639 = iso639FromCountryCode(countryCode);
|
||||||
|
|
|
||||||
28
src/extractor/Voe.test.ts
Normal file
28
src/extractor/Voe.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import { createTestContext } from '../test';
|
||||||
|
import { FetcherMock } from '../utils';
|
||||||
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
|
import { Voe } from './Voe';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
||||||
|
const extractorRegistry = new ExtractorRegistry(logger, [new Voe(new FetcherMock(`${__dirname}/__fixtures__/Voe`))]);
|
||||||
|
|
||||||
|
const ctx = createTestContext({ mediaFlowProxyUrl: 'https://mediaflow.test.org', mediaFlowProxyPassword: 'test' });
|
||||||
|
|
||||||
|
describe('Voe', () => {
|
||||||
|
test('jilliandescribecompany', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://jilliandescribecompany.com/e/ea21l02gcygw'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('premium only without resolution', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://jilliandescribecompany.com/qqfyi04w52mj'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encoding error', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://voe.sx/e/c2yxvit4f6bz'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('embed only urls which otherwise lead to 404', async () => {
|
||||||
|
expect(await extractorRegistry.handle(ctx, new URL('https://mikaylaarealike.com/e/gqlhm9hbobwu'))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
180
src/extractor/Voe.ts
Normal file
180
src/extractor/Voe.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import bytes from 'bytes';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { NotFoundError } from '../error';
|
||||||
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
|
import {
|
||||||
|
buildMediaFlowProxyExtractorStreamUrl, guessHeightFromPlaylist,
|
||||||
|
supportsMediaFlowProxy,
|
||||||
|
} from '../utils';
|
||||||
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
|
/** @see https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/voesx.py */
|
||||||
|
export class Voe extends Extractor {
|
||||||
|
public readonly id = 'voe';
|
||||||
|
|
||||||
|
public readonly label = 'VOE';
|
||||||
|
|
||||||
|
public override viaMediaFlowProxy = true;
|
||||||
|
|
||||||
|
public supports(ctx: Context, url: URL): boolean {
|
||||||
|
const supportedDomain = null !== url.host.match(/voe/)
|
||||||
|
|| [
|
||||||
|
'19turanosephantasia.com',
|
||||||
|
'20demidistance9elongations.com',
|
||||||
|
'30sensualizeexpression.com',
|
||||||
|
'321naturelikefurfuroid.com',
|
||||||
|
'35volitantplimsoles5.com',
|
||||||
|
'449unceremoniousnasoseptal.com',
|
||||||
|
'745mingiestblissfully.com',
|
||||||
|
'adrianmissionminute.com',
|
||||||
|
'alleneconomicmatter.com',
|
||||||
|
'antecoxalbobbing1010.com',
|
||||||
|
'apinchcaseation.com',
|
||||||
|
'audaciousdefaulthouse.com',
|
||||||
|
'availedsmallest.com',
|
||||||
|
'bigclatterhomesguideservice.com',
|
||||||
|
'boonlessbestselling244.com',
|
||||||
|
'bradleyviewdoctor.com',
|
||||||
|
'brittneystandardwestern.com',
|
||||||
|
'brucevotewithin.com',
|
||||||
|
'christopheruntilpoint.com',
|
||||||
|
'chromotypic.com',
|
||||||
|
'chuckle-tube.com',
|
||||||
|
'cindyeyefinal.com',
|
||||||
|
'counterclockwisejacky.com',
|
||||||
|
'crownmakermacaronicism.com',
|
||||||
|
'crystaltreatmenteast.com',
|
||||||
|
'cyamidpulverulence530.com',
|
||||||
|
'diananatureforeign.com',
|
||||||
|
'donaldlineelse.com',
|
||||||
|
'edwardarriveoften.com',
|
||||||
|
'erikcoldperson.com',
|
||||||
|
'figeterpiazine.com',
|
||||||
|
'fittingcentermondaysunday.com',
|
||||||
|
'fraudclatterflyingcar.com',
|
||||||
|
'gamoneinterrupted.com',
|
||||||
|
'generatesnitrosate.com',
|
||||||
|
'goofy-banana.com',
|
||||||
|
'graceaddresscommunity.com',
|
||||||
|
'greaseball6eventual20.com',
|
||||||
|
'guidon40hyporadius9.com',
|
||||||
|
'heatherdiscussionwhen.com',
|
||||||
|
'housecardsummerbutton.com',
|
||||||
|
'jamessoundcost.com',
|
||||||
|
'jamiesamewalk.com',
|
||||||
|
'jasminetesttry.com',
|
||||||
|
'jayservicestuff.com',
|
||||||
|
'jennifercertaindevelopment.com',
|
||||||
|
'jilliandescribecompany.com',
|
||||||
|
'johnalwayssame.com',
|
||||||
|
'jonathansociallike.com',
|
||||||
|
'josephseveralconcern.com',
|
||||||
|
'kathleenmemberhistory.com',
|
||||||
|
'kellywhatcould.com',
|
||||||
|
'kennethofficialitem.com',
|
||||||
|
'kinoger.ru',
|
||||||
|
'kristiesoundsimply.com',
|
||||||
|
'lancewhosedifficult.com',
|
||||||
|
'launchreliantcleaverriver.com',
|
||||||
|
'lauradaydo.com',
|
||||||
|
'lisatrialidea.com',
|
||||||
|
'loriwithinfamily.com',
|
||||||
|
'lukecomparetwo.com',
|
||||||
|
'lukesitturn.com',
|
||||||
|
'mariatheserepublican.com',
|
||||||
|
'matriculant401merited.com',
|
||||||
|
'maxfinishseveral.com',
|
||||||
|
'metagnathtuggers.com',
|
||||||
|
'michaelapplysome.com',
|
||||||
|
'mikaylaarealike.com',
|
||||||
|
'nathanfromsubject.com',
|
||||||
|
'nectareousoverelate.com',
|
||||||
|
'nonesnanking.com',
|
||||||
|
'paulkitchendark.com',
|
||||||
|
'realfinanceblogcenter.com',
|
||||||
|
'rebeccaneverbase.com',
|
||||||
|
'reputationsheriffkennethsand.com',
|
||||||
|
'richardsignfish.com',
|
||||||
|
'roberteachfinal.com',
|
||||||
|
'robertordercharacter.com',
|
||||||
|
'robertplacespace.com',
|
||||||
|
'sandratableother.com',
|
||||||
|
'sandrataxeight.com',
|
||||||
|
'scatch176duplicities.com',
|
||||||
|
'sethniceletter.com',
|
||||||
|
'shannonpersonalcost.com',
|
||||||
|
'simpulumlamerop.com',
|
||||||
|
'smoki.cc',
|
||||||
|
'stevenimaginelittle.com',
|
||||||
|
'strawberriesporail.com',
|
||||||
|
'telyn610zoanthropy.com',
|
||||||
|
'timberwoodanotia.com',
|
||||||
|
'toddpartneranimal.com',
|
||||||
|
'toxitabellaeatrebates306.com',
|
||||||
|
'uptodatefinishconferenceroom.com',
|
||||||
|
'v-o-e-unblock.com',
|
||||||
|
'valeronevijao.com',
|
||||||
|
'walterprettytheir.com',
|
||||||
|
'wolfdyslectic.com',
|
||||||
|
'yodelswartlike.com',
|
||||||
|
].includes(url.host);
|
||||||
|
|
||||||
|
return supportedDomain && supportsMediaFlowProxy(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override normalize(url: URL): URL {
|
||||||
|
return new URL(`/${url.pathname.replace(/\/+$/, '').split('/').at(-1)}`, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
let html: string;
|
||||||
|
try {
|
||||||
|
html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
} catch (error) {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
if (error instanceof NotFoundError && !url.href.includes('/e/')) {
|
||||||
|
return await this.extractInternal(ctx, new URL(`/e${url.pathname}`, url.origin), meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* istanbul ignore next */
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectMatch = html.match(/window\.location\.href\s*=\s*'([^']+)/);
|
||||||
|
if (redirectMatch && redirectMatch[1]) {
|
||||||
|
return await this.extractInternal(ctx, new URL(redirectMatch[1]), meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/An error occurred during encoding/.test(html)) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const title = $('meta[name="description"]').attr('content')?.trim().replace(/^Watch /, '').replace(/ at VOE$/, '').trim();
|
||||||
|
|
||||||
|
const sizeMatch = html.matchAll(/[\d.]+ ?[GM]B/g).toArray().at(-1);
|
||||||
|
const size = sizeMatch ? bytes.parse(sizeMatch[0] as string) as number : /* istanbul ignore next */null;
|
||||||
|
|
||||||
|
const playlistUrl = await buildMediaFlowProxyExtractorStreamUrl(ctx, this.fetcher, 'Voe', url, headers);
|
||||||
|
|
||||||
|
const heightMatch = html.match(/<b>(\d{3,})p<\/b>/);
|
||||||
|
const height = heightMatch
|
||||||
|
? parseInt(heightMatch[1] as string)
|
||||||
|
: meta.height ?? await guessHeightFromPlaylist(ctx, this.fetcher, playlistUrl);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: playlistUrl,
|
||||||
|
format: Format.hls,
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
height,
|
||||||
|
title,
|
||||||
|
...(size && size > 16777216 && { bytes: size }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import winston from 'winston';
|
|
||||||
import { createTestContext } from '../test';
|
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
|
||||||
import { XPrime } from './XPrime';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({ transports: [new winston.transports.Console({ level: 'nope' })] });
|
|
||||||
const extractorRegistry = new ExtractorRegistry(logger, [new XPrime(new FetcherMock(`${__dirname}/__fixtures__/XPrime`))]);
|
|
||||||
|
|
||||||
const ctx = createTestContext();
|
|
||||||
|
|
||||||
describe('XPrime', () => {
|
|
||||||
test('unknown backend is ignored', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/unknown?name=Superman&year=2025'), CountryCode.en, 'Superman (2025)')).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('primebox Superman', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/primebox?name=Superman&year=2025'), CountryCode.en, 'Superman (2025)')).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('primebox Alien: Earth', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/primebox?name=Alien%3A%20Earth&year=2025&season=1&episode=1'), CountryCode.en, 'Alien: Earth 1x1')).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('primebox not found', async () => {
|
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://backend.xprime.tv/primebox?name=Jurassic%20World%20Rebirth&year=2025'), CountryCode.en, 'Jurassic World Rebirth (2025)')).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
|
||||||
import { Fetcher } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
|
||||||
|
|
||||||
interface XPrimePrimeboxResponsePartial {
|
|
||||||
streams: Record<string, string>;
|
|
||||||
series_info?: {
|
|
||||||
episode: number;
|
|
||||||
season: number;
|
|
||||||
title: string;
|
|
||||||
year: string;
|
|
||||||
};
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class XPrime extends Extractor {
|
|
||||||
public readonly id = 'xprime';
|
|
||||||
|
|
||||||
public readonly label = 'XPrime';
|
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
|
||||||
return null !== url.host.match(/xprime/);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
|
||||||
const urlResults: UrlResult[] = [];
|
|
||||||
|
|
||||||
const referer = url.protocol + '//' + url.hostname.split('.').slice(-2).join('.'); // Strip subdomains
|
|
||||||
|
|
||||||
if (url.href.includes('primebox')) {
|
|
||||||
const jsonResponse = JSON.parse(await this.fetcher.text(ctx, url)) as XPrimePrimeboxResponsePartial;
|
|
||||||
|
|
||||||
for (const [resolution, stream] of Object.entries(jsonResponse.streams)) {
|
|
||||||
const url = new URL(stream);
|
|
||||||
const title = jsonResponse.series_info
|
|
||||||
? `${jsonResponse.series_info.title} ${jsonResponse.series_info.season}x${jsonResponse.series_info.episode} (${jsonResponse.series_info.year})`
|
|
||||||
: jsonResponse.title;
|
|
||||||
|
|
||||||
urlResults.push({
|
|
||||||
url,
|
|
||||||
format: Format.mp4,
|
|
||||||
label: `${this.label} Primebox`,
|
|
||||||
sourceId: `${this.id}_${countryCode}_primebox`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
|
||||||
countryCodes: [countryCode],
|
|
||||||
bytes: parseInt((await this.fetcher.head(ctx, url, { headers: { Referer: referer }, minCacheTtl: this.ttl }))['content-length'] as string),
|
|
||||||
height: parseInt(resolution),
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
requestHeaders: {
|
|
||||||
Referer: referer,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return urlResults;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createTestContext } from '../test';
|
import { createTestContext } from '../test';
|
||||||
import { CountryCode } from '../types';
|
|
||||||
import { FetcherMock } from '../utils';
|
import { FetcherMock } from '../utils';
|
||||||
import { ExtractorRegistry } from './ExtractorRegistry';
|
import { ExtractorRegistry } from './ExtractorRegistry';
|
||||||
import { YouTube } from './YouTube';
|
import { YouTube } from './YouTube';
|
||||||
|
|
@ -12,10 +11,10 @@ const ctx = createTestContext();
|
||||||
|
|
||||||
describe('YouTube', () => {
|
describe('YouTube', () => {
|
||||||
test('Solaris', async () => {
|
test('Solaris', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://www.youtube.com/watch?v=Z8ZhQPaw4rE'), CountryCode.en)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://www.youtube.com/watch?v=Z8ZhQPaw4rE'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unsupported format', async () => {
|
test('unsupported format', async () => {
|
||||||
expect(await extractorRegistry.handle(ctx, new URL('https://youtu.be/Z8ZhQPaw4rE?feature=shared'), CountryCode.en)).toMatchSnapshot();
|
expect(await extractorRegistry.handle(ctx, new URL('https://youtu.be/Z8ZhQPaw4rE?feature=shared'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Context, CountryCode, Format, UrlResult } from '../types';
|
import { Context, Format, InternalUrlResult, Meta } from '../types';
|
||||||
import { Fetcher } from '../utils';
|
|
||||||
import { Extractor } from './Extractor';
|
import { Extractor } from './Extractor';
|
||||||
|
|
||||||
export class YouTube extends Extractor {
|
export class YouTube extends Extractor {
|
||||||
|
|
@ -7,20 +6,16 @@ export class YouTube extends Extractor {
|
||||||
|
|
||||||
public readonly label = 'YouTube';
|
public readonly label = 'YouTube';
|
||||||
|
|
||||||
private readonly fetcher: Fetcher;
|
public override readonly ttl: number = 21600000; // 6h
|
||||||
|
|
||||||
public constructor(fetcher: Fetcher) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.fetcher = fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
public supports(_ctx: Context, url: URL): boolean {
|
public supports(_ctx: Context, url: URL): boolean {
|
||||||
return null !== url.host.match(/youtube/) && url.searchParams.has('v');
|
return null !== url.host.match(/youtube/) && url.searchParams.has('v');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async extractInternal(ctx: Context, url: URL, countryCode: CountryCode): Promise<UrlResult[]> {
|
protected async extractInternal(ctx: Context, url: URL, meta: Meta): Promise<InternalUrlResult[]> {
|
||||||
const html = await this.fetcher.text(ctx, url);
|
const headers = { Referer: meta.referer ?? url.href };
|
||||||
|
|
||||||
|
const html = await this.fetcher.text(ctx, url, { headers });
|
||||||
|
|
||||||
const titleMatch = html.match(/"title":{"runs":\[{"text":"(.*?)"/) as string[];
|
const titleMatch = html.match(/"title":{"runs":\[{"text":"(.*?)"/) as string[];
|
||||||
|
|
||||||
|
|
@ -29,11 +24,8 @@ export class YouTube extends Extractor {
|
||||||
url,
|
url,
|
||||||
format: Format.unknown,
|
format: Format.unknown,
|
||||||
ytId: url.searchParams.get('v') as string,
|
ytId: url.searchParams.get('v') as string,
|
||||||
label: this.label,
|
|
||||||
sourceId: `${this.id}_${countryCode}`,
|
|
||||||
ttl: this.ttl,
|
|
||||||
meta: {
|
meta: {
|
||||||
countryCodes: [countryCode],
|
...meta,
|
||||||
title: titleMatch[1] as string,
|
title: titleMatch[1] as string,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
2
src/extractor/__fixtures__/DoodStream/http:dood.toddfx8me4un4ul
generated
Normal file
2
src/extractor/__fixtures__/DoodStream/http:dood.toddfx8me4un4ul
generated
Normal file
File diff suppressed because one or more lines are too long
2
src/extractor/__fixtures__/DoodStream/http:dood.todsk1m9eumzyjj
generated
Normal file
2
src/extractor/__fixtures__/DoodStream/http:dood.todsk1m9eumzyjj
generated
Normal file
File diff suppressed because one or more lines are too long
2
src/extractor/__fixtures__/DoodStream/http:dood.toecz7cus0bvlzr
generated
Normal file
2
src/extractor/__fixtures__/DoodStream/http:dood.toecz7cus0bvlzr
generated
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<!doctype html><html lang="en"><head> <meta charset="utf-8"> <title>Video not found | DoodStream</title> <link rel="preconnect" href="//i.doodcdn.io"><link rel="preconnect" href="//cdnjs.cloudflare.com"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script> <link rel="stylesheet" href="//i.doodcdn.io/theme_2/css/bootstrap.min.css"> <link rel="stylesheet" type="text/css" href="//i.doodcdn.io/theme_2/css/style.css" /></head><body> <style type="text/css"> .not_found img {height:250px !important;} @media (max-width: 768px){.not_found h1{font-size:1.8rem;}.not_found img {height:200px !important;margin: 0 auto 15px;}} @media (max-width: 480px){.not_found h1{font-size:1.4rem;}.not_found img {height:120px !important;margin: 0 auto 15px;}.not_found p{margin-top: -5px;}} </style> <div class="container pt-5 not_found"> <div class="row"> <div class="col-md-12 pt-2 text-center"> <img src="//i.doodcdn.io/img/no_video_3.svg"> <h1>Not Found</h1> <p>video you are looking for is not found.</p> </div> </div> </div><script data-cfasync="false" src="//d1f05vr3sjsuy7.cloudfront.net/?srvfd=908056"></script><script data-cfasync="false" async type="text/javascript" src="//faqirsgoliard.top/gHzOaAdOhbZ/71405"></script><script data-cfasync="false" async type="text/javascript" src="//faqirsgoliard.top/r67c0fc81985e5/70849"></script><script type="text/javascript" data-cfasync="false">/*<![CDATA[/* */(function(){var g=window,r="ca0967284e67bc53cb5b626985619403",w=[["siteId",317+185*82*507-3600486],["minBid",0],["popundersPerIP","0"],["delayBetween",300],["default","https://strettechoco.com/iYa7dlULceKw/27615"],["defaultPerDay",0],["topmostLayer","never"]],n=["d3d3LmJsb2NrYWRzbm90LmNvbS9iYWphLm1pbi5jc3M=","ZG5oZmk1bm4yZHQ2Ny5jbG91ZGZyb250Lm5ldC9RVHd0eC91YWxnZWJyYS5taW4uanM=","d3d3LmJibHZwbGd1b21hLmNvbS9mYWphLm1pbi5jc3M=","d3d3LndqZ3BzcGt4empiZGd1LmNvbS9NTmlIWS9rYWxnZWJyYS5taW4uanM="],f=-1,v,q,h=function(){clearTimeout(q);f++;if(n[f]&&!(1766620801000<(new Date).getTime()&&1<f)){v=g.document.createElement("script");v.type="text/javascript";v.async=!0;var o=g.document.getElementsByTagName("script")[0];v.src="https://"+atob(n[f]);v.crossOrigin="anonymous";v.onerror=h;v.onload=function(){clearTimeout(q);g[r.slice(0,16)+r.slice(0,16)]||h()};q=setTimeout(h,5E3);o.parentNode.insertBefore(v,o)}};if(!g[r]){try{Object.freeze(g[r]=w)}catch(e){}h()}})();/*]]>/* */</script><script defer src="https://static.cloudflareinsights.com/beacon.min.js/vcd15cbe7772f49c399c6a5babf22c1241717689176015" integrity="sha512-ZpsOmlRQV6y907TI0dKBHq9Md29nnaEIPlkf84rnaERnq6zvWvPUqr2ft8M1aS28oN72PdrCzSjY4U6VaAw1EQ==" data-cf-beacon='{"version":"2024.11.0","token":"1c76230f8ab248eab0b0f2bf7c205c51","server_timing":{"name":{"cfCacheStatus":true,"cfEdge":true,"cfExtPri":true,"cfL4":true,"cfOrigin":true,"cfSpeedBrain":true},"location_startswith":null}}' crossorigin="anonymous"></script>
|
||||||
|
</body></html>
|
||||||
2
src/extractor/__fixtures__/DoodStream/http:dood.toedfx8me4un4ul
generated
Normal file
2
src/extractor/__fixtures__/DoodStream/http:dood.toedfx8me4un4ul
generated
Normal file
File diff suppressed because one or more lines are too long
2
src/extractor/__fixtures__/DoodStream/http:dood.toesk1m9eumzyjj
generated
Normal file
2
src/extractor/__fixtures__/DoodStream/http:dood.toesk1m9eumzyjj
generated
Normal file
File diff suppressed because one or more lines are too long
460
src/extractor/__fixtures__/Dropload/https:dropload.iogf2apij3ql37
generated
Normal file
460
src/extractor/__fixtures__/Dropload/https:dropload.iogf2apij3ql37
generated
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,5 @@
|
||||||
|
#EXTM3U
|
||||||
|
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3955353,RESOLUTION=1920x800,FRAME-RATE=23.974,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR
|
||||||
|
index-v1-a1.m3u8?t=jq0VCV2dOyLIIH07IGIpzruCXvbfdVqzGLWfMeo00AM&s=1759845990&e=14400&f=1571895&srv=ds3&i=0.0&sp=0&ii=2.184.0.222&p1=ds3&p2=ds3
|
||||||
|
|
||||||
|
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=68483,RESOLUTION=1920x800,CODECS="avc1.640028",URI="iframes-v1-a1.m3u8?t=jq0VCV2dOyLIIH07IGIpzruCXvbfdVqzGLWfMeo00AM&s=1759845990&e=14400&f=1571895&srv=ds3&i=0.0&sp=0&ii=2.184.0.222&p1=ds3&p2=ds3",VIDEO-RANGE=SDR
|
||||||
230
src/extractor/__fixtures__/Fastream/https:fastream.toda9rp1d1pqom7.html
generated
Normal file
230
src/extractor/__fixtures__/Fastream/https:fastream.toda9rp1d1pqom7.html
generated
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Fastream</title>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="theme-color" content="#FFF">
|
||||||
|
<link rel="apple-touch-icon" href="https://fastream.to/images2/favicon/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="icon" href="https://fastream.to/images2/favicon/favicon-32x32.png" sizes="32x32">
|
||||||
|
<link rel="icon" href="https://fastream.to/images2/favicon/favicon-16x16.png" sizes="16x16">
|
||||||
|
<link rel="manifest" href="https://fastream.to/images2/favicon/site.webmanifest" crossorigin="use-credentials">
|
||||||
|
<link rel="icon" href="https://fastream.to/images2/favicon/favicon.ico">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400..800&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fastream.to/css2/bootstrap.css?" rel="stylesheet">
|
||||||
|
|
||||||
|
<link href="https://fastream.to/css2/style.css?" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="https://fastream.to/js2/jquery.min.js?"></script>
|
||||||
|
<script src="https://fastream.to/js2/bootstrap.bundle.min.js?"></script>
|
||||||
|
<script src="https://fastream.to/js2/app.js?aс1"></script>
|
||||||
|
<script src="https://fastream.to/js2/xupload.js?aс1"></script>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body class="">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row header-row align-items-center flex-nowrap">
|
||||||
|
<div class="col-auto flex-grow-1 flex-shrink-1">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-auto d-lg-none">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn icon-btn" data-bs-toggle="dropdown" data-bs-offset="0, 16">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="22" height="16" viewBox="0 0 22 16">
|
||||||
|
<path d="M0 0h22v2H0V0zm0 7h22v2H0V7zm0 7h22v2H0v-2z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
|
||||||
|
<a href="/premium" class="dropdown-item">Premium</a>
|
||||||
|
|
||||||
|
<a href="/make_money" class="dropdown-item">Make Money</a>
|
||||||
|
<a href="/check_files" class="dropdown-item">Link Checker</a>
|
||||||
|
|
||||||
|
<a href="/api.html" class="dropdown-item">API</a>
|
||||||
|
<a href="/tos" class="dropdown-item">Terms of service</a>
|
||||||
|
|
||||||
|
<a href="/contact" class="dropdown-item">Contact Us</a>
|
||||||
|
<a href="/faq" class="dropdown-item">FAQ</a>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto me-lg-4">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
<img src="https://fastream.to/images2/logo.svg?" alt="">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-none d-lg-block">
|
||||||
|
<nav class="nav">
|
||||||
|
|
||||||
|
|
||||||
|
<a class="nav-link link-body-emphasis" href="/premium">Premium</a>
|
||||||
|
|
||||||
|
<a class="nav-link link-body-emphasis" href="/make_money">Make Money</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="dropdown header-lang">
|
||||||
|
<button type="button" class="btn icon-btn" data-bs-toggle="dropdown" data-bs-offset="0, 16">
|
||||||
|
<img src="https://fastream.to/images2/flags/english.svg" alt="">
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a href="/?op=change_lang&lang=English" class="dropdown-item"><img src="https://fastream.to/images2/flags/english.svg" alt="">English</a>
|
||||||
|
<a href="/?op=change_lang&lang=Russian" class="dropdown-item"><img src="https://fastream.to/images2/flags/russian.svg" alt="">Russian</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="col-auto d-none d-lg-block">
|
||||||
|
<a href="/login.html" class="btn me-2 login-btn link-body-emphasis">Login</a>
|
||||||
|
<a href="/?op=registration" class="btn btn-primary reg-btn">Sign Up</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-lg-none">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn icon-btn" data-bs-toggle="dropdown" data-bs-offset="0, 16">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="22" height="22" viewBox="0 0 22 22">
|
||||||
|
<path d="M11 22C4.9 22 0 17.1 0 11S4.9 0 11 0s11 4.9 11 11-4.9 11-11 11zm0-20c-5 0-9 4-9 9s4 9 9 9 9-4 9-9-4-9-9-9z"></path>
|
||||||
|
<path d="M11 15.3c-3 0-5.4-2.4-5.4-5.4S8 4.4 11 4.4s5.4 2.4 5.4 5.4-2.4 5.5-5.4 5.5zm0-8.9c-1.9 0-3.4 1.5-3.4 3.4s1.5 3.4 3.4 3.4 3.4-1.5 3.4-3.4-1.5-3.4-3.4-3.4z"></path>
|
||||||
|
<path d="M4.9 19.8c-.2 0-.3 0-.5-.1-.5-.3-.6-.9-.4-1.4 1.5-2.5 3.9-3.8 7-3.8s5.5 1.3 7 3.8c.3.5.1 1.1-.3 1.4-.5.3-1.1.1-1.4-.3-1.1-1.9-2.9-2.8-5.2-2.8-2.4 0-4.1.9-5.3 2.8-.2.2-.6.4-.9.4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end">
|
||||||
|
<a class="dropdown-item" href="/login.html">Login</a>
|
||||||
|
<a class="dropdown-item" href="/?op=registration">Sign Up</a>
|
||||||
|
<a class="dropdown-item" href="/?op=forgot_pass">Forgot your password?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
No such file=a9rp1d1pqom7
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-main">
|
||||||
|
<div class="row justify-content-center justify-content-lg-start">
|
||||||
|
<div class="col-12 col-lg-4 col-xl-3 text-center text-lg-start">
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="https://fastream.to/" class="logo">
|
||||||
|
<img src="https://fastream.to/images2/logo-w.svg?" alt="">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 text-uppercase"><!--Share us: --></div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<!--span class="me-1">
|
||||||
|
<a href="#"><img src="https://fastream.to/images2/social/fb.svg" alt=""></a>
|
||||||
|
</span>
|
||||||
|
<span class="me-1">
|
||||||
|
<a href="#"><img src="https://fastream.to/images2/social/red.svg" alt=""></a>
|
||||||
|
</span>
|
||||||
|
<span class="me-1">
|
||||||
|
<a href="#"><img src="https://fastream.to/images2/social/wha.svg" alt=""></a>
|
||||||
|
</span>
|
||||||
|
<span class="me-1">
|
||||||
|
<a href="#"><img src="https://fastream.to/images2/social/x.svg" alt=""></a>
|
||||||
|
</span>
|
||||||
|
<span class="me-1">
|
||||||
|
<a href="#"><img src="https://fastream.to/images2/social/msg.svg" alt=""></a>
|
||||||
|
</span>
|
||||||
|
<span class="me-1">
|
||||||
|
<a href="#"><img src="https://fastream.to/images2/social/in.svg" alt=""></a>
|
||||||
|
</span-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-8 col-xl-7 d-none d-lg-block">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-uppercase text-dark large">Info</div>
|
||||||
|
<div class="nav flex-column">
|
||||||
|
<a href="https://fastream.to/" class="nav-link">Home</a>
|
||||||
|
<a href="/api.html" class="nav-link">API</a>
|
||||||
|
<a href="/tos" class="nav-link">Terms of service</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-uppercase text-dark large">Features</div>
|
||||||
|
<div class="nav flex-column">
|
||||||
|
|
||||||
|
<a href="/premium" class="nav-link">Premium</a>
|
||||||
|
|
||||||
|
<a href="/make_money" class="nav-link">Make Money</a>
|
||||||
|
<a href="/check_files" class="nav-link">Link Checker</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-uppercase text-dark large">Support</div>
|
||||||
|
<div class="nav flex-column">
|
||||||
|
<a href="/contact" class="nav-link">Contact Us</a>
|
||||||
|
<a href="/faq" class="nav-link">FAQ</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="col-xl-auto text-nowrap text-end d-none d-xl-block">
|
||||||
|
<a href="https://fastream.to/login.html" class="btn btn-light login-btn me-2">Login</a>
|
||||||
|
<a href="https://fastream.to/?op=registration" class="btn btn-primary reg-btn">Sign Up</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom small">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center justify-content-between">
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="text-muted">© 2024 Fastream</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div style ="display:none"><script id="_wau1xg">var _wau = _wau || []; _wau.push(["classic", "y7jk20u14x", "1xg"]);</script><script async src="//waust.at/c.js"></script></div>
|
||||||
|
|
||||||
|
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'993a4e2d4ac5d2ab',t:'MTc2MTMxNzYyMw=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||||
|
</html>
|
||||||
145
src/extractor/__fixtures__/FileLions/https:callistanise.comfcy4t5nkerjrt
generated
Normal file
145
src/extractor/__fixtures__/FileLions/https:callistanise.comfcy4t5nkerjrt
generated
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>
|
||||||
|
|
||||||
|
Watch 300938 570f5a24 c281 484d 93fa 734dabbec813 jkgg 2651705 vidhide mp4
|
||||||
|
|
||||||
|
</title>
|
||||||
|
<META NAME="description" CONTENT="">
|
||||||
|
<META NAME="keywords" CONTENT="">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="theme-color" content="#FFF">
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" href="/static/images/favicon/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="icon" href="/static/images/favicon/favicon-32x32.png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/static/images/favicon/favicon-16x16.png" sizes="16x16">
|
||||||
|
<link rel="manifest" href="/static/images/favicon/manifest.json">
|
||||||
|
<link rel="icon" href="/static/images/favicon/favicon.ico">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="crossorigin">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="/static/css/style.css?" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="/static/js/jquery-3.2.1.min.js?"></script>
|
||||||
|
<script src="/static/js/bootstrap.bundle.min.js?"></script>
|
||||||
|
<script src="/static/js/app.js?"></script>
|
||||||
|
<script src="/static/js/xupload.js?"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Google tag (gtag.js) -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-48ZJD1VPGZ"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
|
||||||
|
gtag('config', 'G-48ZJD1VPGZ');
|
||||||
|
</script>
|
||||||
|
<!-- Yandex.Metrika counter -->
|
||||||
|
<script type="text/javascript" >
|
||||||
|
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
||||||
|
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||||
|
|
||||||
|
ym(93381808, "init", {
|
||||||
|
clickmap:true,
|
||||||
|
trackLinks:true,
|
||||||
|
accurateTrackBounce:true,
|
||||||
|
webvisor:true
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<noscript><div><img src="https://mc.yandex.ru/watch/93381808" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||||
|
<!-- /Yandex.Metrika counter -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center flex-nowrap">
|
||||||
|
<div class="col-auto flex-grow-1 flex-shrink-1">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
|
||||||
|
<div class="col-auto" style="margin: 0 auto;">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
|
||||||
|
<img src="/static/images/logo.svg" alt="">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="block">
|
||||||
|
<div class="mb-3">
|
||||||
|
The file was deleted by administration because it didn't comply with our Terms of Use:
|
||||||
|
</div>
|
||||||
|
<h4>
|
||||||
|
300938--570f5a24-c281-484d-93fa-734dabbec813--jkgg--2651705-vidhide.mp4
|
||||||
|
</h4>
|
||||||
|
<br>
|
||||||
|
Reason for deletion: <br>
|
||||||
|
<b class="text-danger">
|
||||||
|
DMCA request
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-4 col-xs-12">
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<a href="#" class="logo">
|
||||||
|
|
||||||
|
<img src="/static/images/logo.svg" alt="">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8 col-xs-12" style="position: relative">
|
||||||
|
<div class="mb-4 text-center">
|
||||||
|
<p class="text-muted ">
|
||||||
|
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<style>
|
||||||
|
@media (min-width:481px) {
|
||||||
|
.text-muted {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:961px) {
|
||||||
|
.text-muted {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 15px
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
||||||
503
src/extractor/__fixtures__/FileLions/https:filelions.toftyn45apubte2
generated
Normal file
503
src/extractor/__fixtures__/FileLions/https:filelions.toftyn45apubte2
generated
Normal file
File diff suppressed because one or more lines are too long
161
src/extractor/__fixtures__/FileLions/https:filelions.tofylcp2cu5qanb
generated
Normal file
161
src/extractor/__fixtures__/FileLions/https:filelions.tofylcp2cu5qanb
generated
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>
|
||||||
|
|
||||||
|
Watch
|
||||||
|
|
||||||
|
</title>
|
||||||
|
<META NAME="description" CONTENT="">
|
||||||
|
<META NAME="keywords" CONTENT="">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="theme-color" content="#FFF">
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" href="/static/images/favicon/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="icon" href="/static/images/favicon/favicon-32x32.png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/static/images/favicon/favicon-16x16.png" sizes="16x16">
|
||||||
|
<link rel="manifest" href="/static/images/favicon/manifest.json">
|
||||||
|
<link rel="icon" href="/static/images/favicon/favicon.ico">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="crossorigin">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="/static/css/style.css?" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="/static/js/jquery-3.2.1.min.js?"></script>
|
||||||
|
<script src="/static/js/bootstrap.bundle.min.js?"></script>
|
||||||
|
<script src="/static/js/app.js?"></script>
|
||||||
|
<script src="/static/js/xupload.js?"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Google tag (gtag.js) -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-48ZJD1VPGZ"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
|
||||||
|
gtag('config', 'G-48ZJD1VPGZ');
|
||||||
|
</script>
|
||||||
|
<!-- Yandex.Metrika counter -->
|
||||||
|
<script type="text/javascript" >
|
||||||
|
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
||||||
|
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||||
|
|
||||||
|
ym(93381808, "init", {
|
||||||
|
clickmap:true,
|
||||||
|
trackLinks:true,
|
||||||
|
accurateTrackBounce:true,
|
||||||
|
webvisor:true
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<noscript><div><img src="https://mc.yandex.ru/watch/93381808" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||||
|
<!-- /Yandex.Metrika counter -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center flex-nowrap">
|
||||||
|
<div class="col-auto flex-grow-1 flex-shrink-1">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
|
||||||
|
<div class="col-auto" style="margin: 0 auto;">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
|
||||||
|
<img src="/static/images/logo.svg" alt="">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="block">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path fill-rule="evenodd" fill="#DC3545" d="M32 64C14.326 64 0 49.674 0 32S14.326 0 32 0c17.666.019 31.981 14.334 32 32 0 17.674-14.326 32-32 32Zm0-61C15.984 3 3 15.984 3 32s12.984 29 29 29c16.008-.019 28.981-12.992 29-29C61 15.984 48.016 3 32 3Zm14.065 43.125a1.504 1.504 0 0 1-2.125 0l-11.94-12L20.065 46.12A1.502 1.502 0 0 1 17.941 44L29.88 32 17.941 20a1.503 1.503 0 0 1 2.124-2.125l11.935 12 11.94-11.98a1.502 1.502 0 0 1 2.12 0A1.5 1.5 0 0 1 46.065 20L34.12 32l11.945 12a1.504 1.504 0 0 1 0 2.125Z"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-center mb-3">
|
||||||
|
File Not Found
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<p class="text-center">The file you were looking for could not be found, sorry for any inconvenience.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-muted">
|
||||||
|
<p class="text-center"><b>Possible causes of this error could be:</b></p>
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
The file expired
|
||||||
|
<li>
|
||||||
|
The file was deleted by its owner
|
||||||
|
<li>
|
||||||
|
The file was deleted by administration because it didn't comply with our Terms of Use
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-4 col-xs-12">
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<a href="#" class="logo">
|
||||||
|
|
||||||
|
<img src="/static/images/logo.svg" alt="">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8 col-xs-12" style="position: relative">
|
||||||
|
<div class="mb-4 text-center">
|
||||||
|
<p class="text-muted ">
|
||||||
|
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<style>
|
||||||
|
@media (min-width:481px) {
|
||||||
|
.text-muted {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:961px) {
|
||||||
|
.text-muted {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 15px
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"destination_url":"https://RNzT2t4XVKU08.acek-cdn.com/hls2/01/05594/tyn45apubte2_n/master.m3u8?t=8y6hK40boUynXnkyW5s63kFaWpt6_A5UcG5LBUX3j_4&s=1756131487&e=129600&f=27974684&srv=F6vE2P5Wjncn&i=0.4&sp=500&p1=F6vE2P5Wjncn&p2=F6vE2P5Wjncn&asn=31898","request_headers":{"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","referer":"https://filelions.to/v/tyn45apubte2"},"mediaflow_proxy_url":"https://mediaflow.test.org/proxy/hls/manifest.m3u8","query_params":{"api_password":"test"}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"destination_url":"https://6dkDxt1udLQhbho.ovaltinecdn.com/hls2/01/01552/tah5znapz3e5_n/master.m3u8?t=MnSHt6lZx3bdKvTdoU1JsTLWp56dlRDLwze5Prnefew&s=1756131487&e=129600&f=7760835&srv=GOweneve6B0I&i=0.4&sp=500&p1=GOweneve6B0I&p2=GOweneve6B0I&asn=31898","request_headers":{"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","referer":"https://kinoger.com"},"mediaflow_proxy_url":"https://mediaflow.test.org/proxy/hls/manifest.m3u8","query_params":{"api_password":"test"}}
|
||||||
138
src/extractor/__fixtures__/FileLions/https:mivalyo.comftah5znapz3e5
generated
Normal file
138
src/extractor/__fixtures__/FileLions/https:mivalyo.comftah5znapz3e5
generated
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>
|
||||||
|
|
||||||
|
Watch Versailles S01E01 German AC3 1080p BDRip x265 FuN
|
||||||
|
|
||||||
|
</title>
|
||||||
|
<META NAME="description" CONTENT="">
|
||||||
|
<META NAME="keywords" CONTENT="">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="theme-color" content="#FFF">
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" href="/static/images/favicon/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="icon" href="/static/images/favicon/favicon-32x32.png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/static/images/favicon/favicon-16x16.png" sizes="16x16">
|
||||||
|
<link rel="manifest" href="/static/images/favicon/manifest.json">
|
||||||
|
<link rel="icon" href="/static/images/favicon/favicon.ico">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="crossorigin">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="/static/css/style.css?" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="/static/js/jquery-3.2.1.min.js?"></script>
|
||||||
|
<script src="/static/js/bootstrap.bundle.min.js?"></script>
|
||||||
|
<script src="/static/js/app.js?"></script>
|
||||||
|
<script src="/static/js/xupload.js?"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Google tag (gtag.js) -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-48ZJD1VPGZ"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
|
||||||
|
gtag('config', 'G-48ZJD1VPGZ');
|
||||||
|
</script>
|
||||||
|
<!-- Yandex.Metrika counter -->
|
||||||
|
<script type="text/javascript" >
|
||||||
|
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
||||||
|
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||||
|
|
||||||
|
ym(93381808, "init", {
|
||||||
|
clickmap:true,
|
||||||
|
trackLinks:true,
|
||||||
|
accurateTrackBounce:true,
|
||||||
|
webvisor:true
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<noscript><div><img src="https://mc.yandex.ru/watch/93381808" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||||
|
<!-- /Yandex.Metrika counter -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center flex-nowrap">
|
||||||
|
<div class="col-auto flex-grow-1 flex-shrink-1">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
|
||||||
|
<div class="col-auto" style="margin: 0 auto;">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
|
||||||
|
<img src="/static/images/logo.svg" alt="">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
This video can be watched as embed only
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-4 col-xs-12">
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<a href="#" class="logo">
|
||||||
|
|
||||||
|
<img src="/static/images/logo.svg" alt="">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8 col-xs-12" style="position: relative">
|
||||||
|
<div class="mb-4 text-center">
|
||||||
|
<p class="text-muted ">
|
||||||
|
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<style>
|
||||||
|
@media (min-width:481px) {
|
||||||
|
.text-muted {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:961px) {
|
||||||
|
.text-muted {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 15px
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
||||||
150
src/extractor/__fixtures__/FileLions/https:mivalyo.comvtah5znapz3e5
generated
Normal file
150
src/extractor/__fixtures__/FileLions/https:mivalyo.comvtah5znapz3e5
generated
Normal file
File diff suppressed because one or more lines are too long
57
src/extractor/__fixtures__/FileMoon/https:filemoon.sxdn7i8zodwjqr9
generated
Normal file
57
src/extractor/__fixtures__/FileMoon/https:filemoon.sxdn7i8zodwjqr9
generated
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Get all script tags in the <head>
|
||||||
|
const scripts = document.head.querySelectorAll('script');
|
||||||
|
|
||||||
|
// Iterate through the scripts to find the one injecting the Proxy
|
||||||
|
scripts.forEach((script) => {
|
||||||
|
if (script.innerHTML.includes('window.open=new Proxy(window.open')) {
|
||||||
|
console.log('Detected and removing injected script:', script);
|
||||||
|
script.remove(); // Remove the script from the <head>
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore the original window.open functionality (optional)
|
||||||
|
const originalOpen = Function.prototype.apply.bind(window.open);
|
||||||
|
window.open = function (...args) {
|
||||||
|
console.log('Restored window.open called:', args);
|
||||||
|
return originalOpen(this, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Injected script removed and window.open restored.');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<title>Not Found</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/assets/css/404.css?v=2">
|
||||||
|
<link rel="apple-touch-icon" href="https://filemoon.to/assets/images/favicon/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="icon" href="https://filemoon.to/assets/images/favicon/favicon-32x32.png" sizes="32x32">
|
||||||
|
<link rel="icon" href="https://filemoon.to/assets/images/favicon/favicon-16x16.png" sizes="16x16">
|
||||||
|
<link rel="icon" href="https://filemoon.to/assets/images/favicon/favicon.ico">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
<script src="/ad.js" type="text/javascript"></script>
|
||||||
|
<div id="adb" class="ad doubleclick skyscraper" style="height:1px;width:1px;position:absolute;left:-999px;top:-999px;">ad</div>
|
||||||
|
<script>
|
||||||
|
window.fmn7i8zodwjqr9 = { token: "bed5d8dfa2e30a9a04", adblock: false, blockadblock: true, fileid: "n7i8zodwjqr9", ampallow: false };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="error e404">
|
||||||
|
<div class="error-footer">
|
||||||
|
<h1>Page not found</h1>
|
||||||
|
<a href="/" class="button">Return home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</html>
|
||||||
227
src/extractor/__fixtures__/FileMoon/https:filemoon.todugj7f1kq94p1
generated
Normal file
227
src/extractor/__fixtures__/FileMoon/https:filemoon.todugj7f1kq94p1
generated
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Watch tt0307453</title>
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="theme-color" content="#FFF">
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" href="//filemoon.to/assets/images/favicon/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="icon" href="//filemoon.to/assets/images/favicon/favicon-32x32.png" sizes="32x32">
|
||||||
|
<link rel="icon" href="//filemoon.to/assets/images/favicon/favicon-16x16.png" sizes="16x16">
|
||||||
|
<link rel="icon" href="//filemoon.to/assets/images/favicon/favicon.ico">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="//filemoon.to/assets/css/front.css?v=51">
|
||||||
|
<META NAME="description" CONTENT="Watch video tt0307453">
|
||||||
|
<META NAME="keywords" CONTENT="tt0307453">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="is-home d-flex flex-column justify-content-between ">
|
||||||
|
<header class="header py-3 py-lg-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center justify-content-between justify-content-lg-start">
|
||||||
|
<div class="col-auto d-lg-none">
|
||||||
|
<button class="btn text-muted ssm-toggle-nav px-1" type="button">
|
||||||
|
<i class="icon icon-menu icon-size-22 text-muted"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="/" class="logo d-block"><img src="//filemoon.to/assets/images/logo.svg" alt=""></a>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-none d-lg-block flex-lg-grow-1">
|
||||||
|
<nav class="nav ml-lg-3">
|
||||||
|
<a class="nav-link" href="/premium">Premium</a>
|
||||||
|
<a class="nav-link" href="/affiliate">Make Money</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="d-none d-lg-flex">
|
||||||
|
|
||||||
|
<a href="/login" class="btn btn-dark sign-btn rounded-pill mr-2">Log In</a>
|
||||||
|
<a href="/register" class="btn btn-success sign-btn rounded-pill">Sign Up</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="d-lg-none">
|
||||||
|
<button class="btn text-muted usrmobile-toggle-nav px-1" type="button">
|
||||||
|
<i class="icon icon-user icon-size-18 text-muted"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://filemoon.to/js/jquery.cookie.js"></script>
|
||||||
|
<script>
|
||||||
|
$.cookie('file_id', '48721931', { expires: 10 });
|
||||||
|
$.cookie('aff', '89667', { expires: 10 });
|
||||||
|
$.cookie('ref_url', 'https://filemoon.to/d/ugj7f1kq94p1', { expires: 10 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src='/js/ls.js'></script>
|
||||||
|
<main class="flex-grow-1 a-bean">
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="mb-4 mb-lg-5 sm-bg px-4 py-5 p-lg-0 mx-lg-5 rounded-lg">
|
||||||
|
<div class="row justify-content-between align-items-center text-muted mb-4 mb-lg-5">
|
||||||
|
<div class="col-auto mb-2">
|
||||||
|
<h3 class="text-light mb-0 font-weight-bold">tt0307453</h3>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto mb-2"> Uploaded: <span class="text-success">2025-08-24 00:12:36</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 mb-lg-5 ">
|
||||||
|
<div class="position-relative">
|
||||||
|
|
||||||
|
<div class="bimg-top text-center">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 640px;" id="iframe-holder">
|
||||||
|
|
||||||
|
<iframe src="https://ico3c.com/bkg/ugj7f1kq94p1" frameborder="0" marginwidth="0" marginheight="0" scrolling="no" width="100%" height="100%" allowfullscreen></iframe>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bimg-bottom text-center">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="mb-4 mb-lg-5 text-center">
|
||||||
|
<a href="/download/ugj7f1kq94p1" class="btn btn-success px-5">Download</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#vplayer {padding-top: 56.25%;}
|
||||||
|
.bimg-top,
|
||||||
|
.bimg-bottom {
|
||||||
|
display: -webkit-flex;
|
||||||
|
display: -ms-flex;
|
||||||
|
display: flex;
|
||||||
|
-ms-align-items: center;
|
||||||
|
align-items: center;
|
||||||
|
-webkit-flex-wrap: wrap;
|
||||||
|
-ms-flex-wrap: wrap;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.bimg-top div,
|
||||||
|
.bimg-bottom div {
|
||||||
|
margin: .5rem;
|
||||||
|
}
|
||||||
|
@media(min-width:1600px){
|
||||||
|
.bimg-top,
|
||||||
|
.bimg-bottom {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.bimg-top {
|
||||||
|
left: -310px;
|
||||||
|
}
|
||||||
|
.bimg-bottom {
|
||||||
|
right: -310px;
|
||||||
|
}
|
||||||
|
.bimg-top div,
|
||||||
|
.bimg-bottom div {
|
||||||
|
margin: .5rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media(min-width:1800px){
|
||||||
|
.bimg-top {
|
||||||
|
left: -350px;
|
||||||
|
}
|
||||||
|
.bimg-bottom {
|
||||||
|
right: -350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<footer class="footer py-4 py-lg-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center justify-content-between">
|
||||||
|
<div class="col-auto my-1">
|
||||||
|
<div class="text-muted"> FileMoon © 2022 </div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto my-1">
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-link text-muted footer-link px-0 mr-4" href="/">Home</a>
|
||||||
|
<a class="nav-link text-muted footer-link px-0 mr-4" href="/tos">Terms of Service</a>
|
||||||
|
<a class="nav-link text-muted footer-link px-0 mr-4" href="/privacy">Privacy Policy</a>
|
||||||
|
<a class="nav-link text-muted footer-link px-0 mr-4" href="/copyright">Copyright Policy</a>
|
||||||
|
<a class="nav-link text-muted footer-link px-0 mr-4" href="/affiliate">Make Money</a>
|
||||||
|
<a class="nav-link text-muted footer-link px-0 mr-4" href="/api">API Documentation</a>
|
||||||
|
<a class="nav-link text-muted footer-link px-0 mr-4" href="https://status.filemoon.sx">Server Status</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<div class="ssm">
|
||||||
|
<nav class="nav ssm-nav flex-column">
|
||||||
|
<a class="nav-link ssm-nav-link active" href="/"><i class="icon icon-home icon-size-16 ssm-nav-icon mr-3"></i>Home</a>
|
||||||
|
<a class="nav-link ssm-nav-link" href="/dashboard"><i class="icon icon-account icon-size-16 ssm-nav-icon mr-3"></i>My account</a>
|
||||||
|
<a class="nav-link ssm-nav-link" href="/premium"><i class="icon icon-money icon-size-16 ssm-nav-icon mr-3"></i>Premium</a>
|
||||||
|
<a class="nav-link ssm-nav-link" href="/affiliate"><i class="icon icon-money icon-size-16 ssm-nav-icon mr-3"></i>Make Money</a>
|
||||||
|
</nav>
|
||||||
|
<hr class="mx-3">
|
||||||
|
<nav class="nav flex-column ssm-nav">
|
||||||
|
<a class="nav-link ssm-nav-link" href="/tos"><i class="icon icon-pen icon-size-16 ssm-nav-icon mr-3"></i>Terms of Service</a>
|
||||||
|
<a class="nav-link ssm-nav-link" href="/privacy"><i class="icon icon-key icon-size-16 ssm-nav-icon mr-3"></i>Privacy Policy</a>
|
||||||
|
<a class="nav-link ssm-nav-link" href="/api"><i class="icon icon-note icon-size-16 ssm-nav-icon mr-3"></i>API Documentation</a>
|
||||||
|
<a class="nav-link ssm-nav-link" href="https://status.filemoon.sx"><i class="icon icon-note icon-size-16 ssm-nav-icon mr-3"></i>Server Status</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="ssm-overlay ssm-toggle-nav"></div>
|
||||||
|
<div class="usrmobile p-5 usrmobile-toggle-nav">
|
||||||
|
|
||||||
|
<a href="/login" class="btn btn-success btn-success-custom btn-lg btn-block text-uppercase shadow">Sign in</a>
|
||||||
|
<a href="/register" class="btn btn-outline-success btn-lg btn-block mb-2 text-uppercase">Sign up</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script src="//filemoon.to/assets/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="//filemoon.to/assets/js/jquery.slideandswipe.min.js"></script>
|
||||||
|
<script src="//filemoon.to/assets/js/jquery.touchSwipe.min.js"></script>
|
||||||
|
<script src="//filemoon.to/assets/js/front.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
76
src/extractor/__fixtures__/FileMoon/https:ico3c.combkgugj7f1kq94p1
generated
Normal file
76
src/extractor/__fixtures__/FileMoon/https:ico3c.combkgugj7f1kq94p1
generated
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"destination_url":"https://be7713.rcr82.waw05.i8yz83pn.com/hls2/05/09711/aeuhexjcoysl_x/master.m3u8?t=fiqqJThcgnrc77etu-nLPLodbF1UeqY5Cou0vvnLIB0&s=1765556185&e=10800&f=48721931&srv=1050&asn=13335&sp=5500&p=","request_headers":{"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","referer":"https://ico3c.com/bkg/ugj7f1kq94p1"},"mediaflow_proxy_url":"https://mediaflow.test.org/proxy/hls/manifest.m3u8","query_params":{"api_password":"test"}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"destination_url":"https://be7713.rcr82.waw05.i8yz83pn.com/hls2/01/09665/wkhcbggdxf1d_h/master.m3u8?t=359izbQ_L-sOHaIjj275YKdBDyiPzUi3ETfbNoN9bzU&s=1759754698&e=10800&f=48329866&srv=1075&asn=13335&sp=4000&p=","request_headers":{"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36","referer":"https://z1ekv717.fun/d/wkhcbggdxf1d"},"mediaflow_proxy_url":"https://mediaflow.test.org/proxy/hls/manifest.m3u8","query_params":{"api_password":"test"}}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue