mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 15:43:24 +00:00
Compare commits
740 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
0b8aeb1832 | ||
|
3bd2bb4b2c | ||
|
6e8e323417 | ||
|
50fdf230a1 | ||
|
765cf2a17a | ||
|
2d431595cd | ||
|
3bceb2a905 | ||
|
e9c0e64cf0 | ||
|
d9c944a8fa | ||
|
bd40165bc2 | ||
|
65c7a461d7 | ||
|
3103ecd004 | ||
|
c744d3bc7d | ||
|
e0009c8f29 | ||
|
f6af13f7a6 | ||
|
f7ebb6ed89 | ||
|
468ee4dcf6 | ||
|
bc21fa4749 | ||
|
3cdb056d43 | ||
|
6a926ec7fe | ||
|
0c18d8f04b | ||
|
6705683c19 | ||
|
91f9f56174 | ||
|
ce71a2d638 | ||
|
d2d710ad37 | ||
|
8d82ee5f88 | ||
|
b1663a919f | ||
|
bc3848fae4 | ||
|
76a12b8f7a | ||
|
8d0cd59d85 | ||
|
8b1a5bce4a | ||
|
9b852f12cf | ||
|
81f0425755 | ||
|
a9a3eac4ea | ||
|
06e54886e5 | ||
|
ce00f1c5c2 | ||
|
244c603ad7 | ||
|
ea52156bb8 | ||
|
1c6b0ae3e8 | ||
|
00e25f1ae4 | ||
|
6aa0c86e42 | ||
|
fcf8a9e755 | ||
|
e5e45c4fa0 | ||
|
f68c8148d8 | ||
|
4563ea2c18 | ||
|
eea9c19b56 | ||
|
c4c7816543 | ||
|
545120d5cc | ||
|
4ff3e43c78 | ||
|
845fd93597 | ||
|
e0bf711a79 | ||
|
9fbba7ea55 | ||
|
f892a3037f | ||
|
394271857f | ||
|
f5f69ca7d4 | ||
|
1c17ef679d | ||
|
09f6a3125b | ||
|
436fb2707b | ||
|
a46cfa43d3 | ||
|
dccab9b0bf | ||
|
7c3d4aac27 | ||
|
1408fcde93 | ||
|
89cdf74b2f | ||
|
984d215312 | ||
|
430486a9b9 | ||
|
9495a3bf41 | ||
|
33b67f32b1 | ||
|
03d414a200 | ||
|
81b22b0473 | ||
|
5c50155718 | ||
|
102d252f82 | ||
|
969aa6156e | ||
|
d19f0cf305 | ||
|
3f241c2d07 | ||
|
5661a7873a | ||
|
4f5a926c90 | ||
|
205248a376 | ||
|
0d249a3e27 | ||
|
4d51de3bd1 | ||
|
c08a6c7e54 | ||
|
c9bac3ed68 | ||
|
06eb8e6b6d | ||
|
0e9263b619 | ||
|
763de37e9e | ||
|
46bd20f718 | ||
|
8da155ba2b | ||
|
b5c330d4e3 | ||
|
879271c239 | ||
|
70f8355386 | ||
|
3af98373fb | ||
|
c17f8a15e8 | ||
|
63f26b81de | ||
|
70852773f9 | ||
|
7e5c2f9b88 | ||
|
a4bd9bb87a | ||
|
89af8156f4 | ||
|
443ab476d8 | ||
|
524c57d4fc | ||
|
ffa1ad3b8a | ||
|
d47acada58 | ||
|
682017977b | ||
|
ab1dd18d39 | ||
|
cffe5080f6 | ||
|
60142acbda | ||
|
688e1ff24a | ||
|
0066cff111 | ||
|
d06f379d1b | ||
|
a04cd37307 | ||
|
dd3c533349 | ||
|
ec5f1dfad9 | ||
|
bc0f9a6abf | ||
|
a0bb03790a | ||
|
7e948c60c1 | ||
|
9003bf6788 | ||
|
e912ea4715 | ||
|
58ca372a49 | ||
|
ad26391645 | ||
|
f6b830d06d | ||
|
d4c6dac9f2 | ||
|
2db7e0bef8 | ||
|
d198760f9c | ||
|
7e696d5c2c | ||
|
4bd00eb47a | ||
|
d961655186 | ||
|
330cbf2d9e | ||
|
28d2dd0e89 | ||
|
74cc50cfa2 | ||
|
07deb1897d | ||
|
be90b02043 | ||
|
61c3ed076f | ||
|
80dd2158df | ||
|
db75f2320d | ||
|
f9d756e0ef | ||
|
424ee6fe77 | ||
|
5d56b847c6 | ||
|
20c4b14799 | ||
|
c4afc37217 | ||
|
3ee9ee43a5 | ||
|
b22e3ff8c1 | ||
|
a7af045308 | ||
|
e889eaebaa | ||
|
baf744b5d6 | ||
|
e5ddb98162 | ||
|
1eac9f886e | ||
|
dfe67157d4 | ||
|
40e45ae103 | ||
|
1a613287f8 | ||
|
ef782974fe | ||
|
893a385f00 | ||
|
18bde24b3a | ||
|
b7033a31c4 | ||
|
cc4f64032a | ||
|
30e5ae7121 | ||
|
ce4721e1bb | ||
|
534edd5883 | ||
|
02135527c1 | ||
|
12ebee622a | ||
|
8c52371c6d | ||
|
3c096c069c | ||
|
f20cb5aad2 | ||
|
519e74480e | ||
|
be03a8eb42 | ||
d586899dbf | |||
|
525f9d0b74 | ||
|
01b019365d | ||
|
5e0e223851 | ||
|
a648f45694 | ||
|
ffc772727a | ||
|
77a0c36a58 | ||
|
766dc63bfa | ||
|
e3d6ec93c7 | ||
|
1fd458fa27 | ||
|
e4c15c624b | ||
|
b12649bd2e | ||
|
37e10fb40e | ||
|
61b75da402 | ||
|
73b2f57fdc | ||
|
0b8c6439d7 | ||
|
4ad0d53683 | ||
|
3958df8e29 | ||
|
fa36493c50 | ||
|
efd87ab96e | ||
|
f80d79070e | ||
|
be7b875666 | ||
|
bb869fd7e3 | ||
|
2b30bb0e2b | ||
|
b9448b5231 | ||
|
7a6af6c072 | ||
|
2657d1f856 | ||
|
21cc8c16d6 | ||
|
b04209d9b3 | ||
|
55bfa2be9d | ||
|
dd8b6c3f9e | ||
|
835e818ca0 | ||
942725d04c | |||
|
010f1d3987 | ||
|
7bad6eaff9 | ||
|
bcff5a8972 | ||
|
caba492ca2 | ||
|
f03145ee6d | ||
|
c0aebca4d9 | ||
|
c7651950ce | ||
|
cd3bd22a2c | ||
|
9773fcc7b5 | ||
|
c937acfb09 | ||
|
d1f3a7ad24 | ||
|
cd0e4522c9 | ||
f4be26d92d | |||
|
22f8d8a581 | ||
|
6cfd1235bc | ||
|
bdeaca3062 | ||
|
15e95923be | ||
|
571df9e0ad | ||
|
cce47fab5d | ||
|
6eb25fb49c | ||
|
e61937b5c4 | ||
|
2338b0d652 | ||
|
37463afc8d | ||
|
9c8e89a274 | ||
|
bf135a2bdf | ||
|
4dc6658e67 | ||
|
fffc119e88 | ||
|
5468a4677b | ||
|
85cfba1a7a | ||
|
fd6895c326 | ||
|
dfc3d9e50f | ||
|
fcdf45d3f5 | ||
|
592837e2a6 | ||
|
9b3c1ffa28 | ||
|
7cb9ccaf14 | ||
|
aa91bae418 | ||
|
7737bd1866 | ||
|
4c0c61b0b9 | ||
|
4880d46dc4 | ||
|
ef39d87b4b | ||
|
e2a4caa8aa | ||
|
b6a60cf5f8 | ||
|
f784f5f4b2 | ||
|
01348f2f9a | ||
|
8200079af7 | ||
|
dcb5d2f068 | ||
|
99e47f16ea | ||
|
6fb76908ae | ||
|
a718abdcdd | ||
|
0e77d63caf | ||
|
106290070a | ||
|
433d618096 | ||
|
af954af36c | ||
|
16841b8e69 | ||
|
41979712c3 | ||
|
9b62b55fbb | ||
|
6ef41bdf1c | ||
|
33ebd34808 | ||
|
52598599e7 | ||
|
cccc84624a | ||
|
d54921900b | ||
|
2a4bc7349c | ||
|
7b641c61cd | ||
|
3a7b05264d | ||
|
a1e3d98538 | ||
|
68e5742c25 | ||
|
283b9cc996 | ||
|
3ed5dcfc15 | ||
|
71235f5174 | ||
|
0d79a677a0 | ||
|
a34d245e2b | ||
|
8b8cbc8cc9 | ||
|
5ee4f013ff | ||
|
75ef831ddc | ||
|
99a3e6db69 | ||
|
7d3e1c0943 | ||
|
e2d1842946 | ||
|
2cfd7e64a2 | ||
|
d6def996bf | ||
|
8bba2961b4 | ||
|
f12f53d32c | ||
|
da05a2597e | ||
|
d40076e950 | ||
|
bb4a6d8a1e | ||
|
7007f030e1 | ||
|
24fa1c449f | ||
|
591b1d3bc5 | ||
|
c162f15496 | ||
|
2650707d2c | ||
|
a0a51c898a | ||
|
43c8da9003 | ||
|
1472b21600 | ||
|
2424cdfc9e | ||
|
2239c186a5 | ||
|
0c2df2cd3c | ||
|
b26b0715bd | ||
|
7b75c36d21 | ||
|
e52b29a1a1 | ||
|
a910c1c18c | ||
|
12c245b2da | ||
|
871780f95e | ||
|
fa985fc2c2 | ||
|
db9eec195a | ||
|
de1221235b | ||
|
b576a298e8 | ||
|
fcb24c783c | ||
c5251401e7 | |||
41fd23cf20 | |||
|
5dfeeadbb8 | ||
|
0794558338 | ||
|
d2ffa35f2c | ||
c330112dbc | |||
84b8a67cea | |||
|
546b008b2e | ||
|
b9b0380dfe | ||
|
c472e7f7b8 | ||
|
3decc9190c | ||
|
184af19498 | ||
|
2eab07b8b6 | ||
|
5d8f03b859 | ||
|
2178057633 | ||
|
9e961223f6 | ||
|
c2b52d3db8 | ||
|
42dee51570 | ||
|
9c13be37e8 | ||
|
06a44da9cc | ||
|
49d7dc9761 | ||
|
1585805d86 | ||
|
7dc76e993f | ||
|
661d995e3b | ||
|
156b693460 | ||
|
d82b32e8d9 | ||
|
8a8dbb2778 | ||
|
6d95f83c0b | ||
|
2fe53a05e8 | ||
|
495222eb10 | ||
|
119bafa516 | ||
|
ba1ee0267b | ||
|
92ef687ddc | ||
|
5e776f8655 | ||
|
c541d4212a | ||
2d17c8abaa | |||
|
4a52fc11ed | ||
|
54d1af0e0a | ||
|
48f54dd7cc | ||
|
3a44eb550d | ||
|
0fa3d3e430 | ||
|
a9849b40c2 | ||
|
80954514b6 | ||
|
e2dd74c0af | ||
|
2f10de415b | ||
|
efcb12f95a | ||
|
307f555b70 | ||
|
4d5f03337d | ||
|
9f008f02d1 | ||
|
e91f65dd91 | ||
|
3aab008f12 | ||
|
659b0168c3 | ||
|
e9e2129aa2 | ||
|
bed3318ebe | ||
|
436a2388b9 | ||
|
1ad1c69d3e | ||
|
fac2b50bfc | ||
|
4d08ecc694 | ||
|
5edc99cdfe | ||
|
3b0232b3d6 | ||
|
f2ea05708f | ||
|
772777835e | ||
|
dc58c2b55e | ||
|
c7f3f774bb | ||
|
96656d9a2f | ||
|
5419430369 | ||
|
603e42b907 | ||
|
d51603a382 | ||
|
731ef6a9aa | ||
|
0de9551080 | ||
|
0f7c51c198 | ||
|
cf2060bd32 | ||
|
ec73d5ef90 | ||
|
9c159f01bd | ||
|
215b5920c3 | ||
|
6136ff92e6 | ||
|
51dfef18fb | ||
|
12f7f2ee03 | ||
|
01f46ce23c | ||
|
ffe817388a | ||
|
37d5aaede9 | ||
|
e2b1a9bfde | ||
|
827d4b576b | ||
|
5664540acc | ||
|
4fe7f1fd1c | ||
|
12555a5933 | ||
|
9fe7bdcf47 | ||
|
20addc039c | ||
|
9dad4e687d | ||
|
870aa4f105 | ||
|
464b78d914 | ||
|
06d043d482 | ||
|
01f98c583a | ||
|
f0c9103e0d | ||
|
53a0168615 | ||
|
c9ccf018f2 | ||
|
fec1d5ac15 | ||
|
9bedf2b9f1 | ||
|
57ac2ac677 | ||
|
60a5f84f2f | ||
|
0d088755ee | ||
|
e5eb09af4d | ||
|
0036c22970 | ||
|
8844efa754 | ||
|
3c68794e5b | ||
|
5fc8355e8e | ||
|
f2efd828dc | ||
|
b36324d58e | ||
|
8e79e3acdb | ||
|
31cd4d3c75 | ||
|
dfe1dd53b7 | ||
|
c2d09566b0 | ||
|
f7d51e6d8b | ||
|
c5ff5817a4 | ||
|
3aa4365a56 | ||
|
80a9f1c91b | ||
|
f02256f9e0 | ||
|
ed5435f69e | ||
|
b494469b71 | ||
|
bbb9072bc9 | ||
|
a34a644d07 | ||
|
506c00960f | ||
|
93fb343fa9 | ||
|
5e8ad2e996 | ||
|
c0867182d7 | ||
|
89f77debca | ||
|
80f7240f58 | ||
|
a520cf02bb | ||
|
051c1ba709 | ||
|
3bee46ff53 | ||
|
315c3de3ab | ||
|
1c77807987 | ||
|
9bba47575a | ||
|
dace2338be | ||
|
30d8e11992 | ||
|
9c9ce92681 | ||
|
30cc5aa78b | ||
|
ac28f32ef4 | ||
|
fca9fea265 | ||
|
c2bd7714ed | ||
|
48214af202 | ||
|
007375c1df | ||
|
72ad53ee56 | ||
|
02d94ba411 | ||
|
84913aa63d | ||
|
9d7ddc03a5 | ||
|
5327cbffaa | ||
|
695ccef2b5 | ||
|
addd8ca031 | ||
|
dd662efd72 | ||
|
900c70e36a | ||
|
68a1470447 | ||
|
b42d36c5ac | ||
|
6b9774a210 | ||
|
a5cd05b144 | ||
|
bdb4b3507a | ||
|
ca6383900a | ||
|
5e97a195d9 | ||
|
25e32a14b7 | ||
|
139a760be0 | ||
|
bd26ed5bc0 | ||
|
ef4cb064e7 | ||
|
875be16c4c | ||
|
f264457c57 | ||
|
7bf1d05f16 | ||
|
a3e244285c | ||
|
935cb2427b | ||
|
404cd897f3 | ||
|
f72d6db253 | ||
|
b9a9db348b | ||
|
fac0a878f3 | ||
|
596e680a18 | ||
|
cc51559c29 | ||
|
c6bf568514 | ||
|
4a38c77e2d | ||
|
163ca0df29 | ||
|
19d2b963a8 | ||
|
3fad6edaad | ||
|
f2f7925cbb | ||
|
b9026c50f5 | ||
|
a1f3986e64 | ||
|
224cdb6710 | ||
|
f76db3e4b7 | ||
|
9abb009725 | ||
|
0ca4b3cf49 | ||
|
9418a7c45d | ||
|
d34d2c8ce0 | ||
|
281785a0ef | ||
|
28c008a77f | ||
|
717ebbaeae | ||
|
f715f70f9e | ||
|
24aeb68f55 | ||
|
8ed0d3740f | ||
|
444c751b78 | ||
|
63b9adf7d8 | ||
|
3a1c3ad260 | ||
|
e68fe0e115 | ||
|
d51246120d | ||
|
23b439ff79 | ||
|
ac350f276c | ||
|
854e6bede4 | ||
|
25670814e4 | ||
|
7c2ad68c2a | ||
|
e82173efbe | ||
|
485698a43c | ||
|
444156236c | ||
|
4f9ef382dc | ||
|
cedc987509 | ||
|
a99437b4cc | ||
|
7f28e7be3d | ||
|
efc2c8a67d | ||
|
02cd565f84 | ||
|
0625719a4d | ||
|
16298431f4 | ||
|
7d6656aef2 | ||
|
564bcccff8 | ||
|
177df9a6f2 | ||
|
e44b36c83e | ||
|
3696a05e1e | ||
|
abeb68d4a3 | ||
|
d10d4faf56 | ||
|
f5e5b48616 | ||
|
9ff49e42a3 | ||
|
d6a46e1cdc | ||
|
d10cbd5e9b | ||
|
1853c8eac7 | ||
|
6908588c00 | ||
|
48ab781bb9 | ||
|
fbd683e0b5 | ||
|
3b3457532a | ||
|
ef7b9ff475 | ||
|
c5aacd72ce | ||
|
620e63f17c | ||
|
4d8257a05f | ||
|
0f9d7faaf2 | ||
|
afa89c02a0 | ||
|
2bef75dd4a | ||
|
35adaf3872 | ||
|
a2e5e08b20 | ||
|
39ede1b042 | ||
|
32288357c4 | ||
|
35ecaece5b | ||
|
25ccd941ca | ||
|
bfbb4c6b11 | ||
|
f13ed7cae1 | ||
|
44f59e9708 | ||
|
92fa9716e5 | ||
|
e289f9a228 | ||
|
68868b37a8 | ||
|
b70b58602d | ||
|
62f8dc0e5e | ||
|
b83258a300 | ||
|
1d1dbf4bec | ||
|
9267b7bca1 | ||
|
0f735f49d9 | ||
|
cca38680fe | ||
|
a8c84f7343 | ||
|
68a186963c | ||
|
a2e647297a | ||
|
398644951e | ||
|
b886443ea7 | ||
|
d6d318006b | ||
|
0c57aa1a73 | ||
|
aaf0b56ee7 | ||
|
b3db58012f | ||
|
c90d59ef93 | ||
|
c441d63074 | ||
|
a0751380e5 | ||
|
209fe4369c | ||
|
4a35287975 | ||
|
b43f39b007 | ||
|
4f682d55a9 | ||
|
ad518a6508 | ||
|
4d4626806d | ||
|
18b7619328 | ||
|
75762aca48 | ||
|
eaf5730415 | ||
|
224de76578 | ||
|
df5f1a5fdb | ||
|
f46263385b | ||
|
bf3bca9b53 | ||
|
a93569a201 | ||
|
4a0392d1f0 | ||
|
424ec25c5a | ||
|
bd48d929b9 | ||
|
e569f15661 | ||
|
dcc158e705 | ||
|
942a6cc9c0 | ||
|
dd14b575eb | ||
|
8f23240ea1 | ||
|
886ffe78ef | ||
|
772be4b42d | ||
|
e448c0b5a8 | ||
|
056f837dcb | ||
|
d89bbaef97 | ||
|
0193e8f0c8 | ||
|
6d24e8aa81 | ||
|
f14606e579 | ||
|
f97b84516b | ||
|
c4712044a9 | ||
|
d9ccce1726 | ||
|
bd7799b5c1 | ||
|
d8e2597db7 | ||
|
f8b5c4169c | ||
|
0105c4f6b2 | ||
|
403142783c | ||
|
2a3c93c24f | ||
|
3b4e9ce2ca | ||
|
6224fb32c4 | ||
|
5e433266ee | ||
|
5d5a727663 | ||
|
76e4bc5851 | ||
|
487ba39bbf | ||
|
d213daf91e | ||
|
210e60c24d | ||
|
63be27b9ae | ||
|
c3b409631e | ||
|
bb14d63a9c | ||
|
27ef9be6b1 | ||
|
a0c24209bb | ||
|
c5a8065db9 | ||
|
6ca3196b75 | ||
|
4d40339602 | ||
|
6e67038ae7 | ||
|
73e6f26adb | ||
|
a1cae1c9f7 | ||
|
b1333cfc16 | ||
|
52fef27374 | ||
|
2b81d061f4 | ||
|
6edc0d3959 | ||
|
8c9d905a91 | ||
|
701b3db798 | ||
|
0ca751f1d2 | ||
|
c3985873d4 | ||
|
3604a2f0d7 | ||
|
6d9a963592 | ||
|
22a2ebac74 | ||
|
dfbaac8e93 | ||
|
da097b97d1 | ||
|
6de43d29b9 | ||
|
177860aed4 | ||
|
a077417761 | ||
|
20685577ab | ||
|
b2748f7390 | ||
|
b8e49850f4 | ||
|
1f7e8abda5 | ||
|
b6ff4bf800 | ||
|
62220532d7 | ||
|
1579e23dba | ||
|
9e8769e4c3 | ||
|
f339a7156a | ||
|
fa9785bf69 | ||
|
ec6e145f82 | ||
|
5e1727e8f7 | ||
|
1185383ae4 | ||
|
7a2865313d | ||
|
e7a6484094 | ||
|
489f536722 | ||
|
f472f04735 | ||
|
5a01a68ce4 | ||
|
b6a23aa0b7 | ||
|
02cc4b7f1d | ||
|
9cb182d201 | ||
|
5ca384a0f7 | ||
|
fb96026195 | ||
|
6353bf3799 | ||
|
40cca10660 | ||
|
4d2fc166bc | ||
|
f37bec7a7a | ||
|
f656f80996 | ||
|
714b378f68 | ||
|
a369682a26 | ||
|
ca169769bb | ||
|
52b063b10a | ||
|
8e522e18d4 | ||
|
d161c948cd | ||
|
2f1058cb9c | ||
|
cf83df64bb | ||
|
5967c83d28 | ||
|
4d07751a4a | ||
|
a64841507f | ||
|
6589e095ec | ||
|
a9ac3e64db | ||
|
094f9208a8 | ||
|
e34ddddddb | ||
|
f1257973e7 | ||
|
8268abc45d | ||
|
46e933dfb7 | ||
|
d28e6e6735 | ||
|
35c7ac4b8d | ||
|
02ef6c5bf1 | ||
|
2d9b66d9b8 | ||
|
351b35ef98 | ||
|
024325f640 | ||
|
098f6af0ae | ||
|
b43b8b19e4 | ||
|
44149203cb | ||
|
a9cf056276 | ||
|
09634c6f97 | ||
|
61abce9386 | ||
|
218a14d5f6 | ||
|
f93b9b5b0f | ||
|
196d6ae6e5 | ||
|
3a67d50f42 | ||
|
eeaa4d7571 | ||
|
b98fdcd94d | ||
|
9fba422673 | ||
|
e7981539e6 | ||
|
42402eb5c7 | ||
|
9d865ca7b4 | ||
|
4dd0f22a04 | ||
|
a9c34d6e35 | ||
|
844f5d8b3f | ||
|
a2e27b1967 | ||
|
06256e311d | ||
|
afd2875715 | ||
|
9851936c69 | ||
|
aab58815e0 | ||
|
77678063b4 | ||
|
2f713d3394 | ||
|
63cc59d518 | ||
|
9a16aff7aa | ||
|
36821ff140 | ||
|
3b7a95ff62 | ||
|
1967c47e31 | ||
|
98ebc9aec8 | ||
|
80799b7600 | ||
|
93cb97b304 | ||
|
131706e2bb | ||
|
ffcba436d7 | ||
|
d73ee207da | ||
|
c23c1feebc | ||
|
388827b56f | ||
|
2e8025a241 | ||
|
d6edb16ab1 | ||
|
4731f350d9 | ||
|
02e912a760 | ||
|
721b8022ab | ||
|
c3e77383ea |
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_size = 2
|
||||
indent_style = space
|
64
.eslintrc.js
64
.eslintrc.js
@@ -1,27 +1,35 @@
|
||||
const a11yOff = Object.keys(require('eslint-plugin-jsx-a11y').rules)
|
||||
.reduce((acc, rule) => { acc[`jsx-a11y/${rule}`] = 'off'; return acc }, {})
|
||||
const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
|
||||
(acc, rule) => {
|
||||
acc[`jsx-a11y/${rule}`] = "off";
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
extends: [
|
||||
"airbnb",
|
||||
"airbnb/hooks",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: "./",
|
||||
},
|
||||
plugins: ["@typescript-eslint", "import"],
|
||||
env: {
|
||||
browser: true,
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: ["@typescript-eslint", "import", "prettier"],
|
||||
rules: {
|
||||
"react/jsx-uses-react": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
@@ -36,11 +44,15 @@ module.exports = {
|
||||
"no-shadow": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
"no-restricted-syntax": "off",
|
||||
"import/no-unresolved": ["error", { ignore: ["^virtual:"] }],
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"consistent-return": "off",
|
||||
"no-continue": "off",
|
||||
"no-eval": "off",
|
||||
"no-await-in-loop": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{ extensions: [".js", ".tsx", ".jsx"] },
|
||||
@@ -53,6 +65,34 @@ module.exports = {
|
||||
tsx: "never",
|
||||
},
|
||||
],
|
||||
...a11yOff
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
groups: [
|
||||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
["sibling", "parent"],
|
||||
"index",
|
||||
"unknown",
|
||||
],
|
||||
"newlines-between": "always",
|
||||
alphabetize: {
|
||||
order: "asc",
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
"sort-imports": [
|
||||
"error",
|
||||
{
|
||||
ignoreCase: false,
|
||||
ignoreDeclarationSort: true,
|
||||
ignoreMemberSort: false,
|
||||
memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
|
||||
allowSeparatedGroups: true,
|
||||
},
|
||||
],
|
||||
...a11yOff,
|
||||
},
|
||||
};
|
||||
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
128
.github/CODE_OF_CONDUCT.md
vendored
Normal file
128
.github/CODE_OF_CONDUCT.md
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
codeofconduct@movie-web.app.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
112
.github/CONTRIBUTING.md
vendored
Normal file
112
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
# Contributing Guidelines for movie-web
|
||||
|
||||
Thank you for investing your time in contributing to our project! Your contribution will be reflected on [movie-web.app](https://movie-web.app).
|
||||
|
||||
Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectable.
|
||||
|
||||
## Contents
|
||||
- [New Contributor Guide](#new-contributor-guide)
|
||||
- [Requesting a feature or reporting a bug](#requesting-a-feature-or-reporting-a-bug)
|
||||
- [Discord Server](#discord-server)
|
||||
- [GitHub Issues](#github-issues)
|
||||
- [Before you start](#before-you-start)
|
||||
- [Contributing](#before-you-start)
|
||||
- [Recommended Development Environment](#recommended-development-environment)
|
||||
- [Tips](#tips)
|
||||
- [Language Contributions](#language-contributions)
|
||||
|
||||
## New contributor guide
|
||||
|
||||
To get an overview of the project, read the [README](README.md). Here are some resources to help you get started with open-source contributions:
|
||||
|
||||
- [Finding ways to contribute to open-source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github)
|
||||
- [Set up Git](https://docs.github.com/en/get-started/quickstart/set-up-git)
|
||||
- [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow)
|
||||
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
|
||||
|
||||
|
||||
## Requesting a feature or reporting a bug
|
||||
There are two places where to request features or report bugs:
|
||||
- GitHub Issues
|
||||
- The movie-web Discord server
|
||||
|
||||
### Discord Server
|
||||
If you do not have a GitHub account or want to discuss a feature or bug with us before making an issue, you can join our Discord server.
|
||||
|
||||
<a href="https://discord.movie-web.app"><img src="https://discord.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
|
||||
### GitHub Issues
|
||||
To make a GitHub issue for movie-web, please visit the [new issue page](https://github.com/movie-web/movie-web/issues/new/choose) where you can pick either the "Bug Report" or "Feature Request" template.
|
||||
|
||||
When filling out an issue template, please include as much detail as possible and any screenshots or console logs as appropriate.
|
||||
|
||||
After an issue is created, it will be assigned either the https://github.com/movie-web/movie-web/labels/bug or https://github.com/movie-web/movie-web/labels/feature label, along with https://github.com/movie-web/movie-web/labels/awaiting-approval. One of our maintainers will review your issue and, if it's accepted, will set the https://github.com/movie-web/movie-web/labels/approved label.
|
||||
|
||||
## Before you start!
|
||||
Before starting a contribution, please check your contribution is part of an open issue on [our issues page](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved).
|
||||
|
||||
GitHub issues are how we track our bugs and feature requests that will be implemented into movie-web - all contributions **must** have an issue and be approved by a maintainer before a pull request can be worked on.
|
||||
|
||||
If a pull request is opened before an issue is created and accepted, you may risk having your pull request rejected! Always check with us before starting work on a feature - we don't want to waste your time!
|
||||
|
||||
> **Note**
|
||||
> The exception to this are language contributions, which are discussed in [this section](#language-contributions)
|
||||
|
||||
Also, make sure that the issue you would like to work on has been given the https://github.com/movie-web/movie-web/labels/approved label by a maintainer. Otherwise, if we reject the issue, it means your work will have gone to waste!
|
||||
|
||||
## Contributing
|
||||
If you're here because you'd like to work on an issue, amazing! Thank you for even considering contributing to movie-web; it means a lot :heart:
|
||||
|
||||
Firstly, make sure you've read the [Before you start!](#before-you-start) section!
|
||||
|
||||
When you have found a GitHub issue you would like to work on, you can request to be assigned to the issue by commenting on the GitHub issue.
|
||||
|
||||
If you are assigned to an issue but can't complete it for whatever reason, no problem! Just let us know, and we will open up the issue to have someone else assigned.
|
||||
|
||||
### Recommended Development Environment
|
||||
Our recommended development environment to work on movie-web is:
|
||||
- [Visual Studio Code](https://code.visualstudio.com/)
|
||||
- [ESLint Extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
|
||||
- [EditorConfig Extension](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
|
||||
|
||||
When opening Visual Studio Code, you will be prompted to install our recommended extensions if they are not installed for you.
|
||||
|
||||
Our project is set up to enforce formatting and code style standards using ESLint.
|
||||
|
||||
### Tips
|
||||
Here are some tips to make sure that your pull requests are :pinched_fingers: first time:
|
||||
|
||||
- KISS - Keep It Simple Soldier! - Simple code makes readable and efficient code!
|
||||
- Follow standard best practices for TypeScript and React.
|
||||
- Keep as much as possible to the style of movie-web. Look around our codebase to familiarise yourself with how we do things!
|
||||
- Ensure to take note of the ESLint errors and warnings! **Do not ignore them!** They are there for a reason.
|
||||
- Test, test, test! Make sure you thoroughly test the features you are contributing.
|
||||
|
||||
### Language Contributions
|
||||
Language contributions help movie-web massively, allowing people worldwide to use our app!
|
||||
|
||||
Like most apps, our translations are stored in `.json` files. Each language string has a unique key (For example, `notFound.genericTitle`) that references a language string in the appropriate language file.
|
||||
|
||||
Each language file is called `translation.json` and is stored in the `src/setup/languages/<language code>/` folder. For example, the English language file is located at `src/setup/languages/en/translation.json`.
|
||||
|
||||
> **Warning**
|
||||
>
|
||||
> Before you start a translation, please:
|
||||
> - Check there isn't an existing GitHub [issue](https://github.com/movie-web/movie-web/issues) or [pull request](https://github.com/movie-web/movie-web/pulls) open for the language.
|
||||
> - Make sure we aren't in the middle of a new feature update. When releasing major versions, we only accept changes to translations once the new version is complete. Otherwise, the language files would need to be updated.
|
||||
>
|
||||
> Please speak to us before starting a language PR. We want to use your time effectively.
|
||||
|
||||
To make a translation:
|
||||
- Copy the `en` folder inside the `src/setup/languages` folder
|
||||
- Rename the copied folder to the 2-letter code for the country/language which is being translated.
|
||||
- [Click this link to see a list of codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). Use the codes in the `639-1` column!
|
||||
- For example, Arabic is `ar`
|
||||
- Edit the language strings inside the `translation.json` file
|
||||
- **Do not** edit the keys. Only edit the values.
|
||||
- e.g. in `"stopEditing": "Stop editing",` - only change the `Stop editing` part, not the `stopEditing` part.
|
||||
- In the `src/setup/i18n.ts` file:
|
||||
- Import your new translation file, e.g. `import ar from "./locales/ar/translation.json";`
|
||||
- Add your translation to the `locales` object (Look at other languages for an example)
|
||||
|
||||
Once you have completed your translation, please open a pull request. We do not accept partial translations, so please ensure every language string is translated to the intended language.
|
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Bug Report
|
||||
description: File a bug report
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
labels: ["bug", "awaiting-approval"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
|
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Feature request
|
||||
name: Feature Request
|
||||
description: Suggest a new feature
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
labels: ["feature", "awaiting-approval"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
|
13
.github/SECURITY.md
vendored
Normal file
13
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
The movie-web maintainers only support the latest version of movie-web published at https://movie-web.app.
|
||||
|
||||
Support is not provided for any forks or mirrors of movie-web.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
There are two ways you can contact the movie-web maintainers to report a vulnerability:
|
||||
- Email [security@movie-web.app](mailto:security@movie-web.app)
|
||||
- Report the vulnerability in the [movie-web Discord server](https://discord.movie-web.app)
|
6
.github/pull_request_template.md
vendored
Normal file
6
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
This pull request resolves #XXX
|
||||
|
||||
- [ ] I have read and agreed to the [code of conduct](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md).
|
||||
- [ ] I have read and complied with the [contributing guidelines](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md).
|
||||
- [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/movie-web/movie-web/projects).
|
||||
- [ ] I have tested all of my changes.
|
38
.github/workflows/deploying.yml
vendored
38
.github/workflows/deploying.yml
vendored
@@ -12,44 +12,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
run: yarn build
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: production-files
|
||||
path: ./dist
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: production-files
|
||||
path: ./dist
|
||||
|
||||
- name: Deploy to gh-pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./dist
|
||||
cname: movie.squeezebox.dev
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: build
|
||||
@@ -57,16 +39,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: production-files
|
||||
path: ./dist
|
||||
|
||||
- name: Zip files
|
||||
run: zip -r ./movie-web.zip ./dist
|
||||
run: cd dist && zip -r ../movie-web.zip .
|
||||
|
||||
- name: Get version
|
||||
id: package-version
|
||||
@@ -91,5 +73,5 @@ jobs:
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./movie-web.zip
|
||||
asset_name: movie-web.js
|
||||
asset_name: movie-web.zip
|
||||
asset_content_type: application/zip
|
||||
|
29
.github/workflows/linting.yml
vendored
29
.github/workflows/linting.yml
vendored
@@ -1,29 +0,0 @@
|
||||
name: Linting
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
linting:
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Run linters
|
||||
run: yarn lint:strict
|
49
.github/workflows/linting_testing.yml
vendored
Normal file
49
.github/workflows/linting_testing.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Linting and Testing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
linting:
|
||||
name: Run Linters
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Run ESLint
|
||||
run: yarn lint
|
||||
|
||||
building:
|
||||
name: Build project
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Build Project
|
||||
run: yarn build
|
30
.github/workflows/testing.yml
vendored
30
.github/workflows/testing.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: Testing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Build project
|
||||
run: yarn build
|
8
.gitignore
vendored
8
.gitignore
vendored
@@ -9,7 +9,8 @@ node_modules
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
dev-dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -22,4 +23,7 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
package-lock.json
|
||||
package-lock.json
|
||||
|
||||
# config
|
||||
.env
|
||||
|
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"editorconfig.editorconfig"
|
||||
]
|
||||
}
|
14
.vscode/settings.json
vendored
14
.vscode/settings.json
vendored
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"files.eol": "\n",
|
||||
"editor.detectIndentation": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"eslint.format.enable": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
|
46
README.md
46
README.md
@@ -1,16 +1,16 @@
|
||||
<h1>movie-web</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/JamesHawkinss/movie-web/deploying.yml?branch=master&style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/JamesHawkinss/movie-web?style=flat-square"></a><br/>
|
||||
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/movie-web/movie-web/deploying.yml?branch=master&style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/movie-web/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/movie-web/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/movie-web/movie-web?style=flat-square"></a><br/>
|
||||
<a href="https://discord.movie-web.app"><img src="https://discord.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
</p>
|
||||
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie-web.app](https://movie-web.app)**.
|
||||
|
||||
This service works by displaying video files from third-party providers inside an intuitive and aesthic user interface.
|
||||
This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.
|
||||
|
||||
Features include:
|
||||
|
||||
@@ -25,26 +25,32 @@ Features include:
|
||||
- No BS: just a search bar and a video player
|
||||
- No responsibility on the hoster, no databases or api's hosted by us, just a static site
|
||||
|
||||
## Self-hosting / running locally
|
||||
## Self-hosting
|
||||
|
||||
A simple guide has been written to assist in hosting your own instance of movie-web.
|
||||
|
||||
Check it out here: [https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md](https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md)
|
||||
|
||||
## Running locally for development
|
||||
|
||||
To run this project locally for contributing or testing, run the following commands:
|
||||
<h5><b>note: must use yarn to install packages and run NodeJS 16</b></h5>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/JamesHawkinss/movie-web
|
||||
git clone https://github.com/movie-web/movie-web
|
||||
cd movie-web
|
||||
yarn install
|
||||
yarn start
|
||||
yarn dev
|
||||
```
|
||||
|
||||
To build production files, simply run `yarn build`.
|
||||
|
||||
You can also deploy the Cloudflare Worker (in worker.js) and update the proxy URL constant in `/src/mw-constants.ts`.
|
||||
You'll need to deploy a cloudflare service worker as well. Check the [selfhosting guide](https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md) on how to run the service worker. Afterwards you can make a `.env` file and put in the URL. (see `example.env` for an example)
|
||||
|
||||
<h2>Contributing - <a href="https://github.com/JamesHawkinss/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/JamesHawkinss/movie-web?style=flat-square"></a></h2>
|
||||
<h2>Contributing - <a href="https://github.com/movie-web/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/movie-web/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/movie-web/movie-web?style=flat-square"></a></h2>
|
||||
|
||||
Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
|
||||
Check out [this project's issues](https://github.com/movie-web/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
|
||||
|
||||
**All pull requests must be merged into the `dev` branch. it will then be deployed with the next version**
|
||||
|
||||
@@ -52,7 +58,11 @@ Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/iss
|
||||
|
||||
This project would not be possible without our amazing contributors and the community.
|
||||
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/movie-web/movie-web?style=flat-square"></a>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/JamesHawkinss.png?size=20" width="20"><span><a href="https://github.com/JamesHawkinss">@JamesHawkinss</a> for original concept.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/JipFr.png?size=20" width="20"><span><a href="https://github.com/JipFr">@JipFr</a> for initial work on <a href="https://github.com/JipFr/movie-cli">movie-cli</a>.</span>
|
||||
@@ -62,14 +72,10 @@ This project would not be possible without our amazing contributors and the comm
|
||||
<img src="https://github.com/mrjvs.png?size=20" width="20"><span><a href="https://github.com/mrjvs">@mrjvs</a> for leading the port to React, and for the beautiful design.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/JoshHeng.png?size=20" width="20"><span><a href="https://github.com/JoshHeng">@JoshHeng</a> for the Cloudflare CORS Proxy and URL routing.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/binaryoverload.png?size=20" width="20"><span><a href="https://github.com/binaryoverload">@binaryoverload</a> for help rewriting the application into React and making the README look ✨ pretty ✨.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/lem6ns.png?size=20" width="20"><span><a href="https://github.com/lem6ns">@lem6ns</a> for helpfully implementing extra scrapers.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
41
SELFHOSTING.md
Normal file
41
SELFHOSTING.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Self-hosting tutorial
|
||||
|
||||
> **Note**
|
||||
> We **do not** provide support on how to self-host. If you can't figure it out then tough luck. Please do not make GitHub issues or ask in our Discord server for support on how to self-host.
|
||||
|
||||
So you would like to self-host. This app is made of two parts:
|
||||
- The proxy
|
||||
- The client
|
||||
|
||||
## Hosting the proxy
|
||||
|
||||
The proxy is made as a Cloudflare worker. Cloudflare has a generous free plan, so you don't need to pay anything unless you get hundreds of users.
|
||||
|
||||
1. Create a Cloudflare account at [https://dash.cloudflare.com](https://dash.cloudflare.com).
|
||||
2. Navigate to `Workers`.
|
||||
3. If it asks you, choose a subdomain.
|
||||
4. If it asks for a workers plan, press "Continue with free".
|
||||
5. Create a new service with a name of your choice. Must be type `HTTP handler`.
|
||||
6. On the service page, Click `Quick edit`.
|
||||
7. Remove the template code in the quick edit window.
|
||||
7. Download the `worker.js` file from the latest release of the proxy: [https://github.com/movie-web/simple-proxy/releases/latest](https://github.com/movie-web/simple-proxy/releases/latest).
|
||||
8. Open the downloaded `worker.js` file in Notepad, Visual Studio Code or similar.
|
||||
9. Copy the text contents of the `worker.js` file.
|
||||
10. Paste the text contents into the edit screen of the Cloudflare service worker.
|
||||
11. Click `Save and deploy` and confirm.
|
||||
|
||||
Your proxy is now hosted on Cloudflare. Note the url of your worker as you will need it later.
|
||||
|
||||
## Hosting the client
|
||||
|
||||
1. Download the file `movie-web.zip` from the latest release: [https://github.com/movie-web/movie-web/releases/latest](https://github.com/movie-web/movie-web/releases/latest).
|
||||
2. Extract the zip file so you can edit the files.
|
||||
3. Open `config.js` in Notepad, Visual Studio Code or similar.
|
||||
4. Put your Cloudflare proxy URL in-between the double quotes of `VITE_CORS_PROXY_URL: ""`. Make sure to not have a slash at the end of your URL.
|
||||
|
||||
Example (THIS IS AN EXAMPLE, IT WON'T WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev"`
|
||||
5. Put your TMDB read access token inside the quotes of `VITE_TMDB_READ_API_KEY: ""`. You can generate it for free at [https://www.themoviedb.org/settings/api](https://www.themoviedb.org/settings/api).
|
||||
6. Save the file
|
||||
|
||||
Your client has now been prepared, you can now host it with any static website hosting (Common ones include [GitHub Pages](https://pages.github.com/), [Netlify](https://www.netlify.com/) and [Vercel](https://vercel.com/) but any will work!).
|
||||
It doesn't require PHP, it's just a standard static page.
|
3
example.env
Normal file
3
example.env
Normal file
@@ -0,0 +1,3 @@
|
||||
# make sure the cors proxy url does NOT have a slash at the end
|
||||
VITE_CORS_PROXY_URL=...
|
||||
VITE_TMDB_READ_API_KEY=...
|
37
index.html
37
index.html
@@ -1,44 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-44YVXRL61C"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "G-44YVXRL61C");
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Because watching movies legally is boring"
|
||||
content="The place for your favourite movies & shows"
|
||||
/>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#E880C5" />
|
||||
<meta name="msapplication-TileColor" content="#E880C5" />
|
||||
<meta name="theme-color" content="#E880C5" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
|
||||
<meta name="msapplication-TileColor" content="#120f1d" />
|
||||
<meta name="theme-color" content="#120f1d" />
|
||||
|
||||
<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"
|
||||
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@8b821f445b83d51ef1b8f42c99b7346f6b47dce5/out.js"></script>
|
||||
|
||||
<!-- prevent darkreader extension from messing with our already dark site -->
|
||||
<meta name="darkreader-lock" />
|
||||
|
||||
<!-- disabling referrer can fix some provider problems -->
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
||||
<title>movie-web</title>
|
||||
</head>
|
||||
<body>
|
||||
|
62
package.json
62
package.json
@@ -1,37 +1,53 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"version": "2.1.0",
|
||||
"version": "3.1.4",
|
||||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"homepage": "https://movie-web.app",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@react-spring/web": "^9.7.1",
|
||||
"@sentry/integrations": "^7.49.0",
|
||||
"@sentry/react": "^7.49.0",
|
||||
"@use-gesture/react": "^10.2.24",
|
||||
"core-js": "^3.29.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"dompurify": "^3.0.1",
|
||||
"fscreen": "^1.2.0",
|
||||
"fuse.js": "^6.4.6",
|
||||
"hls.js": "^1.0.7",
|
||||
"i18next": "^22.4.5",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"i18next-http-backend": "^2.1.0",
|
||||
"json5": "^2.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"ofetch": "^1.0.0",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-ga4": "^2.0.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-i18next": "^12.1.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use": "^17.4.0",
|
||||
"subsrt-ts": "^2.1.1",
|
||||
"unpacker": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .tsx,.ts src",
|
||||
"lint:strict": "eslint --ext .tsx,.ts --max-warnings 0 src"
|
||||
"lint:fix": "eslint --fix --ext .tsx,.ts src",
|
||||
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
"defaults",
|
||||
"chrome > 90"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
@@ -40,30 +56,50 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-typescript": "^7.21.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/chromecast-caf-sender": "^1.0.5",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
"@types/fscreen": "^1.0.1",
|
||||
"@types/lodash.throttle": "^4.1.7",
|
||||
"@types/node": "^17.0.15",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-router": "^5.1.18",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-stickynode": "^4.0.0",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.13.0",
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-import-resolver-typescript": "^2.5.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"jsdom": "^21.1.0",
|
||||
"postcss": "^8.4.20",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
"tailwind-scrollbar": "^2.0.1",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^4.0.1"
|
||||
"vite": "^4.0.1",
|
||||
"vite-plugin-checker": "^0.5.6",
|
||||
"vite-plugin-package-version": "^1.0.2",
|
||||
"vite-plugin-pwa": "^0.14.4",
|
||||
"vitest": "^0.28.5",
|
||||
"workbox-build": "^6.5.4",
|
||||
"workbox-window": "^6.5.4"
|
||||
}
|
||||
}
|
||||
|
4
prettierrc.js
Normal file
4
prettierrc.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
trailingComma: "all",
|
||||
singleQuote: true
|
||||
};
|
5
public/_headers
Normal file
5
public/_headers
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: origin-when-cross-origin
|
1
public/_redirects
Normal file
1
public/_redirects
Normal file
@@ -0,0 +1 @@
|
||||
/* /index.html 200
|
@@ -3,7 +3,7 @@
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
<TileColor>#120f1d</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
5
public/config.js
Normal file
5
public/config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
window.__CONFIG__ = {
|
||||
// url must NOT end with a slash
|
||||
VITE_CORS_PROXY_URL: "",
|
||||
VITE_TMDB_READ_API_KEY: ""
|
||||
};
|
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading": "Fetching your favourite shows...",
|
||||
"providersFailed": "{{fails}}/{{total}} providers failed!",
|
||||
"allResults": "That's all we have!",
|
||||
"noResults": "We couldn't find anything!",
|
||||
"allFailed": "All providers have failed!",
|
||||
"headingTitle": "Search results",
|
||||
"headingLink": "Back to home",
|
||||
"bookmarks": "Bookmarks",
|
||||
"continueWatching": "Continue Watching",
|
||||
"tagline": "Because watching legally is boring",
|
||||
"title": "What do you want to watch?",
|
||||
"placeholder": "What do you want to watch?"
|
||||
},
|
||||
"media": {
|
||||
"invalidUrl": "Your URL may be invalid",
|
||||
"arrowText": "Go back"
|
||||
},
|
||||
"seasons": {
|
||||
"season": "Season {{season}}",
|
||||
"failed": "Failed to get season data"
|
||||
},
|
||||
"notFound": {
|
||||
"backArrow": "Back to home",
|
||||
"media": {
|
||||
"title": "Couldn't find that media",
|
||||
"description": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL"
|
||||
},
|
||||
"provider": {
|
||||
"title": "This provider has been disabled",
|
||||
"description": "We had issues with the provider or it was too unstable to use, so we had to disable it."
|
||||
},
|
||||
"page": {
|
||||
"title": "Couldn't find that page",
|
||||
"description": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Movie",
|
||||
"series": "Series",
|
||||
"Search": "Search"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"text": "The app encountered an error and wasn't able to recover, please report it to the"
|
||||
}
|
||||
}
|
1
public/ping.txt
Normal file
1
public/ping.txt
Normal file
@@ -0,0 +1 @@
|
||||
pong
|
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"short_name": "movie-web",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#E880C5",
|
||||
"background_color": "#16171D",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
28
src/App.tsx
28
src/App.tsx
@@ -1,28 +0,0 @@
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { MWMediaType } from "@/providers";
|
||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||
import { WatchedContextProvider } from "@/state/watched";
|
||||
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
||||
import "./index.css";
|
||||
import { MediaView } from "./views/MediaView";
|
||||
import { SearchView } from "./views/SearchView";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<WatchedContextProvider>
|
||||
<BookmarkContextProvider>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
||||
</Route>
|
||||
<Route exact path="/media/movie/:media" component={MediaView} />
|
||||
<Route exact path="/media/series/:media" component={MediaView} />
|
||||
<Route exact path="/search/:type/:query?" component={SearchView} />
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
</BookmarkContextProvider>
|
||||
</WatchedContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
52
src/__tests__/providers/providers.test.ts
Normal file
52
src/__tests__/providers/providers.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import "@/backend";
|
||||
import { testData } from "@/__tests__/providers/testdata";
|
||||
import { getProviders } from "@/backend/helpers/register";
|
||||
import { runProvider } from "@/backend/helpers/run";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
describe("providers", () => {
|
||||
const providers = getProviders();
|
||||
|
||||
it("have at least one provider", ({ expect }) => {
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const provider of providers) {
|
||||
describe(provider.displayName, () => {
|
||||
it("must have at least one type", async ({ expect }) => {
|
||||
expect(provider.type.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
if (provider.type.includes(MWMediaType.MOVIE)) {
|
||||
it("must work with movies", async ({ expect }) => {
|
||||
const movie = testData.find((v) => v.meta.type === MWMediaType.MOVIE);
|
||||
if (!movie) throw new Error("no movie to test with");
|
||||
const results = await runProvider(provider, {
|
||||
media: movie,
|
||||
progress() {},
|
||||
type: movie.meta.type as any,
|
||||
});
|
||||
expect(results).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
if (provider.type.includes(MWMediaType.SERIES)) {
|
||||
it("must work with series", async ({ expect }) => {
|
||||
const show = testData.find((v) => v.meta.type === MWMediaType.SERIES);
|
||||
if (show?.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("no show to test with");
|
||||
const results = await runProvider(provider, {
|
||||
media: show,
|
||||
progress() {},
|
||||
type: show.meta.type as MWMediaType.SERIES,
|
||||
episode: show.meta.seasonData.episodes[0].id,
|
||||
season: show.meta.seasons[0].id,
|
||||
});
|
||||
expect(results).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
45
src/__tests__/providers/testdata.ts
Normal file
45
src/__tests__/providers/testdata.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
export const testData: DetailedMeta[] = [
|
||||
{
|
||||
imdbId: "tt10954562",
|
||||
tmdbId: "572716",
|
||||
meta: {
|
||||
id: "439596",
|
||||
title: "Hamilton",
|
||||
type: MWMediaType.MOVIE,
|
||||
year: "2020",
|
||||
seasons: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
imdbId: "tt11126994",
|
||||
tmdbId: "94605",
|
||||
meta: {
|
||||
id: "222333",
|
||||
title: "Arcane",
|
||||
type: MWMediaType.SERIES,
|
||||
year: "2021",
|
||||
seasons: [
|
||||
{
|
||||
id: "230301",
|
||||
number: 1,
|
||||
title: "Season 1",
|
||||
},
|
||||
],
|
||||
seasonData: {
|
||||
id: "230301",
|
||||
number: 1,
|
||||
title: "Season 1",
|
||||
episodes: [
|
||||
{
|
||||
id: "4243445",
|
||||
number: 1,
|
||||
title: "Welcome to the Playground",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
152
src/__tests__/subtitles/subtitles.test.ts
Normal file
152
src/__tests__/subtitles/subtitles.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
getMWCaptionTypeFromUrl,
|
||||
isSupportedSubtitle,
|
||||
parseSubtitles,
|
||||
} from "@/backend/helpers/captions";
|
||||
import { MWCaptionType } from "@/backend/helpers/streams";
|
||||
|
||||
import {
|
||||
ass,
|
||||
multilineSubtitlesTestVtt,
|
||||
srt,
|
||||
visibleSubtitlesTestVtt,
|
||||
vtt,
|
||||
} from "./testdata";
|
||||
|
||||
describe("subtitles", () => {
|
||||
it("should return true if given url ends with a known subtitle type", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(isSupportedSubtitle("https://example.com/test.srt")).toBe(true);
|
||||
expect(isSupportedSubtitle("https://example.com/test.vtt")).toBe(true);
|
||||
expect(isSupportedSubtitle("https://example.com/test.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return corresponding MWCaptionType", ({ expect }) => {
|
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.srt")).toBe(
|
||||
MWCaptionType.SRT
|
||||
);
|
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.vtt")).toBe(
|
||||
MWCaptionType.VTT
|
||||
);
|
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.txt")).toBe(
|
||||
MWCaptionType.UNKNOWN
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when empty text is given", ({ expect }) => {
|
||||
expect(() => parseSubtitles("")).toThrow("Given text is empty");
|
||||
});
|
||||
|
||||
it("should parse srt", ({ expect }) => {
|
||||
const parsed = parseSubtitles(srt);
|
||||
const parsedSrt = [
|
||||
{
|
||||
type: "caption",
|
||||
index: 1,
|
||||
start: 0,
|
||||
end: 0,
|
||||
duration: 0,
|
||||
content: "Test",
|
||||
text: "Test",
|
||||
},
|
||||
{
|
||||
type: "caption",
|
||||
index: 2,
|
||||
start: 0,
|
||||
end: 0,
|
||||
duration: 0,
|
||||
content: "Test",
|
||||
text: "Test",
|
||||
},
|
||||
];
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(parsed).toEqual(parsedSrt);
|
||||
});
|
||||
|
||||
it("should parse vtt", ({ expect }) => {
|
||||
const parsed = parseSubtitles(vtt);
|
||||
const parsedVtt = [
|
||||
{
|
||||
type: "caption",
|
||||
index: 1,
|
||||
start: 0,
|
||||
end: 4000,
|
||||
duration: 4000,
|
||||
content: "Where did he go?",
|
||||
text: "Where did he go?",
|
||||
},
|
||||
{
|
||||
type: "caption",
|
||||
index: 2,
|
||||
start: 3000,
|
||||
end: 6500,
|
||||
duration: 3500,
|
||||
content: "I think he went down this lane.",
|
||||
text: "I think he went down this lane.",
|
||||
},
|
||||
{
|
||||
type: "caption",
|
||||
index: 3,
|
||||
start: 4000,
|
||||
end: 6500,
|
||||
duration: 2500,
|
||||
content: "What are you waiting for?",
|
||||
text: "What are you waiting for?",
|
||||
},
|
||||
];
|
||||
expect(parsed).toHaveLength(3);
|
||||
expect(parsed).toEqual(parsedVtt);
|
||||
});
|
||||
|
||||
it("should parse ass", ({ expect }) => {
|
||||
const parsed = parseSubtitles(ass);
|
||||
expect(parsed).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should delay subtitles when given a delay", ({ expect }) => {
|
||||
const videoTime = 11;
|
||||
let delayedSeconds = 0;
|
||||
const parsed = parseSubtitles(visibleSubtitlesTestVtt);
|
||||
const isVisible = (start: number, end: number, delay: number): boolean => {
|
||||
const delayedStart = start / 1000 + delay;
|
||||
const delayedEnd = end / 1000 + delay;
|
||||
return (
|
||||
Math.max(0, delayedStart) <= videoTime &&
|
||||
Math.max(0, delayedEnd) >= videoTime
|
||||
);
|
||||
};
|
||||
const visibleSubtitles = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(visibleSubtitles).toHaveLength(1);
|
||||
|
||||
delayedSeconds = 10;
|
||||
const delayedVisibleSubtitles = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(delayedVisibleSubtitles).toHaveLength(1);
|
||||
|
||||
delayedSeconds = -10;
|
||||
const delayedVisibleSubtitles2 = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(delayedVisibleSubtitles2).toHaveLength(1);
|
||||
|
||||
delayedSeconds = -20;
|
||||
const delayedVisibleSubtitles3 = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(delayedVisibleSubtitles3).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should parse multiline captions", ({ expect }) => {
|
||||
const parsed = parseSubtitles(multilineSubtitlesTestVtt);
|
||||
|
||||
expect(parsed[0].text).toBe(`- Test 1\n- Test 2\n- Test 3`);
|
||||
expect(parsed[1].text).toBe(`- Test 4`);
|
||||
expect(parsed[2].text).toBe(`- Test 6`);
|
||||
});
|
||||
});
|
68
src/__tests__/subtitles/testdata.ts
Normal file
68
src/__tests__/subtitles/testdata.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
const srt = `
|
||||
1
|
||||
00:00:00,000 --> 00:00:00,000
|
||||
Test
|
||||
|
||||
2
|
||||
00:00:00,000 --> 00:00:00,000
|
||||
Test
|
||||
`;
|
||||
const vtt = `
|
||||
WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:04.000 position:10%,line-left align:left size:35%
|
||||
Where did he go?
|
||||
|
||||
00:00:03.000 --> 00:00:06.500 position:90% align:right size:35%
|
||||
I think he went down this lane.
|
||||
|
||||
00:00:04.000 --> 00:00:06.500 position:45%,line-right align:center size:35%
|
||||
What are you waiting for?
|
||||
`;
|
||||
const ass = `[Script Info]
|
||||
; Generated by Ebby.co
|
||||
Title:
|
||||
Original Script:
|
||||
ScriptType: v4.00+
|
||||
Collisions: Normal
|
||||
PlayResX: 384
|
||||
PlayResY: 288
|
||||
PlayDepth: 0
|
||||
Timer: 100.0
|
||||
WrapStyle: 0
|
||||
|
||||
[v4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default, Arial, 16, &H00FFFFFF, &H00000000, &H00000000, &H00000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 1, 0, 2, 15, 15, 15, 0
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:10.00,0:00:20.00,Default,,0000,0000,0000,,This is the first subtitle.
|
||||
Dialogue: 0,0:00:30.00,0:00:34.00,Default,,0000,0000,0000,,This is the second.
|
||||
Dialogue: 0,0:00:34.00,0:00:35.00,Default,,0000,0000,0000,,Third`;
|
||||
|
||||
const visibleSubtitlesTestVtt = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:10.000 position:10%,line-left align:left size:35%
|
||||
Test 1
|
||||
|
||||
00:00:10.000 --> 00:00:20.000 position:90% align:right size:35%
|
||||
Test 2
|
||||
|
||||
00:00:20.000 --> 00:00:31.000 position:45%,line-right align:center size:35%
|
||||
Test 3
|
||||
`;
|
||||
|
||||
const multilineSubtitlesTestVtt = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:10.000
|
||||
- Test 1\n- Test 2\n- Test 3
|
||||
|
||||
00:00:10.000 --> 00:00:20.000
|
||||
- Test 4
|
||||
|
||||
00:00:20.000 --> 00:00:31.000
|
||||
- Test 6
|
||||
`;
|
||||
|
||||
export { vtt, srt, ass, visibleSubtitlesTestVtt, multilineSubtitlesTestVtt };
|
1
src/backend/embeds/.gitkeep
Normal file
1
src/backend/embeds/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
embed scrapers go here
|
32
src/backend/embeds/mp4upload.ts
Normal file
32
src/backend/embeds/mp4upload.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
registerEmbedScraper({
|
||||
id: "mp4upload",
|
||||
displayName: "mp4upload",
|
||||
for: MWEmbedType.MP4UPLOAD,
|
||||
rank: 170,
|
||||
async getStream({ url }) {
|
||||
const embed = await proxiedFetch<any>(url);
|
||||
|
||||
const playerSrcRegex =
|
||||
/(?<=player\.src\()\s*{\s*type:\s*"[^"]+",\s*src:\s*"([^"]+)"\s*}\s*(?=\);)/s;
|
||||
|
||||
const playerSrc = embed.match(playerSrcRegex);
|
||||
|
||||
const streamUrl = playerSrc[1];
|
||||
|
||||
if (!streamUrl) throw new Error("Stream url not found");
|
||||
|
||||
return {
|
||||
embedId: MWEmbedType.MP4UPLOAD,
|
||||
streamUrl,
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
captions: [],
|
||||
type: MWStreamType.MP4,
|
||||
};
|
||||
},
|
||||
});
|
20
src/backend/embeds/playm4u.ts
Normal file
20
src/backend/embeds/playm4u.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
|
||||
registerEmbedScraper({
|
||||
id: "playm4u",
|
||||
displayName: "playm4u",
|
||||
for: MWEmbedType.PLAYM4U,
|
||||
rank: 0,
|
||||
async getStream() {
|
||||
// throw new Error("Oh well 2")
|
||||
return {
|
||||
embedId: "",
|
||||
streamUrl: "",
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
captions: [],
|
||||
type: MWStreamType.MP4,
|
||||
};
|
||||
},
|
||||
});
|
66
src/backend/embeds/streamm4u.ts
Normal file
66
src/backend/embeds/streamm4u.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWEmbedStream,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
|
||||
const HOST = "streamm4u.club";
|
||||
const URL_BASE = `https://${HOST}`;
|
||||
const URL_API = `${URL_BASE}/api`;
|
||||
const URL_API_SOURCE = `${URL_API}/source`;
|
||||
|
||||
async function scrape(embed: string) {
|
||||
const sources: MWEmbedStream[] = [];
|
||||
|
||||
const embedID = embed.split("/").pop();
|
||||
|
||||
console.log(`${URL_API_SOURCE}/${embedID}`);
|
||||
const json = await proxiedFetch<any>(`${URL_API_SOURCE}/${embedID}`, {
|
||||
method: "POST",
|
||||
body: `r=&d=${HOST}`,
|
||||
});
|
||||
|
||||
if (json.success) {
|
||||
const streams = json.data;
|
||||
|
||||
for (const stream of streams) {
|
||||
sources.push({
|
||||
embedId: "",
|
||||
streamUrl: stream.file as string,
|
||||
quality: stream.label as MWStreamQuality,
|
||||
type: stream.type as MWStreamType,
|
||||
captions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
// TODO check out 403 / 404 on successfully returned video stream URLs
|
||||
registerEmbedScraper({
|
||||
id: "streamm4u",
|
||||
displayName: "streamm4u",
|
||||
for: MWEmbedType.STREAMM4U,
|
||||
rank: 100,
|
||||
async getStream({ progress, url }) {
|
||||
// const scrapingThreads = [];
|
||||
// const streams = [];
|
||||
|
||||
const sources = (await scrape(url)).sort(
|
||||
(a, b) =>
|
||||
Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", ""))
|
||||
);
|
||||
// const preferredSourceIndex = 0;
|
||||
const preferredSource = sources[0];
|
||||
|
||||
if (!preferredSource) throw new Error("No source found");
|
||||
|
||||
progress(100);
|
||||
|
||||
return preferredSource;
|
||||
},
|
||||
});
|
211
src/backend/embeds/streamsb.ts
Normal file
211
src/backend/embeds/streamsb.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import Base64 from "crypto-js/enc-base64";
|
||||
import Utf8 from "crypto-js/enc-utf8";
|
||||
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
|
||||
const qualityOrder = [
|
||||
MWStreamQuality.Q1080P,
|
||||
MWStreamQuality.Q720P,
|
||||
MWStreamQuality.Q480P,
|
||||
MWStreamQuality.Q360P,
|
||||
];
|
||||
|
||||
async function fetchCaptchaToken(domain: string, recaptchaKey: string) {
|
||||
const domainHash = Base64.stringify(Utf8.parse(domain)).replace(/=/g, ".");
|
||||
|
||||
const recaptchaRender = await proxiedFetch<any>(
|
||||
`https://www.google.com/recaptcha/api.js?render=${recaptchaKey}`
|
||||
);
|
||||
|
||||
const vToken = recaptchaRender.substring(
|
||||
recaptchaRender.indexOf("/releases/") + 10,
|
||||
recaptchaRender.indexOf("/recaptcha__en.js")
|
||||
);
|
||||
|
||||
const recaptchaAnchor = await proxiedFetch<any>(
|
||||
`https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=flicklax&k=${recaptchaKey}&co=${domainHash}&v=${vToken}`
|
||||
);
|
||||
|
||||
const cToken = new DOMParser()
|
||||
.parseFromString(recaptchaAnchor, "text/html")
|
||||
.getElementById("recaptcha-token")
|
||||
?.getAttribute("value");
|
||||
|
||||
if (!cToken) throw new Error("Unable to find cToken");
|
||||
|
||||
const payload = {
|
||||
v: vToken,
|
||||
reason: "q",
|
||||
k: recaptchaKey,
|
||||
c: cToken,
|
||||
sa: "",
|
||||
co: domain,
|
||||
};
|
||||
|
||||
const tokenData = await proxiedFetch<string>(
|
||||
`https://www.google.com/recaptcha/api2/reload?${new URLSearchParams(
|
||||
payload
|
||||
).toString()}`,
|
||||
{
|
||||
headers: { referer: "https://www.google.com/recaptcha/api2/" },
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const token = tokenData.match('rresp","(.+?)"');
|
||||
return token ? token[1] : null;
|
||||
}
|
||||
|
||||
registerEmbedScraper({
|
||||
id: "streamsb",
|
||||
displayName: "StreamSB",
|
||||
for: MWEmbedType.STREAMSB,
|
||||
rank: 150,
|
||||
async getStream({ url, progress }) {
|
||||
/* Url variations
|
||||
- domain.com/{id}?.html
|
||||
- domain.com/{id}
|
||||
- domain.com/embed-{id}
|
||||
- domain.com/d/{id}
|
||||
- domain.com/e/{id}
|
||||
- domain.com/e/{id}-embed
|
||||
*/
|
||||
const streamsbUrl = url
|
||||
.replace(".html", "")
|
||||
.replace("embed-", "")
|
||||
.replace("e/", "")
|
||||
.replace("d/", "");
|
||||
|
||||
const parsedUrl = new URL(streamsbUrl);
|
||||
const base = await proxiedFetch<any>(
|
||||
`${parsedUrl.origin}/d${parsedUrl.pathname}`
|
||||
);
|
||||
|
||||
progress(20);
|
||||
|
||||
// Parse captions from url
|
||||
const captionUrl = parsedUrl.searchParams.get("caption_1");
|
||||
const captionLang = parsedUrl.searchParams.get("sub_1");
|
||||
|
||||
const basePage = new DOMParser().parseFromString(base, "text/html");
|
||||
|
||||
const downloadVideoFunctions = basePage.querySelectorAll(
|
||||
"[onclick^=download_video]"
|
||||
);
|
||||
|
||||
let dlDetails = [];
|
||||
for (const func of downloadVideoFunctions) {
|
||||
const funcContents = func.getAttribute("onclick");
|
||||
const regExpFunc = /download_video\('(.+?)','(.+?)','(.+?)'\)/;
|
||||
const matchesFunc = regExpFunc.exec(funcContents ?? "");
|
||||
if (matchesFunc !== null) {
|
||||
const quality = func.querySelector("span")?.textContent;
|
||||
const regExpQuality = /(.+?) \((.+?)\)/;
|
||||
const matchesQuality = regExpQuality.exec(quality ?? "");
|
||||
if (matchesQuality !== null) {
|
||||
dlDetails.push({
|
||||
parameters: [matchesFunc[1], matchesFunc[2], matchesFunc[3]],
|
||||
quality: {
|
||||
label: matchesQuality[1].trim(),
|
||||
size: matchesQuality[2],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dlDetails = dlDetails.sort((a, b) => {
|
||||
const aQuality = qualityOrder.indexOf(a.quality.label as MWStreamQuality);
|
||||
const bQuality = qualityOrder.indexOf(b.quality.label as MWStreamQuality);
|
||||
return aQuality - bQuality;
|
||||
});
|
||||
|
||||
progress(40);
|
||||
|
||||
let dls = await Promise.all(
|
||||
dlDetails.map(async (dl) => {
|
||||
const getDownload = await proxiedFetch<any>(
|
||||
`/dl?op=download_orig&id=${dl.parameters[0]}&mode=${dl.parameters[1]}&hash=${dl.parameters[2]}`,
|
||||
{
|
||||
baseURL: parsedUrl.origin,
|
||||
}
|
||||
);
|
||||
|
||||
const downloadPage = new DOMParser().parseFromString(
|
||||
getDownload,
|
||||
"text/html"
|
||||
);
|
||||
|
||||
const recaptchaKey = downloadPage
|
||||
.querySelector(".g-recaptcha")
|
||||
?.getAttribute("data-sitekey");
|
||||
if (!recaptchaKey) throw new Error("Unable to get captcha key");
|
||||
|
||||
const captchaToken = await fetchCaptchaToken(
|
||||
parsedUrl.origin,
|
||||
recaptchaKey
|
||||
);
|
||||
if (!captchaToken) throw new Error("Unable to get captcha token");
|
||||
|
||||
const dlForm = new FormData();
|
||||
dlForm.append("op", "download_orig");
|
||||
dlForm.append("id", dl.parameters[0]);
|
||||
dlForm.append("mode", dl.parameters[1]);
|
||||
dlForm.append("hash", dl.parameters[2]);
|
||||
dlForm.append("g-recaptcha-response", captchaToken);
|
||||
|
||||
const download = await proxiedFetch<any>(
|
||||
`/dl?op=download_orig&id=${dl.parameters[0]}&mode=${dl.parameters[1]}&hash=${dl.parameters[2]}`,
|
||||
{
|
||||
baseURL: parsedUrl.origin,
|
||||
method: "POST",
|
||||
body: dlForm,
|
||||
}
|
||||
);
|
||||
|
||||
const dlLink = new DOMParser()
|
||||
.parseFromString(download, "text/html")
|
||||
.querySelector(".btn.btn-light.btn-lg")
|
||||
?.getAttribute("href");
|
||||
|
||||
return {
|
||||
quality: dl.quality.label as MWStreamQuality,
|
||||
url: dlLink,
|
||||
size: dl.quality.size,
|
||||
captions:
|
||||
captionUrl && captionLang
|
||||
? [
|
||||
{
|
||||
url: captionUrl,
|
||||
langIso: captionLang,
|
||||
type: MWCaptionType.VTT,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
})
|
||||
);
|
||||
dls = dls.filter((d) => !!d.url);
|
||||
|
||||
progress(60);
|
||||
|
||||
// TODO: Quality selection for embed scrapers
|
||||
const dl = dls[0];
|
||||
if (!dl.url) throw new Error("No stream url found");
|
||||
|
||||
return {
|
||||
embedId: MWEmbedType.STREAMSB,
|
||||
streamUrl: dl.url,
|
||||
quality: dl.quality,
|
||||
captions: dl.captions,
|
||||
type: MWStreamType.MP4,
|
||||
};
|
||||
},
|
||||
});
|
93
src/backend/embeds/upcloud.ts
Normal file
93
src/backend/embeds/upcloud.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { AES, enc } from "crypto-js";
|
||||
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
interface StreamRes {
|
||||
server: number;
|
||||
sources: string;
|
||||
tracks: {
|
||||
file: string;
|
||||
kind: "captions" | "thumbnails";
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
function isJSON(json: string) {
|
||||
try {
|
||||
JSON.parse(json);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
registerEmbedScraper({
|
||||
id: "upcloud",
|
||||
displayName: "UpCloud",
|
||||
for: MWEmbedType.UPCLOUD,
|
||||
rank: 200,
|
||||
async getStream({ url }) {
|
||||
// Example url: https://dokicloud.one/embed-4/{id}?z=
|
||||
const parsedUrl = new URL(url.replace("embed-5", "embed-4"));
|
||||
|
||||
const dataPath = parsedUrl.pathname.split("/");
|
||||
const dataId = dataPath[dataPath.length - 1];
|
||||
|
||||
const streamRes = await proxiedFetch<StreamRes>(
|
||||
`${parsedUrl.origin}/ajax/embed-4/getSources?id=${dataId}`,
|
||||
{
|
||||
headers: {
|
||||
Referer: parsedUrl.origin,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let sources:
|
||||
| {
|
||||
file: string;
|
||||
type: string;
|
||||
}
|
||||
| string = streamRes.sources;
|
||||
|
||||
if (!isJSON(sources) || typeof sources === "string") {
|
||||
const decryptionKey = await proxiedFetch<string>(
|
||||
`https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt`
|
||||
);
|
||||
|
||||
const decryptedStream = AES.decrypt(sources, decryptionKey).toString(
|
||||
enc.Utf8
|
||||
);
|
||||
|
||||
const parsedStream = JSON.parse(decryptedStream)[0];
|
||||
if (!parsedStream) throw new Error("No stream found");
|
||||
sources = parsedStream as { file: string; type: string };
|
||||
}
|
||||
|
||||
return {
|
||||
embedId: MWEmbedType.UPCLOUD,
|
||||
streamUrl: sources.file,
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
type: MWStreamType.HLS,
|
||||
captions: streamRes.tracks
|
||||
.filter((sub) => sub.kind === "captions")
|
||||
.map((sub) => {
|
||||
return {
|
||||
langIso: sub.label,
|
||||
url: sub.file,
|
||||
type: sub.file.endsWith("vtt")
|
||||
? MWCaptionType.VTT
|
||||
: MWCaptionType.UNKNOWN,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
62
src/backend/helpers/captions.ts
Normal file
62
src/backend/helpers/captions.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { convert, detect, list, parse } from "subsrt-ts";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
|
||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
|
||||
export const customCaption = "external-custom";
|
||||
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||
}
|
||||
export const subtitleTypeList = list().map((type) => `.${type}`);
|
||||
export function isSupportedSubtitle(url: string): boolean {
|
||||
return subtitleTypeList.some((type) => url.endsWith(type));
|
||||
}
|
||||
|
||||
export function getMWCaptionTypeFromUrl(url: string): MWCaptionType {
|
||||
if (!isSupportedSubtitle(url)) return MWCaptionType.UNKNOWN;
|
||||
const type = subtitleTypeList.find((t) => url.endsWith(t));
|
||||
if (!type) return MWCaptionType.UNKNOWN;
|
||||
return type.slice(1) as MWCaptionType;
|
||||
}
|
||||
|
||||
export const sanitize = DOMPurify.sanitize;
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
let captionBlob: Blob;
|
||||
if (caption.url.startsWith("blob:")) {
|
||||
// custom subtitle
|
||||
captionBlob = await (await fetch(caption.url)).blob();
|
||||
} else if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
// convert to vtt for track element source which will be used in PiP mode
|
||||
const text = await captionBlob.text();
|
||||
const vtt = convert(text, "vtt");
|
||||
return URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
|
||||
}
|
||||
|
||||
export function revokeCaptionBlob(url: string | undefined) {
|
||||
if (url && url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubtitles(text: string): ContentCaption[] {
|
||||
const textTrimmed = text.trim();
|
||||
if (textTrimmed === "") {
|
||||
throw new Error("Given text is empty");
|
||||
}
|
||||
if (detect(textTrimmed) === "") {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
return parse(textTrimmed).filter(
|
||||
(cue) => cue.type === "caption"
|
||||
) as ContentCaption[];
|
||||
}
|
30
src/backend/helpers/embed.ts
Normal file
30
src/backend/helpers/embed.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { MWEmbedStream } from "./streams";
|
||||
|
||||
export enum MWEmbedType {
|
||||
M4UFREE = "m4ufree",
|
||||
STREAMM4U = "streamm4u",
|
||||
PLAYM4U = "playm4u",
|
||||
UPCLOUD = "upcloud",
|
||||
STREAMSB = "streamsb",
|
||||
MP4UPLOAD = "mp4upload",
|
||||
}
|
||||
|
||||
export type MWEmbed = {
|
||||
type: MWEmbedType;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type MWEmbedContext = {
|
||||
progress(percentage: number): void;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type MWEmbedScraper = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
for: MWEmbedType;
|
||||
rank: number;
|
||||
disabled?: boolean;
|
||||
|
||||
getStream(ctx: MWEmbedContext): Promise<MWEmbedStream>;
|
||||
};
|
94
src/backend/helpers/fetch.ts
Normal file
94
src/backend/helpers/fetch.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { FetchOptions, FetchResponse, ofetch } from "ofetch";
|
||||
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
|
||||
|
||||
// round robins all proxy urls
|
||||
function getProxyUrl(): string {
|
||||
const url = conf().PROXY_URLS[proxyUrlIndex];
|
||||
proxyUrlIndex = (proxyUrlIndex + 1) % conf().PROXY_URLS.length;
|
||||
return url;
|
||||
}
|
||||
|
||||
type P<T> = Parameters<typeof ofetch<T>>;
|
||||
type R<T> = ReturnType<typeof ofetch<T>>;
|
||||
|
||||
const baseFetch = ofetch.create({
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
export function makeUrl(url: string, data: Record<string, string>) {
|
||||
let parsedUrl: string = url;
|
||||
Object.entries(data).forEach(([k, v]) => {
|
||||
parsedUrl = parsedUrl.replace(`{${k}}`, encodeURIComponent(v));
|
||||
});
|
||||
return parsedUrl;
|
||||
}
|
||||
|
||||
export function mwFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||
return baseFetch<T>(url, ops);
|
||||
}
|
||||
|
||||
export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||
let combinedUrl = ops?.baseURL ?? "";
|
||||
if (
|
||||
combinedUrl.length > 0 &&
|
||||
combinedUrl.endsWith("/") &&
|
||||
url.startsWith("/")
|
||||
)
|
||||
combinedUrl += url.slice(1);
|
||||
else if (
|
||||
combinedUrl.length > 0 &&
|
||||
!combinedUrl.endsWith("/") &&
|
||||
!url.startsWith("/")
|
||||
)
|
||||
combinedUrl += `/${url}`;
|
||||
else combinedUrl += url;
|
||||
|
||||
const parsedUrl = new URL(combinedUrl);
|
||||
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return baseFetch<T>(getProxyUrl(), {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
params: {
|
||||
destination: parsedUrl.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function rawProxiedFetch<T>(
|
||||
url: string,
|
||||
ops: FetchOptions = {}
|
||||
): Promise<FetchResponse<T>> {
|
||||
let combinedUrl = ops?.baseURL ?? "";
|
||||
if (
|
||||
combinedUrl.length > 0 &&
|
||||
combinedUrl.endsWith("/") &&
|
||||
url.startsWith("/")
|
||||
)
|
||||
combinedUrl += url.slice(1);
|
||||
else if (
|
||||
combinedUrl.length > 0 &&
|
||||
!combinedUrl.endsWith("/") &&
|
||||
!url.startsWith("/")
|
||||
)
|
||||
combinedUrl += `/${url}`;
|
||||
else combinedUrl += url;
|
||||
|
||||
const parsedUrl = new URL(combinedUrl);
|
||||
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return baseFetch.raw(getProxyUrl(), {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
params: {
|
||||
destination: parsedUrl.toString(),
|
||||
},
|
||||
});
|
||||
}
|
36
src/backend/helpers/provider.ts
Normal file
36
src/backend/helpers/provider.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MWEmbed } from "./embed";
|
||||
import { MWStream } from "./streams";
|
||||
import { DetailedMeta } from "../metadata/getmeta";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
export type MWProviderScrapeResult = {
|
||||
stream?: MWStream;
|
||||
embeds: MWEmbed[];
|
||||
};
|
||||
|
||||
type MWProviderBase = {
|
||||
progress(percentage: number): void;
|
||||
media: DetailedMeta;
|
||||
};
|
||||
type MWProviderTypeSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode?: undefined;
|
||||
season?: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: string;
|
||||
season: string;
|
||||
};
|
||||
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;
|
||||
|
||||
export type MWProvider = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
rank: number;
|
||||
disabled?: boolean;
|
||||
type: MWMediaType[];
|
||||
|
||||
scrape(ctx: MWProviderContext): Promise<MWProviderScrapeResult>;
|
||||
};
|
72
src/backend/helpers/register.ts
Normal file
72
src/backend/helpers/register.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { MWEmbedScraper, MWEmbedType } from "./embed";
|
||||
import { MWProvider } from "./provider";
|
||||
|
||||
let providers: MWProvider[] = [];
|
||||
let embeds: MWEmbedScraper[] = [];
|
||||
|
||||
export function registerProvider(provider: MWProvider) {
|
||||
if (provider.disabled) return;
|
||||
providers.push(provider);
|
||||
}
|
||||
export function registerEmbedScraper(embed: MWEmbedScraper) {
|
||||
if (embed.disabled) return;
|
||||
embeds.push(embed);
|
||||
}
|
||||
|
||||
export function initializeScraperStore() {
|
||||
// sort by ranking
|
||||
providers = providers.sort((a, b) => b.rank - a.rank);
|
||||
embeds = embeds.sort((a, b) => b.rank - a.rank);
|
||||
|
||||
// check for invalid ranks
|
||||
let lastRank: null | number = null;
|
||||
providers.forEach((v) => {
|
||||
if (lastRank === null) {
|
||||
lastRank = v.rank;
|
||||
return;
|
||||
}
|
||||
if (lastRank === v.rank)
|
||||
throw new Error(`Duplicate rank number for provider ${v.id}`);
|
||||
lastRank = v.rank;
|
||||
});
|
||||
lastRank = null;
|
||||
providers.forEach((v) => {
|
||||
if (lastRank === null) {
|
||||
lastRank = v.rank;
|
||||
return;
|
||||
}
|
||||
if (lastRank === v.rank)
|
||||
throw new Error(`Duplicate rank number for embed scraper ${v.id}`);
|
||||
lastRank = v.rank;
|
||||
});
|
||||
|
||||
// check for duplicate ids
|
||||
const providerIds = providers.map((v) => v.id);
|
||||
if (
|
||||
providerIds.length > 0 &&
|
||||
new Set(providerIds).size !== providerIds.length
|
||||
)
|
||||
throw new Error("Duplicate IDS in providers");
|
||||
const embedIds = embeds.map((v) => v.id);
|
||||
if (embedIds.length > 0 && new Set(embedIds).size !== embedIds.length)
|
||||
throw new Error("Duplicate IDS in embed scrapers");
|
||||
|
||||
// check for duplicate embed types
|
||||
const embedTypes = embeds.map((v) => v.for);
|
||||
if (embedTypes.length > 0 && new Set(embedTypes).size !== embedTypes.length)
|
||||
throw new Error("Duplicate types in embed scrapers");
|
||||
}
|
||||
|
||||
export function getProviders(): MWProvider[] {
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function getEmbeds(): MWEmbedScraper[] {
|
||||
return embeds;
|
||||
}
|
||||
|
||||
export function getEmbedScraperByType(
|
||||
type: MWEmbedType
|
||||
): MWEmbedScraper | null {
|
||||
return getEmbeds().find((v) => v.for === type) ?? null;
|
||||
}
|
52
src/backend/helpers/run.ts
Normal file
52
src/backend/helpers/run.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { MWEmbed, MWEmbedContext, MWEmbedScraper } from "./embed";
|
||||
import {
|
||||
MWProvider,
|
||||
MWProviderContext,
|
||||
MWProviderScrapeResult,
|
||||
} from "./provider";
|
||||
import { getEmbedScraperByType } from "./register";
|
||||
import { MWStream } from "./streams";
|
||||
|
||||
function sortProviderResult(
|
||||
ctx: MWProviderScrapeResult
|
||||
): MWProviderScrapeResult {
|
||||
ctx.embeds = ctx.embeds
|
||||
.map<[MWEmbed, MWEmbedScraper | null]>((v) => [
|
||||
v,
|
||||
v.type ? getEmbedScraperByType(v.type) : null,
|
||||
])
|
||||
.sort(([, a], [, b]) => (b?.rank ?? 0) - (a?.rank ?? 0))
|
||||
.map((v) => v[0]);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function runProvider(
|
||||
provider: MWProvider,
|
||||
ctx: MWProviderContext
|
||||
): Promise<MWProviderScrapeResult> {
|
||||
try {
|
||||
const data = await provider.scrape(ctx);
|
||||
return sortProviderResult(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to run provider", err, {
|
||||
id: provider.id,
|
||||
ctx: { ...ctx },
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runEmbedScraper(
|
||||
scraper: MWEmbedScraper,
|
||||
ctx: MWEmbedContext
|
||||
): Promise<MWStream> {
|
||||
try {
|
||||
return await scraper.getStream(ctx);
|
||||
} catch (err) {
|
||||
console.error("Failed to run embed scraper", {
|
||||
id: scraper.id,
|
||||
ctx: { ...ctx },
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
173
src/backend/helpers/scrape.ts
Normal file
173
src/backend/helpers/scrape.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { MWProviderContext, MWProviderScrapeResult } from "./provider";
|
||||
import { getEmbedScraperByType, getProviders } from "./register";
|
||||
import { runEmbedScraper, runProvider } from "./run";
|
||||
import { MWStream } from "./streams";
|
||||
import { DetailedMeta } from "../metadata/getmeta";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
interface MWProgressData {
|
||||
type: "embed" | "provider";
|
||||
id: string;
|
||||
eventId: string;
|
||||
percentage: number;
|
||||
errored: boolean;
|
||||
}
|
||||
interface MWNextData {
|
||||
id: string;
|
||||
eventId: string;
|
||||
type: "embed" | "provider";
|
||||
}
|
||||
|
||||
type MWProviderRunContextBase = {
|
||||
media: DetailedMeta;
|
||||
onProgress?: (data: MWProgressData) => void;
|
||||
onNext?: (data: MWNextData) => void;
|
||||
};
|
||||
type MWProviderRunContextTypeSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode: undefined;
|
||||
season: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: string;
|
||||
season: string;
|
||||
};
|
||||
|
||||
export type MWProviderRunContext = MWProviderRunContextBase &
|
||||
MWProviderRunContextTypeSpecific;
|
||||
|
||||
async function findBestEmbedStream(
|
||||
result: MWProviderScrapeResult,
|
||||
providerId: string,
|
||||
ctx: MWProviderRunContext
|
||||
): Promise<MWStream | null> {
|
||||
if (result.stream) {
|
||||
return {
|
||||
...result.stream,
|
||||
providerId,
|
||||
embedId: providerId,
|
||||
};
|
||||
}
|
||||
|
||||
let embedNum = 0;
|
||||
for (const embed of result.embeds) {
|
||||
embedNum += 1;
|
||||
if (!embed.type) continue;
|
||||
const scraper = getEmbedScraperByType(embed.type);
|
||||
if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`);
|
||||
|
||||
const eventId = [providerId, scraper.id, embedNum].join("|");
|
||||
|
||||
ctx.onNext?.({ id: scraper.id, type: "embed", eventId });
|
||||
|
||||
let stream: MWStream;
|
||||
try {
|
||||
stream = await runEmbedScraper(scraper, {
|
||||
url: embed.url,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: num,
|
||||
type: "embed",
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
ctx.onProgress?.({
|
||||
errored: true,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: 100,
|
||||
type: "embed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: 100,
|
||||
type: "embed",
|
||||
});
|
||||
|
||||
stream.providerId = providerId;
|
||||
return stream;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findBestStream(
|
||||
ctx: MWProviderRunContext
|
||||
): Promise<MWStream | null> {
|
||||
const providers = getProviders();
|
||||
|
||||
for (const provider of providers) {
|
||||
const eventId = provider.id;
|
||||
ctx.onNext?.({ id: provider.id, type: "provider", eventId });
|
||||
let result: MWProviderScrapeResult;
|
||||
try {
|
||||
let context: MWProviderContext;
|
||||
if (ctx.type === MWMediaType.SERIES) {
|
||||
context = {
|
||||
media: ctx.media,
|
||||
type: ctx.type,
|
||||
episode: ctx.episode,
|
||||
season: ctx.season,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
percentage: num,
|
||||
eventId,
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
},
|
||||
};
|
||||
} else {
|
||||
context = {
|
||||
media: ctx.media,
|
||||
type: ctx.type,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
percentage: num,
|
||||
eventId,
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
result = await runProvider(provider, context);
|
||||
} catch (err) {
|
||||
ctx.onProgress?.({
|
||||
percentage: 100,
|
||||
errored: true,
|
||||
eventId,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
eventId,
|
||||
percentage: 100,
|
||||
type: "provider",
|
||||
});
|
||||
|
||||
const stream = await findBestEmbedStream(result, provider.id, ctx);
|
||||
if (!stream) continue;
|
||||
return stream;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
46
src/backend/helpers/streams.ts
Normal file
46
src/backend/helpers/streams.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export enum MWStreamType {
|
||||
MP4 = "mp4",
|
||||
HLS = "hls",
|
||||
}
|
||||
|
||||
// subsrt-ts supported types
|
||||
export enum MWCaptionType {
|
||||
VTT = "vtt",
|
||||
SRT = "srt",
|
||||
LRC = "lrc",
|
||||
SBV = "sbv",
|
||||
SUB = "sub",
|
||||
SSA = "ssa",
|
||||
ASS = "ass",
|
||||
JSON = "json",
|
||||
UNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
export enum MWStreamQuality {
|
||||
Q360P = "360p",
|
||||
Q540P = "540p",
|
||||
Q480P = "480p",
|
||||
Q720P = "720p",
|
||||
Q1080P = "1080p",
|
||||
QUNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
export type MWCaption = {
|
||||
needsProxy?: boolean;
|
||||
url: string;
|
||||
type: MWCaptionType;
|
||||
langIso: string;
|
||||
};
|
||||
|
||||
export type MWStream = {
|
||||
streamUrl: string;
|
||||
type: MWStreamType;
|
||||
quality: MWStreamQuality;
|
||||
providerId?: string;
|
||||
embedId?: string;
|
||||
captions: MWCaption[];
|
||||
};
|
||||
|
||||
export type MWEmbedStream = MWStream & {
|
||||
embedId: string;
|
||||
};
|
24
src/backend/index.ts
Normal file
24
src/backend/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { initializeScraperStore } from "./helpers/register";
|
||||
|
||||
// providers
|
||||
// import "./providers/gdriveplayer";
|
||||
import "./providers/flixhq";
|
||||
import "./providers/superstream";
|
||||
import "./providers/netfilm";
|
||||
import "./providers/m4ufree";
|
||||
import "./providers/hdwatched";
|
||||
import "./providers/2embed";
|
||||
import "./providers/sflix";
|
||||
import "./providers/gomovies";
|
||||
import "./providers/kissasian";
|
||||
import "./providers/streamflix";
|
||||
import "./providers/remotestream";
|
||||
|
||||
// embeds
|
||||
import "./embeds/streamm4u";
|
||||
import "./embeds/playm4u";
|
||||
import "./embeds/upcloud";
|
||||
import "./embeds/streamsb";
|
||||
import "./embeds/mp4upload";
|
||||
|
||||
initializeScraperStore();
|
233
src/backend/metadata/getmeta.ts
Normal file
233
src/backend/metadata/getmeta.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
import { formatJWMeta, mediaTypeToJW } from "./justwatch";
|
||||
import {
|
||||
TMDBMediaToMediaType,
|
||||
formatTMDBMeta,
|
||||
getEpisodes,
|
||||
getExternalIds,
|
||||
getMediaDetails,
|
||||
getMediaPoster,
|
||||
getMovieFromExternalId,
|
||||
mediaTypeToTMDB,
|
||||
} from "./tmdb";
|
||||
import {
|
||||
JWMediaResult,
|
||||
JWSeasonMetaResult,
|
||||
JW_API_BASE,
|
||||
} from "./types/justwatch";
|
||||
import { MWMediaMeta, MWMediaType } from "./types/mw";
|
||||
import {
|
||||
TMDBMediaResult,
|
||||
TMDBMovieData,
|
||||
TMDBSeasonMetaResult,
|
||||
TMDBShowData,
|
||||
} from "./types/tmdb";
|
||||
import { makeUrl, proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
type JWExternalIdType =
|
||||
| "eidr"
|
||||
| "imdb_latest"
|
||||
| "imdb"
|
||||
| "tmdb_latest"
|
||||
| "tmdb"
|
||||
| "tms";
|
||||
|
||||
interface JWExternalId {
|
||||
provider: JWExternalIdType;
|
||||
external_id: string;
|
||||
}
|
||||
|
||||
interface JWDetailedMeta extends JWMediaResult {
|
||||
external_ids: JWExternalId[];
|
||||
}
|
||||
|
||||
export interface DetailedMeta {
|
||||
meta: MWMediaMeta;
|
||||
imdbId?: string;
|
||||
tmdbId?: string;
|
||||
}
|
||||
|
||||
export function formatTMDBMetaResult(
|
||||
details: TMDBShowData | TMDBMovieData,
|
||||
type: MWMediaType
|
||||
): TMDBMediaResult {
|
||||
if (type === MWMediaType.MOVIE) {
|
||||
const movie = details as TMDBMovieData;
|
||||
return {
|
||||
id: details.id,
|
||||
title: movie.title,
|
||||
object_type: mediaTypeToTMDB(type),
|
||||
poster: getMediaPoster(movie.poster_path) ?? undefined,
|
||||
original_release_year: new Date(movie.release_date).getFullYear(),
|
||||
};
|
||||
}
|
||||
if (type === MWMediaType.SERIES) {
|
||||
const show = details as TMDBShowData;
|
||||
return {
|
||||
id: details.id,
|
||||
title: show.name,
|
||||
object_type: mediaTypeToTMDB(type),
|
||||
seasons: show.seasons.map((v) => ({
|
||||
id: v.id,
|
||||
season_number: v.season_number,
|
||||
title: v.name,
|
||||
})),
|
||||
poster: getMediaPoster(show.poster_path) ?? undefined,
|
||||
original_release_year: new Date(show.first_air_date).getFullYear(),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export async function getMetaFromId(
|
||||
type: MWMediaType,
|
||||
id: string,
|
||||
seasonId?: string
|
||||
): Promise<DetailedMeta | null> {
|
||||
const details = await getMediaDetails(id, mediaTypeToTMDB(type));
|
||||
|
||||
if (!details) return null;
|
||||
|
||||
const externalIds = await getExternalIds(id, mediaTypeToTMDB(type));
|
||||
const imdbId = externalIds.imdb_id ?? undefined;
|
||||
|
||||
let seasonData: TMDBSeasonMetaResult | undefined;
|
||||
|
||||
if (type === MWMediaType.SERIES) {
|
||||
const seasons = (details as TMDBShowData).seasons;
|
||||
|
||||
let selectedSeason = seasons.find((v) => v.id.toString() === seasonId);
|
||||
if (!selectedSeason) {
|
||||
selectedSeason = seasons.find((v) => v.season_number === 1);
|
||||
}
|
||||
|
||||
if (selectedSeason) {
|
||||
const episodes = await getEpisodes(
|
||||
details.id.toString(),
|
||||
selectedSeason.season_number
|
||||
);
|
||||
|
||||
seasonData = {
|
||||
id: selectedSeason.id.toString(),
|
||||
season_number: selectedSeason.season_number,
|
||||
title: selectedSeason.name,
|
||||
episodes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const tmdbmeta = formatTMDBMetaResult(details, type);
|
||||
if (!tmdbmeta) return null;
|
||||
const meta = formatTMDBMeta(tmdbmeta, seasonData);
|
||||
if (!meta) return null;
|
||||
|
||||
return {
|
||||
meta,
|
||||
imdbId,
|
||||
tmdbId: id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLegacyMetaFromId(
|
||||
type: MWMediaType,
|
||||
id: string,
|
||||
seasonId?: string
|
||||
): Promise<DetailedMeta | null> {
|
||||
const queryType = mediaTypeToJW(type);
|
||||
|
||||
let data: JWDetailedMeta;
|
||||
try {
|
||||
const url = makeUrl("/content/titles/{type}/{id}/locale/en_US", {
|
||||
type: queryType,
|
||||
id,
|
||||
});
|
||||
data = await proxiedFetch<JWDetailedMeta>(url, { baseURL: JW_API_BASE });
|
||||
} catch (err) {
|
||||
if (err instanceof FetchError) {
|
||||
// 400 and 404 are treated as not found
|
||||
if (err.statusCode === 400 || err.statusCode === 404) return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let imdbId = data.external_ids.find(
|
||||
(v) => v.provider === "imdb_latest"
|
||||
)?.external_id;
|
||||
if (!imdbId)
|
||||
imdbId = data.external_ids.find((v) => v.provider === "imdb")?.external_id;
|
||||
|
||||
let tmdbId = data.external_ids.find(
|
||||
(v) => v.provider === "tmdb_latest"
|
||||
)?.external_id;
|
||||
if (!tmdbId)
|
||||
tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id;
|
||||
|
||||
let seasonData: JWSeasonMetaResult | undefined;
|
||||
if (data.object_type === "show") {
|
||||
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";
|
||||
const url = makeUrl("/content/titles/show_season/{id}/locale/en_US", {
|
||||
id: seasonToScrape,
|
||||
});
|
||||
seasonData = await proxiedFetch<any>(url, { baseURL: JW_API_BASE });
|
||||
}
|
||||
|
||||
return {
|
||||
meta: formatJWMeta(data, seasonData),
|
||||
imdbId,
|
||||
tmdbId,
|
||||
};
|
||||
}
|
||||
|
||||
export function TMDBMediaToId(media: MWMediaMeta): string {
|
||||
return ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-");
|
||||
}
|
||||
|
||||
export function decodeTMDBId(
|
||||
paramId: string
|
||||
): { id: string; type: MWMediaType } | null {
|
||||
const [prefix, type, id] = paramId.split("-", 3);
|
||||
if (prefix !== "tmdb") return null;
|
||||
let mediaType;
|
||||
try {
|
||||
mediaType = TMDBMediaToMediaType(type);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: mediaType,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLegacyUrl(url: string): boolean {
|
||||
if (url.startsWith("/media/JW")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function convertLegacyUrl(
|
||||
url: string
|
||||
): Promise<string | undefined> {
|
||||
if (!isLegacyUrl(url)) return undefined;
|
||||
|
||||
const urlParts = url.split("/").slice(2);
|
||||
const [, type, id] = urlParts[0].split("-", 3);
|
||||
|
||||
const mediaType = TMDBMediaToMediaType(type);
|
||||
const meta = await getLegacyMetaFromId(mediaType, id);
|
||||
|
||||
if (!meta) return undefined;
|
||||
const { tmdbId, imdbId } = meta;
|
||||
if (!tmdbId && !imdbId) return undefined;
|
||||
|
||||
// movies always have an imdb id on tmdb
|
||||
if (imdbId && mediaType === MWMediaType.MOVIE) {
|
||||
const movieId = await getMovieFromExternalId(imdbId);
|
||||
if (movieId) return `/media/tmdb-movie-${movieId}`;
|
||||
}
|
||||
|
||||
if (tmdbId) {
|
||||
return `/media/tmdb-${type}-${tmdbId}`;
|
||||
}
|
||||
}
|
84
src/backend/metadata/justwatch.ts
Normal file
84
src/backend/metadata/justwatch.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
JWContentTypes,
|
||||
JWMediaResult,
|
||||
JWSeasonMetaResult,
|
||||
JW_IMAGE_BASE,
|
||||
} from "./types/justwatch";
|
||||
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw";
|
||||
|
||||
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
|
||||
if (type === MWMediaType.MOVIE) return "movie";
|
||||
if (type === MWMediaType.SERIES) return "show";
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function JWMediaToMediaType(type: string): MWMediaType {
|
||||
if (type === "movie") return MWMediaType.MOVIE;
|
||||
if (type === "show") return MWMediaType.SERIES;
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function formatJWMeta(
|
||||
media: JWMediaResult,
|
||||
season?: JWSeasonMetaResult
|
||||
): MWMediaMeta {
|
||||
const type = JWMediaToMediaType(media.object_type);
|
||||
let seasons: undefined | MWSeasonMeta[];
|
||||
if (type === MWMediaType.SERIES) {
|
||||
seasons = media.seasons
|
||||
?.sort((a, b) => a.season_number - b.season_number)
|
||||
.map(
|
||||
(v): MWSeasonMeta => ({
|
||||
id: v.id.toString(),
|
||||
number: v.season_number,
|
||||
title: v.title,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title: media.title,
|
||||
id: media.id.toString(),
|
||||
year: media.original_release_year?.toString(),
|
||||
poster: media.poster
|
||||
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
|
||||
: undefined,
|
||||
type,
|
||||
seasons: seasons as any,
|
||||
seasonData: season
|
||||
? ({
|
||||
id: season.id.toString(),
|
||||
number: season.season_number,
|
||||
title: season.title,
|
||||
episodes: season.episodes
|
||||
.sort((a, b) => a.episode_number - b.episode_number)
|
||||
.map((v) => ({
|
||||
id: v.id.toString(),
|
||||
number: v.episode_number,
|
||||
title: v.title,
|
||||
})),
|
||||
} as any)
|
||||
: (undefined as any),
|
||||
};
|
||||
}
|
||||
|
||||
export function JWMediaToId(media: MWMediaMeta): string {
|
||||
return ["JW", mediaTypeToJW(media.type), media.id].join("-");
|
||||
}
|
||||
|
||||
export function decodeJWId(
|
||||
paramId: string
|
||||
): { id: string; type: MWMediaType } | null {
|
||||
const [prefix, type, id] = paramId.split("-", 3);
|
||||
if (prefix !== "JW") return null;
|
||||
let mediaType;
|
||||
try {
|
||||
mediaType = JWMediaToMediaType(type);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: mediaType,
|
||||
id,
|
||||
};
|
||||
}
|
29
src/backend/metadata/search.ts
Normal file
29
src/backend/metadata/search.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { SimpleCache } from "@/utils/cache";
|
||||
|
||||
import {
|
||||
formatTMDBMeta,
|
||||
formatTMDBSearchResult,
|
||||
mediaTypeToTMDB,
|
||||
searchMedia,
|
||||
} from "./tmdb";
|
||||
import { MWMediaMeta, MWQuery } from "./types/mw";
|
||||
|
||||
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
||||
cache.setCompare((a, b) => {
|
||||
return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim();
|
||||
});
|
||||
cache.initialize();
|
||||
|
||||
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
|
||||
if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
|
||||
const { searchQuery, type } = query;
|
||||
|
||||
const data = await searchMedia(searchQuery, mediaTypeToTMDB(type));
|
||||
const results = data.results.map((v) => {
|
||||
const formattedResult = formatTMDBSearchResult(v, mediaTypeToTMDB(type));
|
||||
return formatTMDBMeta(formattedResult);
|
||||
});
|
||||
|
||||
cache.set(query, results, 3600); // cache results for 1 hour
|
||||
return results;
|
||||
}
|
239
src/backend/metadata/tmdb.ts
Normal file
239
src/backend/metadata/tmdb.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw";
|
||||
import {
|
||||
ExternalIdMovieSearchResult,
|
||||
TMDBContentTypes,
|
||||
TMDBEpisodeShort,
|
||||
TMDBExternalIds,
|
||||
TMDBMediaResult,
|
||||
TMDBMovieData,
|
||||
TMDBMovieExternalIds,
|
||||
TMDBMovieResponse,
|
||||
TMDBMovieResult,
|
||||
TMDBSeason,
|
||||
TMDBSeasonMetaResult,
|
||||
TMDBShowData,
|
||||
TMDBShowExternalIds,
|
||||
TMDBShowResponse,
|
||||
TMDBShowResult,
|
||||
} from "./types/tmdb";
|
||||
import { mwFetch } from "../helpers/fetch";
|
||||
|
||||
export function mediaTypeToTMDB(type: MWMediaType): TMDBContentTypes {
|
||||
if (type === MWMediaType.MOVIE) return "movie";
|
||||
if (type === MWMediaType.SERIES) return "show";
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function TMDBMediaToMediaType(type: string): MWMediaType {
|
||||
if (type === "movie") return MWMediaType.MOVIE;
|
||||
if (type === "show") return MWMediaType.SERIES;
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function formatTMDBMeta(
|
||||
media: TMDBMediaResult,
|
||||
season?: TMDBSeasonMetaResult
|
||||
): MWMediaMeta {
|
||||
const type = TMDBMediaToMediaType(media.object_type);
|
||||
let seasons: undefined | MWSeasonMeta[];
|
||||
if (type === MWMediaType.SERIES) {
|
||||
seasons = media.seasons
|
||||
?.sort((a, b) => a.season_number - b.season_number)
|
||||
.map(
|
||||
(v): MWSeasonMeta => ({
|
||||
title: v.title,
|
||||
id: v.id.toString(),
|
||||
number: v.season_number,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title: media.title,
|
||||
id: media.id.toString(),
|
||||
year: media.original_release_year?.toString(),
|
||||
poster: media.poster,
|
||||
type,
|
||||
seasons: seasons as any,
|
||||
seasonData: season
|
||||
? ({
|
||||
id: season.id.toString(),
|
||||
number: season.season_number,
|
||||
title: season.title,
|
||||
episodes: season.episodes
|
||||
.sort((a, b) => a.episode_number - b.episode_number)
|
||||
.map((v) => ({
|
||||
id: v.id.toString(),
|
||||
number: v.episode_number,
|
||||
title: v.title,
|
||||
})),
|
||||
} as any)
|
||||
: (undefined as any),
|
||||
};
|
||||
}
|
||||
|
||||
export function TMDBMediaToId(media: MWMediaMeta): string {
|
||||
return ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-");
|
||||
}
|
||||
|
||||
export function decodeTMDBId(
|
||||
paramId: string
|
||||
): { id: string; type: MWMediaType } | null {
|
||||
const [prefix, type, id] = paramId.split("-", 3);
|
||||
if (prefix !== "tmdb") return null;
|
||||
let mediaType;
|
||||
try {
|
||||
mediaType = TMDBMediaToMediaType(type);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: mediaType,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
const baseURL = "https://api.themoviedb.org/3";
|
||||
|
||||
const headers = {
|
||||
accept: "application/json",
|
||||
Authorization: `Bearer ${conf().TMDB_READ_API_KEY}`,
|
||||
};
|
||||
|
||||
async function get<T>(url: string, params?: object): Promise<T> {
|
||||
const res = await mwFetch<any>(encodeURI(url), {
|
||||
headers,
|
||||
baseURL,
|
||||
params: {
|
||||
...params,
|
||||
},
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function searchMedia(
|
||||
query: string,
|
||||
type: TMDBContentTypes
|
||||
): Promise<TMDBMovieResponse | TMDBShowResponse> {
|
||||
let data;
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
data = await get<TMDBMovieResponse>("search/movie", {
|
||||
query,
|
||||
include_adult: false,
|
||||
language: "en-US",
|
||||
page: 1,
|
||||
});
|
||||
break;
|
||||
case "show":
|
||||
data = await get<TMDBShowResponse>("search/tv", {
|
||||
query,
|
||||
include_adult: false,
|
||||
language: "en-US",
|
||||
page: 1,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid media type");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Conditional type which for inferring the return type based on the content type
|
||||
type MediaDetailReturn<T extends TMDBContentTypes> = T extends "movie"
|
||||
? TMDBMovieData
|
||||
: T extends "show"
|
||||
? TMDBShowData
|
||||
: never;
|
||||
|
||||
export function getMediaDetails<
|
||||
T extends TMDBContentTypes,
|
||||
TReturn = MediaDetailReturn<T>
|
||||
>(id: string, type: T): Promise<TReturn> {
|
||||
if (type === "movie") {
|
||||
return get<TReturn>(`/movie/${id}`);
|
||||
}
|
||||
if (type === "show") {
|
||||
return get<TReturn>(`/tv/${id}`);
|
||||
}
|
||||
throw new Error("Invalid media type");
|
||||
}
|
||||
|
||||
export function getMediaPoster(posterPath: string | null): string | undefined {
|
||||
if (posterPath) return `https://image.tmdb.org/t/p/w185/${posterPath}`;
|
||||
}
|
||||
|
||||
export async function getEpisodes(
|
||||
id: string,
|
||||
season: number
|
||||
): Promise<TMDBEpisodeShort[]> {
|
||||
const data = await get<TMDBSeason>(`/tv/${id}/season/${season}`);
|
||||
return data.episodes.map((e) => ({
|
||||
id: e.id,
|
||||
episode_number: e.episode_number,
|
||||
title: e.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getExternalIds(
|
||||
id: string,
|
||||
type: TMDBContentTypes
|
||||
): Promise<TMDBExternalIds> {
|
||||
let data;
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
data = await get<TMDBMovieExternalIds>(`/movie/${id}/external_ids`);
|
||||
break;
|
||||
case "show":
|
||||
data = await get<TMDBShowExternalIds>(`/tv/${id}/external_ids`);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid media type");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMovieFromExternalId(
|
||||
imdbId: string
|
||||
): Promise<string | undefined> {
|
||||
const data = await get<ExternalIdMovieSearchResult>(`/find/${imdbId}`, {
|
||||
external_source: "imdb_id",
|
||||
});
|
||||
|
||||
const movie = data.movie_results[0];
|
||||
if (!movie) return undefined;
|
||||
|
||||
return movie.id.toString();
|
||||
}
|
||||
|
||||
export function formatTMDBSearchResult(
|
||||
result: TMDBShowResult | TMDBMovieResult,
|
||||
mediatype: TMDBContentTypes
|
||||
): TMDBMediaResult {
|
||||
const type = TMDBMediaToMediaType(mediatype);
|
||||
if (type === MWMediaType.SERIES) {
|
||||
const show = result as TMDBShowResult;
|
||||
return {
|
||||
title: show.name,
|
||||
poster: getMediaPoster(show.poster_path),
|
||||
id: show.id,
|
||||
original_release_year: new Date(show.first_air_date).getFullYear(),
|
||||
object_type: mediatype,
|
||||
};
|
||||
}
|
||||
const movie = result as TMDBMovieResult;
|
||||
|
||||
return {
|
||||
title: movie.title,
|
||||
poster: getMediaPoster(movie.poster_path),
|
||||
id: movie.id,
|
||||
original_release_year: new Date(movie.release_date).getFullYear(),
|
||||
object_type: mediatype,
|
||||
};
|
||||
}
|
48
src/backend/metadata/types/justwatch.ts
Normal file
48
src/backend/metadata/types/justwatch.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export type JWContentTypes = "movie" | "show";
|
||||
|
||||
export type JWSearchQuery = {
|
||||
content_types: JWContentTypes[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type JWPage<T> = {
|
||||
items: T[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
};
|
||||
|
||||
export const JW_API_BASE = "https://apis.justwatch.com";
|
||||
export const JW_IMAGE_BASE = "https://images.justwatch.com";
|
||||
|
||||
export type JWSeasonShort = {
|
||||
title: string;
|
||||
id: number;
|
||||
season_number: number;
|
||||
};
|
||||
|
||||
export type JWEpisodeShort = {
|
||||
title: string;
|
||||
id: number;
|
||||
episode_number: number;
|
||||
};
|
||||
|
||||
export type JWMediaResult = {
|
||||
title: string;
|
||||
poster?: string;
|
||||
id: number;
|
||||
original_release_year?: number;
|
||||
jw_entity_id: string;
|
||||
object_type: JWContentTypes;
|
||||
seasons?: JWSeasonShort[];
|
||||
};
|
||||
|
||||
export type JWSeasonMetaResult = {
|
||||
title: string;
|
||||
id: string;
|
||||
season_number: number;
|
||||
episodes: JWEpisodeShort[];
|
||||
};
|
53
src/backend/metadata/types/mw.ts
Normal file
53
src/backend/metadata/types/mw.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export enum MWMediaType {
|
||||
MOVIE = "movie",
|
||||
SERIES = "series",
|
||||
ANIME = "anime",
|
||||
}
|
||||
|
||||
export type MWSeasonMeta = {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type MWSeasonWithEpisodeMeta = {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
episodes: {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type MWMediaMetaBase = {
|
||||
title: string;
|
||||
id: string;
|
||||
year?: string;
|
||||
poster?: string;
|
||||
};
|
||||
|
||||
type MWMediaMetaSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
seasons: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
seasons: MWSeasonMeta[];
|
||||
seasonData: MWSeasonWithEpisodeMeta;
|
||||
};
|
||||
|
||||
export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific;
|
||||
|
||||
export interface MWQuery {
|
||||
searchQuery: string;
|
||||
type: MWMediaType;
|
||||
}
|
||||
|
||||
export interface DetailedMeta {
|
||||
meta: MWMediaMeta;
|
||||
imdbId?: string;
|
||||
tmdbId?: string;
|
||||
}
|
308
src/backend/metadata/types/tmdb.ts
Normal file
308
src/backend/metadata/types/tmdb.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
export type TMDBContentTypes = "movie" | "show";
|
||||
|
||||
export type TMDBSeasonShort = {
|
||||
title: string;
|
||||
id: number;
|
||||
season_number: number;
|
||||
};
|
||||
|
||||
export type TMDBEpisodeShort = {
|
||||
title: string;
|
||||
id: number;
|
||||
episode_number: number;
|
||||
};
|
||||
|
||||
export type TMDBMediaResult = {
|
||||
title: string;
|
||||
poster?: string;
|
||||
id: number;
|
||||
original_release_year?: number;
|
||||
object_type: TMDBContentTypes;
|
||||
seasons?: TMDBSeasonShort[];
|
||||
};
|
||||
|
||||
export type TMDBSeasonMetaResult = {
|
||||
title: string;
|
||||
id: string;
|
||||
season_number: number;
|
||||
episodes: TMDBEpisodeShort[];
|
||||
};
|
||||
|
||||
export interface TMDBShowData {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
created_by: {
|
||||
id: number;
|
||||
credit_id: string;
|
||||
name: string;
|
||||
gender: number;
|
||||
profile_path: string | null;
|
||||
}[];
|
||||
episode_run_time: number[];
|
||||
first_air_date: string;
|
||||
genres: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
homepage: string;
|
||||
id: number;
|
||||
in_production: boolean;
|
||||
languages: string[];
|
||||
last_air_date: string;
|
||||
last_episode_to_air: {
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
air_date: string;
|
||||
episode_number: number;
|
||||
production_code: string;
|
||||
runtime: number | null;
|
||||
season_number: number;
|
||||
show_id: number;
|
||||
still_path: string | null;
|
||||
} | null;
|
||||
name: string;
|
||||
next_episode_to_air: {
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
air_date: string;
|
||||
episode_number: number;
|
||||
production_code: string;
|
||||
runtime: number | null;
|
||||
season_number: number;
|
||||
show_id: number;
|
||||
still_path: string | null;
|
||||
} | null;
|
||||
networks: {
|
||||
id: number;
|
||||
logo_path: string;
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
number_of_episodes: number;
|
||||
number_of_seasons: number;
|
||||
origin_country: string[];
|
||||
original_language: string;
|
||||
original_name: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
production_companies: {
|
||||
id: number;
|
||||
logo_path: string | null;
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
production_countries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
seasons: {
|
||||
air_date: string;
|
||||
episode_count: number;
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
poster_path: string | null;
|
||||
season_number: number;
|
||||
}[];
|
||||
spoken_languages: {
|
||||
english_name: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
status: string;
|
||||
tagline: string;
|
||||
type: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface TMDBMovieData {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
belongs_to_collection: {
|
||||
id: number;
|
||||
name: string;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
} | null;
|
||||
budget: number;
|
||||
genres: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
homepage: string | null;
|
||||
id: number;
|
||||
imdb_id: string | null;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string | null;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
production_companies: {
|
||||
id: number;
|
||||
logo_path: string | null;
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
production_countries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
release_date: string;
|
||||
revenue: number;
|
||||
runtime: number | null;
|
||||
spoken_languages: {
|
||||
english_name: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
status: string;
|
||||
tagline: string | null;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface TMDBEpisodeResult {
|
||||
season: number;
|
||||
number: number;
|
||||
title: string;
|
||||
ids: {
|
||||
trakt: number;
|
||||
tvdb: number;
|
||||
imdb: string;
|
||||
tmdb: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TMDBShowResult {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
origin_country: string[];
|
||||
original_language: string;
|
||||
original_name: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
first_air_date: string;
|
||||
name: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface TMDBShowResponse {
|
||||
page: number;
|
||||
results: TMDBShowResult[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export interface TMDBMovieResult {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
release_date: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface TMDBMovieResponse {
|
||||
page: number;
|
||||
results: TMDBMovieResult[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export interface TMDBEpisode {
|
||||
air_date: string;
|
||||
episode_number: number;
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
production_code: string;
|
||||
runtime: number;
|
||||
season_number: number;
|
||||
show_id: number;
|
||||
still_path: string | null;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
crew: any[];
|
||||
guest_stars: any[];
|
||||
}
|
||||
|
||||
export interface TMDBSeason {
|
||||
_id: string;
|
||||
air_date: string;
|
||||
episodes: TMDBEpisode[];
|
||||
name: string;
|
||||
overview: string;
|
||||
id: number;
|
||||
poster_path: string | null;
|
||||
season_number: number;
|
||||
}
|
||||
|
||||
export interface TMDBShowExternalIds {
|
||||
id: number;
|
||||
imdb_id: null | string;
|
||||
freebase_mid: null | string;
|
||||
freebase_id: null | string;
|
||||
tvdb_id: number;
|
||||
tvrage_id: null | string;
|
||||
wikidata_id: null | string;
|
||||
facebook_id: null | string;
|
||||
instagram_id: null | string;
|
||||
twitter_id: null | string;
|
||||
}
|
||||
|
||||
export interface TMDBMovieExternalIds {
|
||||
id: number;
|
||||
imdb_id: null | string;
|
||||
wikidata_id: null | string;
|
||||
facebook_id: null | string;
|
||||
instagram_id: null | string;
|
||||
twitter_id: null | string;
|
||||
}
|
||||
|
||||
export type TMDBExternalIds = TMDBShowExternalIds | TMDBMovieExternalIds;
|
||||
|
||||
export interface ExternalIdMovieSearchResult {
|
||||
movie_results: {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
id: number;
|
||||
title: string;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
poster_path: string;
|
||||
media_type: string;
|
||||
genre_ids: number[];
|
||||
popularity: number;
|
||||
release_date: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}[];
|
||||
person_results: any[];
|
||||
tv_results: any[];
|
||||
tv_episode_results: any[];
|
||||
tv_season_results: any[];
|
||||
}
|
252
src/backend/providers/2embed.ts
Normal file
252
src/backend/providers/2embed.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import Base64 from "crypto-js/enc-base64";
|
||||
import Utf8 from "crypto-js/enc-utf8";
|
||||
|
||||
import { proxiedFetch, rawProxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const twoEmbedBase = "https://www.2embed.to";
|
||||
|
||||
async function fetchCaptchaToken(recaptchaKey: string) {
|
||||
const domainHash = Base64.stringify(Utf8.parse(twoEmbedBase)).replace(
|
||||
/=/g,
|
||||
"."
|
||||
);
|
||||
|
||||
const recaptchaRender = await proxiedFetch<any>(
|
||||
`https://www.google.com/recaptcha/api.js?render=${recaptchaKey}`
|
||||
);
|
||||
|
||||
const vToken = recaptchaRender.substring(
|
||||
recaptchaRender.indexOf("/releases/") + 10,
|
||||
recaptchaRender.indexOf("/recaptcha__en.js")
|
||||
);
|
||||
|
||||
const recaptchaAnchor = await proxiedFetch<any>(
|
||||
`https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=flicklax&k=${recaptchaKey}&co=${domainHash}&v=${vToken}`
|
||||
);
|
||||
|
||||
const cToken = new DOMParser()
|
||||
.parseFromString(recaptchaAnchor, "text/html")
|
||||
.getElementById("recaptcha-token")
|
||||
?.getAttribute("value");
|
||||
|
||||
if (!cToken) throw new Error("Unable to find cToken");
|
||||
|
||||
const payload = {
|
||||
v: vToken,
|
||||
reason: "q",
|
||||
k: recaptchaKey,
|
||||
c: cToken,
|
||||
sa: "",
|
||||
co: twoEmbedBase,
|
||||
};
|
||||
|
||||
const tokenData = await proxiedFetch<string>(
|
||||
`https://www.google.com/recaptcha/api2/reload?${new URLSearchParams(
|
||||
payload
|
||||
).toString()}`,
|
||||
{
|
||||
headers: { referer: "https://www.google.com/recaptcha/api2/" },
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const token = tokenData.match('rresp","(.+?)"');
|
||||
return token ? token[1] : null;
|
||||
}
|
||||
|
||||
interface IEmbedRes {
|
||||
link: string;
|
||||
sources: [];
|
||||
tracks: [];
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface IStreamData {
|
||||
status: string;
|
||||
message: string;
|
||||
type: string;
|
||||
token: string;
|
||||
result:
|
||||
| {
|
||||
Original: {
|
||||
label: string;
|
||||
file: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
label: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ISubtitles {
|
||||
url: string;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
async function fetchStream(sourceId: string, captchaToken: string) {
|
||||
const embedRes = await proxiedFetch<IEmbedRes>(
|
||||
`${twoEmbedBase}/ajax/embed/play?id=${sourceId}&_token=${captchaToken}`,
|
||||
{
|
||||
headers: {
|
||||
Referer: twoEmbedBase,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Link format: https://rabbitstream.net/embed-4/{data-id}?z=
|
||||
const rabbitStreamUrl = new URL(embedRes.link);
|
||||
|
||||
const dataPath = rabbitStreamUrl.pathname.split("/");
|
||||
const dataId = dataPath[dataPath.length - 1];
|
||||
|
||||
// https://rabbitstream.net/embed/m-download/{data-id}
|
||||
const download = await proxiedFetch<any>(
|
||||
`${rabbitStreamUrl.origin}/embed/m-download/${dataId}`,
|
||||
{
|
||||
headers: {
|
||||
referer: twoEmbedBase,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const downloadPage = new DOMParser().parseFromString(download, "text/html");
|
||||
|
||||
const streamlareEl = Array.from(
|
||||
downloadPage.querySelectorAll(".dls-brand")
|
||||
).find((el) => el.textContent?.trim() === "Streamlare");
|
||||
if (!streamlareEl) throw new Error("Unable to find streamlare element");
|
||||
|
||||
const streamlareUrl =
|
||||
streamlareEl.nextElementSibling?.querySelector("a")?.href;
|
||||
if (!streamlareUrl) throw new Error("Unable to parse streamlare url");
|
||||
|
||||
const subtitles: ISubtitles[] = [];
|
||||
const subtitlesDropdown = downloadPage.querySelectorAll(
|
||||
"#user_menu .dropdown-item"
|
||||
);
|
||||
subtitlesDropdown.forEach((item) => {
|
||||
const url = item.getAttribute("href");
|
||||
const lang = item.textContent?.trim().replace("Download", "").trim();
|
||||
if (url && lang) subtitles.push({ url, lang });
|
||||
});
|
||||
|
||||
const streamlare = await proxiedFetch<any>(streamlareUrl);
|
||||
|
||||
const streamlarePage = new DOMParser().parseFromString(
|
||||
streamlare,
|
||||
"text/html"
|
||||
);
|
||||
|
||||
const csrfToken = streamlarePage
|
||||
.querySelector("head > meta:nth-child(3)")
|
||||
?.getAttribute("content");
|
||||
|
||||
if (!csrfToken) throw new Error("Unable to find CSRF token");
|
||||
|
||||
const videoId = streamlareUrl.match("/[ve]/([^?#&/]+)")?.[1];
|
||||
if (!videoId) throw new Error("Unable to get streamlare video id");
|
||||
|
||||
const streamRes = await proxiedFetch<IStreamData>(
|
||||
`${new URL(streamlareUrl).origin}/api/video/download/get`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id: videoId,
|
||||
}),
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (streamRes.message !== "OK") throw new Error("Unable to fetch stream");
|
||||
|
||||
const streamData = Array.isArray(streamRes.result)
|
||||
? streamRes.result[0]
|
||||
: streamRes.result.Original;
|
||||
if (!streamData) throw new Error("Unable to get stream data");
|
||||
|
||||
const followStream = await rawProxiedFetch(streamData.url, {
|
||||
method: "HEAD",
|
||||
referrer: new URL(streamlareUrl).origin,
|
||||
});
|
||||
|
||||
const finalStreamUrl = followStream.headers.get("X-Final-Destination");
|
||||
return { url: finalStreamUrl, subtitles };
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "2embed",
|
||||
displayName: "2Embed",
|
||||
rank: 125,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
disabled: true, // Disabled, not working
|
||||
async scrape({ media, episode, progress }) {
|
||||
let embedUrl = `${twoEmbedBase}/embed/tmdb/movie?id=${media.tmdbId}`;
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
embedUrl = `${twoEmbedBase}/embed/tmdb/tv?id=${media.tmdbId}&s=${seasonNumber}&e=${episodeNumber}`;
|
||||
}
|
||||
|
||||
const embed = await proxiedFetch<any>(embedUrl);
|
||||
progress(20);
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(embed, "text/html");
|
||||
|
||||
const pageServerItems = Array.from(
|
||||
embedPage.querySelectorAll(".item-server")
|
||||
);
|
||||
const pageStreamItem = pageServerItems.find((item) =>
|
||||
item.textContent?.includes("Vidcloud")
|
||||
);
|
||||
|
||||
const sourceId = pageStreamItem
|
||||
? pageStreamItem.getAttribute("data-id")
|
||||
: null;
|
||||
if (!sourceId) throw new Error("Unable to get source id");
|
||||
|
||||
const siteKey = embedPage
|
||||
.querySelector("body")
|
||||
?.getAttribute("data-recaptcha-key");
|
||||
if (!siteKey) throw new Error("Unable to get site key");
|
||||
|
||||
const captchaToken = await fetchCaptchaToken(siteKey);
|
||||
if (!captchaToken) throw new Error("Unable to fetch captcha token");
|
||||
progress(35);
|
||||
|
||||
const stream = await fetchStream(sourceId, captchaToken);
|
||||
if (!stream.url) throw new Error("Unable to find stream url");
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: stream.url,
|
||||
quality: MWStreamQuality.QUNKNOWN,
|
||||
type: MWStreamType.MP4,
|
||||
captions: stream.subtitles.map((sub) => {
|
||||
return {
|
||||
langIso: sub.lang,
|
||||
url: `https://cc.2cdns.com${new URL(sub.url).pathname}`,
|
||||
type: MWCaptionType.VTT,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
128
src/backend/providers/flixhq.ts
Normal file
128
src/backend/providers/flixhq.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { compareTitle } from "@/utils/titleMatch";
|
||||
|
||||
import {
|
||||
getMWCaptionTypeFromUrl,
|
||||
isSupportedSubtitle,
|
||||
} from "../helpers/captions";
|
||||
import { mwFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const flixHqBase = "https://api.consumet.org/meta/tmdb";
|
||||
|
||||
type FlixHQMediaType = "Movie" | "TV Series";
|
||||
interface FLIXMediaBase {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
image: string;
|
||||
type: FlixHQMediaType;
|
||||
releaseDate: string;
|
||||
}
|
||||
interface FLIXSubType {
|
||||
url: string;
|
||||
lang: string;
|
||||
}
|
||||
function convertSubtitles({ url, lang }: FLIXSubType): MWCaption | null {
|
||||
if (lang.includes("(maybe)")) return null;
|
||||
const supported = isSupportedSubtitle(url);
|
||||
if (!supported) return null;
|
||||
const type = getMWCaptionTypeFromUrl(url);
|
||||
return {
|
||||
url,
|
||||
langIso: lang,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
const qualityMap: Record<string, MWStreamQuality> = {
|
||||
"360": MWStreamQuality.Q360P,
|
||||
"540": MWStreamQuality.Q540P,
|
||||
"480": MWStreamQuality.Q480P,
|
||||
"720": MWStreamQuality.Q720P,
|
||||
"1080": MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
function flixTypeToMWType(type: FlixHQMediaType) {
|
||||
if (type === "Movie") return MWMediaType.MOVIE;
|
||||
return MWMediaType.SERIES;
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "flixhq",
|
||||
displayName: "FlixHQ",
|
||||
rank: 100,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
// search for relevant item
|
||||
const searchResults = await mwFetch<any>(
|
||||
`/${encodeURIComponent(media.meta.title)}`,
|
||||
{
|
||||
baseURL: flixHqBase,
|
||||
}
|
||||
);
|
||||
|
||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
||||
if (v.type !== "Movie" && v.type !== "TV Series") return false;
|
||||
return (
|
||||
compareTitle(v.title, media.meta.title) &&
|
||||
flixTypeToMWType(v.type) === media.meta.type &&
|
||||
v.releaseDate === media.meta.year
|
||||
);
|
||||
});
|
||||
|
||||
if (!foundItem) throw new Error("No watchable item found");
|
||||
|
||||
// get media info
|
||||
progress(25);
|
||||
const mediaInfo = await mwFetch<any>(`/info/${foundItem.id}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
type: flixTypeToMWType(foundItem.type),
|
||||
},
|
||||
});
|
||||
if (!mediaInfo.id) throw new Error("No watchable item found");
|
||||
// get stream info from media
|
||||
progress(50);
|
||||
|
||||
let episodeId: string | undefined;
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
episodeId = mediaInfo.episodeId;
|
||||
} else if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasonNo = media.meta.seasonData.number;
|
||||
const episodeNo = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
|
||||
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
|
||||
}
|
||||
if (!episodeId) throw new Error("No watchable item found");
|
||||
progress(75);
|
||||
const watchInfo = await mwFetch<any>(`/watch/${episodeId}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
id: mediaInfo.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!watchInfo.sources) throw new Error("No watchable item found");
|
||||
|
||||
// get best quality source
|
||||
// comes sorted by quality in descending order
|
||||
const source = watchInfo.sources[0];
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
quality: qualityMap[source.quality],
|
||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
||||
captions: watchInfo.subtitles.map(convertSubtitles).filter(Boolean),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
@@ -1,15 +1,11 @@
|
||||
import { unpack } from "unpacker";
|
||||
import CryptoJS from "crypto-js";
|
||||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWProviderMediaResult,
|
||||
} from "@/providers/types";
|
||||
import { unpack } from "unpacker";
|
||||
|
||||
import { CORS_PROXY_URL } from "@/mw_constants";
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality } from "@/backend/helpers/streams";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
const format = {
|
||||
stringify: (cipher: any) => {
|
||||
@@ -37,46 +33,25 @@ const format = {
|
||||
},
|
||||
};
|
||||
|
||||
export const gDrivePlayerScraper: MWMediaProvider = {
|
||||
registerProvider({
|
||||
id: "gdriveplayer",
|
||||
enabled: true,
|
||||
type: [MWMediaType.MOVIE],
|
||||
displayName: "gdriveplayer",
|
||||
disabled: true,
|
||||
rank: 69,
|
||||
type: [MWMediaType.MOVIE],
|
||||
|
||||
async getMediaFromPortable(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWProviderMediaResult> {
|
||||
const res = await fetch(
|
||||
`${CORS_PROXY_URL}https://api.gdriveplayer.us/v1/imdb/${media.mediaId}`
|
||||
).then((d) => d.json());
|
||||
|
||||
return {
|
||||
...media,
|
||||
title: res.Title,
|
||||
year: res.Year,
|
||||
} as MWProviderMediaResult;
|
||||
},
|
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
|
||||
const searchRes = await fetch(
|
||||
`${CORS_PROXY_URL}https://api.gdriveplayer.us/v1/movie/search?title=${query.searchQuery}`
|
||||
).then((d) => d.json());
|
||||
|
||||
const results: MWProviderMediaResult[] = (searchRes || []).map(
|
||||
(item: any) => ({
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
mediaId: item.imdb,
|
||||
})
|
||||
async scrape({ progress, media: { imdbId } }) {
|
||||
if (!imdbId) throw new Error("not enough info");
|
||||
progress(10);
|
||||
const streamRes = await proxiedFetch<string>(
|
||||
"https://database.gdriveplayer.us/player.php",
|
||||
{
|
||||
params: {
|
||||
imdb: imdbId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
||||
const streamRes = await fetch(
|
||||
`${CORS_PROXY_URL}https://database.gdriveplayer.us/player.php?imdb=${media.mediaId}`
|
||||
).then((d) => d.text());
|
||||
progress(90);
|
||||
const page = new DOMParser().parseFromString(streamRes, "text/html");
|
||||
|
||||
const script: HTMLElement | undefined = Array.from(
|
||||
@@ -99,6 +74,7 @@ export const gDrivePlayerScraper: MWMediaProvider = {
|
||||
{ format }
|
||||
).toString(CryptoJS.enc.Utf8)
|
||||
);
|
||||
|
||||
// eslint-disable-next-line
|
||||
const sources = JSON.parse(
|
||||
JSON.stringify(
|
||||
@@ -114,6 +90,18 @@ export const gDrivePlayerScraper: MWMediaProvider = {
|
||||
const source = sources[sources.length - 1];
|
||||
/// END
|
||||
|
||||
return { url: `https:${source.file}`, type: source.type, captions: [] };
|
||||
let quality;
|
||||
if (source.label === "720p") quality = MWStreamQuality.Q720P;
|
||||
else quality = MWStreamQuality.QUNKNOWN;
|
||||
|
||||
return {
|
||||
stream: {
|
||||
streamUrl: `https:${source.file}`,
|
||||
type: source.type,
|
||||
quality,
|
||||
captions: [],
|
||||
},
|
||||
embeds: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
162
src/backend/providers/gomovies.ts
Normal file
162
src/backend/providers/gomovies.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { MWEmbedType } from "../helpers/embed";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const gomoviesBase = "https://gomovies.sx";
|
||||
|
||||
registerProvider({
|
||||
id: "gomovies",
|
||||
displayName: "GOmovies",
|
||||
rank: 200,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode }) {
|
||||
const search = await proxiedFetch<any>("/ajax/search", {
|
||||
baseURL: gomoviesBase,
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
keyword: media.meta.title,
|
||||
}),
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
});
|
||||
|
||||
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||
const mediaElements = searchPage.querySelectorAll("a.nav-item");
|
||||
|
||||
const mediaData = Array.from(mediaElements).map((movieEl) => {
|
||||
const name = movieEl?.querySelector("h3.film-name")?.textContent;
|
||||
const year = movieEl?.querySelector(
|
||||
"div.film-infor span:first-of-type"
|
||||
)?.textContent;
|
||||
const path = movieEl.getAttribute("href");
|
||||
return { name, year, path };
|
||||
});
|
||||
|
||||
const targetMedia = mediaData.find(
|
||||
(m) =>
|
||||
m.name === media.meta.title &&
|
||||
(media.meta.type === MWMediaType.MOVIE
|
||||
? m.year === media.meta.year
|
||||
: true)
|
||||
);
|
||||
if (!targetMedia?.path) throw new Error("Media not found");
|
||||
|
||||
// Example movie path: /movie/watch-{slug}-{id}
|
||||
// Example series path: /tv/watch-{slug}-{id}
|
||||
let mediaId = targetMedia.path.split("-").pop()?.replace("/", "");
|
||||
|
||||
let sources = null;
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasons = await proxiedFetch<any>(
|
||||
`/ajax/v2/tv/seasons/${mediaId}`,
|
||||
{
|
||||
baseURL: gomoviesBase,
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const seasonsEl = new DOMParser()
|
||||
.parseFromString(seasons, "text/html")
|
||||
.querySelectorAll(".ss-item");
|
||||
|
||||
const seasonsData = [...seasonsEl].map((season) => ({
|
||||
number: season.innerHTML.replace("Season ", ""),
|
||||
dataId: season.getAttribute("data-id"),
|
||||
}));
|
||||
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const targetSeason = seasonsData.find(
|
||||
(season) => +season.number === seasonNumber
|
||||
);
|
||||
if (!targetSeason) throw new Error("Season not found");
|
||||
|
||||
const episodes = await proxiedFetch<any>(
|
||||
`/ajax/v2/season/episodes/${targetSeason.dataId}`,
|
||||
{
|
||||
baseURL: gomoviesBase,
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const episodesEl = new DOMParser()
|
||||
.parseFromString(episodes, "text/html")
|
||||
.querySelectorAll(".eps-item");
|
||||
|
||||
const episodesData = Array.from(episodesEl).map((ep) => ({
|
||||
dataId: ep.getAttribute("data-id"),
|
||||
number: ep
|
||||
.querySelector("strong")
|
||||
?.textContent?.replace("Eps", "")
|
||||
.replace(":", "")
|
||||
.trim(),
|
||||
}));
|
||||
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
const targetEpisode = episodesData.find((ep) =>
|
||||
ep.number ? +ep.number === episodeNumber : false
|
||||
);
|
||||
|
||||
if (!targetEpisode?.dataId) throw new Error("Episode not found");
|
||||
|
||||
mediaId = targetEpisode.dataId;
|
||||
|
||||
sources = await proxiedFetch<any>(`/ajax/v2/episode/servers/${mediaId}`, {
|
||||
baseURL: gomoviesBase,
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sources = await proxiedFetch<any>(`/ajax/movie/episodes/${mediaId}`, {
|
||||
baseURL: gomoviesBase,
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const upcloud = new DOMParser()
|
||||
.parseFromString(sources, "text/html")
|
||||
.querySelector('a[title*="upcloud" i]');
|
||||
|
||||
const upcloudDataId =
|
||||
upcloud?.getAttribute("data-id") ?? upcloud?.getAttribute("data-linkid");
|
||||
|
||||
if (!upcloudDataId) throw new Error("Upcloud source not available");
|
||||
|
||||
const upcloudSource = await proxiedFetch<{
|
||||
type: "iframe" | string;
|
||||
link: string;
|
||||
sources: [];
|
||||
title: string;
|
||||
tracks: [];
|
||||
}>(`/ajax/sources/${upcloudDataId}`, {
|
||||
baseURL: gomoviesBase,
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
});
|
||||
|
||||
if (!upcloudSource.link || upcloudSource.type !== "iframe")
|
||||
throw new Error("No upcloud stream found");
|
||||
|
||||
return {
|
||||
embeds: [
|
||||
{
|
||||
type: MWEmbedType.UPCLOUD,
|
||||
url: upcloudSource.link,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
197
src/backend/providers/hdwatched.ts
Normal file
197
src/backend/providers/hdwatched.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { MWProviderContext } from "../helpers/provider";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const hdwatchedBase = "https://www.hdwatched.xyz";
|
||||
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
interface SearchRes {
|
||||
title: string;
|
||||
year?: number;
|
||||
href: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function getStreamFromEmbed(stream: string) {
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch stream");
|
||||
}
|
||||
|
||||
const streamSrc = source.getAttribute("src");
|
||||
const streamRes = source.getAttribute("res");
|
||||
|
||||
if (!streamSrc || !streamRes) throw new Error("Unable to find stream");
|
||||
|
||||
return {
|
||||
streamUrl: streamSrc,
|
||||
quality:
|
||||
streamRes && typeof +streamRes === "number"
|
||||
? qualityMap[+streamRes]
|
||||
: MWStreamQuality.QUNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMovie(targetSource: SearchRes) {
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetSource.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
async function fetchSeries(
|
||||
targetSource: SearchRes,
|
||||
{ media, episode, progress }: MWProviderContext
|
||||
) {
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Media type mismatch");
|
||||
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
if (!seasonNumber || !episodeNumber)
|
||||
throw new Error("Unable to get season or episode number");
|
||||
|
||||
const seriesPage = await proxiedFetch<any>(
|
||||
`${targetSource.href}?season=${media.meta.seasonData.number}`,
|
||||
{
|
||||
baseURL: hdwatchedBase,
|
||||
}
|
||||
);
|
||||
|
||||
const seasonPage = new DOMParser().parseFromString(seriesPage, "text/html");
|
||||
const pageElements = seasonPage.querySelectorAll("div.i-container");
|
||||
|
||||
const seriesList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
|
||||
seriesList.push({
|
||||
title,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{series-slug}-season-{season-number}-episode-{episode-number}
|
||||
});
|
||||
});
|
||||
|
||||
const targetEpisode = seriesList.find(
|
||||
(episodeEl) =>
|
||||
episodeEl.title.trim().toLowerCase() === `episode ${episodeNumber}`
|
||||
);
|
||||
|
||||
if (!targetEpisode) throw new Error("Unable to find episode");
|
||||
|
||||
progress(70);
|
||||
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetEpisode.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "hdwatched",
|
||||
displayName: "HDwatched",
|
||||
rank: 150,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape(options) {
|
||||
const { media, progress } = options;
|
||||
if (!media.imdbId) throw new Error("not enough info");
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
|
||||
const search = await proxiedFetch<any>(`/search/${media.imdbId}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||
const pageElements = searchPage.querySelectorAll("div.i-container");
|
||||
|
||||
const searchList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
const year =
|
||||
parseInt(
|
||||
pageElement
|
||||
?.querySelector("div.duration")
|
||||
?.textContent?.trim()
|
||||
?.split(" ")
|
||||
?.pop() || "",
|
||||
10
|
||||
) || 0;
|
||||
|
||||
searchList.push({
|
||||
title,
|
||||
year,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{movie-slug} or /series/{id}/{series-slug}
|
||||
});
|
||||
});
|
||||
|
||||
progress(20);
|
||||
|
||||
const targetSource = searchList.find(
|
||||
(source) => source.year === (media.meta.year ? +media.meta.year : 0) // Compare year to make the search more robust
|
||||
);
|
||||
|
||||
if (!targetSource) {
|
||||
throw new Error("Could not find stream");
|
||||
}
|
||||
|
||||
progress(40);
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const series = await fetchSeries(targetSource, options);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: series.streamUrl,
|
||||
quality: series.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const movie = await fetchMovie(targetSource);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: movie.streamUrl,
|
||||
quality: movie.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
119
src/backend/providers/kissasian.ts
Normal file
119
src/backend/providers/kissasian.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { MWEmbedType } from "../helpers/embed";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const kissasianBase = "https://kissasian.li";
|
||||
|
||||
const embedProviders = [
|
||||
{
|
||||
type: MWEmbedType.MP4UPLOAD,
|
||||
id: "mp",
|
||||
},
|
||||
{
|
||||
type: MWEmbedType.STREAMSB,
|
||||
id: "sb",
|
||||
},
|
||||
];
|
||||
|
||||
registerProvider({
|
||||
id: "kissasian",
|
||||
displayName: "KissAsian",
|
||||
rank: 130,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
let seasonNumber = "";
|
||||
let episodeNumber = "";
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
seasonNumber =
|
||||
media.meta.seasonData.number === 1
|
||||
? ""
|
||||
: `${media.meta.seasonData.number}`;
|
||||
episodeNumber = `${
|
||||
media.meta.seasonData.episodes.find((e) => e.id === episode)?.number ??
|
||||
""
|
||||
}`;
|
||||
}
|
||||
|
||||
const searchForm = new FormData();
|
||||
searchForm.append("keyword", `${media.meta.title} ${seasonNumber}`.trim());
|
||||
searchForm.append("type", "Drama");
|
||||
|
||||
const search = await proxiedFetch<any>("/Search/SearchSuggest", {
|
||||
baseURL: kissasianBase,
|
||||
method: "POST",
|
||||
body: searchForm,
|
||||
});
|
||||
|
||||
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||
|
||||
const dramas = Array.from(searchPage.querySelectorAll("a")).map((drama) => {
|
||||
return {
|
||||
name: drama.textContent,
|
||||
url: drama.href,
|
||||
};
|
||||
});
|
||||
|
||||
const targetDrama =
|
||||
dramas.find(
|
||||
(d) => d.name?.toLowerCase() === media.meta.title.toLowerCase()
|
||||
) ?? dramas[0];
|
||||
if (!targetDrama) throw new Error("Drama not found");
|
||||
|
||||
progress(30);
|
||||
|
||||
const drama = await proxiedFetch<any>(targetDrama.url);
|
||||
|
||||
const dramaPage = new DOMParser().parseFromString(drama, "text/html");
|
||||
|
||||
const episodesEl = dramaPage.querySelectorAll("tbody tr:not(:first-child)");
|
||||
|
||||
const episodes = Array.from(episodesEl)
|
||||
.map((ep) => {
|
||||
const number = ep
|
||||
?.querySelector("td.episodeSub a")
|
||||
?.textContent?.split("Episode")[1]
|
||||
?.trim();
|
||||
const url = ep?.querySelector("td.episodeSub a")?.getAttribute("href");
|
||||
return { number, url };
|
||||
})
|
||||
.filter((e) => !!e.url);
|
||||
|
||||
const targetEpisode =
|
||||
media.meta.type === MWMediaType.MOVIE
|
||||
? episodes[0]
|
||||
: episodes.find((e) => e.number === `${episodeNumber}`);
|
||||
if (!targetEpisode?.url) throw new Error("Episode not found");
|
||||
|
||||
progress(70);
|
||||
|
||||
let embeds = await Promise.all(
|
||||
embedProviders.map(async (provider) => {
|
||||
const watch = await proxiedFetch<any>(
|
||||
`${targetEpisode.url}&s=${provider.id}`,
|
||||
{
|
||||
baseURL: kissasianBase,
|
||||
}
|
||||
);
|
||||
|
||||
const watchPage = new DOMParser().parseFromString(watch, "text/html");
|
||||
|
||||
const embedUrl = watchPage
|
||||
.querySelector("iframe[id=my_video_1]")
|
||||
?.getAttribute("src");
|
||||
|
||||
return {
|
||||
type: provider.type,
|
||||
url: embedUrl ?? "",
|
||||
};
|
||||
})
|
||||
);
|
||||
embeds = embeds.filter((e) => e.url !== "");
|
||||
|
||||
return {
|
||||
embeds,
|
||||
};
|
||||
},
|
||||
});
|
236
src/backend/providers/m4ufree.ts
Normal file
236
src/backend/providers/m4ufree.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
||||
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const HOST = "m4ufree.com";
|
||||
const URL_BASE = `https://${HOST}`;
|
||||
const URL_SEARCH = `${URL_BASE}/search`;
|
||||
const URL_AJAX = `${URL_BASE}/ajax`;
|
||||
const URL_AJAX_TV = `${URL_BASE}/ajaxtv`;
|
||||
|
||||
// * Years can be in one of 4 formats:
|
||||
// * - "startyear" (for movies, EX: 2022)
|
||||
// * - "startyear-" (for TV series which has not ended, EX: 2022-)
|
||||
// * - "startyear-endyear" (for TV series which has ended, EX: 2022-2023)
|
||||
// * - "startyearendyear" (for TV series which has ended, EX: 20222023)
|
||||
const REGEX_TITLE_AND_YEAR = /(.*) \(?(\d*|\d*-|\d*-\d*)\)?$/;
|
||||
const REGEX_TYPE = /.*-(movie|tvshow)-online-free-m4ufree\.html/;
|
||||
const REGEX_COOKIES = /XSRF-TOKEN=(.*?);.*laravel_session=(.*?);/;
|
||||
const REGEX_SEASON_EPISODE = /S(\d*)-E(\d*)/;
|
||||
|
||||
function toDom(html: string) {
|
||||
return new DOMParser().parseFromString(html, "text/html");
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "m4ufree",
|
||||
displayName: "m4ufree",
|
||||
rank: -1,
|
||||
disabled: true, // Disables because the redirector URLs it returns will throw 404 / 403 depending on if you view it in the browser or fetch it respectively. It just does not work.
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, type, episode: episodeId, season: seasonId }) {
|
||||
const season =
|
||||
media.meta.seasons?.find((s) => s.id === seasonId)?.number || 1;
|
||||
const episode =
|
||||
media.meta.type === MWMediaType.SERIES
|
||||
? media.meta.seasonData.episodes.find((ep) => ep.id === episodeId)
|
||||
?.number || 1
|
||||
: undefined;
|
||||
|
||||
const embeds: MWEmbed[] = [];
|
||||
|
||||
/*
|
||||
, {
|
||||
responseType: "text" as any,
|
||||
}
|
||||
*/
|
||||
const responseText = await proxiedFetch<string>(
|
||||
`${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html`
|
||||
);
|
||||
let dom = toDom(responseText);
|
||||
|
||||
const searchResults = [...dom.querySelectorAll(".item")]
|
||||
.map((element) => {
|
||||
const tooltipText = element.querySelector(".tiptitle p")?.innerHTML;
|
||||
if (!tooltipText) return;
|
||||
|
||||
let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText);
|
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = regexResult[1];
|
||||
const year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year
|
||||
const a = element.querySelector("a");
|
||||
if (!a) return;
|
||||
const href = a.href;
|
||||
|
||||
regexResult = REGEX_TYPE.exec(href);
|
||||
|
||||
if (!regexResult || !regexResult[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scraperDeterminedType = regexResult[1];
|
||||
|
||||
scraperDeterminedType =
|
||||
scraperDeterminedType === "tvshow" ? "show" : "movie"; // * Map to Trakt type
|
||||
|
||||
return { type: scraperDeterminedType, title, year, href };
|
||||
})
|
||||
.filter((item) => item);
|
||||
|
||||
const mediaInResults = searchResults.find(
|
||||
(item) =>
|
||||
item &&
|
||||
item.title === media.meta.title &&
|
||||
item.year.toString() === media.meta.year
|
||||
);
|
||||
|
||||
if (!mediaInResults) {
|
||||
// * Nothing found
|
||||
return {
|
||||
embeds,
|
||||
};
|
||||
}
|
||||
|
||||
let cookies: string | null = "";
|
||||
const responseTextFromMedia = await proxiedFetch<string>(
|
||||
mediaInResults.href,
|
||||
{
|
||||
onResponse(context) {
|
||||
cookies = context.response.headers.get("X-Set-Cookie");
|
||||
},
|
||||
}
|
||||
);
|
||||
dom = toDom(responseTextFromMedia);
|
||||
|
||||
let regexResult = REGEX_COOKIES.exec(cookies);
|
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
// * DO SOMETHING?
|
||||
throw new Error("No regexResults, yikesssssss kinda gross idk");
|
||||
}
|
||||
|
||||
const cookieHeader = `XSRF-TOKEN=${regexResult[1]}; laravel_session=${regexResult[2]}`;
|
||||
|
||||
const token = dom
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute("content");
|
||||
if (!token) return { embeds };
|
||||
|
||||
if (type === MWMediaType.SERIES) {
|
||||
// * Get the season/episode data
|
||||
const episodes = [...dom.querySelectorAll(".episode")]
|
||||
.map((element) => {
|
||||
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML);
|
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newEpisode = Number(regexResult[1]);
|
||||
const newSeason = Number(regexResult[2]);
|
||||
|
||||
return {
|
||||
id: element.getAttribute("idepisode"),
|
||||
episode: newEpisode,
|
||||
season: newSeason,
|
||||
};
|
||||
})
|
||||
.filter((item) => item);
|
||||
|
||||
const ep = episodes.find(
|
||||
(newEp) => newEp && newEp.episode === episode && newEp.season === season
|
||||
);
|
||||
if (!ep) return { embeds };
|
||||
|
||||
const form = `idepisode=${ep.id}&_token=${token}`;
|
||||
|
||||
const response = await proxiedFetch<string>(URL_AJAX_TV, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Sec-CH-UA":
|
||||
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||
"Sec-CH-UA-Mobile": "?0",
|
||||
"Sec-CH-UA-Platform": '"Linux"',
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"X-Cookie": cookieHeader,
|
||||
"X-Origin": URL_BASE,
|
||||
"X-Referer": mediaInResults.href,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
dom = toDom(response);
|
||||
}
|
||||
|
||||
const servers = [...dom.querySelectorAll(".singlemv")].map((element) =>
|
||||
element.getAttribute("data")
|
||||
);
|
||||
|
||||
for (const server of servers) {
|
||||
const form = `m4u=${server}&_token=${token}`;
|
||||
|
||||
const response = await proxiedFetch<string>(URL_AJAX, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Sec-CH-UA":
|
||||
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||
"Sec-CH-UA-Mobile": "?0",
|
||||
"Sec-CH-UA-Platform": '"Linux"',
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"X-Cookie": cookieHeader,
|
||||
"X-Origin": URL_BASE,
|
||||
"X-Referer": mediaInResults.href,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
const serverDom = toDom(response);
|
||||
|
||||
const link = serverDom.querySelector("iframe")?.src;
|
||||
|
||||
const getEmbedType = (url: string) => {
|
||||
if (url.startsWith("https://streamm4u.club"))
|
||||
return MWEmbedType.STREAMM4U;
|
||||
if (url.startsWith("https://play.playm4u.xyz"))
|
||||
return MWEmbedType.PLAYM4U;
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!link) continue;
|
||||
|
||||
const embedType = getEmbedType(link);
|
||||
if (embedType) {
|
||||
embeds.push({
|
||||
url: link,
|
||||
type: embedType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(embeds);
|
||||
return {
|
||||
embeds,
|
||||
};
|
||||
},
|
||||
});
|
154
src/backend/providers/netfilm.ts
Normal file
154
src/backend/providers/netfilm.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const netfilmBase = "https://net-film.vercel.app";
|
||||
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
registerProvider({
|
||||
id: "netfilm",
|
||||
displayName: "NetFilm",
|
||||
rank: 15,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
disabled: true, // The creator has asked us (very nicely) to leave him alone. Until (if) we self-host, netfilm should remain disabled
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
// search for relevant item
|
||||
const searchResponse = await proxiedFetch<any>(
|
||||
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
|
||||
{
|
||||
baseURL: netfilmBase,
|
||||
}
|
||||
);
|
||||
|
||||
const searchResults = searchResponse.data.results;
|
||||
progress(25);
|
||||
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
const foundItem = searchResults.find((v: any) => {
|
||||
return v.name === media.meta.title && v.releaseTime === media.meta.year;
|
||||
});
|
||||
if (!foundItem) throw new Error("No watchable item found");
|
||||
const netfilmId = foundItem.id;
|
||||
|
||||
// get stream info from media
|
||||
progress(75);
|
||||
const watchInfo = await proxiedFetch<any>(
|
||||
`/api/episode?id=${netfilmId}`,
|
||||
{
|
||||
baseURL: netfilmBase,
|
||||
}
|
||||
);
|
||||
|
||||
const data = watchInfo.data;
|
||||
|
||||
// get best quality source
|
||||
const source: { url: string; quality: number } = data.qualities.reduce(
|
||||
(p: any, c: any) => (c.quality > p.quality ? c : p)
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
needsProxy: false,
|
||||
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
|
||||
type: MWCaptionType.SRT,
|
||||
langIso: sub.language,
|
||||
}));
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality],
|
||||
type: MWStreamType.HLS,
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Unsupported type");
|
||||
|
||||
const desiredSeason = media.meta.seasonData.number;
|
||||
|
||||
const searchItems = searchResults
|
||||
.filter((v: any) => {
|
||||
return v.name.includes(media.meta.title);
|
||||
})
|
||||
.map((v: any) => {
|
||||
return {
|
||||
...v,
|
||||
season: parseInt(v.name.split(" ").at(-1), 10) || 1,
|
||||
};
|
||||
});
|
||||
|
||||
const foundItem = searchItems.find((v: any) => {
|
||||
return v.season === desiredSeason;
|
||||
});
|
||||
|
||||
progress(50);
|
||||
const seasonDetail = await proxiedFetch<any>(
|
||||
`/api/detail?id=${foundItem.id}&category=${foundItem.categoryTag[0].id}`,
|
||||
{
|
||||
baseURL: netfilmBase,
|
||||
}
|
||||
);
|
||||
|
||||
const episodeNo = media.meta.seasonData.episodes.find(
|
||||
(v: any) => v.id === episode
|
||||
)?.number;
|
||||
const episodeData = seasonDetail.data.episodeVo.find(
|
||||
(v: any) => v.seriesNo === episodeNo
|
||||
);
|
||||
|
||||
progress(75);
|
||||
const episodeStream = await proxiedFetch<any>(
|
||||
`/api/episode?id=${foundItem.id}&category=1&episode=${episodeData.id}`,
|
||||
{
|
||||
baseURL: netfilmBase,
|
||||
}
|
||||
);
|
||||
|
||||
const data = episodeStream.data;
|
||||
|
||||
// get best quality source
|
||||
const source: { url: string; quality: number } = data.qualities.reduce(
|
||||
(p: any, c: any) => (c.quality > p.quality ? c : p)
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
needsProxy: false,
|
||||
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
|
||||
type: MWCaptionType.SRT,
|
||||
langIso: sub.language,
|
||||
}));
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality],
|
||||
type: MWStreamType.HLS,
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
49
src/backend/providers/remotestream.ts
Normal file
49
src/backend/providers/remotestream.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { mwFetch } from "@/backend/helpers/fetch";
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
const remotestreamBase = `https://fsa.remotestre.am`;
|
||||
|
||||
registerProvider({
|
||||
id: "remotestream",
|
||||
displayName: "Remote Stream",
|
||||
disabled: false,
|
||||
rank: 55,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
|
||||
progress(30);
|
||||
const type = media.meta.type === MWMediaType.MOVIE ? "Movies" : "Shows";
|
||||
let playlistLink = `${remotestreamBase}/${type}/${media.tmdbId}`;
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
playlistLink += `/${seasonNumber}/${episodeNumber}/${episodeNumber}.m3u8`;
|
||||
} else {
|
||||
playlistLink += `/${media.tmdbId}.m3u8`;
|
||||
}
|
||||
|
||||
const streamRes = await mwFetch<Blob>(playlistLink);
|
||||
if (streamRes.type !== "application/x-mpegurl")
|
||||
throw new Error("No watchable item found");
|
||||
progress(90);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: playlistLink,
|
||||
quality: MWStreamQuality.QUNKNOWN,
|
||||
type: MWStreamType.HLS,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
99
src/backend/providers/sflix.ts
Normal file
99
src/backend/providers/sflix.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const sflixBase = "https://sflix.video";
|
||||
|
||||
registerProvider({
|
||||
id: "sflix",
|
||||
displayName: "Sflix",
|
||||
rank: 50,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape({ media, episode, progress }) {
|
||||
let searchQuery = `${media.meta.title} `;
|
||||
|
||||
if (media.meta.type === MWMediaType.MOVIE)
|
||||
searchQuery += media.meta.year ?? "";
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES)
|
||||
searchQuery += `S${String(media.meta.seasonData.number).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}`;
|
||||
|
||||
const search = await proxiedFetch<any>(
|
||||
`/?s=${encodeURIComponent(searchQuery)}`,
|
||||
{
|
||||
baseURL: sflixBase,
|
||||
}
|
||||
);
|
||||
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||
|
||||
const moviePageUrl = searchPage
|
||||
.querySelector(".movies-list .ml-item:first-child a")
|
||||
?.getAttribute("href");
|
||||
if (!moviePageUrl) throw new Error("Movie does not exist");
|
||||
|
||||
progress(25);
|
||||
|
||||
const movie = await proxiedFetch<any>(moviePageUrl);
|
||||
const moviePage = new DOMParser().parseFromString(movie, "text/html");
|
||||
|
||||
progress(45);
|
||||
|
||||
let outerEmbedSrc = null;
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
outerEmbedSrc = moviePage
|
||||
.querySelector("iframe")
|
||||
?.getAttribute("data-lazy-src");
|
||||
} else if (media.meta.type === MWMediaType.SERIES) {
|
||||
const series = Array.from(moviePage.querySelectorAll(".desc p a")).map(
|
||||
(a) => ({
|
||||
title: a.getAttribute("title"),
|
||||
link: a.getAttribute("href"),
|
||||
})
|
||||
);
|
||||
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
const targetSeries = series.find((s) =>
|
||||
s.title?.endsWith(String(episodeNumber).padStart(2, "0"))
|
||||
);
|
||||
if (!targetSeries) throw new Error("Episode does not exist");
|
||||
|
||||
outerEmbedSrc = targetSeries.link;
|
||||
}
|
||||
if (!outerEmbedSrc) throw new Error("Outer embed source not found");
|
||||
|
||||
progress(65);
|
||||
|
||||
const outerEmbed = await proxiedFetch<any>(outerEmbedSrc);
|
||||
const outerEmbedPage = new DOMParser().parseFromString(
|
||||
outerEmbed,
|
||||
"text/html"
|
||||
);
|
||||
|
||||
const embedSrc = outerEmbedPage
|
||||
.querySelector("iframe")
|
||||
?.getAttribute("src");
|
||||
if (!embedSrc) throw new Error("Embed source not found");
|
||||
|
||||
const embed = await proxiedFetch<string>(embedSrc);
|
||||
|
||||
const streamUrl = embed.match(/file\s*:\s*"([^"]+\.mp4)"/)?.[1];
|
||||
if (!streamUrl) throw new Error("Unable to get stream");
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl,
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
70
src/backend/providers/streamflix.ts
Normal file
70
src/backend/providers/streamflix.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
const streamflixBase = "https://us-west2-compute-proxied.streamflix.one";
|
||||
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
registerProvider({
|
||||
id: "streamflix",
|
||||
displayName: "StreamFlix",
|
||||
disabled: false,
|
||||
rank: 69,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
|
||||
progress(30);
|
||||
const type = media.meta.type === MWMediaType.MOVIE ? "movies" : "tv";
|
||||
let seasonNumber: number | undefined;
|
||||
let episodeNumber: number | undefined;
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
// can't do type === "tv" here :(
|
||||
seasonNumber = media.meta.seasonData.number;
|
||||
episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e: any) => e.id === episode
|
||||
)?.number;
|
||||
}
|
||||
|
||||
const streamRes = await proxiedFetch<any>(`/api/player/${type}`, {
|
||||
baseURL: streamflixBase,
|
||||
params: {
|
||||
id: media.tmdbId,
|
||||
s: seasonNumber,
|
||||
e: episodeNumber,
|
||||
},
|
||||
});
|
||||
if (!streamRes.headers.Referer) throw new Error("No watchable item found");
|
||||
progress(90);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: streamRes.sources[0].url,
|
||||
quality: qualityMap[streamRes.sources[0].quality],
|
||||
type: MWStreamType.HLS,
|
||||
captions: streamRes.subtitles.map((s: Record<string, any>) => ({
|
||||
needsProxy: true,
|
||||
url: s.url,
|
||||
type: MWCaptionType.VTT,
|
||||
langIso: s.lang,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
263
src/backend/providers/superstream/index.ts
Normal file
263
src/backend/providers/superstream/index.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import CryptoJS from "crypto-js";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
import {
|
||||
getMWCaptionTypeFromUrl,
|
||||
isSupportedSubtitle,
|
||||
} from "@/backend/helpers/captions";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWCaption,
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
import { compareTitle } from "@/utils/titleMatch";
|
||||
|
||||
const nanoid = customAlphabet("0123456789abcdef", 32);
|
||||
|
||||
const qualityMap = {
|
||||
"360p": MWStreamQuality.Q360P,
|
||||
"480p": MWStreamQuality.Q480P,
|
||||
"720p": MWStreamQuality.Q720P,
|
||||
"1080p": MWStreamQuality.Q1080P,
|
||||
};
|
||||
type QualityInMap = keyof typeof qualityMap;
|
||||
|
||||
// CONSTANTS, read below (taken from og)
|
||||
// We do not want content scanners to notice this scraping going on so we've hidden all constants
|
||||
// The source has its origins in China so I added some extra security with banned words
|
||||
// Mayhaps a tiny bit unethical, but this source is just too good :)
|
||||
// If you are copying this code please use precautions so they do not change their api.
|
||||
const iv = atob("d0VpcGhUbiE=");
|
||||
const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2");
|
||||
const apiUrls = [
|
||||
atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="),
|
||||
atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="),
|
||||
];
|
||||
const appKey = atob("bW92aWVib3g=");
|
||||
const appId = atob("Y29tLnRkby5zaG93Ym94");
|
||||
|
||||
// cryptography stuff
|
||||
const crypto = {
|
||||
encrypt(str: string) {
|
||||
return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), {
|
||||
iv: CryptoJS.enc.Utf8.parse(iv),
|
||||
}).toString();
|
||||
},
|
||||
getVerify(str: string, str2: string, str3: string) {
|
||||
if (str) {
|
||||
return CryptoJS.MD5(
|
||||
CryptoJS.MD5(str2).toString() + str3 + str
|
||||
).toString();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
// get expire time
|
||||
const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12);
|
||||
|
||||
// sending requests
|
||||
const get = (data: object, altApi = false) => {
|
||||
const defaultData = {
|
||||
childmode: "0",
|
||||
app_version: "11.5",
|
||||
appid: appId,
|
||||
lang: "en",
|
||||
expired_date: `${expiry()}`,
|
||||
platform: "android",
|
||||
channel: "Website",
|
||||
};
|
||||
const encryptedData = crypto.encrypt(
|
||||
JSON.stringify({
|
||||
...defaultData,
|
||||
...data,
|
||||
})
|
||||
);
|
||||
const appKeyHash = CryptoJS.MD5(appKey).toString();
|
||||
const verify = crypto.getVerify(encryptedData, appKey, key);
|
||||
const body = JSON.stringify({
|
||||
app_key: appKeyHash,
|
||||
verify,
|
||||
encrypt_data: encryptedData,
|
||||
});
|
||||
const b64Body = btoa(body);
|
||||
|
||||
const formatted = new URLSearchParams();
|
||||
formatted.append("data", b64Body);
|
||||
formatted.append("appid", "27");
|
||||
formatted.append("platform", "android");
|
||||
formatted.append("version", "129");
|
||||
formatted.append("medium", "Website");
|
||||
|
||||
const requestUrl = altApi ? apiUrls[1] : apiUrls[0];
|
||||
return proxiedFetch<any>(requestUrl, {
|
||||
method: "POST",
|
||||
parseResponse: JSON.parse,
|
||||
headers: {
|
||||
Platform: "android",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `${formatted.toString()}&token${nanoid()}`,
|
||||
});
|
||||
};
|
||||
|
||||
// Find best resolution
|
||||
const getBestQuality = (list: any[]) => {
|
||||
return (
|
||||
list.find((quality: any) => quality.quality === "1080p" && quality.path) ??
|
||||
list.find((quality: any) => quality.quality === "720p" && quality.path) ??
|
||||
list.find((quality: any) => quality.quality === "480p" && quality.path) ??
|
||||
list.find((quality: any) => quality.quality === "360p" && quality.path)
|
||||
);
|
||||
};
|
||||
|
||||
const convertSubtitles = (subtitleGroup: any): MWCaption | null => {
|
||||
let subtitles = subtitleGroup.subtitles;
|
||||
subtitles = subtitles
|
||||
.map((subFile: any) => {
|
||||
const supported = isSupportedSubtitle(subFile.file_path);
|
||||
if (!supported) return null;
|
||||
const type = getMWCaptionTypeFromUrl(subFile.file_path);
|
||||
return {
|
||||
...subFile,
|
||||
type: type as MWCaptionType,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (subtitles.length === 0) return null;
|
||||
const subFile = subtitles[0];
|
||||
return {
|
||||
needsProxy: true,
|
||||
langIso: subtitleGroup.language,
|
||||
url: subFile.file_path,
|
||||
type: subFile.type,
|
||||
};
|
||||
};
|
||||
|
||||
registerProvider({
|
||||
id: "superstream",
|
||||
displayName: "Superstream",
|
||||
rank: 300,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
// Find Superstream ID for show
|
||||
const searchQuery = {
|
||||
module: "Search3",
|
||||
page: "1",
|
||||
type: "all",
|
||||
keyword: media.meta.title,
|
||||
pagelimit: "20",
|
||||
};
|
||||
const searchRes = (await get(searchQuery, true)).data;
|
||||
progress(33);
|
||||
|
||||
const superstreamEntry = searchRes.find(
|
||||
(res: any) =>
|
||||
compareTitle(res.title, media.meta.title) &&
|
||||
res.year === Number(media.meta.year)
|
||||
);
|
||||
|
||||
if (!superstreamEntry) throw new Error("No entry found on SuperStream");
|
||||
const superstreamId = superstreamEntry.id;
|
||||
|
||||
// Movie logic
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
const apiQuery = {
|
||||
uid: "",
|
||||
module: "Movie_downloadurl_v3",
|
||||
mid: superstreamId,
|
||||
oss: "1",
|
||||
group: "",
|
||||
};
|
||||
|
||||
const mediaRes = (await get(apiQuery)).data;
|
||||
progress(50);
|
||||
|
||||
const hdQuality = getBestQuality(mediaRes.list);
|
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found.");
|
||||
|
||||
const subtitleApiQuery = {
|
||||
fid: hdQuality.fid,
|
||||
uid: "",
|
||||
module: "Movie_srt_list_v2",
|
||||
mid: superstreamId,
|
||||
};
|
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||
|
||||
const mappedCaptions = subtitleRes.list
|
||||
.map(convertSubtitles)
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: hdQuality.path,
|
||||
quality: qualityMap[hdQuality.quality as QualityInMap],
|
||||
type: MWStreamType.MP4,
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Unsupported type");
|
||||
|
||||
// Fetch requested episode
|
||||
const apiQuery = {
|
||||
uid: "",
|
||||
module: "TV_downloadurl_v3",
|
||||
tid: superstreamId,
|
||||
season: media.meta.seasonData.number.toString(),
|
||||
episode: (
|
||||
media.meta.seasonData.episodes.find(
|
||||
(episodeInfo) => episodeInfo.id === episode
|
||||
)?.number ?? 1
|
||||
).toString(),
|
||||
oss: "1",
|
||||
group: "",
|
||||
};
|
||||
|
||||
const mediaRes = (await get(apiQuery)).data;
|
||||
progress(66);
|
||||
|
||||
const hdQuality = getBestQuality(mediaRes.list);
|
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found.");
|
||||
|
||||
const subtitleApiQuery = {
|
||||
fid: hdQuality.fid,
|
||||
uid: "",
|
||||
module: "TV_srt_list_v2",
|
||||
episode:
|
||||
media.meta.seasonData.episodes.find(
|
||||
(episodeInfo) => episodeInfo.id === episode
|
||||
)?.number ?? 1,
|
||||
tid: superstreamId,
|
||||
season: media.meta.seasonData.number.toString(),
|
||||
};
|
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||
const mappedCaptions = subtitleRes.list
|
||||
.map(convertSubtitles)
|
||||
.filter(Boolean);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
quality: qualityMap[
|
||||
hdQuality.quality as QualityInMap
|
||||
] as MWStreamQuality,
|
||||
streamUrl: hdQuality.path,
|
||||
type: MWStreamType.MP4,
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
28
src/components/Banner.tsx
Normal file
28
src/components/Banner.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useBanner } from "@/hooks/useBanner";
|
||||
|
||||
export function Banner(props: { children: React.ReactNode; type: "error" }) {
|
||||
const [ref] = useBanner<HTMLDivElement>("internet");
|
||||
const styles = {
|
||||
error: "bg-[#C93957] text-white",
|
||||
};
|
||||
const icons = {
|
||||
error: Icons.CIRCLE_EXCLAMATION,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div
|
||||
className={[
|
||||
styles[props.type],
|
||||
"flex items-center justify-center p-1",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Icon icon={icons[props.type]} />
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
26
src/components/Button.tsx
Normal file
26
src/components/Button.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
interface Props {
|
||||
icon?: Icons;
|
||||
onClick?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Button(props: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
className="inline-flex items-center justify-center rounded-lg bg-white px-8 py-3 font-bold text-black transition-[transform,background-color] duration-100 hover:bg-gray-200 active:scale-105 md:px-16"
|
||||
>
|
||||
{props.icon ? (
|
||||
<span className="mr-3 hidden md:inline-block">
|
||||
<Icon icon={props.icon} />
|
||||
</span>
|
||||
) : null}
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
30
src/components/CaptionColorSelector.tsx
Normal file
30
src/components/CaptionColorSelector.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useSettings } from "@/state/settings";
|
||||
|
||||
import { Icon, Icons } from "./Icon";
|
||||
|
||||
export const colors = ["#ffffff", "#00ffff", "#ffff00"];
|
||||
export default function CaptionColorSelector({ color }: { color: string }) {
|
||||
const { captionSettings, setCaptionColor } = useSettings();
|
||||
return (
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${
|
||||
color === captionSettings.style.color ? "bg-[#1C161B]" : ""
|
||||
}`}
|
||||
onClick={() => setCaptionColor(color)}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
className={[
|
||||
"absolute text-xs text-[#1C161B]",
|
||||
color === captionSettings.style.color ? "" : "hidden",
|
||||
].join(" ")}
|
||||
icon={Icons.CHECKMARK}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
export interface OptionItem {
|
||||
@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:bottom-10 sm:text-sm">
|
||||
<Listbox.Options className="absolute left-0 right-0 top-10 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
@@ -57,5 +57,5 @@ export function Dropdown(props: DropdownProps) {
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@@ -1,12 +1,16 @@
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
|
||||
export enum Icons {
|
||||
SEARCH = "search",
|
||||
BOOKMARK = "bookmark",
|
||||
BOOKMARK_OUTLINE = "bookmark_outline",
|
||||
CLOCK = "clock",
|
||||
EYE_SLASH = "eyeSlash",
|
||||
ARROW_LEFT = "arrowLeft",
|
||||
ARROW_RIGHT = "arrowRight",
|
||||
CHEVRON_DOWN = "chevronDown",
|
||||
CHEVRON_RIGHT = "chevronRight",
|
||||
CHEVRON_LEFT = "chevronLeft",
|
||||
CLAPPER_BOARD = "clapperBoard",
|
||||
FILM = "film",
|
||||
DRAGON = "dragon",
|
||||
@@ -14,6 +18,29 @@ export enum Icons {
|
||||
MOVIE_WEB = "movieWeb",
|
||||
DISCORD = "discord",
|
||||
GITHUB = "github",
|
||||
PLAY = "play",
|
||||
PAUSE = "pause",
|
||||
EXPAND = "expand",
|
||||
COMPRESS = "compress",
|
||||
VOLUME = "volume",
|
||||
VOLUME_X = "volume_x",
|
||||
X = "x",
|
||||
EDIT = "edit",
|
||||
AIRPLAY = "airplay",
|
||||
EPISODES = "episodes",
|
||||
SKIP_FORWARD = "skip_forward",
|
||||
SKIP_BACKWARD = "skip_backward",
|
||||
FILE = "file",
|
||||
CAPTIONS = "captions",
|
||||
LINK = "link",
|
||||
CASTING = "casting",
|
||||
CIRCLE_EXCLAMATION = "circle_exclamation",
|
||||
DOWNLOAD = "download",
|
||||
GEAR = "gear",
|
||||
WATCH_PARTY = "watch_party",
|
||||
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||
CHECKMARK = "checkmark",
|
||||
TACHOMETER = "tachometer",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
@@ -29,6 +56,7 @@ const iconList: Record<Icons, string> = {
|
||||
arrowLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>`,
|
||||
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
|
||||
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
|
||||
chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-left"><polyline points="15 18 9 12 15 6"></polyline></svg>`,
|
||||
clapperBoard: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`,
|
||||
film: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M463.1 32h-416C21.49 32-.0001 53.49-.0001 80v352c0 26.51 21.49 48 47.1 48h416c26.51 0 48-21.49 48-48v-352C511.1 53.49 490.5 32 463.1 32zM111.1 408c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 408zM111.1 280c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM111.1 152c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 152zM351.1 400c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V400zM351.1 208c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V208zM463.1 408c0 4.418-3.582 8-8 8h-47.1c-4.418 0-7.1-3.582-7.1-8l0-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V408zM463.1 280c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM463.1 152c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8l0-48c0-4.418 3.582-8 7.1-8h47.1c4.418 0 8 3.582 8 8V152z"/></svg>`,
|
||||
dragon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M18.43 255.8L192 224L100.8 292.6C90.67 302.8 97.8 320 112 320h222.7c-9.499-26.5-14.75-54.5-14.75-83.38V194.2L200.3 106.8C176.5 90.88 145 92.75 123.3 111.2l-117.5 116.4C-6.562 238 2.436 258 18.43 255.8zM575.2 289.9l-100.7-50.25c-16.25-8.125-26.5-24.75-26.5-43V160h63.99l28.12 22.62C546.1 188.6 554.2 192 562.7 192h30.1c11.1 0 23.12-6.875 28.5-17.75l14.37-28.62c5.374-10.87 4.25-23.75-2.999-33.5l-74.49-99.37C552.1 4.75 543.5 0 533.5 0H296C288.9 0 285.4 8.625 290.4 13.62L351.1 64L292.4 88.75c-5.874 3-5.874 11.37 0 14.37L351.1 128l-.0011 108.6c0 72 35.99 139.4 95.99 179.4c-195.6 6.75-344.4 41-434.1 60.88c-8.124 1.75-13.87 9-13.87 17.38C.0463 504 8.045 512 17.79 512h499.1c63.24 0 119.6-47.5 122.1-110.8C642.3 354 617.1 310.9 575.2 289.9zM489.1 66.25l45.74 11.38c-2.75 11-12.5 18.88-24.12 18.25C497.7 95.25 484.8 83.38 489.1 66.25z"/></svg>`,
|
||||
@@ -37,13 +65,53 @@ const iconList: Record<Icons, string> = {
|
||||
movieWeb: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20.927 20.927"><path d="M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z" transform="translate(10.018 -7.425) rotate(45)" fill="currentColor"/></svg>`,
|
||||
discord: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`,
|
||||
github: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 496 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>`,
|
||||
play: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" style="transform: translateX(5%)" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>`,
|
||||
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M48 64C21.5 64 0 85.5 0 112V400c0 26.5 21.5 48 48 48H80c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zm192 0c-26.5 0-48 21.5-48 48V400c0 26.5 21.5 48 48 48h32c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H240z"/></svg>`,
|
||||
expand: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64v96c0 17.7 14.3 32 32 32s32-14.3 32-32V96h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H64V352zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64v64c0 17.7 14.3 32 32 32s32-14.3 32-32V64c0-17.7-14.3-32-32-32H320zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H320c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V352z"/></svg>`,
|
||||
compress: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M160 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V64zM32 320c-17.7 0-32 14.3-32 32s14.3 32 32 32H96v64c0 17.7 14.3 32 32 32s32-14.3 32-32V352c0-17.7-14.3-32-32-32H32zM352 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H352V64zM320 320c-17.7 0-32 14.3-32 32v96c0 17.7 14.3 32 32 32s32-14.3 32-32V384h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H320z"/></svg>`,
|
||||
volume: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>`,
|
||||
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
|
||||
x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"/></svg>`,
|
||||
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>`,
|
||||
bookmark_outline: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M336 0h-288C21.49 0 0 21.49 0 48v431.9c0 24.7 26.79 40.08 48.12 27.64L192 423.6l143.9 83.93C357.2 519.1 384 504.6 384 479.9V48C384 21.49 362.5 0 336 0zM336 452L192 368l-144 84V54C48 50.63 50.63 48 53.1 48h276C333.4 48 336 50.63 336 54V452z"/></svg>`,
|
||||
airplay: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-airplay"><path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"></path><polygon fill="currentColor" points="12 15 17 21 7 21 12 15"></polygon></svg>`,
|
||||
episodes: `<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4C1.34315 4 0 5.34314 0 7V13.9496C0 15.6065 1.34315 16.9496 3 16.9496H5.86645V14.9496H3C2.44772 14.9496 2 14.5019 2 13.9496V7C2 6.44771 2.44771 6 3 6H16.0327C16.585 6 17.0327 6.44772 17.0327 7V9.86645H19.0327V7C19.0327 5.34315 17.6896 4 16.0327 4H3Z" fill="currentColor"/><rect x="5.89929" y="10.5444" width="17" height="10" rx="2" stroke="currentColor" stroke-width="2"/></svg>`,
|
||||
skip_forward: `<svg width="1em" height="1em" viewBox="0 0 26 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.3333 12.3333L16 7.66667M16 7.66667L11.3333 3M16 7.66667H6.66667C5.42899 7.66667 4.242 8.15833 3.36684 9.0335C2.49167 9.90867 2 11.0957 2 12.3333C2 13.571 2.49167 14.758 3.36684 15.6332C4.242 16.5083 5.42899 17 6.66667 17H9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16.5043 14.2727V23H14.6591V16.0241H14.608L12.6094 17.277V15.6406L14.7699 14.2727H16.5043ZM22.0004 23.1918C21.2674 23.1889 20.6367 23.0085 20.1083 22.6506C19.5827 22.2926 19.1779 21.7741 18.8938 21.0952C18.6126 20.4162 18.4734 19.5994 18.4762 18.6449C18.4762 17.6932 18.6168 16.8821 18.8981 16.2116C19.1822 15.5412 19.587 15.0312 20.1126 14.6818C20.641 14.3295 21.2702 14.1534 22.0004 14.1534C22.7305 14.1534 23.3583 14.3295 23.8839 14.6818C24.4123 15.0341 24.8185 15.5455 25.1026 16.2159C25.3867 16.8835 25.5273 17.6932 25.5245 18.6449C25.5245 19.6023 25.3825 20.4205 25.0984 21.0994C24.8171 21.7784 24.4137 22.2969 23.8881 22.6548C23.3626 23.0128 22.7333 23.1918 22.0004 23.1918ZM22.0004 21.6619C22.5004 21.6619 22.8995 21.4105 23.1978 20.9077C23.4961 20.4048 23.6438 19.6506 23.641 18.6449C23.641 17.983 23.5728 17.4318 23.4364 16.9915C23.3029 16.5511 23.1126 16.2202 22.8654 15.9986C22.6211 15.777 22.3327 15.6662 22.0004 15.6662C21.5032 15.6662 21.1055 15.9148 20.8072 16.4119C20.5089 16.9091 20.3583 17.6534 20.3555 18.6449C20.3555 19.3153 20.4222 19.875 20.5558 20.3239C20.6921 20.7699 20.8839 21.1051 21.131 21.3295C21.3782 21.5511 21.668 21.6619 22.0004 21.6619Z" fill="currentColor" /></svg>`,
|
||||
skip_backward: `<svg width="1em" height="1em" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`,
|
||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
||||
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 25 20"><path transform="translate(-3 -6)" d="M25.5,6H5.5A2.507,2.507,0,0,0,3,8.5v15A2.507,2.507,0,0,0,5.5,26h20A2.507,2.507,0,0,0,28,23.5V8.5A2.507,2.507,0,0,0,25.5,6ZM5.5,16h5v2.5h-5ZM18,23.5H5.5V21H18Zm7.5,0h-5V21h5Zm0-5H13V16H25.5Z" fill="currentColor"/></svg>`,
|
||||
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
|
||||
circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
|
||||
casting: "",
|
||||
download: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
|
||||
gear: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>`,
|
||||
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`,
|
||||
pictureInPicture: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/></svg>`,
|
||||
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
|
||||
tachometer: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 576 512"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M128 288c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zm154.65-97.08l16.24-48.71c1.16-3.45 3.18-6.35 4.92-9.43-4.73-2.76-9.94-4.78-15.81-4.78-17.67 0-32 14.33-32 32 0 15.78 11.63 28.29 26.65 30.92zM176 176c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm212.27 400H75.73C57.56 397.63 48 359.12 48 320 48 187.66 155.66 80 288 80s240 107.66 240 240c0 39.12-9.56 77.63-27.73 112zM416 320c0 17.67 14.33 32 32 32s32-14.33 32-32-14.33-32-32-32-32 14.33-32 32zm-56.41-182.77c-12.72-4.23-26.16 2.62-30.38 15.17l-45.34 136.01C250.49 290.58 224 318.06 224 352c0 11.72 3.38 22.55 8.88 32h110.25c5.5-9.45 8.88-20.28 8.88-32 0-19.45-8.86-36.66-22.55-48.4l45.34-136.01c4.17-12.57-2.64-26.17-15.21-30.36zM432 208c0-15.8-11.66-28.33-26.72-30.93-.07.21-.07.43-.14.65l-19.5 58.49c4.37 2.24 9.11 3.8 14.36 3.8 17.67-.01 32-14.34 32-32.01z"/></svg>`,
|
||||
};
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
function ChromeCastButton() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const tag = document.createElement("google-cast-launcher");
|
||||
tag.setAttribute("id", "castbutton");
|
||||
ref.current?.appendChild(tag);
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} className="h-6" />;
|
||||
}
|
||||
|
||||
export const Icon = memo((props: IconProps) => {
|
||||
if (props.icon === Icons.CASTING) {
|
||||
return <ChromeCastButton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
|
||||
className={props.className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
21
src/components/Overlay.tsx
Normal file
21
src/components/Overlay.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import { Transition } from "@/components/Transition";
|
||||
|
||||
export function Overlay(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<body data-no-scroll />
|
||||
</Helmet>
|
||||
<div className="fixed inset-0 z-[99999]">
|
||||
<Transition
|
||||
animation="fade"
|
||||
className="absolute inset-0 bg-[rgba(8,6,18,0.85)]"
|
||||
isChild
|
||||
/>
|
||||
{props.children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { MWMediaType, MWQuery } from "@/providers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
|
||||
|
||||
import { DropdownButton } from "./buttons/DropdownButton";
|
||||
import { Icons } from "./Icon";
|
||||
import { Icon, Icons } from "./Icon";
|
||||
import { TextInputControl } from "./text-inputs/TextInputControl";
|
||||
|
||||
export interface SearchBarProps {
|
||||
@@ -37,42 +39,43 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 rounded-[28px] bg-denim-300 px-4 py-4 transition-colors focus-within:bg-denim-400 hover:bg-denim-400 sm:flex-row sm:py-2 sm:pl-8 sm:pr-2">
|
||||
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
|
||||
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center">
|
||||
<Icon icon={Icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
<TextInputControl
|
||||
onUnFocus={props.onUnFocus}
|
||||
onChange={(val) => setSearch(val)}
|
||||
value={props.value.searchQuery}
|
||||
className="w-full flex-1 bg-transparent text-white placeholder-denim-700 focus:outline-none"
|
||||
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2"
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
|
||||
<DropdownButton
|
||||
icon={Icons.SEARCH}
|
||||
open={dropdownOpen}
|
||||
setOpen={(val) => setDropdownOpen(val)}
|
||||
selectedItem={props.value.type}
|
||||
setSelectedItem={(val) => setType(val)}
|
||||
options={[
|
||||
{
|
||||
id: MWMediaType.MOVIE,
|
||||
name: t('searchBar.movie'),
|
||||
icon: Icons.FILM,
|
||||
},
|
||||
{
|
||||
id: MWMediaType.SERIES,
|
||||
name: t('searchBar.series'),
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
// {
|
||||
// id: MWMediaType.ANIME,
|
||||
// name: "Anime",
|
||||
// icon: Icons.DRAGON,
|
||||
// },
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
{props.buttonText || t('searchBar.search')}
|
||||
</DropdownButton>
|
||||
<div className="px-4 py-4 pt-0 sm:px-2 sm:py-2">
|
||||
<DropdownButton
|
||||
icon={Icons.SEARCH}
|
||||
open={dropdownOpen}
|
||||
setOpen={(val) => setDropdownOpen(val)}
|
||||
selectedItem={props.value.type}
|
||||
setSelectedItem={(val) => setType(val)}
|
||||
options={[
|
||||
{
|
||||
id: MWMediaType.MOVIE,
|
||||
name: t("searchBar.movie"),
|
||||
icon: Icons.FILM,
|
||||
},
|
||||
{
|
||||
id: MWMediaType.SERIES,
|
||||
name: t("searchBar.series"),
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
{props.buttonText || t("searchBar.search")}
|
||||
</DropdownButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
47
src/components/Slider.tsx
Normal file
47
src/components/Slider.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ChangeEventHandler, useEffect, useRef } from "react";
|
||||
|
||||
export type SliderProps = {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value?: number;
|
||||
valueDisplay?: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const e = ref.current as HTMLInputElement;
|
||||
e.style.setProperty("--value", e.value);
|
||||
e.style.setProperty("--min", e.min === "" ? "0" : e.min);
|
||||
e.style.setProperty("--max", e.max === "" ? "100" : e.max);
|
||||
e.addEventListener("input", () => e.style.setProperty("--value", e.value));
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-row gap-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{props.label ? (
|
||||
<label className="font-bold">{props.label}</label>
|
||||
) : null}
|
||||
<input
|
||||
type="range"
|
||||
ref={ref}
|
||||
className="styled-slider slider-progress mt-[20px]"
|
||||
onChange={props.onChange}
|
||||
value={props.value}
|
||||
max={props.max}
|
||||
min={props.min}
|
||||
step={props.step}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1">
|
||||
<div className="text-center font-bold text-white">
|
||||
{props.valueDisplay ?? props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
103
src/components/Transition.tsx
Normal file
103
src/components/Transition.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Transition as HeadlessTransition,
|
||||
TransitionClasses,
|
||||
} from "@headlessui/react";
|
||||
import { Fragment, ReactNode } from "react";
|
||||
|
||||
type TransitionAnimations =
|
||||
| "slide-down"
|
||||
| "slide-full-left"
|
||||
| "slide-full-right"
|
||||
| "slide-up"
|
||||
| "fade"
|
||||
| "none";
|
||||
|
||||
interface Props {
|
||||
show?: boolean;
|
||||
durationClass?: string;
|
||||
animation: TransitionAnimations;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
isChild?: boolean;
|
||||
}
|
||||
|
||||
function getClasses(
|
||||
animation: TransitionAnimations,
|
||||
duration: string
|
||||
): TransitionClasses {
|
||||
if (animation === "slide-down") {
|
||||
return {
|
||||
leave: `transition-[transform,opacity] ${duration}`,
|
||||
leaveFrom: "opacity-100 translate-y-0",
|
||||
leaveTo: "-translate-y-4 opacity-0",
|
||||
enter: `transition-[transform,opacity] ${duration}`,
|
||||
enterFrom: "opacity-0 -translate-y-4",
|
||||
enterTo: "translate-y-0 opacity-100",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "slide-up") {
|
||||
return {
|
||||
leave: `transition-[transform,opacity] ${duration}`,
|
||||
leaveFrom: "opacity-100 translate-y-0",
|
||||
leaveTo: "translate-y-4 opacity-0",
|
||||
enter: `transition-[transform,opacity] ${duration}`,
|
||||
enterFrom: "opacity-0 translate-y-4",
|
||||
enterTo: "translate-y-0 opacity-100",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "slide-full-left") {
|
||||
return {
|
||||
leave: `transition-[transform] ${duration}`,
|
||||
leaveFrom: "translate-x-0",
|
||||
leaveTo: "-translate-x-full",
|
||||
enter: `transition-[transform] ${duration}`,
|
||||
enterFrom: "-translate-x-full",
|
||||
enterTo: "translate-x-0",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "slide-full-right") {
|
||||
return {
|
||||
leave: `transition-[transform] ${duration}`,
|
||||
leaveFrom: "translate-x-0",
|
||||
leaveTo: "translate-x-full",
|
||||
enter: `transition-[transform] ${duration}`,
|
||||
enterFrom: "translate-x-full",
|
||||
enterTo: "translate-x-0",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "fade") {
|
||||
return {
|
||||
leave: `transition-[transform,opacity] ${duration}`,
|
||||
leaveFrom: "opacity-100",
|
||||
leaveTo: "opacity-0",
|
||||
enter: `transition-[transform,opacity] ${duration}`,
|
||||
enterFrom: "opacity-0",
|
||||
enterTo: "opacity-100",
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function Transition(props: Props) {
|
||||
const duration = props.durationClass ?? "duration-200";
|
||||
const classes = getClasses(props.animation, duration);
|
||||
|
||||
if (props.isChild) {
|
||||
return (
|
||||
<HeadlessTransition.Child as={Fragment} {...classes}>
|
||||
<div className={props.className}>{props.children}</div>
|
||||
</HeadlessTransition.Child>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HeadlessTransition show={props.show} as={Fragment} {...classes}>
|
||||
<div className={props.className}>{props.children}</div>
|
||||
</HeadlessTransition>
|
||||
);
|
||||
}
|
@@ -4,10 +4,11 @@ import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { Backdrop, useBackdrop } from "@/components/layout/Backdrop";
|
||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
|
||||
|
||||
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
|
||||
|
||||
export interface OptionItem {
|
||||
id: string;
|
||||
@@ -56,7 +57,7 @@ export const DropdownButton = React.forwardRef<
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let id: NodeJS.Timeout;
|
||||
let id: ReturnType<typeof setTimeout>;
|
||||
|
||||
if (props.open) {
|
||||
setDelayedSelectedId(props.selectedItem);
|
||||
@@ -93,37 +94,43 @@ export const DropdownButton = React.forwardRef<
|
||||
className="relative w-full sm:w-auto"
|
||||
{...highlightedProps}
|
||||
>
|
||||
<ButtonControl
|
||||
{...props}
|
||||
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-200 px-4 py-2 text-white hover:bg-bink-300"
|
||||
<BackdropContainer
|
||||
onClick={() => props.setOpen(false)}
|
||||
{...backdropProps}
|
||||
>
|
||||
<Icon icon={selectedItem.icon} />
|
||||
<span className="flex-1">{selectedItem.name}</span>
|
||||
<Icon
|
||||
icon={Icons.CHEVRON_DOWN}
|
||||
className={`transition-transform ${props.open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</ButtonControl>
|
||||
<div
|
||||
className={`absolute top-0 z-10 w-full rounded-[20px] bg-denim-300 pt-[40px] transition-all duration-200 ${
|
||||
props.open
|
||||
? "block max-h-60 opacity-100"
|
||||
: "invisible max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{props.options
|
||||
.filter((opt) => opt.id !== delayedSelectedId)
|
||||
.map((opt) => (
|
||||
<Option
|
||||
option={opt}
|
||||
key={opt.id}
|
||||
onClick={(e) => onOptionClick(e, opt)}
|
||||
tabIndex={props.open ? 0 : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ButtonControl
|
||||
{...props}
|
||||
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-400 px-4 py-2 text-white hover:bg-bink-300"
|
||||
>
|
||||
<Icon icon={selectedItem.icon} />
|
||||
<span className="flex-1">{selectedItem.name}</span>
|
||||
<Icon
|
||||
icon={Icons.CHEVRON_DOWN}
|
||||
className={`transition-transform ${
|
||||
props.open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</ButtonControl>
|
||||
<div
|
||||
className={`absolute top-0 z-10 w-full rounded-[20px] bg-denim-300 pt-[40px] transition-all duration-200 ${
|
||||
props.open
|
||||
? "block max-h-60 opacity-100"
|
||||
: "invisible max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{props.options
|
||||
.filter((opt) => opt.id !== delayedSelectedId)
|
||||
.map((opt) => (
|
||||
<Option
|
||||
option={opt}
|
||||
key={opt.id}
|
||||
onClick={(e) => onOptionClick(e, opt)}
|
||||
tabIndex={props.open ? 0 : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BackdropContainer>
|
||||
</div>
|
||||
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
38
src/components/buttons/EditButton.tsx
Normal file
38
src/components/buttons/EditButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface EditButtonProps {
|
||||
editing: boolean;
|
||||
onEdit?: (editing: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditButton(props: EditButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [parent] = useAutoAnimate<HTMLSpanElement>();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
props.onEdit?.(!props.editing);
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<ButtonControl
|
||||
onClick={onClick}
|
||||
className="flex h-12 items-center overflow-hidden rounded-full bg-denim-400 px-4 py-2 text-white transition-[background-color,transform] hover:bg-denim-500 active:scale-105"
|
||||
>
|
||||
<span ref={parent}>
|
||||
{props.editing ? (
|
||||
<span className="mx-4 whitespace-nowrap">
|
||||
{t("media.stopEditing")}
|
||||
</span>
|
||||
) : (
|
||||
<Icon icon={Icons.EDIT} />
|
||||
)}
|
||||
</span>
|
||||
</ButtonControl>
|
||||
);
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
|
||||
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
|
||||
|
||||
export interface IconButtonProps extends ButtonControlProps {
|
||||
icon: Icons;
|
||||
|
@@ -6,17 +6,24 @@ export interface IconPatchProps {
|
||||
clickable?: boolean;
|
||||
className?: string;
|
||||
icon: Icons;
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
export function IconPatch(props: IconPatchProps) {
|
||||
const clickableClasses = props.clickable
|
||||
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
|
||||
: "";
|
||||
const transparentClasses = props.transparent
|
||||
? "bg-opacity-0 hover:bg-opacity-50"
|
||||
: "";
|
||||
const activeClasses = props.active
|
||||
? "border-bink-600 bg-bink-100 text-bink-600"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className={props.className || undefined} onClick={props.onClick}>
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-300 transition-[color,transform,border-color] duration-75 ${
|
||||
props.clickable
|
||||
? "cursor-pointer hover:scale-110 hover:bg-denim-400 hover:text-white active:scale-125"
|
||||
: ""
|
||||
} ${props.active ? "border-bink-600 bg-bink-100 text-bink-600" : ""}`}
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-500 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses}`}
|
||||
>
|
||||
<Icon icon={props.icon} />
|
||||
</div>
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { createRef, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useFade } from "@/hooks/useFade";
|
||||
|
||||
interface BackdropProps {
|
||||
@@ -39,7 +41,7 @@ export function useBackdrop(): [
|
||||
return [setBackdrop, backdropProps, highlightedProps];
|
||||
}
|
||||
|
||||
export function Backdrop(props: BackdropProps) {
|
||||
function Backdrop(props: BackdropProps) {
|
||||
const clickEvent = props.onClick || (() => {});
|
||||
const animationEvent = props.onBackdropHide || (() => {});
|
||||
const [isVisible, setVisible, fadeProps] = useFade();
|
||||
@@ -58,7 +60,7 @@ export function Backdrop(props: BackdropProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 left-0 right-0 z-[999] h-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||
className={`pointer-events-auto fixed left-0 right-0 top-0 h-screen w-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||
!isVisible ? "opacity-0" : ""
|
||||
}`}
|
||||
{...fadeProps}
|
||||
@@ -66,3 +68,47 @@ export function Backdrop(props: BackdropProps) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackdropContainer(
|
||||
props: {
|
||||
children: React.ReactNode;
|
||||
} & BackdropProps
|
||||
) {
|
||||
const root = createRef<HTMLDivElement>();
|
||||
const copy = createRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
let frame = -1;
|
||||
function poll() {
|
||||
if (root.current && copy.current) {
|
||||
const rect = root.current.getBoundingClientRect();
|
||||
copy.current.style.top = `${rect.top}px`;
|
||||
copy.current.style.left = `${rect.left}px`;
|
||||
copy.current.style.width = `${rect.width}px`;
|
||||
copy.current.style.height = `${rect.height}px`;
|
||||
}
|
||||
frame = window.requestAnimationFrame(poll);
|
||||
}
|
||||
poll();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
// we dont want this to run only on mount, dont care about ref updates
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [root, copy]);
|
||||
|
||||
return (
|
||||
<div ref={root}>
|
||||
{createPortal(
|
||||
<div className="pointer-events-none fixed left-0 top-0 z-[999]">
|
||||
<Backdrop active={props.active} {...props} />
|
||||
<div ref={copy} className="pointer-events-auto absolute">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<div className="invisible">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,18 +1,30 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function BrandPill(props: { clickable?: boolean }) {
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
export function BrandPill(props: {
|
||||
clickable?: boolean;
|
||||
hideTextOnMobile?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center space-x-2 rounded-full bg-bink-100 bg-opacity-50 px-4 py-2 text-bink-600 ${props.clickable
|
||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-200 hover:text-bink-700 active:scale-95"
|
||||
className={`flex items-center space-x-2 rounded-full bg-bink-300 bg-opacity-50 px-4 py-2 text-bink-600 ${
|
||||
props.clickable
|
||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-400 hover:text-bink-700 active:scale-95"
|
||||
: ""
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
||||
<span className="font-semibold text-white">{t('global.name')}</span>
|
||||
<span
|
||||
className={[
|
||||
"font-semibold text-white",
|
||||
props.hideTextOnMobile ? "hidden sm:block" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("global.name")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,9 +1,68 @@
|
||||
import { Component } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { DISCORD_LINK, GITHUB_LINK } from "@/mw_constants";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
interface ErrorShowcaseProps {
|
||||
error: {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ErrorShowcase(props: ErrorShowcaseProps) {
|
||||
return (
|
||||
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
|
||||
<p className="mb-1 break-words font-bold text-white">
|
||||
{props.error.name} - {props.error.description}
|
||||
</p>
|
||||
<p className="break-words">{props.error.path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorMessageProps {
|
||||
error?: {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
};
|
||||
localSize?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ErrorMessage(props: ErrorMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
props.localSize ? "h-full" : "min-h-screen"
|
||||
} flex w-full flex-col items-center justify-center px-4 py-12`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-start text-center">
|
||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
||||
<Title>{t("media.errors.genericTitle")}</Title>
|
||||
{props.children ? (
|
||||
<p className="my-6 max-w-lg">{props.children}</p>
|
||||
) : (
|
||||
<p className="my-6 max-w-lg">
|
||||
<Trans i18nKey="media.errors.videoFailed">
|
||||
<Link url={conf().DISCORD_LINK} newTab />
|
||||
<Link url={conf().GITHUB_LINK} newTab />
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{props.error ? <ErrorShowcase error={props.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
@@ -50,33 +109,6 @@ export class ErrorBoundary extends Component<
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children as any;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div className="flex flex-col items-center justify-start text-center">
|
||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
||||
<Title>Whoops, it broke</Title>
|
||||
<p className="my-6 max-w-lg">
|
||||
The app encountered an error and wasn't able to recover, please
|
||||
report it to the{" "}
|
||||
<Link url={DISCORD_LINK} newTab>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
or on{" "}
|
||||
<Link url={GITHUB_LINK} newTab>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{this.state.error ? (
|
||||
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
|
||||
<p className="mb-1 break-words font-bold text-white">
|
||||
{this.state.error.name} - {this.state.error.description}
|
||||
</p>
|
||||
<p className="break-words">{this.state.error.path}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return <ErrorMessage error={this.state.error} />;
|
||||
}
|
||||
}
|
||||
|
@@ -8,10 +8,10 @@ export function Loading(props: LoadingProps) {
|
||||
<div className={props.className}>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="flex h-12 items-center justify-center">
|
||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full" />
|
||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:150ms]" />
|
||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:300ms]" />
|
||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:450ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:150ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:300ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:450ms]" />
|
||||
</div>
|
||||
{props.text && props.text.length ? (
|
||||
<p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p>
|
||||
|
50
src/components/layout/Modal.tsx
Normal file
50
src/components/layout/Modal.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Overlay } from "@/components/Overlay";
|
||||
import { Transition } from "@/components/Transition";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ModalFrame(props: Props) {
|
||||
return (
|
||||
<Transition
|
||||
className="fixed inset-0 z-[9999]"
|
||||
animation="none"
|
||||
show={props.show}
|
||||
>
|
||||
<Overlay>
|
||||
<Transition
|
||||
isChild
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
animation="slide-up"
|
||||
>
|
||||
{props.children}
|
||||
</Transition>
|
||||
</Overlay>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal(props: Props) {
|
||||
return createPortal(
|
||||
<ModalFrame show={props.show}>{props.children}</ModalFrame>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalCard(props: { className?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"relative mx-2 w-[500px] overflow-hidden rounded-lg bg-denim-300 px-10 py-10 sm:w-[500px] md:w-[500px] lg:w-[1000px]",
|
||||
props.className ?? "",
|
||||
].join(" ")}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,47 +1,77 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { DISCORD_LINK, GITHUB_LINK } from "@/mw_constants";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
import { conf } from "@/setup/config";
|
||||
import SettingsModal from "@/views/SettingsModal";
|
||||
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
children?: ReactNode;
|
||||
bg?: boolean;
|
||||
}
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
const bannerHeight = useBannerSize();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-0 flex min-h-[88px] items-center justify-between py-5 px-7">
|
||||
<div className="flex w-full items-center justify-center sm:w-fit">
|
||||
<div className="mr-auto sm:mr-6">
|
||||
<Link to="/">
|
||||
<BrandPill clickable />
|
||||
</Link>
|
||||
<div
|
||||
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
|
||||
style={{
|
||||
top: `${bannerHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
|
||||
<div
|
||||
className={`${
|
||||
props.bg ? "opacity-100" : "opacity-0"
|
||||
} absolute inset-0 block bg-denim-100 transition-opacity duration-300`}
|
||||
>
|
||||
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
props.children ? "hidden sm:flex" : "flex"
|
||||
} flex-row gap-4`}
|
||||
>
|
||||
<a
|
||||
href={DISCORD_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
<div className="relative flex w-full items-center justify-center sm:w-fit">
|
||||
<div className="mr-auto sm:mr-6">
|
||||
<Link to="/">
|
||||
<BrandPill clickable />
|
||||
</Link>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
props.children ? "hidden sm:flex" : "flex"
|
||||
} relative flex-row gap-4`}
|
||||
>
|
||||
<IconPatch icon={Icons.DISCORD} clickable />
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
>
|
||||
<IconPatch icon={Icons.GITHUB} clickable />
|
||||
</a>
|
||||
<IconPatch
|
||||
className="text-2xl text-white"
|
||||
icon={Icons.GEAR}
|
||||
clickable
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={conf().DISCORD_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
>
|
||||
<IconPatch icon={Icons.DISCORD} clickable />
|
||||
</a>
|
||||
<a
|
||||
href={conf().GITHUB_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
>
|
||||
<IconPatch icon={Icons.GITHUB} clickable />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,14 +1,16 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface PaperProps {
|
||||
children?: ReactNode,
|
||||
className?: string,
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Paper(props: PaperProps) {
|
||||
return (
|
||||
<div className={`bg-denim-200 lg:rounded-xl px-4 sm:px-8 md:px-12 py-6 sm:py-8 md:py-12 ${props.className}`}>
|
||||
<div
|
||||
className={`bg-denim-200 px-4 py-6 sm:px-8 sm:py-8 md:px-12 md:py-12 lg:rounded-xl ${props.className}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
39
src/components/layout/ProgressRing.tsx
Normal file
39
src/components/layout/ProgressRing.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
interface Props {
|
||||
className?: string;
|
||||
radius?: number;
|
||||
percentage: number;
|
||||
backingRingClassname?: string;
|
||||
}
|
||||
|
||||
export function ProgressRing(props: Props) {
|
||||
const radius = props.radius ?? 40;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`${props.className ?? ""} -rotate-90`}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
className={`fill-transparent stroke-denim-700 stroke-[15] opacity-25 ${
|
||||
props.backingRingClassname ?? ""
|
||||
}`}
|
||||
r={radius}
|
||||
cx="50"
|
||||
cy="50"
|
||||
/>
|
||||
<circle
|
||||
className="fill-transparent stroke-current stroke-[15] transition-[stroke-dashoffset] duration-150"
|
||||
r={radius}
|
||||
cx="50"
|
||||
cy="50"
|
||||
style={{
|
||||
strokeDasharray: `${2 * Math.PI * radius} ${2 * Math.PI * radius}`,
|
||||
strokeDashoffset: `${
|
||||
2 * Math.PI * radius -
|
||||
(props.percentage / 100) * (2 * Math.PI * radius)
|
||||
}`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
@@ -1,124 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Dropdown, OptionItem } from "@/components/Dropdown";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { WatchedEpisode } from "@/components/media/WatchedEpisodeButton";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { serializePortableMedia } from "@/hooks/usePortableMedia";
|
||||
import {
|
||||
convertMediaToPortable,
|
||||
MWMedia,
|
||||
MWMediaSeasons,
|
||||
MWMediaSeason,
|
||||
MWPortableMedia,
|
||||
} from "@/providers";
|
||||
import { getSeasonDataFromMedia } from "@/providers/methods/seasons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SeasonsProps {
|
||||
media: MWMedia;
|
||||
}
|
||||
|
||||
export function LoadingSeasons(props: { error?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="mb-3 mt-5 h-10 w-56 rounded bg-denim-400 opacity-50" />
|
||||
</div>
|
||||
{!props.error ? (
|
||||
<>
|
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3">
|
||||
<IconPatch icon={Icons.WARNING} className="text-red-400" />
|
||||
<p>{t('seasons.failed')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Seasons(props: SeasonsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [searchSeasons, loading, error, success] = useLoading(
|
||||
(portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia)
|
||||
);
|
||||
const history = useHistory();
|
||||
const [seasons, setSeasons] = useState<MWMediaSeasons>({ seasons: [] });
|
||||
const seasonSelected = props.media.seasonId as string;
|
||||
const episodeSelected = props.media.episodeId as string;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const seasonData = await searchSeasons(props.media);
|
||||
setSeasons(seasonData);
|
||||
})();
|
||||
}, [searchSeasons, props.media]);
|
||||
|
||||
function navigateToSeasonAndEpisode(seasonId: string, episodeId: string) {
|
||||
const newMedia: MWMedia = { ...props.media };
|
||||
newMedia.episodeId = episodeId;
|
||||
newMedia.seasonId = seasonId;
|
||||
history.replace(
|
||||
`/media/${newMedia.mediaType}/${serializePortableMedia(
|
||||
convertMediaToPortable(newMedia)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const mapSeason = (season: MWMediaSeason) => ({
|
||||
id: season.id,
|
||||
name: season.title || `${t('seasons.season', { season: season.sort })}`,
|
||||
});
|
||||
|
||||
const options = seasons.seasons.map(mapSeason);
|
||||
|
||||
const foundSeason = seasons.seasons.find(
|
||||
(season) => season.id === seasonSelected
|
||||
);
|
||||
const selectedItem = foundSeason ? mapSeason(foundSeason) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? <LoadingSeasons /> : null}
|
||||
{error ? <LoadingSeasons error /> : null}
|
||||
{success && seasons.seasons.length ? (
|
||||
<>
|
||||
<Dropdown
|
||||
selectedItem={selectedItem as OptionItem}
|
||||
options={options}
|
||||
setSelectedItem={(seasonItem) =>
|
||||
navigateToSeasonAndEpisode(
|
||||
seasonItem.id,
|
||||
seasons.seasons.find((s) => s.id === seasonItem.id)?.episodes[0]
|
||||
.id as string
|
||||
)
|
||||
}
|
||||
/>
|
||||
{seasons.seasons
|
||||
.find((s) => s.id === seasonSelected)
|
||||
?.episodes.map((v) => (
|
||||
<WatchedEpisode
|
||||
key={v.id}
|
||||
media={{
|
||||
...props.media,
|
||||
seriesData: seasons,
|
||||
episodeId: v.id,
|
||||
seasonId: seasonSelected,
|
||||
}}
|
||||
active={v.id === episodeSelected}
|
||||
onClick={() => navigateToSeasonAndEpisode(seasonSelected, v.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,20 +1,18 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
icon?: Icons;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
linkText?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeading(props: SectionHeadingProps) {
|
||||
return (
|
||||
<div className={`mt-12 ${props.className}`}>
|
||||
<div className="mb-4 flex items-end">
|
||||
<div className={props.className}>
|
||||
<div className="mb-5 flex items-center">
|
||||
<p className="flex flex-1 items-center font-bold uppercase text-denim-700">
|
||||
{props.icon ? (
|
||||
<span className="mr-2 text-xl">
|
||||
@@ -23,15 +21,8 @@ export function SectionHeading(props: SectionHeadingProps) {
|
||||
) : null}
|
||||
{props.title}
|
||||
</p>
|
||||
{props.linkText ? (
|
||||
<ArrowLink
|
||||
linkText={props.linkText}
|
||||
direction="left"
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
) : null}
|
||||
{props.children}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
20
src/components/layout/Spinner.css
Normal file
20
src/components/layout/Spinner.css
Normal file
@@ -0,0 +1,20 @@
|
||||
.spinner {
|
||||
font-size: 48px;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 0.12em solid var(--color,white);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: spinner-rotation 800ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
9
src/components/layout/Spinner.tsx
Normal file
9
src/components/layout/Spinner.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import "./Spinner.css";
|
||||
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Spinner(props: SpinnerProps) {
|
||||
return <div className={["spinner", props.className ?? ""].join(" ")} />;
|
||||
}
|
@@ -8,7 +8,9 @@ interface ThinContainerProps {
|
||||
export function ThinContainer(props: ThinContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[600px] mx-auto px-2 sm:px-0 ${props.classNames || ""}`}
|
||||
className={`mx-auto w-[600px] max-w-full px-2 sm:px-0 ${
|
||||
props.classNames || ""
|
||||
}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
18
src/components/layout/WideContainer.tsx
Normal file
18
src/components/layout/WideContainer.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface WideContainerProps {
|
||||
classNames?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function WideContainer(props: WideContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`mx-auto w-[700px] max-w-full px-8 sm:px-4 ${
|
||||
props.classNames || ""
|
||||
}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -9,12 +9,12 @@ export function Episode(props: EpisodeProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={props.onClick}
|
||||
className={`bg-denim-500 hover:bg-denim-400 transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded font-bold text-white active:scale-110 ${
|
||||
props.active ? "shadow-bink-500 shadow-[inset_0_0_0_2px]" : ""
|
||||
className={`transition-[background-color, transform, box-shadow] relative mb-3 mr-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
|
||||
props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50"
|
||||
className="absolute bottom-0 left-0 top-0 bg-bink-500 bg-opacity-50"
|
||||
style={{
|
||||
width: `${props.progress || 0}%`,
|
||||
}}
|
||||
|
@@ -1,96 +1,152 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
convertMediaToPortable,
|
||||
getProviderFromId,
|
||||
MWMediaMeta,
|
||||
MWMediaType,
|
||||
} from "@/providers";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { serializePortableMedia } from "@/hooks/usePortableMedia";
|
||||
|
||||
import { TMDBMediaToId } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||
import { DotList } from "@/components/text/DotList";
|
||||
|
||||
import { IconPatch } from "../buttons/IconPatch";
|
||||
import { Icons } from "../Icon";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
watchedPercentage: number;
|
||||
linkable?: boolean;
|
||||
series?: boolean;
|
||||
series?: {
|
||||
episode: number;
|
||||
season?: number;
|
||||
episodeId: string;
|
||||
seasonId: string;
|
||||
};
|
||||
percentage?: number;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function MediaCardContent({
|
||||
media,
|
||||
linkable,
|
||||
watchedPercentage,
|
||||
series,
|
||||
percentage,
|
||||
closable,
|
||||
onClose,
|
||||
}: MediaCardProps) {
|
||||
const provider = getProviderFromId(media.providerId);
|
||||
const { t } = useTranslation();
|
||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const canLink = linkable && !closable;
|
||||
|
||||
const dotListContent = [t(`media.${media.type}`)];
|
||||
if (media.year) dotListContent.push(media.year);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group relative mb-4 flex overflow-hidden rounded bg-denim-300 py-4 px-5 ${
|
||||
linkable ? "hover:bg-denim-400" : ""
|
||||
<div
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||
canLink ? "hover:bg-opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
{/* progress background */}
|
||||
{watchedPercentage > 0 ? (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0">
|
||||
<article
|
||||
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||
canLink ? "group-hover:scale-95" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
|
||||
closable ? "" : "group-hover:rounded-lg",
|
||||
].join(" ")}
|
||||
style={{
|
||||
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
||||
}}
|
||||
>
|
||||
{series ? (
|
||||
<div
|
||||
className={[
|
||||
"absolute right-2 top-2 rounded-md bg-denim-200 px-2 py-1 transition-colors",
|
||||
closable ? "" : "group-hover:bg-denim-500",
|
||||
].join(" ")}
|
||||
>
|
||||
<p
|
||||
className={[
|
||||
"text-center text-xs font-bold text-slate-400 transition-colors",
|
||||
closable ? "" : "group-hover:text-white",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("seasons.seasonAndEpisode", {
|
||||
season: series.season || 1,
|
||||
episode: series.episode,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{percentage !== undefined ? (
|
||||
<>
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-bink-700"
|
||||
style={{
|
||||
width: percentageString,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="relative h-full bg-bink-300 bg-opacity-30"
|
||||
style={{
|
||||
width: `${watchedPercentage}%`,
|
||||
}}
|
||||
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
|
||||
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="absolute right-0 top-0 bottom-0 ml-auto w-40 bg-gradient-to-l from-bink-400 to-transparent opacity-40" />
|
||||
<IconPatch
|
||||
clickable
|
||||
className="text-2xl text-slate-400"
|
||||
onClick={() => closable && onClose?.()}
|
||||
icon={Icons.X}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative flex flex-1">
|
||||
{/* card content */}
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-1 font-bold text-white">
|
||||
{media.title}
|
||||
{series && media.seasonId && media.episodeId ? (
|
||||
<span className="ml-2 text-xs text-denim-700">
|
||||
S{media.seasonId} E{media.episodeId}
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[provider.displayName, media.mediaType, media.year]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* hoverable chevron */}
|
||||
<div
|
||||
className={`flex translate-x-3 items-center justify-end text-xl text-white opacity-0 transition-[opacity,transform] ${
|
||||
linkable ? "group-hover:translate-x-0 group-hover:opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_RIGHT} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
||||
<span>{media.title}</span>
|
||||
</h1>
|
||||
<DotList className="text-xs" content={dotListContent} />
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaCard(props: MediaCardProps) {
|
||||
let link = "movie";
|
||||
if (props.media.mediaType === MWMediaType.SERIES) link = "series";
|
||||
|
||||
const content = <MediaCardContent {...props} />;
|
||||
|
||||
const canLink = props.linkable && !props.closable;
|
||||
|
||||
let link = canLink
|
||||
? `/media/${encodeURIComponent(TMDBMediaToId(props.media))}`
|
||||
: "#";
|
||||
if (canLink && props.series) {
|
||||
if (props.series.season === 0 && !props.series.episodeId) {
|
||||
link += `/${encodeURIComponent(props.series.seasonId)}`;
|
||||
} else {
|
||||
link += `/${encodeURIComponent(
|
||||
props.series.seasonId
|
||||
)}/${encodeURIComponent(props.series.episodeId)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.linkable) return <span>{content}</span>;
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${link}/${serializePortableMedia(
|
||||
convertMediaToPortable(props.media)
|
||||
)}`}
|
||||
>
|
||||
<Link to={link} className={props.closable ? "hover:cursor-default" : ""}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
|
15
src/components/media/MediaGrid.tsx
Normal file
15
src/components/media/MediaGrid.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { forwardRef } from "react";
|
||||
|
||||
interface MediaGridProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
@@ -1,109 +0,0 @@
|
||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { MWMediaCaption, MWMediaStream } from "@/providers";
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
source: MWMediaStream;
|
||||
captions: MWMediaCaption[];
|
||||
startAt?: number;
|
||||
onProgress?: (event: ProgressEvent) => void;
|
||||
}
|
||||
|
||||
export function SkeletonVideoPlayer(props: { error?: boolean }) {
|
||||
return (
|
||||
<div className="flex aspect-video w-full items-center justify-center bg-denim-200 lg:rounded-xl">
|
||||
{props.error ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<IconPatch icon={Icons.WARNING} className="text-red-400" />
|
||||
<p className="mt-5 text-white">Couldn't get your stream</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<Loading />
|
||||
<p className="mt-3 text-white">Getting your stream...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoPlayer(props: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [hasErrored, setErrored] = useState(false);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const showVideo = !isLoading && !hasErrored;
|
||||
const mustUseHls = props.source.type === "m3u8";
|
||||
|
||||
// reset if stream url changes
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setErrored(false);
|
||||
|
||||
// hls support
|
||||
if (mustUseHls) {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (!Hls.isSupported()) {
|
||||
setLoading(false);
|
||||
setErrored(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const hls = new Hls();
|
||||
|
||||
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
videoRef.current.src = props.source.url;
|
||||
return;
|
||||
}
|
||||
|
||||
hls.attachMedia(videoRef.current);
|
||||
hls.loadSource(props.source.url);
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
setErrored(true);
|
||||
console.error(data);
|
||||
});
|
||||
}
|
||||
}, [props.source.url, videoRef, mustUseHls]);
|
||||
|
||||
let skeletonUi: null | ReactElement = null;
|
||||
if (hasErrored) {
|
||||
skeletonUi = <SkeletonVideoPlayer error />;
|
||||
} else if (isLoading) {
|
||||
skeletonUi = <SkeletonVideoPlayer />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{skeletonUi}
|
||||
<video
|
||||
className={`w-full rounded-xl bg-black ${!showVideo ? "hidden" : ""}`}
|
||||
ref={videoRef}
|
||||
onProgress={(e) =>
|
||||
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
|
||||
}
|
||||
onLoadedData={(e) => {
|
||||
setLoading(false);
|
||||
if (props.startAt)
|
||||
(e.target as HTMLVideoElement).currentTime = props.startAt;
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error("failed to playback stream", e);
|
||||
setErrored(true);
|
||||
}}
|
||||
controls
|
||||
autoPlay
|
||||
>
|
||||
{!mustUseHls ? (
|
||||
<source src={props.source.url} type="video/mp4" />
|
||||
) : null}
|
||||
{props.captions.map((v) => (
|
||||
<track key={v.id} kind="captions" label={v.label} src={v.url} />
|
||||
))}
|
||||
</video>
|
||||
</>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user