Compare commits
513 Commits
README-upd
...
preview-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5acc09ba9 | ||
|
|
506ea92826 | ||
|
|
200d47bb43 | ||
|
|
be047427df | ||
|
|
e297d25603 | ||
|
|
89287af096 | ||
|
|
3a593d9d76 | ||
|
|
10737dd4ec | ||
|
|
7c03b831f5 | ||
|
|
cdf1e1ecc7 | ||
|
|
b9c0d5f46e | ||
|
|
a45fc86032 | ||
|
|
59dabed380 | ||
|
|
b40ba07a4d | ||
|
|
246887efa1 | ||
|
|
28a2c50495 | ||
|
|
c84ca43074 | ||
|
|
e2771a3011 | ||
|
|
3ea5076053 | ||
|
|
bd65f940e3 | ||
|
|
7bdd25e5a4 | ||
|
|
f6286359cf | ||
|
|
ac7fe1baf0 | ||
|
|
9c895f26e3 | ||
|
|
591533f850 | ||
|
|
127897b9d7 | ||
|
|
92507359b4 | ||
|
|
ca4c4440ae | ||
|
|
eb4306a2b8 | ||
|
|
baa847330d | ||
|
|
39372d2182 | ||
|
|
c484810f96 | ||
|
|
0c39057ca5 | ||
|
|
28d6e5f5ce | ||
|
|
e62a078298 | ||
|
|
3fd016808b | ||
|
|
b7282ce990 | ||
|
|
8685f5796a | ||
|
|
acc230fd20 | ||
|
|
30361f2ab7 | ||
|
|
6a8406b5e3 | ||
|
|
7980212bee | ||
|
|
317110855e | ||
|
|
048fa967f2 | ||
|
|
f7b4dfcac4 | ||
|
|
a686d31e4d | ||
|
|
cb63bf217b | ||
|
|
7eed23637d | ||
|
|
46e21c4e3e | ||
|
|
b4191f9c65 | ||
|
|
83b008c839 | ||
|
|
68c7b3650e | ||
|
|
2816c66300 | ||
|
|
01de972a8f | ||
|
|
da2d8fe35b | ||
|
|
a761b7dd35 | ||
|
|
4f89286fa8 | ||
|
|
d0836ce0ef | ||
|
|
4740476c9a | ||
|
|
c167d3ac38 | ||
|
|
2c3f533076 | ||
|
|
55baca57c1 | ||
|
|
0b797964a8 | ||
|
|
c1a47bd9de | ||
|
|
030cbc535a | ||
|
|
b0fd0f59c4 | ||
|
|
47287c3688 | ||
|
|
cc041b5e0a | ||
|
|
b4c74de7b3 | ||
|
|
9daceb7017 | ||
|
|
ff7f9725f8 | ||
|
|
cd7930eef9 | ||
|
|
24d94ef6fd | ||
|
|
04fbd00d4a | ||
|
|
33ec4436fb | ||
|
|
e848386d10 | ||
|
|
235cee1d28 | ||
|
|
8d4943997e | ||
|
|
2ab814574c | ||
|
|
c6b2dd3728 | ||
|
|
825fa75ee2 | ||
|
|
21231186d1 | ||
|
|
48f76662d5 | ||
|
|
4920670495 | ||
|
|
0a30cd356d | ||
|
|
1fe4bb8a04 | ||
|
|
21c1bbec90 | ||
|
|
ad69d6715e | ||
|
|
46cd4d01d9 | ||
|
|
672061cd64 | ||
|
|
df332cec84 | ||
|
|
d7fa35e066 | ||
|
|
f33eb862fd | ||
|
|
0a007ca805 | ||
|
|
24f268b6cb | ||
|
|
03316c642d | ||
|
|
b8e3c07c47 | ||
|
|
aa84977680 | ||
|
|
e051b1dfea | ||
|
|
c27f96096a | ||
|
|
4bd87647d0 | ||
|
|
c1e10338c1 | ||
|
|
cd1cacad55 | ||
|
|
ac77b037d5 | ||
|
|
10eb69a7dc | ||
|
|
70b1540ae2 | ||
|
|
7522aa3174 | ||
|
|
77a33cb74d | ||
|
|
c08897bdc1 | ||
|
|
469f64d484 | ||
|
|
b7e3d285ed | ||
|
|
5f1c10d50a | ||
|
|
9637c3f4ab | ||
|
|
a15c85cbd1 | ||
|
|
53f6a890b9 | ||
|
|
7dbe6f61d0 | ||
|
|
fd460df243 | ||
|
|
2e5cf22626 | ||
|
|
092d639dd9 | ||
|
|
fc1f3202e8 | ||
|
|
3bf04f2abd | ||
|
|
38fb66d31e | ||
|
|
8b3801539e | ||
|
|
101ffae641 | ||
|
|
bc9017f54d | ||
|
|
b90dedfafc | ||
|
|
a4d07f5afa | ||
|
|
f5191aded6 | ||
|
|
2520d8f739 | ||
|
|
ee23de6d2f | ||
|
|
04980f93ab | ||
|
|
2a3213d706 | ||
|
|
c36a4ba2b8 | ||
|
|
ae3818304b | ||
|
|
b3882de893 | ||
|
|
af880a6c83 | ||
|
|
eb5502a16f | ||
|
|
50f06dabbf | ||
|
|
ddbc377d79 | ||
|
|
1e2c6f46ab | ||
|
|
dd1378cef5 | ||
|
|
e684456bba | ||
|
|
6bd3f015d6 | ||
|
|
7bd4c4d1d4 | ||
|
|
3005e577d7 | ||
|
|
2d97be0d6c | ||
|
|
966639df43 | ||
|
|
33e7691b94 | ||
|
|
d7b83d22ce | ||
|
|
b6eac0f364 | ||
|
|
572a7db4aa | ||
|
|
862cd2d6ac | ||
|
|
6f23abaa6d | ||
|
|
81518df89a | ||
|
|
604335a16d | ||
|
|
78ccea94bd | ||
|
|
a487ab4506 | ||
|
|
c93467b3ac | ||
|
|
c709e8596a | ||
|
|
26e49e73a5 | ||
|
|
d954328911 | ||
|
|
3e43586acc | ||
|
|
7040da1334 | ||
|
|
9d10e6a88c | ||
|
|
4c22a71cdf | ||
|
|
b9e7933e09 | ||
|
|
2508b4340f | ||
|
|
e1f67ad8ba | ||
|
|
91abdb2ba5 | ||
|
|
8942eb8b7c | ||
|
|
ad3d922440 | ||
|
|
51b05cd8fb | ||
|
|
374c78c989 | ||
|
|
507693881b | ||
|
|
f4a22dc437 | ||
|
|
5d1c6f7065 | ||
|
|
3db010b9ea | ||
|
|
fd219717c0 | ||
|
|
d328485161 | ||
|
|
da00d454e1 | ||
|
|
d7bfc73727 | ||
|
|
5940ff7f5f | ||
|
|
fcbca1722f | ||
|
|
19370f856c | ||
|
|
154f3e72ef | ||
|
|
6fd11cf425 | ||
|
|
1154156459 | ||
|
|
cb650745f6 | ||
|
|
3aefddd488 | ||
|
|
1bf0103422 | ||
|
|
a672b324ec | ||
|
|
a343f8ad91 | ||
|
|
7a69cb35f8 | ||
|
|
c2a1a20a3b | ||
|
|
dd00e48f59 | ||
|
|
b5157010c4 | ||
|
|
812fb2f087 | ||
|
|
7b6db50ae5 | ||
|
|
3ba6df1a41 | ||
|
|
2eebb7fd39 | ||
|
|
c60667ba63 | ||
|
|
7d6831483a | ||
|
|
58c5c27929 | ||
|
|
f5c7a4fa97 | ||
|
|
50e85c975a | ||
|
|
62e2de70bf | ||
|
|
0683f4f000 | ||
|
|
b1bd569335 | ||
|
|
a49ea92692 | ||
|
|
d23b2132de | ||
|
|
8bd10b5bf3 | ||
|
|
08c9085f0d | ||
|
|
0d8b390b67 | ||
|
|
19b4dc424f | ||
|
|
7fef48df63 | ||
|
|
8220ea55ae | ||
|
|
4de4a1a52c | ||
|
|
042a1a950f | ||
|
|
421029ebab | ||
|
|
4e9be7a3f7 | ||
|
|
af4a3b4279 | ||
|
|
5ce59cc2ee | ||
|
|
ff2fa66002 | ||
|
|
9388a1e61c | ||
|
|
b44a1b4a99 | ||
|
|
6f73dbc36a | ||
|
|
9d3446d370 | ||
|
|
2f680b4cec | ||
|
|
2179637d43 | ||
|
|
e084649878 | ||
|
|
edf5010659 | ||
|
|
93afead92e | ||
|
|
dd48d59b20 | ||
|
|
c4b16abc62 | ||
|
|
1a95d423f2 | ||
|
|
cd3574851a | ||
|
|
299f65c597 | ||
|
|
6021d1e336 | ||
|
|
3ce1ef350e | ||
|
|
06c91744f3 | ||
|
|
f14d9407d8 | ||
|
|
68223f4b1e | ||
|
|
76335ec8d3 | ||
|
|
2714cbcefd | ||
|
|
3309f77aa4 | ||
|
|
6face8cc45 | ||
|
|
27feeea691 | ||
|
|
03853a1b91 | ||
|
|
357cab87ac | ||
|
|
d18e3d185f | ||
|
|
e222463a63 | ||
|
|
03b9bda287 | ||
|
|
7e20c7cb78 | ||
|
|
d0cdce9e90 | ||
|
|
113b09bf2b | ||
|
|
b16f192b92 | ||
|
|
d9ca3c6e52 | ||
|
|
ba82ecec5c | ||
|
|
c052a2455c | ||
|
|
2d99a8b03c | ||
|
|
7434c0cf2f | ||
|
|
afcb096f49 | ||
|
|
9dc11cedbf | ||
|
|
22aab783d4 | ||
|
|
a2babb83ad | ||
|
|
76a7ceb758 | ||
|
|
9688acaa87 | ||
|
|
64339e5f03 | ||
|
|
1ceea3dcca | ||
|
|
e3c3283603 | ||
|
|
4ac02d3aac | ||
|
|
8eacfe045f | ||
|
|
15e246929b | ||
|
|
c1424634fb | ||
|
|
07ec3efbca | ||
|
|
9b07b10901 | ||
|
|
b1e9cdbea2 | ||
|
|
9aee630392 | ||
|
|
6b50f77624 | ||
|
|
16f1c286c4 | ||
|
|
64aab6dd82 | ||
|
|
144bb84bdc | ||
|
|
76260f9b22 | ||
|
|
500cd1f872 | ||
|
|
9252817b58 | ||
|
|
a66925067d | ||
|
|
d037d178aa | ||
|
|
ab09664d41 | ||
|
|
bfe56c3470 | ||
|
|
1dfa9431a9 | ||
|
|
0faae20bac | ||
|
|
5b10da4073 | ||
|
|
6049edffca | ||
|
|
f27200c8c1 | ||
|
|
613ebb95d2 | ||
|
|
15c79e03a5 | ||
|
|
ed95b0af25 | ||
|
|
f5c2fc1c20 | ||
|
|
3ba69f9a74 | ||
|
|
bcd2bb7c96 | ||
|
|
66357019f0 | ||
|
|
21d20fdfd6 | ||
|
|
cf96db90ad | ||
|
|
430b1ab871 | ||
|
|
7404d68143 | ||
|
|
16cb53f703 | ||
|
|
407af32d32 | ||
|
|
5c01313cc4 | ||
|
|
d8da5cbe9d | ||
|
|
5a72f5f86e | ||
|
|
3d458dd2fd | ||
|
|
e486623310 | ||
|
|
e0f9a6e12f | ||
|
|
05139717d1 | ||
|
|
f20ba3fc2e | ||
|
|
30141f76e0 | ||
|
|
87825a0e05 | ||
|
|
99fc9a2da0 | ||
|
|
6dbb99e0b6 | ||
|
|
3b0c0915fb | ||
|
|
5f7e7eef11 | ||
|
|
2dd3925e92 | ||
|
|
611ceeb5f4 | ||
|
|
0636ff83a2 | ||
|
|
aa005149be | ||
|
|
13130188fc | ||
|
|
8724058aa5 | ||
|
|
94513425be | ||
|
|
323086db09 | ||
|
|
9518cb3635 | ||
|
|
b66f12a0e1 | ||
|
|
e9eba96f5a | ||
|
|
14280c5437 | ||
|
|
867286996b | ||
|
|
03d5e56678 | ||
|
|
410ad0d4b4 | ||
|
|
23f93e311d | ||
|
|
2950cf4438 | ||
|
|
dbdecb1e0a | ||
|
|
833f52de56 | ||
|
|
889caaa733 | ||
|
|
4d56320870 | ||
|
|
1a0053221b | ||
|
|
b925857dfa | ||
|
|
c4aa08f5f0 | ||
|
|
5d73bc2238 | ||
|
|
095048d94a | ||
|
|
98028bf2f4 | ||
|
|
baf1ea95a3 | ||
|
|
23409e6f2f | ||
|
|
dd28200040 | ||
|
|
22360f3b87 | ||
|
|
815d709bcf | ||
|
|
8a2acb7f2b | ||
|
|
67f3a3829e | ||
|
|
f5e5016ca5 | ||
|
|
6e60a275c7 | ||
|
|
3b2633812b | ||
|
|
507227aa49 | ||
|
|
29ab178fb0 | ||
|
|
f5e6b620c1 | ||
|
|
0839718806 | ||
|
|
950b1712b7 | ||
|
|
43a9067976 | ||
|
|
c6a133d4e5 | ||
|
|
4b855b8114 | ||
|
|
6c0fd40877 | ||
|
|
301f2bf7ab | ||
|
|
7943e0c339 | ||
|
|
6ce0aa5b10 | ||
|
|
a0301e2d83 | ||
|
|
9021696cf0 | ||
|
|
9bc1f89777 | ||
|
|
a12697b061 | ||
|
|
5247f14968 | ||
|
|
fd0ff4bd5f | ||
|
|
16545eec22 | ||
|
|
36d17fed6e | ||
|
|
ac34328074 | ||
|
|
91e0928aa0 | ||
|
|
f836cadd23 | ||
|
|
f4910a1483 | ||
|
|
103c4ca49c | ||
|
|
c143c0b8d2 | ||
|
|
e5d8c93ab8 | ||
|
|
72d7a3477f | ||
|
|
808fabba9a | ||
|
|
7a5fab35ff | ||
|
|
17ac5069e5 | ||
|
|
cfab63c0ca | ||
|
|
0fa84eae8d | ||
|
|
821bb79d83 | ||
|
|
233035dbd7 | ||
|
|
114943ae2c | ||
|
|
a6f7b19693 | ||
|
|
3db3044210 | ||
|
|
1fcfe93b58 | ||
|
|
6cb456cb69 | ||
|
|
e939dc678e | ||
|
|
f3e56da3b7 | ||
|
|
70dc4c4b3b | ||
|
|
6428b8d419 | ||
|
|
004e1bb17e | ||
|
|
ebd22ffcea | ||
|
|
22ec058431 | ||
|
|
7d4455ba6b | ||
|
|
db898db9f2 | ||
|
|
b33956e6b8 | ||
|
|
f5864b49de | ||
|
|
25eb765f9b | ||
|
|
9da8461225 | ||
|
|
aed1409f29 | ||
|
|
575da306b0 | ||
|
|
f4c38fa81f | ||
|
|
a3b620efb3 | ||
|
|
054da8e456 | ||
|
|
6cd0c9b2c8 | ||
|
|
2e7458457e | ||
|
|
b67844a0ee | ||
|
|
b08025195e | ||
|
|
a5cc36c88f | ||
|
|
c744e2a9b6 | ||
|
|
4a34574a23 | ||
|
|
38fc150892 | ||
|
|
6e2cf2f80e | ||
|
|
8feb20ff52 | ||
|
|
f2c659c6f3 | ||
|
|
99f1a4e4f3 | ||
|
|
fea9457dad | ||
|
|
883b9377be | ||
|
|
c7ba553208 | ||
|
|
76472521ed | ||
|
|
a34e14b496 | ||
|
|
23c9595933 | ||
|
|
715e229e01 | ||
|
|
a5e6217f85 | ||
|
|
8619724c65 | ||
|
|
af522516f7 | ||
|
|
647f594dc8 | ||
|
|
ae60d44f99 | ||
|
|
9275119163 | ||
|
|
94b418bd47 | ||
|
|
a6c1f3f7ce | ||
|
|
eb5248d8d1 | ||
|
|
eceedbbaad | ||
|
|
29f06a965c | ||
|
|
9ec05d3ba4 | ||
|
|
ee14ff5a51 | ||
|
|
6b62d4b862 | ||
|
|
706fea0e97 | ||
|
|
80956d1a83 | ||
|
|
6d530d9028 | ||
|
|
f12237565f | ||
|
|
11f5594ed4 | ||
|
|
e4e58bee05 | ||
|
|
13ee3a836c | ||
|
|
3f16a353f5 | ||
|
|
9c43ba95e6 | ||
|
|
13fb6fd1a7 | ||
|
|
16e8e3a38e | ||
|
|
6fecdf094d | ||
|
|
69b271b018 | ||
|
|
d6ebd9a9b9 | ||
|
|
70dad332fc | ||
|
|
a65e430c60 | ||
|
|
18f4b67b72 | ||
|
|
506c31562a | ||
|
|
7a9d7a4834 | ||
|
|
902a033b8a | ||
|
|
00eb20aa5e | ||
|
|
a2c27cfa95 | ||
|
|
7122b4d08b | ||
|
|
b03b9b1dbb | ||
|
|
73672e29f8 | ||
|
|
cc5192209f | ||
|
|
278dcf4b44 | ||
|
|
36e092f225 | ||
|
|
46d5c737a2 | ||
|
|
cba4878db3 | ||
|
|
57cc48a699 | ||
|
|
84f488be06 | ||
|
|
f885f2a0f3 | ||
|
|
eef3e5ea4c | ||
|
|
8db821c1c1 | ||
|
|
a39b882f09 | ||
|
|
754dccc4bf | ||
|
|
f97ee11430 | ||
|
|
54868fd486 | ||
|
|
eea389879f | ||
|
|
5c917f95b4 | ||
|
|
dd4d42fd31 | ||
|
|
e5c6b9cd74 | ||
|
|
508fccae4e | ||
|
|
f77573c838 | ||
|
|
7dfe38001e | ||
|
|
48f55da43e | ||
|
|
1e97503802 | ||
|
|
42ff34bb3d | ||
|
|
107b766c44 | ||
|
|
fb51ce5570 | ||
|
|
3357343d98 | ||
|
|
9d61092f37 | ||
|
|
29274614c3 | ||
|
|
19b51592ea | ||
|
|
757c0fc29e | ||
|
|
3eb48abc14 | ||
|
|
01cd9d3872 | ||
|
|
9582196e1f | ||
|
|
3743edab8d | ||
|
|
d81e7cdbab | ||
|
|
6e1d7f7075 | ||
|
|
91cf2de33a | ||
|
|
a6ec2d5220 |
@@ -665,6 +665,249 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"translation"
|
"translation"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "sambartik",
|
||||||
|
"name": "Samuel Bartík",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/63553146?v=4",
|
||||||
|
"profile": "https://github.com/sambartik",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "frank-cywong",
|
||||||
|
"name": "Chun Yeung Wong",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/90653148?v=4",
|
||||||
|
"profile": "https://github.com/frank-cywong",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "TheMeanCanEHdian",
|
||||||
|
"name": "TheMeanCanEHdian",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/16025103?v=4",
|
||||||
|
"profile": "https://github.com/TheMeanCanEHdian",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Gylesie",
|
||||||
|
"name": "Gylesie",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/86306812?v=4",
|
||||||
|
"profile": "https://github.com/Gylesie",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Fhd-pro",
|
||||||
|
"name": "Fhd-pro",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/82862079?v=4",
|
||||||
|
"profile": "https://github.com/Fhd-pro",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "PovilasID",
|
||||||
|
"name": "PovilasID",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/396243?v=4",
|
||||||
|
"profile": "https://github.com/PovilasID",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "byakurau",
|
||||||
|
"name": "byakurau",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1811683?v=4",
|
||||||
|
"profile": "https://github.com/byakurau",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "miknii",
|
||||||
|
"name": "miknii",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/109232569?v=4",
|
||||||
|
"profile": "https://github.com/miknii",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Eclipseop",
|
||||||
|
"name": "Mackenzie",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5846213?v=4",
|
||||||
|
"profile": "https://github.com/Eclipseop",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "s0up4200",
|
||||||
|
"name": "soup",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18177310?v=4",
|
||||||
|
"profile": "https://github.com/s0up4200",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "ceptonit",
|
||||||
|
"name": "ceptonit",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/12678743?v=4",
|
||||||
|
"profile": "https://github.com/ceptonit",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "aedelbro",
|
||||||
|
"name": "aedelbro",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/36162221?v=4",
|
||||||
|
"profile": "https://github.com/aedelbro",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "lunks",
|
||||||
|
"name": "Pedro Nascimento",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4",
|
||||||
|
"profile": "http://twitter.com/lunks/",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "owenvoke",
|
||||||
|
"name": "Owen Voke",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4",
|
||||||
|
"profile": "https://voke.dev",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Nimelrian",
|
||||||
|
"name": "Sebastian K",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4",
|
||||||
|
"profile": "https://github.com/Nimelrian",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "jariz",
|
||||||
|
"name": "jariz",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4",
|
||||||
|
"profile": "https://github.com/jariz",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Alexays",
|
||||||
|
"name": "Alex",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/13947260?v=4",
|
||||||
|
"profile": "https://arouillard.fr",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Zebebles",
|
||||||
|
"name": "Zeb Muller",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/11425451?v=4",
|
||||||
|
"profile": "https://github.com/Zebebles",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "SMores",
|
||||||
|
"name": "Shane Friedman",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5354254?v=4",
|
||||||
|
"profile": "http://smoores.dev",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "IzaacJ",
|
||||||
|
"name": "Izaac Brånn",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/711323?v=4",
|
||||||
|
"profile": "https://izaacj.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "SalmanTariq",
|
||||||
|
"name": "Salman Tariq",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/13284494?v=4",
|
||||||
|
"profile": "https://github.com/SalmanTariq",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "andrew-kennedy",
|
||||||
|
"name": "Andrew Kennedy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/2387159?v=4",
|
||||||
|
"profile": "https://github.com/andrew-kennedy",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Fallenbagel",
|
||||||
|
"name": "Fallenbagel",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/98979876?v=4",
|
||||||
|
"profile": "https://github.com/Fallenbagel",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "scorp200",
|
||||||
|
"name": "Anton K. (ai Doge)",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/9427639?v=4",
|
||||||
|
"profile": "http://aidoge.xyz",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "marcofaggian",
|
||||||
|
"name": "Marco Faggian",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/19221001?v=4",
|
||||||
|
"profile": "https://marcofaggian.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "nemchik",
|
||||||
|
"name": "Eric Nemchik",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/725456?v=4",
|
||||||
|
"profile": "http://nemchik.com/",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "RemiRigal",
|
||||||
|
"name": "RemiRigal",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/19256051?v=4",
|
||||||
|
"profile": "https://github.com/RemiRigal",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
@@ -673,5 +916,7 @@
|
|||||||
"projectOwner": "sct",
|
"projectOwner": "sct",
|
||||||
"repoType": "github",
|
"repoType": "github",
|
||||||
"repoHost": "https://github.com",
|
"repoHost": "https://github.com",
|
||||||
"skipCi": true
|
"skipCi": false,
|
||||||
|
"commitConvention": "angular",
|
||||||
|
"commitType": "docs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ public/os_logo_filled.png
|
|||||||
public/preview.jpg
|
public/preview.jpg
|
||||||
snap
|
snap
|
||||||
stylelint.config.js
|
stylelint.config.js
|
||||||
|
cypress
|
||||||
|
|||||||
15
.eslintrc.js
15
.eslintrc.js
@@ -7,6 +7,7 @@ module.exports = {
|
|||||||
'plugin:jsx-a11y/recommended',
|
'plugin:jsx-a11y/recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
'plugin:react-hooks/recommended',
|
'plugin:react-hooks/recommended',
|
||||||
|
'plugin:react/jsx-runtime',
|
||||||
'prettier',
|
'prettier',
|
||||||
],
|
],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
@@ -26,11 +27,21 @@ module.exports = {
|
|||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
|
||||||
'formatjs/no-offset': 'error',
|
'formatjs/no-offset': 'error',
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': ['error'],
|
'@typescript-eslint/no-unused-vars': ['error'],
|
||||||
|
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
||||||
'jsx-a11y/no-onchange': 'off',
|
'jsx-a11y/no-onchange': 'off',
|
||||||
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
prefer: 'type-imports',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'no-relative-import-paths/no-relative-import-paths': [
|
||||||
|
'error',
|
||||||
|
{ allowSameFolder: true },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
@@ -40,7 +51,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
plugins: ['jsx-a11y', 'prettier', 'react-hooks', 'formatjs'],
|
plugins: ['jsx-a11y', 'react-hooks', 'formatjs', 'no-relative-import-paths'],
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
pragma: 'React',
|
pragma: 'React',
|
||||||
|
|||||||
21
.gitattributes
vendored
21
.gitattributes
vendored
@@ -24,3 +24,24 @@
|
|||||||
*.woff binary
|
*.woff binary
|
||||||
*.pyc binary
|
*.pyc binary
|
||||||
*.pdf binary
|
*.pdf binary
|
||||||
|
|
||||||
|
#
|
||||||
|
## Theses files/directories should be excluded from git archives
|
||||||
|
#
|
||||||
|
|
||||||
|
.husky export-ignore
|
||||||
|
.vscode export-ignore
|
||||||
|
docs export-ignore
|
||||||
|
|
||||||
|
.git* export-ignore
|
||||||
|
*ignore export-ignore
|
||||||
|
*.md export-ignore
|
||||||
|
|
||||||
|
.all-contributorsrc export-ignore
|
||||||
|
.editorconfig export-ignore
|
||||||
|
Dockerfile.local export-ignore
|
||||||
|
docker-compose.yml export-ignore
|
||||||
|
stylelint.config.js export-ignore
|
||||||
|
|
||||||
|
public/os_logo_filled.png export-ignore
|
||||||
|
public/preview.jpg export-ignore
|
||||||
|
|||||||
2
.github/CODEOWNERS
vendored
Normal file
2
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Global code ownership
|
||||||
|
* @Fallenbagel
|
||||||
5
.github/holopin.yml
vendored
Normal file
5
.github/holopin.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
organization: overseerr
|
||||||
|
defaultSticker: clcyagj1j329008l468ya8pu2
|
||||||
|
stickers:
|
||||||
|
- id: clcyagj1j329008l468ya8pu2
|
||||||
|
alias: overseerr-contributor
|
||||||
50
.github/workflows/ci.yml
vendored
50
.github/workflows/ci.yml
vendored
@@ -3,7 +3,7 @@ name: Jellyseerr CI
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- "*"
|
- '*'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
@@ -12,45 +12,46 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Lint & Test Build
|
name: Lint & Test Build
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
container: node:16.14-alpine
|
container: node:18.18-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
HUSKY_SKIP_INSTALL: 1
|
HUSKY: 0
|
||||||
run: yarn
|
run: yarn
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: yarn lint
|
run: yarn lint
|
||||||
|
- name: Formatting
|
||||||
|
run: yarn format:check
|
||||||
- name: Build
|
- name: Build
|
||||||
run: yarn build
|
run: yarn build
|
||||||
|
|
||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish Docker Images
|
name: Build & Publish Docker Images
|
||||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: Cache Docker layers
|
|
||||||
uses: actions/cache@v2
|
|
||||||
with:
|
|
||||||
path: /tmp/.buildx-cache
|
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-buildx-
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -60,32 +61,23 @@ jobs:
|
|||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
fallenbagel/jellyseerr:develop
|
fallenbagel/jellyseerr:develop
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
|
||||||
- # Temporary fix
|
|
||||||
# https://github.com/docker/build-push-action/issues/252
|
|
||||||
# https://github.com/moby/buildkit/issues/1896
|
|
||||||
name: Move cache
|
|
||||||
run: |
|
|
||||||
rm -rf /tmp/.buildx-cache
|
|
||||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: build_and_push
|
needs: build_and_push
|
||||||
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v2
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
- name: Combine Job Status
|
- name: Combine Job Status
|
||||||
id: status
|
id: status
|
||||||
run: |
|
run: |
|
||||||
failures=(neutral, skipped, timed_out, action_required)
|
failures=(neutral, skipped, timed_out, action_required)
|
||||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||||
echo ::set-output name=status::failure
|
echo "status=failure" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Post Status to Discord
|
- name: Post Status to Discord
|
||||||
uses: sarisia/actions-status-discord@v1
|
uses: sarisia/actions-status-discord@v1
|
||||||
|
|||||||
41
.github/workflows/codeql.yml
vendored
Normal file
41
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: 'CodeQL'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['develop']
|
||||||
|
pull_request:
|
||||||
|
branches: ['develop']
|
||||||
|
schedule:
|
||||||
|
- cron: '50 7 * * 5'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [javascript]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
queries: +security-and-quality
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
|
with:
|
||||||
|
category: '/language:${{ matrix.language }}'
|
||||||
30
.github/workflows/cypress.yml
vendored
Normal file
30
.github/workflows/cypress.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Cypress Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '*'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cypress-run:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Cypress run
|
||||||
|
uses: cypress-io/github-action@v4
|
||||||
|
with:
|
||||||
|
build: yarn cypress:build
|
||||||
|
start: yarn start
|
||||||
|
wait-on: 'http://localhost:5055'
|
||||||
|
record: true
|
||||||
|
env:
|
||||||
|
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
WITH_MIGRATIONS: true
|
||||||
|
# Fix test titles in cypress dashboard
|
||||||
|
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
||||||
|
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
||||||
14
.github/workflows/preview.yml
vendored
14
.github/workflows/preview.yml
vendored
@@ -3,29 +3,29 @@ name: Jellyseerr Preview
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "preview-*"
|
- 'preview-*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish Docker Preview Images
|
name: Build & Publish Docker Preview Images
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Get the version
|
- name: Get the version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
39
.github/workflows/private_registery_push.yml
vendored
39
.github/workflows/private_registery_push.yml
vendored
@@ -1,39 +0,0 @@
|
|||||||
name: 'create docker image on pull request and push to private registery'
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-image:
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
-
|
|
||||||
name: Checkout
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
-
|
|
||||||
name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
|
|
||||||
-
|
|
||||||
name: Login to private registery
|
|
||||||
uses: docker/login-action@v2.0.0
|
|
||||||
with:
|
|
||||||
registry: ${{ secrets.REGISTRY_URL }}
|
|
||||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
-
|
|
||||||
name: Build and push
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: ./
|
|
||||||
file: ./Dockerfile
|
|
||||||
builder: ${{ steps.buildx.outputs.name }}
|
|
||||||
push: true
|
|
||||||
tags: '${{ secrets.REGISTRY_URL }}/fallenbagel/jellyseerr:${{ github.sha }}'
|
|
||||||
cache-from: 'type=registry,ref=${{ secrets.REGISTRY_URL }}/fallenbagel/jellyseerr:buildcache'
|
|
||||||
cache-to: 'type=registry,ref=${{ secrets.REGISTRY_URL }}/fallenbagel/jellyseerr:buildcache,mode=max'
|
|
||||||
71
.github/workflows/release.yml
vendored
71
.github/workflows/release.yml
vendored
@@ -5,7 +5,7 @@ on: workflow_dispatch
|
|||||||
jobs:
|
jobs:
|
||||||
semantic-release:
|
semantic-release:
|
||||||
name: Tag and release latest version
|
name: Tag and release latest version
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
env:
|
env:
|
||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
steps:
|
steps:
|
||||||
@@ -18,11 +18,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -35,22 +35,77 @@ jobs:
|
|||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
run: npx semantic-release
|
run: npx semantic-release
|
||||||
|
|
||||||
|
build-snap:
|
||||||
|
name: Build Snap Package (${{ matrix.architecture }})
|
||||||
|
needs: semantic-release
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
architecture:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- armhf
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Switch to main branch
|
||||||
|
run: git checkout main
|
||||||
|
- name: Pull latest changes
|
||||||
|
run: git pull
|
||||||
|
- name: Prepare
|
||||||
|
id: prepare
|
||||||
|
run: |
|
||||||
|
git fetch --prune --tags
|
||||||
|
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||||
|
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
- name: Set Up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
with:
|
||||||
|
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||||
|
- name: Build Snap Package
|
||||||
|
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||||
|
id: build
|
||||||
|
with:
|
||||||
|
architecture: ${{ matrix.architecture }}
|
||||||
|
- name: Upload Snap Package
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: jellyseerr-snap-package-${{ matrix.architecture }}
|
||||||
|
path: ${{ steps.build.outputs.snap }}
|
||||||
|
- name: Review Snap Package
|
||||||
|
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||||
|
with:
|
||||||
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
|
- name: Publish Snap Package
|
||||||
|
uses: snapcore/action-publish@v1
|
||||||
|
env:
|
||||||
|
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||||
|
with:
|
||||||
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
|
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: semantic-release
|
needs: semantic-release
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v2
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
- name: Combine Job Status
|
- name: Combine Job Status
|
||||||
id: status
|
id: status
|
||||||
run: |
|
run: |
|
||||||
failures=(neutral, skipped, timed_out, action_required)
|
failures=(neutral, skipped, timed_out, action_required)
|
||||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||||
echo ::set-output name=status::failure
|
echo "status=failure" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Post Status to Discord
|
- name: Post Status to Discord
|
||||||
uses: sarisia/actions-status-discord@v1
|
uses: sarisia/actions-status-discord@v1
|
||||||
|
|||||||
91
.github/workflows/snap.yaml
vendored
Normal file
91
.github/workflows/snap.yaml
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
name: Publish Snap
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
jobs:
|
||||||
|
name: Job Check
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||||
|
steps:
|
||||||
|
- name: Cancel Previous Runs
|
||||||
|
uses: styfle/cancel-workflow-action@0.10.0
|
||||||
|
with:
|
||||||
|
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
build-snap:
|
||||||
|
name: Build Snap Package (${{ matrix.architecture }})
|
||||||
|
needs: jobs
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
architecture:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- armhf
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Prepare
|
||||||
|
id: prepare
|
||||||
|
run: |
|
||||||
|
git fetch --prune --unshallow --tags
|
||||||
|
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||||
|
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
- name: Set Up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
- name: Configure Git
|
||||||
|
run: git config --add safe.directory /data/parts/jellyseerr/src
|
||||||
|
- name: Build Snap Package
|
||||||
|
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||||
|
id: build
|
||||||
|
with:
|
||||||
|
architecture: ${{ matrix.architecture }}
|
||||||
|
- name: Upload Snap Package
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: jellyseerr-snap-package-${{ matrix.architecture }}
|
||||||
|
path: ${{ steps.build.outputs.snap }}
|
||||||
|
- name: Review Snap Package
|
||||||
|
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||||
|
with:
|
||||||
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
|
- name: Publish Snap Package
|
||||||
|
uses: snapcore/action-publish@v1
|
||||||
|
env:
|
||||||
|
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||||
|
with:
|
||||||
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
|
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||||
|
|
||||||
|
discord:
|
||||||
|
name: Send Discord Notification
|
||||||
|
needs: build-snap
|
||||||
|
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Get Build Job Status
|
||||||
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
|
- name: Combine Job Status
|
||||||
|
id: status
|
||||||
|
run: |
|
||||||
|
failures=(neutral, skipped, timed_out, action_required)
|
||||||
|
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||||
|
echo "status=failure" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
- name: Post Status to Discord
|
||||||
|
uses: sarisia/actions-status-discord@v1
|
||||||
|
with:
|
||||||
|
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
status: ${{ steps.status.outputs.status }}
|
||||||
|
title: ${{ github.workflow }}
|
||||||
|
nofail: true
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -54,5 +54,19 @@ config/db/db.sqlite3-journal
|
|||||||
# VS Code
|
# VS Code
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
cypress.env.json
|
||||||
|
cypress/videos
|
||||||
|
cypress/screenshots
|
||||||
|
|
||||||
|
# ESLint
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# TS Build Info
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
# Webstorm
|
# Webstorm
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# Config Cache Directory
|
||||||
|
config/cache
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
.next/
|
.next/
|
||||||
dist/
|
dist/
|
||||||
config/
|
config/
|
||||||
|
CHANGELOG.md
|
||||||
|
|
||||||
# assets
|
# assets
|
||||||
src/assets/
|
src/assets/
|
||||||
|
|||||||
5
.prettierrc.js
Normal file
5
.prettierrc.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: [require('./merged-prettier-plugin.js')],
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'es5',
|
||||||
|
};
|
||||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -11,9 +11,6 @@
|
|||||||
// https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode
|
// https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
|
|
||||||
// https://marketplace.visualstudio.com/items?itemName=eg2.vscode-npm-script
|
|
||||||
"eg2.vscode-npm-script",
|
|
||||||
|
|
||||||
// https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest
|
// https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest
|
||||||
"Orta.vscode-jest",
|
"Orta.vscode-jest",
|
||||||
|
|
||||||
|
|||||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -15,8 +15,9 @@
|
|||||||
"database": "./config/db/db.sqlite3"
|
"database": "./config/db/db.sqlite3"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor.codeActionsOnSave": {
|
"editor.formatOnSave": true,
|
||||||
"source.organizeImports": true
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
},
|
"files.associations": {
|
||||||
"editor.formatOnSave": true
|
"globals.css": "tailwindcss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1866
CHANGELOG.md
1866
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,7 @@ When adding new UI text, please try to adhere to the following guidelines:
|
|||||||
1. Be concise and clear, and use as few words as possible to make your point.
|
1. Be concise and clear, and use as few words as possible to make your point.
|
||||||
2. Use the Oxford comma where appropriate.
|
2. Use the Oxford comma where appropriate.
|
||||||
3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols.
|
3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols.
|
||||||
4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., TMDb and IMDb have a lowercase 'b', whereas TheTVDB has a capital 'B'.
|
4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., IMDb has a lowercase 'b', whereas TMDB and TheTVDB have a capital 'B'.
|
||||||
5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized).
|
5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized).
|
||||||
6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation.
|
6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation.
|
||||||
7. Ensure that toast notification strings are complete sentences ending in punctuation.
|
7. Ensure that toast notification strings are complete sentences ending in punctuation.
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:16.14-alpine AS BUILD_IMAGE
|
FROM node:18.18-alpine AS BUILD_IMAGE
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,14 +7,15 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
|||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
case "${TARGETPLATFORM}" in \
|
case "${TARGETPLATFORM}" in \
|
||||||
'linux/arm64' | 'linux/arm/v7') \
|
'linux/arm64' | 'linux/arm/v7') \
|
||||||
apk add --no-cache python3 make g++ && \
|
apk update && \
|
||||||
ln -s /usr/bin/python3 /usr/bin/python \
|
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
|
||||||
;; \
|
yarn global add node-gyp \
|
||||||
|
;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install --frozen-lockfile --network-timeout 1000000
|
RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ RUN touch config/DOCKER
|
|||||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||||
|
|
||||||
|
|
||||||
FROM node:16.14-alpine
|
FROM node:18.18-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:16.14-alpine
|
FROM node:18.18-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
235
README.md
235
README.md
@@ -5,7 +5,9 @@
|
|||||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
||||||
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
||||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||||
</p>
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||||
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-99-orange.svg"/></a>
|
||||||
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
|
||||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
|
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
|
||||||
|
|
||||||
@@ -13,36 +15,105 @@ _The original Overseerr team have been busy and Jellyfin/Emby support aren't on
|
|||||||
|
|
||||||
## Current Features
|
## Current Features
|
||||||
|
|
||||||
- Jellyfin Support
|
- Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex!
|
||||||
- Emby Support
|
- Supports Movies, Shows, Mixed Libraries!
|
||||||
(Upcoming Features include: Multiple Server Instances, Music Support, Ability to change email address and much more!)
|
- Ability to change email addresses for smtp purposes
|
||||||
|
- Ability to import all jellyfin/emby users
|
||||||
Along with all the existing Overseerr features:
|
|
||||||
|
|
||||||
- Full Plex integration. Authenticate and manage user access with Plex!
|
|
||||||
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
|
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
|
||||||
- Plex library scan, to keep track of the titles which are already available.
|
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
|
||||||
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
||||||
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
|
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
|
||||||
- Granular permission system.
|
- Granular permission system.
|
||||||
- Support for various notification agents.
|
- Support for various notification agents.
|
||||||
- Mobile-friendly design, for when you need to approve requests on the go!
|
- Mobile-friendly design, for when you need to approve requests on the go!
|
||||||
|
|
||||||
|
(Upcoming Features include: Multiple Server Instances, and much more!)
|
||||||
|
|
||||||
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
|
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
#### Pre-requisite (Important)
|
||||||
|
|
||||||
|
_*On Jellyfin/Emby, ensure the `settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_
|
||||||
|
|
||||||
|
### Launching Jellyseerr using Docker
|
||||||
|
|
||||||
Check out our dockerhub for instructions on how to install and run Jellyseerr:
|
Check out our dockerhub for instructions on how to install and run Jellyseerr:
|
||||||
https://hub.docker.com/r/fallenbagel/jellyseerr
|
https://hub.docker.com/r/fallenbagel/jellyseerr
|
||||||
|
|
||||||
### Launching Jellyseerr manually:
|
### Launching Jellyseerr manually:
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
Pre-requisites:
|
||||||
|
|
||||||
|
- Nodejs (atleast LTS version)
|
||||||
|
- Yarn
|
||||||
|
- Download the source code from the github (Either develop branch or main for stable)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm i -g win-node-env
|
||||||
yarn install
|
yarn install
|
||||||
yarn run build
|
yarn run build
|
||||||
yarn start
|
yarn start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|
Pre-requisites:
|
||||||
|
|
||||||
|
- Nodejs (atleast LTS version)
|
||||||
|
- Yarn
|
||||||
|
- Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr
|
||||||
|
git checkout main #if you want to run stable instead of develop
|
||||||
|
yarn install
|
||||||
|
yarn run build
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
_Systemd-service:_
|
||||||
|
|
||||||
|
- assuming jellyseerr was cloned to `/opt/`
|
||||||
|
and the environmentfile is located at `/etc/jellyseerr`
|
||||||
|
|
||||||
|
service:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=Jellyseerr Service
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
EnvironmentFile=/etc/jellyseerr/jellyseerr.conf
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Type=exec
|
||||||
|
Restart=on-failure
|
||||||
|
WorkingDirectory=/opt/jellyseerr
|
||||||
|
ExecStart=/root/.nvm/versions/node/v18.7.0/bin/node dist/index.js
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Environmentfile:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Jellyseerr's default port is 5055, if you want to use both, change this.
|
||||||
|
# specify on which port to listen
|
||||||
|
PORT=5055
|
||||||
|
|
||||||
|
# specify on which interface to listen, by default jellyseerr listens on all interfaces
|
||||||
|
#HOST=127.0.0.1
|
||||||
|
|
||||||
|
# Uncomment if your media server is emby instead of jellyfin.
|
||||||
|
# JELLYFIN_TYPE=emby
|
||||||
|
```
|
||||||
|
|
||||||
### Packages:
|
### Packages:
|
||||||
|
|
||||||
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
|
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
|
||||||
@@ -72,3 +143,149 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD
|
|||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
|
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||||
|
|
||||||
|
## Contributors ✨
|
||||||
|
|
||||||
|
Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcontributors.org/docs/en/emoji-key)) and all those that contributed directly to Jellyseerr:
|
||||||
|
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||||
|
<!-- prettier-ignore-start -->
|
||||||
|
<!-- markdownlint-disable -->
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4?s=100" width="100px;" alt="sct"/><br /><sub><b>sct</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sct" title="Code">💻</a> <a href="#design-sct" title="Design">🎨</a> <a href="#ideas-sct" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4?s=100" width="100px;" alt="Alex Zoitos"/><br /><sub><b>Alex Zoitos</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=azoitos" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4?s=100" width="100px;" alt="Brandon Cohen"/><br /><sub><b>Brandon Cohen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4?s=100" width="100px;" alt="Ahreluth"/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4?s=100" width="100px;" alt="KovalevArtem"/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4?s=100" width="100px;" alt="GiyomuWeb"/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4?s=100" width="100px;" alt="Angry Cuban"/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4?s=100" width="100px;" alt="jvennik"/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4?s=100" width="100px;" alt="darknessgp"/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4?s=100" width="100px;" alt="salty"/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4?s=100" width="100px;" alt="Shutruk"/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4?s=100" width="100px;" alt="Krystian Charubin"/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4?s=100" width="100px;" alt="Kieron Boswell"/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4?s=100" width="100px;" alt="samwiseg0"/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4?s=100" width="100px;" alt="ecelebi29"/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4?s=100" width="100px;" alt="Mārtiņš Možeiko"/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4?s=100" width="100px;" alt="mazzetta86"/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4?s=100" width="100px;" alt="Paul Hagedorn"/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4?s=100" width="100px;" alt="Shagon94"/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4?s=100" width="100px;" alt="sebstrgg"/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4?s=100" width="100px;" alt="Danshil Mungur"/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4?s=100" width="100px;" alt="doob187"/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4?s=100" width="100px;" alt="johnpyp"/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" width="100px;" alt="Jakob Ankarhem"/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a> <a href="#translation-ankarhem" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4?s=100" width="100px;" alt="Jayesh"/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt="flying-sausages"/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt="hirenshah"/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt="TheCatLady"/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Documentation">📖</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt="Chris Pritchard"/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt="Tamberlox"/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt="David"/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt="Douglas Parker"/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt="Daniel Carter"/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://nuro.dev"><img src="https://avatars.githubusercontent.com/u/4991309?v=4?s=100" width="100px;" alt="nuro"/><br /><sub><b>nuro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=NuroDev" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/onedr0p"><img src="https://avatars.githubusercontent.com/u/213795?v=4?s=100" width="100px;" alt="ᗪєνιη ᗷυнʟ"/><br /><sub><b>ᗪєνιη ᗷυнʟ</b></sub></a><br /><a href="#infra-onedr0p" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt="JonnyWong16"/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt="Roxedus"/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt="WoisWoi"/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt="HubDuck"/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=HubDuck" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt="costaht"/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shjosan"><img src="https://avatars.githubusercontent.com/u/20847626?v=4?s=100" width="100px;" alt="Shjosan"/><br /><sub><b>Shjosan</b></sub></a><br /><a href="#translation-Shjosan" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kobaubarr"><img src="https://avatars.githubusercontent.com/u/28481522?v=4?s=100" width="100px;" alt="kobaubarr"/><br /><sub><b>kobaubarr</b></sub></a><br /><a href="#translation-kobaubarr" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notorius28"><img src="https://avatars.githubusercontent.com/u/1621513?v=4?s=100" width="100px;" alt="Ricardo González"/><br /><sub><b>Ricardo González</b></sub></a><br /><a href="#translation-notorius28" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://torkili.uz"><img src="https://avatars.githubusercontent.com/u/460764?v=4?s=100" width="100px;" alt="Torkil"/><br /><sub><b>Torkil</b></sub></a><br /><a href="#translation-Torkiliuz" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://www.jagandeepbrar.io"><img src="https://avatars.githubusercontent.com/u/3048295?v=4?s=100" width="100px;" alt="Jagandeep Brar"/><br /><sub><b>Jagandeep Brar</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JagandeepBrar" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://dtalens.com"><img src="https://avatars.githubusercontent.com/u/6631832?v=4?s=100" width="100px;" alt="dtalens"/><br /><sub><b>dtalens</b></sub></a><br /><a href="#translation-dtalens" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/acortelyou"><img src="https://avatars.githubusercontent.com/u/1689668?v=4?s=100" width="100px;" alt="Alex Cortelyou"/><br /><sub><b>Alex Cortelyou</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=acortelyou" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://nz.linkedin.com/in/jonocairns"><img src="https://avatars.githubusercontent.com/u/182836?v=4?s=100" width="100px;" alt="Jono Cairns"/><br /><sub><b>Jono Cairns</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jonocairns" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://scias.net/"><img src="https://avatars.githubusercontent.com/u/439655?v=4?s=100" width="100px;" alt="DJScias"/><br /><sub><b>DJScias</b></sub></a><br /><a href="#translation-DJScias" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Dabu-dot"><img src="https://avatars.githubusercontent.com/u/52525576?v=4?s=100" width="100px;" alt="Dabu-dot"/><br /><sub><b>Dabu-dot</b></sub></a><br /><a href="#translation-Dabu-dot" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jabster28"><img src="https://avatars.githubusercontent.com/u/29015942?v=4?s=100" width="100px;" alt="Jabster28"/><br /><sub><b>Jabster28</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Jabster28" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/littlerooster"><img src="https://avatars.githubusercontent.com/u/83890654?v=4?s=100" width="100px;" alt="littlerooster"/><br /><sub><b>littlerooster</b></sub></a><br /><a href="#translation-littlerooster" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dphildebrandt"><img src="https://avatars.githubusercontent.com/u/154459?v=4?s=100" width="100px;" alt="Dustin Hildebrandt"/><br /><sub><b>Dustin Hildebrandt</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dphildebrandt" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Generator"><img src="https://avatars.githubusercontent.com/u/44146?v=4?s=100" width="100px;" alt="Bruno Guerreiro"/><br /><sub><b>Bruno Guerreiro</b></sub></a><br /><a href="#translation-Generator" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/iceHtwoO"><img src="https://avatars.githubusercontent.com/u/27020492?v=4?s=100" width="100px;" alt="Alexander Neuhäuser"/><br /><sub><b>Alexander Neuhäuser</b></sub></a><br /><a href="#translation-iceHtwoO" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://www.unext.co.jp"><img src="https://avatars.githubusercontent.com/u/37431541?v=4?s=100" width="100px;" alt="Livio"/><br /><sub><b>Livio</b></sub></a><br /><a href="#design-liviokanone" title="Design">🎨</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tangentThought"><img src="https://avatars.githubusercontent.com/u/25516090?v=4?s=100" width="100px;" alt="tangentThought"/><br /><sub><b>tangentThought</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=tangentThought" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nicospz"><img src="https://avatars.githubusercontent.com/u/31373060?v=4?s=100" width="100px;" alt="Nicolás Espinoza"/><br /><sub><b>Nicolás Espinoza</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nicospz" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sootylunatic"><img src="https://avatars.githubusercontent.com/u/36486087?v=4?s=100" width="100px;" alt="sootylunatic"/><br /><sub><b>sootylunatic</b></sub></a><br /><a href="#translation-sootylunatic" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JoKerIsCraZy"><img src="https://avatars.githubusercontent.com/u/47474211?v=4?s=100" width="100px;" alt="JoKerIsCraZy"/><br /><sub><b>JoKerIsCraZy</b></sub></a><br /><a href="#translation-JoKerIsCraZy" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://daddie.dev"><img src="https://avatars.githubusercontent.com/u/33762262?v=4?s=100" width="100px;" alt="Daddie0"/><br /><sub><b>Daddie0</b></sub></a><br /><a href="#translation-GoByeBye" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://ungaro.me"><img src="https://avatars.githubusercontent.com/u/43807696?v=4?s=100" width="100px;" alt="Simone"/><br /><sub><b>Simone</b></sub></a><br /><a href="#translation-Simoneu01" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/adan89lion"><img src="https://avatars.githubusercontent.com/u/6585644?v=4?s=100" width="100px;" alt="Seohyun Joo"/><br /><sub><b>Seohyun Joo</b></sub></a><br /><a href="#translation-adan89lion" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ty4ko"><img src="https://avatars.githubusercontent.com/u/21213535?v=4?s=100" width="100px;" alt="Sergey"/><br /><sub><b>Sergey</b></sub></a><br /><a href="#translation-ty4ko" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/skafte1990"><img src="https://avatars.githubusercontent.com/u/31465453?v=4?s=100" width="100px;" alt="Shaaft"/><br /><sub><b>Shaaft</b></sub></a><br /><a href="#translation-skafte1990" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sr093906"><img src="https://avatars.githubusercontent.com/u/8369201?v=4?s=100" width="100px;" alt="sr093906"/><br /><sub><b>sr093906</b></sub></a><br /><a href="#translation-sr093906" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nackophilz"><img src="https://avatars.githubusercontent.com/u/61667226?v=4?s=100" width="100px;" alt="Nackophilz"/><br /><sub><b>Nackophilz</b></sub></a><br /><a href="#translation-Nackophilz" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/schambers"><img src="https://avatars.githubusercontent.com/u/31563?v=4?s=100" width="100px;" alt="Sean Chambers"/><br /><sub><b>Sean Chambers</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=schambers" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/deniscerri"><img src="https://avatars.githubusercontent.com/u/64997243?v=4?s=100" width="100px;" alt="deniscerri"/><br /><sub><b>deniscerri</b></sub></a><br /><a href="#translation-deniscerri" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tomgacz"><img src="https://avatars.githubusercontent.com/u/14138209?v=4?s=100" width="100px;" alt="tomgacz"/><br /><sub><b>tomgacz</b></sub></a><br /><a href="#translation-tomgacz" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Andersborrits"><img src="https://avatars.githubusercontent.com/u/29452218?v=4?s=100" width="100px;" alt="Andersborrits"/><br /><sub><b>Andersborrits</b></sub></a><br /><a href="#translation-Andersborrits" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://maxentrouault.fr"><img src="https://avatars.githubusercontent.com/u/67283154?v=4?s=100" width="100px;" alt="Maxent"/><br /><sub><b>Maxent</b></sub></a><br /><a href="#translation-Maxentr" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/frank-cywong"><img src="https://avatars.githubusercontent.com/u/90653148?v=4?s=100" width="100px;" alt="Chun Yeung Wong"/><br /><sub><b>Chun Yeung Wong</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=frank-cywong" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TheMeanCanEHdian"><img src="https://avatars.githubusercontent.com/u/16025103?v=4?s=100" width="100px;" alt="TheMeanCanEHdian"/><br /><sub><b>TheMeanCanEHdian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheMeanCanEHdian" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gylesie"><img src="https://avatars.githubusercontent.com/u/86306812?v=4?s=100" width="100px;" alt="Gylesie"/><br /><sub><b>Gylesie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Gylesie" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fhd-pro"><img src="https://avatars.githubusercontent.com/u/82862079?v=4?s=100" width="100px;" alt="Fhd-pro"/><br /><sub><b>Fhd-pro</b></sub></a><br /><a href="#translation-Fhd-pro" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/PovilasID"><img src="https://avatars.githubusercontent.com/u/396243?v=4?s=100" width="100px;" alt="PovilasID"/><br /><sub><b>PovilasID</b></sub></a><br /><a href="#translation-PovilasID" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/lunks/"><img src="https://avatars.githubusercontent.com/u/91118?v=4?s=100" width="100px;" alt="Pedro Nascimento"/><br /><sub><b>Pedro Nascimento</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lunks" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://voke.dev"><img src="https://avatars.githubusercontent.com/u/1899334?v=4?s=100" width="100px;" alt="Owen Voke"/><br /><sub><b>Owen Voke</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=owenvoke" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nimelrian"><img src="https://avatars.githubusercontent.com/u/8960836?v=4?s=100" width="100px;" alt="Sebastian K"/><br /><sub><b>Sebastian K</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Nimelrian" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jariz"><img src="https://avatars.githubusercontent.com/u/1415847?v=4?s=100" width="100px;" alt="jariz"/><br /><sub><b>jariz</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jariz" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://arouillard.fr"><img src="https://avatars.githubusercontent.com/u/13947260?v=4?s=100" width="100px;" alt="Alex"/><br /><sub><b>Alex</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Alexays" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zebebles"><img src="https://avatars.githubusercontent.com/u/11425451?v=4?s=100" width="100px;" alt="Zeb Muller"/><br /><sub><b>Zeb Muller</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Zebebles" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://smoores.dev"><img src="https://avatars.githubusercontent.com/u/5354254?v=4?s=100" width="100px;" alt="Shane Friedman"/><br /><sub><b>Shane Friedman</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SMores" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Jellyseerr">🪼⌨️</a> <a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RemiRigal"><img src="https://avatars.githubusercontent.com/u/19256051?v=4?s=100" width="100px;" alt="RemiRigal"/><br /><sub><b>RemiRigal</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=RemiRigal" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- markdownlint-restore -->
|
||||||
|
<!-- prettier-ignore-end -->
|
||||||
|
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||||
|
|||||||
19
cypress.config.ts
Normal file
19
cypress.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'cypress';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
projectId: 'xkm1b4',
|
||||||
|
e2e: {
|
||||||
|
baseUrl: 'http://localhost:5055',
|
||||||
|
experimentalSessionAndOrigin: true,
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
ADMIN_EMAIL: 'admin@seerr.dev',
|
||||||
|
ADMIN_PASSWORD: 'test1234',
|
||||||
|
USER_EMAIL: 'friend@seerr.dev',
|
||||||
|
USER_PASSWORD: 'test1234',
|
||||||
|
},
|
||||||
|
retries: {
|
||||||
|
runMode: 2,
|
||||||
|
openMode: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
149
cypress/config/settings.cypress.json
Normal file
149
cypress/config/settings.cypress.json
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"clientId": "6919275e-142a-48d8-be6b-93594cbd4626",
|
||||||
|
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
|
||||||
|
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||||
|
"main": {
|
||||||
|
"apiKey": "testkey",
|
||||||
|
"applicationTitle": "Overseerr",
|
||||||
|
"applicationUrl": "",
|
||||||
|
"csrfProtection": false,
|
||||||
|
"cacheImages": false,
|
||||||
|
"defaultPermissions": 32,
|
||||||
|
"defaultQuotas": {
|
||||||
|
"movie": {},
|
||||||
|
"tv": {}
|
||||||
|
},
|
||||||
|
"hideAvailable": false,
|
||||||
|
"localLogin": true,
|
||||||
|
"newPlexLogin": true,
|
||||||
|
"region": "",
|
||||||
|
"originalLanguage": "",
|
||||||
|
"trustProxy": false,
|
||||||
|
"partialRequestsEnabled": true,
|
||||||
|
"locale": "en"
|
||||||
|
},
|
||||||
|
"plex": {
|
||||||
|
"name": "Seerr",
|
||||||
|
"ip": "192.168.1.1",
|
||||||
|
"port": 32400,
|
||||||
|
"useSsl": false,
|
||||||
|
"libraries": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Movies",
|
||||||
|
"enabled": true,
|
||||||
|
"type": "movie"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"machineId": "test"
|
||||||
|
},
|
||||||
|
"tautulli": {},
|
||||||
|
"radarr": [],
|
||||||
|
"sonarr": [],
|
||||||
|
"public": {
|
||||||
|
"initialized": true
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"agents": {
|
||||||
|
"email": {
|
||||||
|
"enabled": false,
|
||||||
|
"options": {
|
||||||
|
"emailFrom": "",
|
||||||
|
"smtpHost": "",
|
||||||
|
"smtpPort": 587,
|
||||||
|
"secure": false,
|
||||||
|
"ignoreTls": false,
|
||||||
|
"requireTls": false,
|
||||||
|
"allowSelfSigned": false,
|
||||||
|
"senderName": "Overseerr"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"webhookUrl": "",
|
||||||
|
"enableMentions": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lunasea": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"webhookUrl": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"slack": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"webhookUrl": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"botAPI": "",
|
||||||
|
"chatId": "",
|
||||||
|
"sendSilently": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pushbullet": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"accessToken": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pushover": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"accessToken": "",
|
||||||
|
"userToken": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"webhook": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"webhookUrl": "",
|
||||||
|
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"webpush": {
|
||||||
|
"enabled": false,
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"gotify": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"url": "",
|
||||||
|
"token": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"jobs": {
|
||||||
|
"plex-recently-added-scan": {
|
||||||
|
"schedule": "0 */5 * * * *"
|
||||||
|
},
|
||||||
|
"plex-full-scan": {
|
||||||
|
"schedule": "0 0 3 * * *"
|
||||||
|
},
|
||||||
|
"radarr-scan": {
|
||||||
|
"schedule": "0 0 4 * * *"
|
||||||
|
},
|
||||||
|
"sonarr-scan": {
|
||||||
|
"schedule": "0 30 4 * * *"
|
||||||
|
},
|
||||||
|
"download-sync": {
|
||||||
|
"schedule": "0 * * * * *"
|
||||||
|
},
|
||||||
|
"download-sync-reset": {
|
||||||
|
"schedule": "0 0 1 * * *"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
cypress/e2e/discover.cy.ts
Normal file
214
cypress/e2e/discover.cy.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
const clickFirstTitleCardInSlider = (sliderTitle: string): void => {
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.trigger('mouseover')
|
||||||
|
.find('[data-testid=title-card-title]')
|
||||||
|
.invoke('text')
|
||||||
|
.then((text) => {
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
cy.get('[data-testid=media-title]').should('contain', text);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Discover', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads a trending item', () => {
|
||||||
|
cy.intercept('/api/v1/discover/trending*').as('getTrending');
|
||||||
|
cy.visit('/');
|
||||||
|
cy.wait('@getTrending');
|
||||||
|
clickFirstTitleCardInSlider('Trending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads popular movies', () => {
|
||||||
|
cy.intercept('/api/v1/discover/movies*').as('getPopularMovies');
|
||||||
|
cy.visit('/');
|
||||||
|
cy.wait('@getPopularMovies');
|
||||||
|
clickFirstTitleCardInSlider('Popular Movies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads upcoming movies', () => {
|
||||||
|
cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as(
|
||||||
|
'getUpcomingMovies'
|
||||||
|
);
|
||||||
|
cy.visit('/');
|
||||||
|
cy.wait('@getUpcomingMovies');
|
||||||
|
clickFirstTitleCardInSlider('Upcoming Movies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads popular series', () => {
|
||||||
|
cy.intercept('/api/v1/discover/tv*').as('getPopularTv');
|
||||||
|
cy.visit('/');
|
||||||
|
cy.wait('@getPopularTv');
|
||||||
|
clickFirstTitleCardInSlider('Popular Series');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads upcoming series', () => {
|
||||||
|
cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as(
|
||||||
|
'getUpcomingSeries'
|
||||||
|
);
|
||||||
|
cy.visit('/');
|
||||||
|
cy.wait('@getUpcomingSeries');
|
||||||
|
clickFirstTitleCardInSlider('Upcoming Series');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error for media with invalid TMDB ID', () => {
|
||||||
|
cy.intercept('GET', '/api/v1/media?*', {
|
||||||
|
pageInfo: { pages: 1, pageSize: 20, results: 1, page: 1 },
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
downloadStatus: [],
|
||||||
|
downloadStatus4k: [],
|
||||||
|
id: 1922,
|
||||||
|
mediaType: 'movie',
|
||||||
|
tmdbId: 998814,
|
||||||
|
tvdbId: null,
|
||||||
|
imdbId: null,
|
||||||
|
status: 5,
|
||||||
|
status4k: 1,
|
||||||
|
createdAt: '2022-08-18T18:11:13.000Z',
|
||||||
|
updatedAt: '2022-08-18T19:56:41.000Z',
|
||||||
|
lastSeasonChange: '2022-08-18T19:56:41.000Z',
|
||||||
|
mediaAddedAt: '2022-08-18T19:56:41.000Z',
|
||||||
|
serviceId: null,
|
||||||
|
serviceId4k: null,
|
||||||
|
externalServiceId: null,
|
||||||
|
externalServiceId4k: null,
|
||||||
|
externalServiceSlug: null,
|
||||||
|
externalServiceSlug4k: null,
|
||||||
|
ratingKey: null,
|
||||||
|
ratingKey4k: null,
|
||||||
|
seasons: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).as('getMedia');
|
||||||
|
|
||||||
|
cy.visit('/');
|
||||||
|
cy.wait('@getMedia');
|
||||||
|
cy.contains('.slider-header', 'Recently Added')
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.find('[data-testid=title-card-title]')
|
||||||
|
.contains('Movie Not Found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error for request with invalid TMDB ID', () => {
|
||||||
|
cy.intercept('GET', '/api/v1/request?*', {
|
||||||
|
pageInfo: { pages: 1, pageSize: 10, results: 1, page: 1 },
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 582,
|
||||||
|
status: 1,
|
||||||
|
createdAt: '2022-08-18T18:11:13.000Z',
|
||||||
|
updatedAt: '2022-08-18T18:11:13.000Z',
|
||||||
|
type: 'movie',
|
||||||
|
is4k: false,
|
||||||
|
serverId: null,
|
||||||
|
profileId: null,
|
||||||
|
rootFolder: null,
|
||||||
|
languageProfileId: null,
|
||||||
|
tags: null,
|
||||||
|
media: {
|
||||||
|
downloadStatus: [],
|
||||||
|
downloadStatus4k: [],
|
||||||
|
id: 1922,
|
||||||
|
mediaType: 'movie',
|
||||||
|
tmdbId: 998814,
|
||||||
|
tvdbId: null,
|
||||||
|
imdbId: null,
|
||||||
|
status: 2,
|
||||||
|
status4k: 1,
|
||||||
|
createdAt: '2022-08-18T18:11:13.000Z',
|
||||||
|
updatedAt: '2022-08-18T18:11:13.000Z',
|
||||||
|
lastSeasonChange: '2022-08-18T18:11:13.000Z',
|
||||||
|
mediaAddedAt: null,
|
||||||
|
serviceId: null,
|
||||||
|
serviceId4k: null,
|
||||||
|
externalServiceId: null,
|
||||||
|
externalServiceId4k: null,
|
||||||
|
externalServiceSlug: null,
|
||||||
|
externalServiceSlug4k: null,
|
||||||
|
ratingKey: null,
|
||||||
|
ratingKey4k: null,
|
||||||
|
},
|
||||||
|
seasons: [],
|
||||||
|
modifiedBy: null,
|
||||||
|
requestedBy: {
|
||||||
|
permissions: 4194336,
|
||||||
|
id: 18,
|
||||||
|
email: 'friend@seerr.dev',
|
||||||
|
plexUsername: null,
|
||||||
|
username: '',
|
||||||
|
recoveryLinkExpirationDate: null,
|
||||||
|
userType: 2,
|
||||||
|
avatar:
|
||||||
|
'https://gravatar.com/avatar/c77fdc27cab83732b8623d2ea873d330?default=mm&size=200',
|
||||||
|
movieQuotaLimit: null,
|
||||||
|
movieQuotaDays: null,
|
||||||
|
tvQuotaLimit: null,
|
||||||
|
tvQuotaDays: null,
|
||||||
|
createdAt: '2022-08-17T04:55:28.000Z',
|
||||||
|
updatedAt: '2022-08-17T04:55:28.000Z',
|
||||||
|
requestCount: 1,
|
||||||
|
displayName: 'friend@seerr.dev',
|
||||||
|
},
|
||||||
|
seasonCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).as('getRequests');
|
||||||
|
|
||||||
|
cy.visit('/');
|
||||||
|
cy.wait('@getRequests');
|
||||||
|
cy.contains('.slider-header', 'Recent Requests')
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=request-card]')
|
||||||
|
.first()
|
||||||
|
.find('[data-testid=request-card-title]')
|
||||||
|
.contains('Movie Not Found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads plex watchlist', () => {
|
||||||
|
cy.intercept('/api/v1/discover/watchlist', {
|
||||||
|
fixture: 'watchlist.json',
|
||||||
|
}).as('getWatchlist');
|
||||||
|
// Wait for one of the watchlist movies to resolve
|
||||||
|
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
||||||
|
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.wait('@getWatchlist');
|
||||||
|
|
||||||
|
const sliderHeader = cy.contains('.slider-header', 'Watchlist');
|
||||||
|
|
||||||
|
sliderHeader.scrollIntoView();
|
||||||
|
|
||||||
|
cy.wait('@getTmdbMovie');
|
||||||
|
// Wait a little longer to make sure the movie component reloaded
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
sliderHeader
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.trigger('mouseover')
|
||||||
|
.find('[data-testid=title-card-title]')
|
||||||
|
.invoke('text')
|
||||||
|
.then((text) => {
|
||||||
|
cy.contains('.slider-header', 'Watchlist')
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
cy.get('[data-testid=media-title]').should('contain', text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
13
cypress/e2e/login.cy.ts
Normal file
13
cypress/e2e/login.cy.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
describe('Login Page', () => {
|
||||||
|
it('succesfully logs in as an admin', () => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
cy.visit('/');
|
||||||
|
cy.contains('Trending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('succesfully logs in as a local user', () => {
|
||||||
|
cy.loginAsUser();
|
||||||
|
cy.visit('/');
|
||||||
|
cy.contains('Trending');
|
||||||
|
});
|
||||||
|
});
|
||||||
12
cypress/e2e/movie-details.cy.ts
Normal file
12
cypress/e2e/movie-details.cy.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
describe('Movie Details', () => {
|
||||||
|
it('loads a movie page', () => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
// Try to load minions: rise of gru
|
||||||
|
cy.visit('/movie/438148');
|
||||||
|
|
||||||
|
cy.get('[data-testid=media-title]').should(
|
||||||
|
'contain',
|
||||||
|
'Minions: The Rise of Gru (2022)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
cypress/e2e/pull-to-refresh.cy.ts
Normal file
25
cypress/e2e/pull-to-refresh.cy.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
describe('Pull To Refresh', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||||
|
cy.viewport(390, 844);
|
||||||
|
cy.visitMobile('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads the current page', () => {
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
cy.intercept({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/*',
|
||||||
|
}).as('apiCall');
|
||||||
|
|
||||||
|
cy.get('.searchbar').swipe('bottom', [190, 500]);
|
||||||
|
|
||||||
|
cy.wait('@apiCall').then((interception) => {
|
||||||
|
assert.isNotNull(
|
||||||
|
interception.response.body,
|
||||||
|
'API was called and received data'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
163
cypress/e2e/settings/discover-customization.cy.ts
Normal file
163
cypress/e2e/settings/discover-customization.cy.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
describe('Discover Customization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show the discover customization settings', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-slider-header')
|
||||||
|
.should('contain', 'Create New Slider')
|
||||||
|
.scrollIntoView();
|
||||||
|
|
||||||
|
// There should be some built in options
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||||
|
'contain',
|
||||||
|
'Recently Added'
|
||||||
|
);
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||||
|
'contain',
|
||||||
|
'Recent Requests'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can drag to re-order elements and save to persist the changes', () => {
|
||||||
|
let dataTransfer = new DataTransfer();
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.trigger('dragstart', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('drop', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('dragend', { dataTransfer });
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recently Added');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
dataTransfer = new DataTransfer();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recently Added');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.trigger('dragstart', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('drop', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('dragend', { dataTransfer });
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recent Requests');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a new discover option and remove it', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
|
||||||
|
cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
const sliderTitle = 'Custom Keyword Slider';
|
||||||
|
|
||||||
|
cy.get('#sliderType').select('TMDB Movie Keyword');
|
||||||
|
|
||||||
|
cy.get('#title').type(sliderTitle);
|
||||||
|
// First confirm that an invalid keyword doesn't allow us to submit anything
|
||||||
|
cy.get('#data').type('invalidkeyword{enter}', { delay: 100 });
|
||||||
|
cy.wait('@searchKeyword');
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-discover-option-form]')
|
||||||
|
.find('button')
|
||||||
|
.should('be.disabled');
|
||||||
|
|
||||||
|
cy.get('#data').clear();
|
||||||
|
cy.get('#data').type('christmas{enter}', { delay: 100 });
|
||||||
|
|
||||||
|
// Confirming we have some results
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]');
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-discover-option-form]').submit();
|
||||||
|
|
||||||
|
cy.wait('@discoverSlider');
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.should('contain', sliderTitle);
|
||||||
|
|
||||||
|
// Make sure its still there even if we reload
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.should('contain', sliderTitle);
|
||||||
|
|
||||||
|
// Verify it's not rendering on our discover page (its still disabled!)
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('.slider-header').should('not.contain', sliderTitle);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
// Enable it, and check again
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.find('[role="checkbox"]')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
// let's delete it and confirm its deleted.
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.find('[data-testid=discover-slider-remove-button]')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.wait('@discoverSlider');
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.should('not.contain', sliderTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
cypress/e2e/settings/general-settings.cy.ts
Normal file
32
cypress/e2e/settings/general-settings.cy.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
describe('General Settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the settings page from the home page', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=sidebar-toggle]').click();
|
||||||
|
cy.get('[data-testid=sidebar-menu-settings-mobile]').click();
|
||||||
|
|
||||||
|
cy.get('.heading').should('contain', 'General Settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('modifies setting that requires restart', () => {
|
||||||
|
cy.visit('/settings');
|
||||||
|
|
||||||
|
cy.get('#trustProxy').click();
|
||||||
|
cy.get('[data-testid=settings-main-form]').submit();
|
||||||
|
cy.get('[data-testid=modal-title]').should(
|
||||||
|
'contain',
|
||||||
|
'Server Restart Required'
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get('[data-testid=modal-ok-button]').click();
|
||||||
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
|
|
||||||
|
cy.get('[type=checkbox]#trustProxy').click();
|
||||||
|
cy.get('[data-testid=settings-main-form]').submit();
|
||||||
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
28
cypress/e2e/tv-details.cy.ts
Normal file
28
cypress/e2e/tv-details.cy.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
describe('TV Details', () => {
|
||||||
|
it('loads a tv details page', () => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
// Try to load stranger things
|
||||||
|
cy.visit('/tv/66732');
|
||||||
|
|
||||||
|
cy.get('[data-testid=media-title]').should(
|
||||||
|
'contain',
|
||||||
|
'Stranger Things (2016)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows seasons and expands episodes', () => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
|
||||||
|
// Try to load stranger things
|
||||||
|
cy.visit('/tv/66732');
|
||||||
|
|
||||||
|
// intercept request for season info
|
||||||
|
cy.intercept('/api/v1/tv/66732/season/4').as('season4');
|
||||||
|
|
||||||
|
cy.contains('Season 4').should('be.visible').scrollIntoView().click();
|
||||||
|
|
||||||
|
cy.wait('@season4');
|
||||||
|
|
||||||
|
cy.contains('Chapter Nine').should('be.visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
74
cypress/e2e/user/auto-request-settings.cy.ts
Normal file
74
cypress/e2e/user/auto-request-settings.cy.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const visitUserEditPage = (email: string): void => {
|
||||||
|
cy.visit('/users');
|
||||||
|
|
||||||
|
cy.contains('[data-testid=user-list-row]', email).contains('Edit').click();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Auto Request Settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not see watchlist sync settings on an account without permissions', () => {
|
||||||
|
visitUserEditPage(Cypress.env('USER_EMAIL'));
|
||||||
|
|
||||||
|
cy.contains('Auto-Request Movies').should('not.exist');
|
||||||
|
cy.contains('Auto-Request Series').should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should see watchlist sync settings on an admin account', () => {
|
||||||
|
visitUserEditPage(Cypress.env('ADMIN_EMAIL'));
|
||||||
|
|
||||||
|
cy.contains('Auto-Request Movies').should('exist');
|
||||||
|
cy.contains('Auto-Request Series').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should see auto-request settings after being given permission', () => {
|
||||||
|
visitUserEditPage(Cypress.env('USER_EMAIL'));
|
||||||
|
|
||||||
|
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
|
||||||
|
|
||||||
|
cy.get('#autorequest').should('not.be.checked').click();
|
||||||
|
|
||||||
|
cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions');
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
|
||||||
|
cy.wait('@userPermissions');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('#autorequest').should('be.checked');
|
||||||
|
cy.get('#autorequestmovies').should('be.checked');
|
||||||
|
cy.get('#autorequesttv').should('be.checked');
|
||||||
|
|
||||||
|
cy.get('[data-testid=settings-nav-desktop').contains('General').click();
|
||||||
|
|
||||||
|
cy.contains('Auto-Request Movies').should('exist');
|
||||||
|
cy.contains('Auto-Request Series').should('exist');
|
||||||
|
|
||||||
|
cy.get('#watchlistSyncMovies').should('not.be.checked').click();
|
||||||
|
cy.get('#watchlistSyncTv').should('not.be.checked').click();
|
||||||
|
|
||||||
|
cy.intercept('/api/v1/user/*/settings/main').as('userMain');
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
|
||||||
|
cy.wait('@userMain');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('#watchlistSyncMovies').should('be.checked').click();
|
||||||
|
cy.get('#watchlistSyncTv').should('be.checked').click();
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
|
||||||
|
cy.wait('@userMain');
|
||||||
|
|
||||||
|
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
|
||||||
|
|
||||||
|
cy.get('#autorequest').should('be.checked').click();
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
});
|
||||||
|
});
|
||||||
50
cypress/e2e/user/profile.cy.ts
Normal file
50
cypress/e2e/user/profile.cy.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
describe('User Profile', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens user profile page from the home page', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=user-menu]').click();
|
||||||
|
cy.get('[data-testid=user-menu-profile]').click();
|
||||||
|
|
||||||
|
cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads plex watchlist', () => {
|
||||||
|
cy.intercept('/api/v1/user/[0-9]*/watchlist', {
|
||||||
|
fixture: 'watchlist.json',
|
||||||
|
}).as('getWatchlist');
|
||||||
|
// Wait for one of the watchlist movies to resolve
|
||||||
|
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
||||||
|
|
||||||
|
cy.visit('/profile');
|
||||||
|
|
||||||
|
cy.wait('@getWatchlist');
|
||||||
|
|
||||||
|
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
|
||||||
|
|
||||||
|
sliderHeader.scrollIntoView();
|
||||||
|
|
||||||
|
cy.wait('@getTmdbMovie');
|
||||||
|
// Wait a little longer to make sure the movie component reloaded
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
sliderHeader
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.trigger('mouseover')
|
||||||
|
.find('[data-testid=title-card-title]')
|
||||||
|
.invoke('text')
|
||||||
|
.then((text) => {
|
||||||
|
cy.contains('.slider-header', 'Plex Watchlist')
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
cy.get('[data-testid=media-title]').should('contain', text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
70
cypress/e2e/user/user-list.cy.ts
Normal file
70
cypress/e2e/user/user-list.cy.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const testUser = {
|
||||||
|
displayName: 'Test User',
|
||||||
|
emailAddress: 'test@seeerr.dev',
|
||||||
|
password: 'test1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('User List', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the user list from the home page', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=sidebar-toggle]').click();
|
||||||
|
cy.get('[data-testid=sidebar-menu-users-mobile]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=page-header]').should('contain', 'User List');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can find the admin user and friend user in the user list', () => {
|
||||||
|
cy.visit('/users');
|
||||||
|
|
||||||
|
cy.get('[data-testid=user-list-row]').contains(Cypress.env('ADMIN_EMAIL'));
|
||||||
|
cy.get('[data-testid=user-list-row]').contains(Cypress.env('USER_EMAIL'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a local user', () => {
|
||||||
|
cy.visit('/users');
|
||||||
|
|
||||||
|
cy.contains('Create Local User').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
|
||||||
|
|
||||||
|
cy.get('#displayName').type(testUser.displayName);
|
||||||
|
cy.get('#email').type(testUser.emailAddress);
|
||||||
|
cy.get('#password').type(testUser.password);
|
||||||
|
|
||||||
|
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||||
|
|
||||||
|
cy.get('[data-testid=modal-ok-button]').click();
|
||||||
|
|
||||||
|
cy.wait('@user');
|
||||||
|
// Wait a little longer for the user list to fully re-render
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=user-list-row]').contains(testUser.emailAddress);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can delete the created local test user', () => {
|
||||||
|
cy.visit('/users');
|
||||||
|
|
||||||
|
cy.contains('[data-testid=user-list-row]', testUser.emailAddress)
|
||||||
|
.contains('Delete')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=modal-title]').should('contain', `Delete User`);
|
||||||
|
|
||||||
|
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||||
|
|
||||||
|
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
|
||||||
|
|
||||||
|
cy.wait('@user');
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=user-list-row]')
|
||||||
|
.contains(testUser.emailAddress)
|
||||||
|
.should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
25
cypress/fixtures/watchlist.json
Normal file
25
cypress/fixtures/watchlist.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"totalPages": 1,
|
||||||
|
"totalResults": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"ratingKey": "5d776be17a53e9001e732ab9",
|
||||||
|
"title": "Top Gun: Maverick",
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 361743
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ratingKey": "5e16338fbc1372003ea68ab3",
|
||||||
|
"title": "Nope",
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 762504
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ratingKey": "5f409b8452f200004161e126",
|
||||||
|
"title": "Hocus Pocus 2",
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 642885
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
cypress/support/commands.ts
Normal file
35
cypress/support/commands.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
import 'cy-mobile-commands';
|
||||||
|
|
||||||
|
Cypress.Commands.add('login', (email, password) => {
|
||||||
|
cy.session(
|
||||||
|
[email, password],
|
||||||
|
() => {
|
||||||
|
cy.visit('/login');
|
||||||
|
cy.contains('Use your Overseerr account').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=email]').type(email);
|
||||||
|
cy.get('[data-testid=password]').type(password);
|
||||||
|
|
||||||
|
cy.intercept('/api/v1/auth/local').as('localLogin');
|
||||||
|
cy.get('[data-testid=local-signin-button]').click();
|
||||||
|
|
||||||
|
cy.wait('@localLogin');
|
||||||
|
|
||||||
|
cy.url().should('contain', '/');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
cy.request('/api/v1/auth/me').its('status').should('eq', 200);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('loginAsAdmin', () => {
|
||||||
|
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('loginAsUser', () => {
|
||||||
|
cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD'));
|
||||||
|
});
|
||||||
7
cypress/support/e2e.ts
Normal file
7
cypress/support/e2e.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import './commands';
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
if (Cypress.env('SEED_DATABASE')) {
|
||||||
|
cy.exec('yarn cypress:prepare');
|
||||||
|
}
|
||||||
|
});
|
||||||
14
cypress/support/index.ts
Normal file
14
cypress/support/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
login(email?: string, password?: string): Chainable<Element>;
|
||||||
|
loginAsAdmin(): Chainable<Element>;
|
||||||
|
loginAsUser(): Chainable<Element>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
10
cypress/tsconfig.json
Normal file
10
cypress/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["es5", "dom"],
|
||||||
|
"types": ["cypress", "node"],
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
overseerr:
|
jellyseerr:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.local
|
dockerfile: Dockerfile.local
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
|
|||||||
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
|
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
|
||||||
```
|
```
|
||||||
|
|
||||||
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.
|
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ location ^~ /overseerr {
|
|||||||
sub_filter 'href="/"' 'href="/$app"';
|
sub_filter 'href="/"' 'href="/$app"';
|
||||||
sub_filter 'href="/login"' 'href="/$app/login"';
|
sub_filter 'href="/login"' 'href="/$app/login"';
|
||||||
sub_filter 'href:"/"' 'href:"/$app"';
|
sub_filter 'href:"/"' 'href:"/$app"';
|
||||||
|
sub_filter '\/_next' '\/$app\/_next';
|
||||||
sub_filter '/_next' '/$app/_next';
|
sub_filter '/_next' '/$app/_next';
|
||||||
sub_filter '/api/v1' '/$app/api/v1';
|
sub_filter '/api/v1' '/$app/api/v1';
|
||||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS
|
- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS
|
||||||
- [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
|
- [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
|
||||||
- [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot
|
- [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot
|
||||||
- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDb and IMDb
|
- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDB and IMDb
|
||||||
- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
|
- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
|
||||||
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
|
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
|
||||||
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter
|
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ docker run -d \
|
|||||||
--name overseerr \
|
--name overseerr \
|
||||||
-e LOG_LEVEL=debug \
|
-e LOG_LEVEL=debug \
|
||||||
-e TZ=Asia/Tokyo \
|
-e TZ=Asia/Tokyo \
|
||||||
|
-e PORT=5055 `#optional` \
|
||||||
-p 5055:5055 \
|
-p 5055:5055 \
|
||||||
-v /path/to/appdata/config:/app/config \
|
-v /path/to/appdata/config:/app/config \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
@@ -81,6 +82,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
- TZ=Asia/Tokyo
|
- TZ=Asia/Tokyo
|
||||||
|
- PORT=5055 #optional
|
||||||
ports:
|
ports:
|
||||||
- 5055:5055
|
- 5055:5055
|
||||||
volumes:
|
volumes:
|
||||||
@@ -88,7 +90,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, start all services defined in the your Compose file:
|
Then, start all services defined in the Compose file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
@@ -146,8 +148,6 @@ Then, create and start the Overseerr container:
|
|||||||
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
|
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
|
|
||||||
|
|
||||||
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
|
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
|
||||||
|
|
||||||
{% hint style="info" %}
|
{% hint style="info" %}
|
||||||
@@ -155,7 +155,7 @@ Docker on Windows works differently than it does on Linux; it runs Docker inside
|
|||||||
|
|
||||||
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
|
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
|
||||||
|
|
||||||
Named volumes, like in the example commands above, are automatically mounted inside the VM.
|
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
|
||||||
{% endhint %}
|
{% endhint %}
|
||||||
|
|
||||||
## Linux
|
## Linux
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Overseerr currently supports the following agents:
|
|||||||
- New Plex TV
|
- New Plex TV
|
||||||
- Legacy Plex TV
|
- Legacy Plex TV
|
||||||
- TheTVDB
|
- TheTVDB
|
||||||
- TMDb
|
- TMDB
|
||||||
- [HAMA](https://github.com/ZeroQI/Hama.bundle)
|
- [HAMA](https://github.com/ZeroQI/Hama.bundle)
|
||||||
|
|
||||||
Please verify that your library is using one of the agents previously listed.
|
Please verify that your library is using one of the agents previously listed.
|
||||||
@@ -67,7 +67,7 @@ You can also perform the following to verify the media item has a GUID Overseerr
|
|||||||
1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**.
|
1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**.
|
||||||
2. Verify that the media item's GUID follows one of the below formats:
|
2. Verify that the media item's GUID follows one of the below formats:
|
||||||
|
|
||||||
1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"`
|
1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"`
|
||||||
2. New Plex Movie agent `<Guid id="tmdb://464052"/>`
|
2. New Plex Movie agent `<Guid id="tmdb://464052"/>`
|
||||||
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
|
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
|
||||||
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
|
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ These following special variables are only included in media-related notificatio
|
|||||||
| Variable | Value |
|
| Variable | Value |
|
||||||
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
| `{{media_type}}` | The media type (`movie` or `tv`) |
|
| `{{media_type}}` | The media type (`movie` or `tv`) |
|
||||||
| `{{media_tmdbid}}` | The media's TMDb ID |
|
| `{{media_tmdbid}}` | The media's TMDB ID |
|
||||||
| `{{media_tvdbid}}` | The media's TheTVDB ID |
|
| `{{media_tvdbid}}` | The media's TheTVDB ID |
|
||||||
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||||
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ If you enable this setting and find yourself unable to access Overseerr, you can
|
|||||||
|
|
||||||
This setting is **disabled** by default.
|
This setting is **disabled** by default.
|
||||||
|
|
||||||
|
### Enable Image Caching
|
||||||
|
|
||||||
|
When enabled, Overseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space.
|
||||||
|
|
||||||
|
Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours.
|
||||||
|
|
||||||
|
You should enable this if you are having issues with loading images directly from TMDB in your browser.
|
||||||
|
|
||||||
### Display Language
|
### Display Language
|
||||||
|
|
||||||
Set the default display language for Overseerr. Users can override this setting in their user settings.
|
Set the default display language for Overseerr. Users can override this setting in their user settings.
|
||||||
|
|||||||
21
merged-prettier-plugin.js
Normal file
21
merged-prettier-plugin.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
const tailwind = require('prettier-plugin-tailwindcss');
|
||||||
|
const organizeImports = require('prettier-plugin-organize-imports');
|
||||||
|
|
||||||
|
const combinedFormatter = {
|
||||||
|
...tailwind,
|
||||||
|
parsers: {
|
||||||
|
...tailwind.parsers,
|
||||||
|
...Object.keys(organizeImports.parsers).reduce((acc, key) => {
|
||||||
|
acc[key] = {
|
||||||
|
...tailwind.parsers[key],
|
||||||
|
preprocess(code, options) {
|
||||||
|
return organizeImports.parsers[key].preprocess(code, options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = combinedFormatter;
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @type {import('next').NextConfig}
|
||||||
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
env: {
|
env: {
|
||||||
commitTag: process.env.COMMIT_TAG || 'local',
|
commitTag: process.env.COMMIT_TAG || 'local',
|
||||||
@@ -18,4 +21,8 @@ module.exports = {
|
|||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
scrollRestoration: true,
|
||||||
|
largePageDataBytes: 256000,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
287
package.json
287
package.json
@@ -3,18 +3,26 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates",
|
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||||
"build:next": "next build",
|
"build:next": "next build",
|
||||||
"build": "yarn build:next && yarn build:server",
|
"build": "yarn build:next && yarn build:server",
|
||||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
|
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||||
|
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate",
|
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create",
|
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
||||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run",
|
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --loglevel warn --write --cache .",
|
||||||
"prepare": "husky install"
|
"format:check": "prettier --check --cache .",
|
||||||
|
"typecheck": "yarn typecheck:server && yarn typecheck:client",
|
||||||
|
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
||||||
|
"typecheck:client": "tsc --noEmit",
|
||||||
|
"prepare": "husky install",
|
||||||
|
"cypress:open": "cypress open",
|
||||||
|
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
|
||||||
|
"cypress:build": "yarn build && yarn cypress:prepare"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -22,129 +30,148 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.5.0",
|
"@formatjs/intl-displaynames": "6.2.6",
|
||||||
"@heroicons/react": "^1.0.6",
|
"@formatjs/intl-locale": "3.1.1",
|
||||||
"@supercharge/request-ip": "^1.2.0",
|
"@formatjs/intl-pluralrules": "5.1.10",
|
||||||
"@svgr/webpack": "^6.2.1",
|
"@formatjs/intl-utils": "3.8.4",
|
||||||
"@tanem/react-nprogress": "^4.0.10",
|
"@headlessui/react": "1.7.12",
|
||||||
"ace-builds": "^1.4.14",
|
"@heroicons/react": "2.0.16",
|
||||||
"axios": "^0.26.1",
|
"@supercharge/request-ip": "1.2.0",
|
||||||
"bcrypt": "^5.0.1",
|
"@svgr/webpack": "6.5.1",
|
||||||
"bowser": "^2.11.0",
|
"@tanem/react-nprogress": "5.0.30",
|
||||||
"connect-typeorm": "^1.1.4",
|
"ace-builds": "1.15.2",
|
||||||
"cookie-parser": "^1.4.6",
|
"axios": "1.3.4",
|
||||||
"copy-to-clipboard": "^3.3.1",
|
"axios-rate-limit": "1.3.0",
|
||||||
"country-flag-icons": "^1.4.21",
|
"bcrypt": "5.1.0",
|
||||||
"csurf": "^1.11.0",
|
"bowser": "2.11.0",
|
||||||
"email-templates": "^8.0.10",
|
"connect-typeorm": "1.1.4",
|
||||||
"email-validator": "^2.0.4",
|
"cookie-parser": "1.4.6",
|
||||||
"express": "^4.17.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"express-openapi-validator": "^4.13.6",
|
"country-flag-icons": "1.5.5",
|
||||||
"express-rate-limit": "^6.3.0",
|
"cronstrue": "2.23.0",
|
||||||
"express-session": "^1.17.2",
|
"csurf": "1.11.0",
|
||||||
"formik": "^2.2.9",
|
"date-fns": "2.29.3",
|
||||||
"gravatar-url": "^3.1.0",
|
"dayjs": "1.11.7",
|
||||||
"intl": "^1.2.5",
|
"email-templates": "9.0.0",
|
||||||
"lodash": "^4.17.21",
|
"email-validator": "2.0.4",
|
||||||
"next": "12.1.0",
|
"express": "4.18.2",
|
||||||
"node-cache": "^5.1.2",
|
"express-openapi-validator": "4.13.8",
|
||||||
"node-gyp": "^9.0.0",
|
"express-rate-limit": "6.7.0",
|
||||||
"node-schedule": "^2.1.0",
|
"express-session": "1.17.3",
|
||||||
"nodemailer": "^6.7.2",
|
"formik": "2.2.9",
|
||||||
"openpgp": "^5.2.0",
|
"gravatar-url": "3.1.0",
|
||||||
"plex-api": "^5.3.2",
|
"intl": "1.2.5",
|
||||||
"pug": "^3.0.2",
|
"lodash": "4.17.21",
|
||||||
"react": "17.0.2",
|
"next": "12.3.4",
|
||||||
"react-ace": "^9.5.0",
|
"node-cache": "5.1.2",
|
||||||
"react-animate-height": "^2.0.23",
|
"node-gyp": "9.3.1",
|
||||||
"react-dom": "17.0.2",
|
"node-schedule": "2.1.1",
|
||||||
"react-intersection-observer": "^8.33.1",
|
"nodemailer": "6.9.1",
|
||||||
"react-intl": "5.24.7",
|
"openpgp": "5.7.0",
|
||||||
"react-markdown": "^8.0.0",
|
"plex-api": "5.3.2",
|
||||||
"react-select": "^5.2.2",
|
"pug": "3.0.2",
|
||||||
"react-spring": "^9.4.4",
|
"react": "18.2.0",
|
||||||
"react-toast-notifications": "^2.5.1",
|
"react-ace": "10.1.0",
|
||||||
"react-transition-group": "^4.4.2",
|
"react-animate-height": "2.1.2",
|
||||||
"react-truncate-markup": "^5.1.0",
|
"react-aria": "3.23.0",
|
||||||
"react-use-clipboard": "1.0.7",
|
"react-dom": "18.2.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"react-intersection-observer": "9.4.3",
|
||||||
"secure-random-password": "^0.2.3",
|
"react-intl": "6.2.10",
|
||||||
"semver": "^7.3.5",
|
"react-markdown": "8.0.5",
|
||||||
"sqlite3": "^5.0.2",
|
"react-popper-tooltip": "4.4.2",
|
||||||
"swagger-ui-express": "^4.3.0",
|
"react-select": "5.7.0",
|
||||||
"swr": "^1.2.2",
|
"react-spring": "9.7.1",
|
||||||
"typeorm": "0.2.45",
|
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||||
"web-push": "^3.4.5",
|
"react-toast-notifications": "2.5.1",
|
||||||
"winston": "^3.6.0",
|
"react-truncate-markup": "5.1.2",
|
||||||
"winston-daily-rotate-file": "^4.6.1",
|
"react-use-clipboard": "1.0.9",
|
||||||
"xml2js": "^0.4.23",
|
"reflect-metadata": "0.1.13",
|
||||||
"yamljs": "^0.3.0",
|
"secure-random-password": "0.2.3",
|
||||||
"yup": "^0.32.11"
|
"semver": "7.3.8",
|
||||||
|
"sqlite3": "5.1.4",
|
||||||
|
"swagger-ui-express": "4.6.2",
|
||||||
|
"swr": "2.0.4",
|
||||||
|
"typeorm": "0.3.12",
|
||||||
|
"web-push": "3.5.0",
|
||||||
|
"winston": "3.8.2",
|
||||||
|
"winston-daily-rotate-file": "4.7.1",
|
||||||
|
"xml2js": "0.4.23",
|
||||||
|
"yamljs": "0.3.0",
|
||||||
|
"yup": "0.32.11",
|
||||||
|
"zod": "3.20.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.17.6",
|
"@babel/cli": "7.21.0",
|
||||||
"@commitlint/cli": "^16.2.1",
|
"@commitlint/cli": "17.4.4",
|
||||||
"@commitlint/config-conventional": "^16.2.1",
|
"@commitlint/config-conventional": "17.4.4",
|
||||||
"@next/eslint-plugin-next": "^12.1.6",
|
"@semantic-release/changelog": "6.0.2",
|
||||||
"@semantic-release/changelog": "^6.0.1",
|
"@semantic-release/commit-analyzer": "9.0.2",
|
||||||
"@semantic-release/commit-analyzer": "^9.0.2",
|
"@semantic-release/exec": "6.0.3",
|
||||||
"@semantic-release/exec": "^6.0.3",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@semantic-release/git": "^10.0.1",
|
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||||
"@tailwindcss/aspect-ratio": "^0.4.0",
|
"@tailwindcss/forms": "0.5.3",
|
||||||
"@tailwindcss/forms": "^0.5.0",
|
"@tailwindcss/typography": "0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.2",
|
"@types/bcrypt": "5.0.0",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/cookie-parser": "1.4.3",
|
||||||
"@types/cookie-parser": "^1.4.2",
|
"@types/country-flag-icons": "1.2.0",
|
||||||
"@types/country-flag-icons": "^1.2.0",
|
"@types/csurf": "1.11.2",
|
||||||
"@types/csurf": "^1.11.2",
|
"@types/email-templates": "8.0.4",
|
||||||
"@types/email-templates": "^8.0.4",
|
"@types/express": "4.17.17",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express-session": "1.17.6",
|
||||||
"@types/express-session": "^1.17.4",
|
"@types/lodash": "4.14.191",
|
||||||
"@types/lodash": "^4.14.179",
|
"@types/node": "17.0.36",
|
||||||
"@types/node": "^17.0.21",
|
"@types/node-schedule": "2.1.0",
|
||||||
"@types/node-schedule": "^1.3.2",
|
"@types/nodemailer": "6.4.7",
|
||||||
"@types/nodemailer": "^6.4.4",
|
"@types/react": "18.0.28",
|
||||||
"@types/react": "^17.0.40",
|
"@types/react-dom": "18.0.11",
|
||||||
"@types/react-dom": "^17.0.13",
|
"@types/react-transition-group": "4.4.5",
|
||||||
"@types/react-transition-group": "^4.4.4",
|
"@types/secure-random-password": "0.2.1",
|
||||||
"@types/secure-random-password": "^0.2.1",
|
"@types/semver": "7.3.13",
|
||||||
"@types/semver": "^7.3.9",
|
"@types/swagger-ui-express": "4.1.3",
|
||||||
"@types/swagger-ui-express": "^4.1.3",
|
"@types/web-push": "3.3.2",
|
||||||
"@types/web-push": "^3.3.2",
|
"@types/xml2js": "0.4.11",
|
||||||
"@types/xml2js": "^0.4.9",
|
"@types/yamljs": "0.2.31",
|
||||||
"@types/yamljs": "^0.2.31",
|
"@types/yup": "0.29.14",
|
||||||
"@types/yup": "^0.29.13",
|
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.14.0",
|
"@typescript-eslint/parser": "5.54.0",
|
||||||
"@typescript-eslint/parser": "^5.14.0",
|
"autoprefixer": "10.4.13",
|
||||||
"autoprefixer": "^10.4.2",
|
"babel-plugin-react-intl": "8.2.25",
|
||||||
"babel-plugin-react-intl": "^8.2.25",
|
"babel-plugin-react-intl-auto": "3.3.0",
|
||||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
"commitizen": "4.3.0",
|
||||||
"commitizen": "^4.2.4",
|
"copyfiles": "2.4.1",
|
||||||
"copyfiles": "^2.4.1",
|
"cy-mobile-commands": "0.3.0",
|
||||||
"cz-conventional-changelog": "^3.3.0",
|
"cypress": "12.7.0",
|
||||||
"eslint": "^8.11.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint-config-next": "^12.1.0",
|
"eslint": "8.35.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-next": "12.3.4",
|
||||||
"eslint-plugin-formatjs": "^3.0.0",
|
"eslint-config-prettier": "8.6.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
"eslint-plugin-formatjs": "4.9.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||||
"eslint-plugin-react": "^7.29.3",
|
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||||
"eslint-plugin-react-hooks": "^4.3.0",
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
"extract-react-intl-messages": "^4.1.1",
|
"eslint-plugin-react": "7.32.2",
|
||||||
"husky": "^7.0.4",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"lint-staged": "^12.3.5",
|
"extract-react-intl-messages": "4.1.1",
|
||||||
"nodemon": "^2.0.15",
|
"husky": "8.0.3",
|
||||||
"postcss": "^8.4.8",
|
"lint-staged": "13.1.2",
|
||||||
"prettier": "^2.5.1",
|
"nodemon": "2.0.20",
|
||||||
"prettier-plugin-tailwindcss": "^0.1.8",
|
"postcss": "8.4.21",
|
||||||
"semantic-release": "^19.0.2",
|
"prettier": "2.8.4",
|
||||||
"semantic-release-docker-buildx": "^1.0.1",
|
"prettier-plugin-organize-imports": "3.2.2",
|
||||||
"tailwindcss": "^3.0.23",
|
"prettier-plugin-tailwindcss": "0.2.3",
|
||||||
"ts-node": "^10.7.0",
|
"semantic-release": "19.0.5",
|
||||||
"typescript": "^4.6.2"
|
"semantic-release-docker-buildx": "1.0.1",
|
||||||
|
"tailwindcss": "3.2.7",
|
||||||
|
"ts-node": "10.9.1",
|
||||||
|
"tsc-alias": "1.8.2",
|
||||||
|
"tsconfig-paths": "4.1.2",
|
||||||
|
"typescript": "4.9.5"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"sqlite3/node-gyp": "^8.4.1"
|
"sqlite3/node-gyp": "8.4.1",
|
||||||
|
"@types/react": "18.0.28",
|
||||||
|
"@types/react-dom": "18.0.11",
|
||||||
|
"@types/express-session": "1.17.6"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
@@ -165,10 +192,6 @@
|
|||||||
"@commitlint/config-conventional"
|
"@commitlint/config-conventional"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"prettier": {
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "es5"
|
|
||||||
},
|
|
||||||
"release": {
|
"release": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@semantic-release/commit-analyzer",
|
"@semantic-release/commit-analyzer",
|
||||||
@@ -206,7 +229,7 @@
|
|||||||
{
|
{
|
||||||
"path": "semantic-release-docker-buildx",
|
"path": "semantic-release-docker-buildx",
|
||||||
"buildArgs": {
|
"buildArgs": {
|
||||||
"COMMIT_TAG": "$GITHUB_SHA"
|
"COMMIT_TAG": "$GIT_SHA"
|
||||||
},
|
},
|
||||||
"imageNames": [
|
"imageNames": [
|
||||||
"fallenbagel/jellyseerr"
|
"fallenbagel/jellyseerr"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: #6366F1;
|
color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<!-- Inline the page's JavaScript file. -->
|
<!-- Inline the page's JavaScript file. -->
|
||||||
<script>
|
<script>
|
||||||
// Manual reload feature.
|
// Manual reload feature.
|
||||||
document.querySelector("button").addEventListener("click", () => {
|
document.querySelector('button').addEventListener('click', () => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
74
public/sw.js
74
public/sw.js
@@ -4,30 +4,30 @@
|
|||||||
// This variable is intentionally declared and unused.
|
// This variable is intentionally declared and unused.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const OFFLINE_VERSION = 3;
|
const OFFLINE_VERSION = 3;
|
||||||
const CACHE_NAME = "offline";
|
const CACHE_NAME = 'offline';
|
||||||
// Customize this with a different URL if needed.
|
// Customize this with a different URL if needed.
|
||||||
const OFFLINE_URL = "/offline.html";
|
const OFFLINE_URL = '/offline.html';
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
// Setting {cache: 'reload'} in the new request will ensure that the
|
// Setting {cache: 'reload'} in the new request will ensure that the
|
||||||
// response isn't fulfilled from the HTTP cache; i.e., it will be from
|
// response isn't fulfilled from the HTTP cache; i.e., it will be from
|
||||||
// the network.
|
// the network.
|
||||||
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
|
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
// Force the waiting service worker to become the active service worker.
|
// Force the waiting service worker to become the active service worker.
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
// Enable navigation preload if it's supported.
|
// Enable navigation preload if it's supported.
|
||||||
// See https://developers.google.com/web/updates/2017/02/navigation-preload
|
// See https://developers.google.com/web/updates/2017/02/navigation-preload
|
||||||
if ("navigationPreload" in self.registration) {
|
if ('navigationPreload' in self.registration) {
|
||||||
await self.registration.navigationPreload.enable();
|
await self.registration.navigationPreload.enable();
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -37,10 +37,10 @@ self.addEventListener("activate", (event) => {
|
|||||||
clients.claim();
|
clients.claim();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
// We only want to call event.respondWith() if this is a navigation request
|
// We only want to call event.respondWith() if this is a navigation request
|
||||||
// for an HTML page.
|
// for an HTML page.
|
||||||
if (event.request.mode === "navigate") {
|
if (event.request.mode === 'navigate') {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -59,7 +59,7 @@ self.addEventListener("fetch", (event) => {
|
|||||||
// If fetch() returns a valid HTTP response with a response code in
|
// If fetch() returns a valid HTTP response with a response code in
|
||||||
// the 4xx or 5xx range, the catch() will NOT be called.
|
// the 4xx or 5xx range, the catch() will NOT be called.
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("Fetch failed; returning offline page instead.", error);
|
console.log('Fetch failed; returning offline page instead.', error);
|
||||||
|
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
const cachedResponse = await cache.match(OFFLINE_URL);
|
const cachedResponse = await cache.match(OFFLINE_URL);
|
||||||
@@ -85,15 +85,13 @@ self.addEventListener('push', (event) => {
|
|||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
},
|
},
|
||||||
actions: [],
|
actions: [],
|
||||||
}
|
};
|
||||||
|
|
||||||
if (payload.actionUrl){
|
if (payload.actionUrl) {
|
||||||
options.actions.push(
|
options.actions.push({
|
||||||
{
|
action: 'view',
|
||||||
action: 'view',
|
title: payload.actionUrlTitle ?? 'View',
|
||||||
title: payload.actionUrlTitle ?? 'View',
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.notificationType === 'MEDIA_PENDING') {
|
if (payload.notificationType === 'MEDIA_PENDING') {
|
||||||
@@ -109,27 +107,29 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
||||||
self.registration.showNotification(payload.subject, options)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener(
|
||||||
const notificationData = event.notification.data;
|
'notificationclick',
|
||||||
|
(event) => {
|
||||||
|
const notificationData = event.notification.data;
|
||||||
|
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
if (event.action === 'approve') {
|
if (event.action === 'approve') {
|
||||||
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
} else if (event.action === 'decline') {
|
} else if (event.action === 'decline') {
|
||||||
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notificationData.actionUrl) {
|
if (notificationData.actionUrl) {
|
||||||
clients.openWindow(notificationData.actionUrl);
|
clients.openWindow(notificationData.actionUrl);
|
||||||
}
|
}
|
||||||
}, false);
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|||||||
21
renovate.json
Normal file
21
renovate.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:js-app",
|
||||||
|
"group:allNonMajor",
|
||||||
|
"docker:disableMajor",
|
||||||
|
"helpers:disableTypesNodeMajor"
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"groupName": "GitHub Actions",
|
||||||
|
"groupSlug": "github-actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["node"],
|
||||||
|
"groupName": "Node.js",
|
||||||
|
"groupSlug": "node"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import xml2js from 'xml2js';
|
|
||||||
import fs, { promises as fsp } from 'fs';
|
import fs, { promises as fsp } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import logger from '../logger';
|
import xml2js from 'xml2js';
|
||||||
|
|
||||||
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
||||||
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
|
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
|
||||||
@@ -14,7 +14,7 @@ const LOCAL_PATH = process.env.CONFIG_DIRECTORY
|
|||||||
|
|
||||||
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);
|
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);
|
||||||
|
|
||||||
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs
|
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDB IDs
|
||||||
// https://github.com/Anime-Lists/anime-lists/
|
// https://github.com/Anime-Lists/anime-lists/
|
||||||
|
|
||||||
interface AnimeMapping {
|
interface AnimeMapping {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||||
import NodeCache from 'node-cache';
|
import axios from 'axios';
|
||||||
|
import rateLimit from 'axios-rate-limit';
|
||||||
|
import type NodeCache from 'node-cache';
|
||||||
|
|
||||||
// 5 minute default TTL (in seconds)
|
// 5 minute default TTL (in seconds)
|
||||||
const DEFAULT_TTL = 300;
|
const DEFAULT_TTL = 300;
|
||||||
@@ -10,6 +12,10 @@ const DEFAULT_ROLLING_BUFFER = 10000;
|
|||||||
interface ExternalAPIOptions {
|
interface ExternalAPIOptions {
|
||||||
nodeCache?: NodeCache;
|
nodeCache?: NodeCache;
|
||||||
headers?: Record<string, unknown>;
|
headers?: Record<string, unknown>;
|
||||||
|
rateLimit?: {
|
||||||
|
maxRPS: number;
|
||||||
|
maxRequests: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExternalAPI {
|
class ExternalAPI {
|
||||||
@@ -31,6 +37,14 @@ class ExternalAPI {
|
|||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.rateLimit) {
|
||||||
|
this.axios = rateLimit(this.axios, {
|
||||||
|
maxRequests: options.rateLimit.maxRequests,
|
||||||
|
maxRPS: options.rateLimit.maxRPS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.cache = options.nodeCache;
|
this.cache = options.nodeCache;
|
||||||
}
|
}
|
||||||
@@ -55,6 +69,30 @@ class ExternalAPI {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async post<T>(
|
||||||
|
endpoint: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
ttl?: number
|
||||||
|
): Promise<T> {
|
||||||
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
|
config: config?.params,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
|
if (cachedItem) {
|
||||||
|
return cachedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.axios.post<T>(endpoint, data, config);
|
||||||
|
|
||||||
|
if (this.cache) {
|
||||||
|
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
protected async getRolling<T>(
|
protected async getRolling<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
config?: AxiosRequestConfig,
|
config?: AxiosRequestConfig,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import cacheManager from '../lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import logger from '../logger';
|
import logger from '@server/logger';
|
||||||
import ExternalAPI from './externalapi';
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface GitHubRelease {
|
interface GitHubRelease {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import logger from '@server/logger';
|
||||||
import logger from '../logger';
|
import type { AxiosInstance } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
export interface JellyfinUserResponse {
|
export interface JellyfinUserResponse {
|
||||||
Name: string;
|
Name: string;
|
||||||
@@ -16,7 +17,7 @@ export interface JellyfinLoginResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface JellyfinUserListResponse {
|
export interface JellyfinUserListResponse {
|
||||||
users: Array<JellyfinUserResponse>;
|
users: JellyfinUserResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JellyfinLibrary {
|
export interface JellyfinLibrary {
|
||||||
@@ -37,6 +38,7 @@ export interface JellyfinLibraryItem {
|
|||||||
SeasonId?: string;
|
SeasonId?: string;
|
||||||
SeasonName?: string;
|
SeasonName?: string;
|
||||||
IndexNumber?: number;
|
IndexNumber?: number;
|
||||||
|
IndexNumberEnd?: number;
|
||||||
ParentIndexNumber?: number;
|
ParentIndexNumber?: number;
|
||||||
MediaType: string;
|
MediaType: string;
|
||||||
}
|
}
|
||||||
@@ -169,6 +171,9 @@ class JellyfinAPI {
|
|||||||
|
|
||||||
public async getLibraries(): Promise<JellyfinLibrary[]> {
|
public async getLibraries(): Promise<JellyfinLibrary[]> {
|
||||||
try {
|
try {
|
||||||
|
// TODO: Try to fix automatic grouping without fucking up LDAP users
|
||||||
|
// const libraries = await this.axios.get<any>('/Library/VirtualFolders');
|
||||||
|
|
||||||
const account = await this.axios.get<any>(
|
const account = await this.axios.get<any>(
|
||||||
`/Users/${this.userId ?? 'Me'}/Views`
|
`/Users/${this.userId ?? 'Me'}/Views`
|
||||||
);
|
);
|
||||||
@@ -177,8 +182,10 @@ class JellyfinAPI {
|
|||||||
(Item: any) => {
|
(Item: any) => {
|
||||||
return (
|
return (
|
||||||
Item.Type === 'CollectionFolder' &&
|
Item.Type === 'CollectionFolder' &&
|
||||||
(Item.CollectionType === 'tvshows' ||
|
Item.CollectionType !== 'music' &&
|
||||||
Item.CollectionType === 'movies')
|
Item.CollectionType !== 'books' &&
|
||||||
|
Item.CollectionType !== 'musicvideos' &&
|
||||||
|
Item.CollectionType !== 'homevideos'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
).map((Item: any) => {
|
).map((Item: any) => {
|
||||||
@@ -203,7 +210,7 @@ class JellyfinAPI {
|
|||||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(
|
const contents = await this.axios.get<any>(
|
||||||
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie&Recursive=true&StartIndex=0&ParentId=${id}`
|
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return contents.data.Items.filter(
|
return contents.data.Items.filter(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import type { Library, PlexSettings } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import NodePlexAPI from 'plex-api';
|
import NodePlexAPI from 'plex-api';
|
||||||
import { getSettings, Library, PlexSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
|
|
||||||
export interface PlexLibraryItem {
|
export interface PlexLibraryItem {
|
||||||
ratingKey: string;
|
ratingKey: string;
|
||||||
@@ -130,7 +131,6 @@ class PlexAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
|
||||||
public async getStatus() {
|
public async getStatus() {
|
||||||
return await this.plexClient.query('/');
|
return await this.plexClient.query('/');
|
||||||
}
|
}
|
||||||
@@ -226,12 +226,17 @@ class PlexAPI {
|
|||||||
id: string,
|
id: string,
|
||||||
options: { addedAt: number } = {
|
options: { addedAt: number } = {
|
||||||
addedAt: Date.now() - 1000 * 60 * 60,
|
addedAt: Date.now() - 1000 * 60 * 60,
|
||||||
}
|
},
|
||||||
|
mediaType: 'movie' | 'show'
|
||||||
): Promise<PlexLibraryItem[]> {
|
): Promise<PlexLibraryItem[]> {
|
||||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||||
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
uri: `/library/sections/${id}/all?type=${
|
||||||
options.addedAt / 1000
|
mediaType === 'show' ? '4' : '1'
|
||||||
)}`,
|
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||||
|
extraHeaders: {
|
||||||
|
'X-Plex-Container-Start': `0`,
|
||||||
|
'X-Plex-Container-Size': `500`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.MediaContainer.Metadata;
|
return response.MediaContainer.Metadata;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import axios, { AxiosInstance } from 'axios';
|
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
||||||
|
import cacheManager from '@server/lib/cache';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
import { PlexDevice } from '../interfaces/api/plexInterfaces';
|
import ExternalAPI from './externalapi';
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
|
|
||||||
interface PlexAccountResponse {
|
interface PlexAccountResponse {
|
||||||
user: PlexUser;
|
user: PlexUser;
|
||||||
@@ -81,21 +82,6 @@ interface ServerResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FriendResponse {
|
|
||||||
MediaContainer: {
|
|
||||||
User: {
|
|
||||||
$: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
thumb: string;
|
|
||||||
};
|
|
||||||
Server?: ServerResponse[];
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsersResponse {
|
interface UsersResponse {
|
||||||
MediaContainer: {
|
MediaContainer: {
|
||||||
User: {
|
User: {
|
||||||
@@ -111,20 +97,54 @@ interface UsersResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlexTvAPI {
|
interface WatchlistResponse {
|
||||||
|
MediaContainer: {
|
||||||
|
totalSize: number;
|
||||||
|
Metadata?: {
|
||||||
|
ratingKey: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataResponse {
|
||||||
|
MediaContainer: {
|
||||||
|
Metadata: {
|
||||||
|
ratingKey: string;
|
||||||
|
type: 'movie' | 'show';
|
||||||
|
title: string;
|
||||||
|
Guid: {
|
||||||
|
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlexWatchlistItem {
|
||||||
|
ratingKey: string;
|
||||||
|
tmdbId: number;
|
||||||
|
tvdbId?: number;
|
||||||
|
type: 'movie' | 'show';
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlexTvAPI extends ExternalAPI {
|
||||||
private authToken: string;
|
private authToken: string;
|
||||||
private axios: AxiosInstance;
|
|
||||||
|
|
||||||
constructor(authToken: string) {
|
constructor(authToken: string) {
|
||||||
|
super(
|
||||||
|
'https://plex.tv',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Plex-Token': authToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
nodeCache: cacheManager.getCache('plextv').data,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
this.authToken = authToken;
|
this.authToken = authToken;
|
||||||
this.axios = axios.create({
|
|
||||||
baseURL: 'https://plex.tv',
|
|
||||||
headers: {
|
|
||||||
'X-Plex-Token': this.authToken,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDevices(): Promise<PlexDevice[]> {
|
public async getDevices(): Promise<PlexDevice[]> {
|
||||||
@@ -199,19 +219,6 @@ class PlexTvAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFriends(): Promise<FriendResponse> {
|
|
||||||
const response = await this.axios.get('/pms/friends/all', {
|
|
||||||
transformResponse: [],
|
|
||||||
responseType: 'text',
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedXml = (await xml2js.parseStringPromise(
|
|
||||||
response.data
|
|
||||||
)) as FriendResponse;
|
|
||||||
|
|
||||||
return parsedXml;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkUserAccess(userId: number): Promise<boolean> {
|
public async checkUserAccess(userId: number): Promise<boolean> {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
@@ -220,9 +227,9 @@ class PlexTvAPI {
|
|||||||
throw new Error('Plex is not configured!');
|
throw new Error('Plex is not configured!');
|
||||||
}
|
}
|
||||||
|
|
||||||
const friends = await this.getFriends();
|
const usersResponse = await this.getUsers();
|
||||||
|
|
||||||
const users = friends.MediaContainer.User;
|
const users = usersResponse.MediaContainer.User;
|
||||||
|
|
||||||
const user = users.find((u) => parseInt(u.$.id) === userId);
|
const user = users.find((u) => parseInt(u.$.id) === userId);
|
||||||
|
|
||||||
@@ -252,6 +259,83 @@ class PlexTvAPI {
|
|||||||
)) as UsersResponse;
|
)) as UsersResponse;
|
||||||
return parsedXml;
|
return parsedXml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getWatchlist({
|
||||||
|
offset = 0,
|
||||||
|
size = 20,
|
||||||
|
}: { offset?: number; size?: number } = {}): Promise<{
|
||||||
|
offset: number;
|
||||||
|
size: number;
|
||||||
|
totalSize: number;
|
||||||
|
items: PlexWatchlistItem[];
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<WatchlistResponse>(
|
||||||
|
'/library/sections/watchlist/all',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
'X-Plex-Container-Start': offset,
|
||||||
|
'X-Plex-Container-Size': size,
|
||||||
|
},
|
||||||
|
baseURL: 'https://metadata.provider.plex.tv',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const watchlistDetails = await Promise.all(
|
||||||
|
(response.data.MediaContainer.Metadata ?? []).map(
|
||||||
|
async (watchlistItem) => {
|
||||||
|
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
|
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||||
|
{
|
||||||
|
baseURL: 'https://metadata.provider.plex.tv',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||||
|
|
||||||
|
const tmdbString = metadata.Guid.find((guid) =>
|
||||||
|
guid.id.startsWith('tmdb')
|
||||||
|
);
|
||||||
|
const tvdbString = metadata.Guid.find((guid) =>
|
||||||
|
guid.id.startsWith('tvdb')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ratingKey: metadata.ratingKey,
|
||||||
|
// This should always be set? But I guess it also cannot be?
|
||||||
|
// We will filter out the 0's afterwards
|
||||||
|
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
|
||||||
|
tvdbId: tvdbString
|
||||||
|
? Number(tvdbString.id.split('//')[1])
|
||||||
|
: undefined,
|
||||||
|
title: metadata.title,
|
||||||
|
type: metadata.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
offset,
|
||||||
|
size,
|
||||||
|
totalSize: response.data.MediaContainer.totalSize,
|
||||||
|
items: filteredList,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to retrieve watchlist items', {
|
||||||
|
label: 'Plex.TV Metadata API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
offset,
|
||||||
|
size,
|
||||||
|
totalSize: 0,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlexTvAPI;
|
export default PlexTvAPI;
|
||||||
|
|||||||
56
server/api/pushover.ts
Normal file
56
server/api/pushover.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
|
interface PushoverSoundsResponse {
|
||||||
|
sounds: {
|
||||||
|
[name: string]: string;
|
||||||
|
};
|
||||||
|
status: number;
|
||||||
|
request: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushoverSound {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapSounds = (sounds: {
|
||||||
|
[name: string]: string;
|
||||||
|
}): PushoverSound[] =>
|
||||||
|
Object.entries(sounds).map(
|
||||||
|
([name, description]) =>
|
||||||
|
({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
} as PushoverSound)
|
||||||
|
);
|
||||||
|
|
||||||
|
class PushoverAPI extends ExternalAPI {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
'https://api.pushover.net/1',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSounds(appToken: string): Promise<PushoverSound[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
|
||||||
|
params: {
|
||||||
|
token: appToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapSounds(data.sounds);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Pushover] Failed to retrieve sounds: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PushoverAPI;
|
||||||
195
server/api/rating/imdbRadarrProxy.ts
Normal file
195
server/api/rating/imdbRadarrProxy.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import cacheManager from '@server/lib/cache';
|
||||||
|
|
||||||
|
type IMDBRadarrProxyResponse = IMDBMovie[];
|
||||||
|
|
||||||
|
interface IMDBMovie {
|
||||||
|
ImdbId: string;
|
||||||
|
Overview: string;
|
||||||
|
Title: string;
|
||||||
|
OriginalTitle: string;
|
||||||
|
TitleSlug: string;
|
||||||
|
Ratings: Rating[];
|
||||||
|
MovieRatings: MovieRatings;
|
||||||
|
Runtime: number;
|
||||||
|
Images: Image[];
|
||||||
|
Genres: string[];
|
||||||
|
Popularity: number;
|
||||||
|
Premier: string;
|
||||||
|
InCinema: string;
|
||||||
|
PhysicalRelease: any;
|
||||||
|
DigitalRelease: string;
|
||||||
|
Year: number;
|
||||||
|
AlternativeTitles: AlternativeTitle[];
|
||||||
|
Translations: Translation[];
|
||||||
|
Recommendations: Recommendation[];
|
||||||
|
Credits: Credits;
|
||||||
|
Studio: string;
|
||||||
|
YoutubeTrailerId: string;
|
||||||
|
Certifications: Certification[];
|
||||||
|
Status: any;
|
||||||
|
Collection: Collection;
|
||||||
|
OriginalLanguage: string;
|
||||||
|
Homepage: string;
|
||||||
|
TmdbId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rating {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Origin: string;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieRatings {
|
||||||
|
Tmdb: Tmdb;
|
||||||
|
Imdb: Imdb;
|
||||||
|
Metacritic: Metacritic;
|
||||||
|
RottenTomatoes: RottenTomatoes;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tmdb {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Imdb {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Metacritic {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RottenTomatoes {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Image {
|
||||||
|
CoverType: string;
|
||||||
|
Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlternativeTitle {
|
||||||
|
Title: string;
|
||||||
|
Type: string;
|
||||||
|
Language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Translation {
|
||||||
|
Title: string;
|
||||||
|
Overview: string;
|
||||||
|
Language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Recommendation {
|
||||||
|
TmdbId: number;
|
||||||
|
Title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Credits {
|
||||||
|
Cast: Cast[];
|
||||||
|
Crew: Crew[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cast {
|
||||||
|
Name: string;
|
||||||
|
Order: number;
|
||||||
|
Character: string;
|
||||||
|
TmdbId: number;
|
||||||
|
CreditId: string;
|
||||||
|
Images: Image2[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Image2 {
|
||||||
|
CoverType: string;
|
||||||
|
Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Crew {
|
||||||
|
Name: string;
|
||||||
|
Job: string;
|
||||||
|
Department: string;
|
||||||
|
TmdbId: number;
|
||||||
|
CreditId: string;
|
||||||
|
Images: Image3[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Image3 {
|
||||||
|
CoverType: string;
|
||||||
|
Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Certification {
|
||||||
|
Country: string;
|
||||||
|
Certification: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Collection {
|
||||||
|
Name: string;
|
||||||
|
Images: any;
|
||||||
|
Overview: any;
|
||||||
|
Translations: any;
|
||||||
|
Parts: any;
|
||||||
|
TmdbId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMDBRating {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
criticsScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a best-effort API. The IMDB API is technically
|
||||||
|
* private and getting access costs money/requires approval.
|
||||||
|
*
|
||||||
|
* Radarr hosts a public proxy that's in use by all Radarr instances.
|
||||||
|
*/
|
||||||
|
class IMDBRadarrProxy extends ExternalAPI {
|
||||||
|
constructor() {
|
||||||
|
super('https://api.radarr.video/v1', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
nodeCache: cacheManager.getCache('imdb').data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the Radarr IMDB Proxy for the movie
|
||||||
|
*
|
||||||
|
* @param IMDBid Id of IMDB movie
|
||||||
|
*/
|
||||||
|
public async getMovieRatings(IMDBid: string): Promise<IMDBRating | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<IMDBRadarrProxyResponse>(
|
||||||
|
`/movie/imdb/${IMDBid}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data?.length || data[0].ImdbId !== IMDBid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data[0].Title,
|
||||||
|
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
|
||||||
|
criticsScore: data[0].MovieRatings.Imdb.Value,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IMDBRadarrProxy;
|
||||||
209
server/api/rating/rottentomatoes.ts
Normal file
209
server/api/rating/rottentomatoes.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import cacheManager from '@server/lib/cache';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
|
interface RTAlgoliaSearchResponse {
|
||||||
|
results: {
|
||||||
|
hits: RTAlgoliaHit[];
|
||||||
|
index: 'content_rt' | 'people_rt';
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RTAlgoliaHit {
|
||||||
|
emsId: string;
|
||||||
|
emsVersionId: string;
|
||||||
|
tmsId: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
titles: string[];
|
||||||
|
description: string;
|
||||||
|
releaseYear: number;
|
||||||
|
rating: string;
|
||||||
|
genres: string[];
|
||||||
|
updateDate: string;
|
||||||
|
isEmsSearchable: boolean;
|
||||||
|
rtId: number;
|
||||||
|
vanity: string;
|
||||||
|
aka: string[];
|
||||||
|
posterImageUrl: string;
|
||||||
|
rottenTomatoes: {
|
||||||
|
audienceScore: number;
|
||||||
|
criticsIconUrl: string;
|
||||||
|
wantToSeeCount: number;
|
||||||
|
audienceIconUrl: string;
|
||||||
|
scoreSentiment: string;
|
||||||
|
certifiedFresh: boolean;
|
||||||
|
criticsScore: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RTRating {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten';
|
||||||
|
criticsScore: number;
|
||||||
|
audienceRating?: 'Upright' | 'Spilled';
|
||||||
|
audienceScore?: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a best-effort API. The Rotten Tomatoes API is technically
|
||||||
|
* private and getting access costs money/requires approval.
|
||||||
|
*
|
||||||
|
* They do, however, have a "public" api that they use to request the
|
||||||
|
* data on their own site. We use this to get ratings for movies/tv shows.
|
||||||
|
*
|
||||||
|
* Unfortunately, we need to do it by searching for the movie name, so it's
|
||||||
|
* not always accurate.
|
||||||
|
*/
|
||||||
|
class RottenTomatoes extends ExternalAPI {
|
||||||
|
constructor() {
|
||||||
|
const settings = getSettings();
|
||||||
|
super(
|
||||||
|
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
||||||
|
{
|
||||||
|
'x-algolia-agent':
|
||||||
|
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
|
||||||
|
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
||||||
|
'x-algolia-application-id': '79FRDP12PN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'x-algolia-usertoken': settings.clientId,
|
||||||
|
},
|
||||||
|
nodeCache: cacheManager.getCache('rt').data,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the RT algolia api for the movie title
|
||||||
|
*
|
||||||
|
* We compare the release date to make sure its the correct
|
||||||
|
* match. But it's not guaranteed to have results.
|
||||||
|
*
|
||||||
|
* @param name Movie name
|
||||||
|
* @param year Release Year
|
||||||
|
*/
|
||||||
|
public async getMovieRatings(
|
||||||
|
name: string,
|
||||||
|
year: number
|
||||||
|
): Promise<RTRating | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||||
|
requests: [
|
||||||
|
{
|
||||||
|
indexName: 'content_rt',
|
||||||
|
query: name,
|
||||||
|
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||||
|
|
||||||
|
if (!contentResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, attempt to match exact name and year
|
||||||
|
let movie = contentResults.hits.find(
|
||||||
|
(movie) => movie.releaseYear === year && movie.title === name
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we don't find a movie, try to match partial name and year
|
||||||
|
if (!movie) {
|
||||||
|
movie = contentResults.hits.find(
|
||||||
|
(movie) => movie.releaseYear === year && movie.title.includes(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still dont find a movie, try to match just on year
|
||||||
|
if (!movie) {
|
||||||
|
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One last try, try exact name match only
|
||||||
|
if (!movie) {
|
||||||
|
movie = contentResults.hits.find((movie) => movie.title === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!movie) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: movie.title,
|
||||||
|
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
|
||||||
|
criticsRating: movie.rottenTomatoes.certifiedFresh
|
||||||
|
? 'Certified Fresh'
|
||||||
|
: movie.rottenTomatoes.criticsScore >= 60
|
||||||
|
? 'Fresh'
|
||||||
|
: 'Rotten',
|
||||||
|
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||||
|
audienceRating:
|
||||||
|
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||||
|
audienceScore: movie.rottenTomatoes.audienceScore,
|
||||||
|
year: Number(movie.releaseYear),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[RT API] Failed to retrieve movie ratings: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTVRatings(
|
||||||
|
name: string,
|
||||||
|
year?: number
|
||||||
|
): Promise<RTRating | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||||
|
requests: [
|
||||||
|
{
|
||||||
|
indexName: 'content_rt',
|
||||||
|
query: name,
|
||||||
|
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||||
|
|
||||||
|
if (!contentResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
|
||||||
|
|
||||||
|
if (year) {
|
||||||
|
tvshow = contentResults.hits.find(
|
||||||
|
(series) => series.releaseYear === year
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tvshow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: tvshow.title,
|
||||||
|
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
|
||||||
|
criticsRating:
|
||||||
|
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
||||||
|
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
||||||
|
audienceRating:
|
||||||
|
tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||||
|
audienceScore: tvshow.rottenTomatoes.audienceScore,
|
||||||
|
year: Number(tvshow.releaseYear),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RottenTomatoes;
|
||||||
7
server/api/ratings.ts
Normal file
7
server/api/ratings.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy';
|
||||||
|
import { type RTRating } from '@server/api/rating/rottentomatoes';
|
||||||
|
|
||||||
|
export interface RatingResponse {
|
||||||
|
rt?: RTRating;
|
||||||
|
imdb?: IMDBRating;
|
||||||
|
}
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import cacheManager from '../lib/cache';
|
|
||||||
import ExternalAPI from './externalapi';
|
|
||||||
|
|
||||||
interface RTSearchResult {
|
|
||||||
meterClass: 'certified_fresh' | 'fresh' | 'rotten';
|
|
||||||
meterScore: number;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RTTvSearchResult extends RTSearchResult {
|
|
||||||
title: string;
|
|
||||||
startYear: number;
|
|
||||||
endYear: number;
|
|
||||||
}
|
|
||||||
interface RTMovieSearchResult extends RTSearchResult {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
year: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RTMultiSearchResponse {
|
|
||||||
tvCount: number;
|
|
||||||
tvSeries: RTTvSearchResult[];
|
|
||||||
movieCount: number;
|
|
||||||
movies: RTMovieSearchResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RTRating {
|
|
||||||
title: string;
|
|
||||||
year: number;
|
|
||||||
criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten';
|
|
||||||
criticsScore: number;
|
|
||||||
audienceRating?: 'Upright' | 'Spilled';
|
|
||||||
audienceScore?: number;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is a best-effort API. The Rotten Tomatoes API is technically
|
|
||||||
* private and getting access costs money/requires approval.
|
|
||||||
*
|
|
||||||
* They do, however, have a "public" api that they use to request the
|
|
||||||
* data on their own site. We use this to get ratings for movies/tv shows.
|
|
||||||
*
|
|
||||||
* Unfortunately, we need to do it by searching for the movie name, so it's
|
|
||||||
* not always accurate.
|
|
||||||
*/
|
|
||||||
class RottenTomatoes extends ExternalAPI {
|
|
||||||
constructor() {
|
|
||||||
super(
|
|
||||||
'https://www.rottentomatoes.com/api/private',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
nodeCache: cacheManager.getCache('rt').data,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search the 1.0 api for the movie title
|
|
||||||
*
|
|
||||||
* We compare the release date to make sure its the correct
|
|
||||||
* match. But it's not guaranteed to have results.
|
|
||||||
*
|
|
||||||
* We use the 1.0 API here because the 2.0 search api does
|
|
||||||
* not return audience ratings.
|
|
||||||
*
|
|
||||||
* @param name Movie name
|
|
||||||
* @param year Release Year
|
|
||||||
*/
|
|
||||||
public async getMovieRatings(
|
|
||||||
name: string,
|
|
||||||
year: number
|
|
||||||
): Promise<RTRating | null> {
|
|
||||||
try {
|
|
||||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
|
||||||
params: { q: name, limit: 10 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// First, attempt to match exact name and year
|
|
||||||
let movie = data.movies.find(
|
|
||||||
(movie) => movie.year === year && movie.name === name
|
|
||||||
);
|
|
||||||
|
|
||||||
// If we don't find a movie, try to match partial name and year
|
|
||||||
if (!movie) {
|
|
||||||
movie = data.movies.find(
|
|
||||||
(movie) => movie.year === year && movie.name.includes(name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we still dont find a movie, try to match just on year
|
|
||||||
if (!movie) {
|
|
||||||
movie = data.movies.find((movie) => movie.year === year);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One last try, try exact name match only
|
|
||||||
if (!movie) {
|
|
||||||
movie = data.movies.find((movie) => movie.name === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!movie) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: movie.name,
|
|
||||||
url: `https://www.rottentomatoes.com${movie.url}`,
|
|
||||||
criticsRating:
|
|
||||||
movie.meterClass === 'certified_fresh'
|
|
||||||
? 'Certified Fresh'
|
|
||||||
: movie.meterClass === 'fresh'
|
|
||||||
? 'Fresh'
|
|
||||||
: 'Rotten',
|
|
||||||
criticsScore: movie.meterScore,
|
|
||||||
year: movie.year,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`[RT API] Failed to retrieve movie ratings: ${e.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getTVRatings(
|
|
||||||
name: string,
|
|
||||||
year?: number
|
|
||||||
): Promise<RTRating | null> {
|
|
||||||
try {
|
|
||||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
|
||||||
params: { q: name, limit: 10 },
|
|
||||||
});
|
|
||||||
|
|
||||||
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
|
|
||||||
|
|
||||||
if (year) {
|
|
||||||
tvshow = data.tvSeries.find((series) => series.startYear === year);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tvshow) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: tvshow.title,
|
|
||||||
url: `https://www.rottentomatoes.com${tvshow.url}`,
|
|
||||||
criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten',
|
|
||||||
criticsScore: tvshow.meterScore,
|
|
||||||
year: tvshow.startYear,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RottenTomatoes;
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import { DVRSettings } from '../../lib/settings';
|
import type { AvailableCacheIds } from '@server/lib/cache';
|
||||||
import ExternalAPI from '../externalapi';
|
import cacheManager from '@server/lib/cache';
|
||||||
|
import type { DVRSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
export interface SystemStatus {
|
export interface SystemStatus {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -157,7 +158,12 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||||
`/queue`
|
`/queue`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
includeEpisode: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data.records;
|
return response.data.records;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import logger from '../../logger';
|
import logger from '@server/logger';
|
||||||
import ServarrBase from './base';
|
import ServarrBase from './base';
|
||||||
|
|
||||||
export interface RadarrMovieOptions {
|
export interface RadarrMovieOptions {
|
||||||
@@ -69,7 +69,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
return response.data[0];
|
return response.data[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error retrieving movie by TMDb ID', {
|
logger.error('Error retrieving movie by TMDB ID', {
|
||||||
label: 'Radarr API',
|
label: 'Radarr API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
tmdbId: id,
|
tmdbId: id,
|
||||||
@@ -213,6 +213,20 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public removeMovie = async (movieId: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id, title } = await this.getMovieByTmdbId(movieId);
|
||||||
|
await this.axios.delete(`/movie/${id}`, {
|
||||||
|
params: {
|
||||||
|
deleteFiles: true,
|
||||||
|
addImportExclusion: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info(`[Radarr] Removed movie ${title}`);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RadarrAPI;
|
export default RadarrAPI;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logger from '../../logger';
|
import logger from '@server/logger';
|
||||||
import ServarrBase from './base';
|
import ServarrBase from './base';
|
||||||
|
|
||||||
interface SonarrSeason {
|
export interface SonarrSeason {
|
||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
monitored: boolean;
|
monitored: boolean;
|
||||||
statistics?: {
|
statistics?: {
|
||||||
@@ -13,6 +13,21 @@ interface SonarrSeason {
|
|||||||
percentOfEpisodes: number;
|
percentOfEpisodes: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
interface EpisodeResult {
|
||||||
|
seriesId: number;
|
||||||
|
episodeFileId: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
|
title: string;
|
||||||
|
airDate: string;
|
||||||
|
airDateUtc: string;
|
||||||
|
overview: string;
|
||||||
|
hasFile: boolean;
|
||||||
|
monitored: boolean;
|
||||||
|
absoluteEpisodeNumber: number;
|
||||||
|
unverifiedSceneNumbering: boolean;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SonarrSeries {
|
export interface SonarrSeries {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -61,6 +76,15 @@ export interface SonarrSeries {
|
|||||||
ignoreEpisodesWithoutFiles?: boolean;
|
ignoreEpisodesWithoutFiles?: boolean;
|
||||||
searchForMissingEpisodes?: boolean;
|
searchForMissingEpisodes?: boolean;
|
||||||
};
|
};
|
||||||
|
statistics: {
|
||||||
|
seasonCount: number;
|
||||||
|
episodeFileCount: number;
|
||||||
|
episodeCount: number;
|
||||||
|
totalEpisodeCount: number;
|
||||||
|
sizeOnDisk: number;
|
||||||
|
releaseGroups: string[];
|
||||||
|
percentOfEpisodes: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddSeriesOptions {
|
export interface AddSeriesOptions {
|
||||||
@@ -82,7 +106,11 @@ export interface LanguageProfile {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
class SonarrAPI extends ServarrBase<{
|
||||||
|
seriesId: number;
|
||||||
|
episodeId: number;
|
||||||
|
episode: EpisodeResult;
|
||||||
|
}> {
|
||||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||||
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
|
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
|
||||||
}
|
}
|
||||||
@@ -97,6 +125,16 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getSeriesById(id: number): Promise<SonarrSeries> {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||||
@@ -302,6 +340,20 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
|||||||
|
|
||||||
return newSeasons;
|
return newSeasons;
|
||||||
}
|
}
|
||||||
|
public removeSerie = async (serieId: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
||||||
|
await this.axios.delete(`/series/${id}`, {
|
||||||
|
params: {
|
||||||
|
deleteFiles: true,
|
||||||
|
addImportExclusion: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info(`[Radarr] Removed serie ${title}`);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Radarr] Failed to remove serie: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SonarrAPI;
|
export default SonarrAPI;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import axios, { AxiosInstance } from 'axios';
|
import type { User } from '@server/entity/User';
|
||||||
|
import type { TautulliSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import type { AxiosInstance } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
import { User } from '../entity/User';
|
|
||||||
import { TautulliSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
|
|
||||||
export interface TautulliHistoryRecord {
|
export interface TautulliHistoryRecord {
|
||||||
date: number;
|
date: number;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import cacheManager from '@server/lib/cache';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import cacheManager from '../../lib/cache';
|
import type {
|
||||||
import ExternalAPI from '../externalapi';
|
|
||||||
import {
|
|
||||||
TmdbCollection,
|
TmdbCollection,
|
||||||
|
TmdbCompanySearchResponse,
|
||||||
TmdbExternalIdResponse,
|
TmdbExternalIdResponse,
|
||||||
TmdbGenre,
|
TmdbGenre,
|
||||||
TmdbGenresResult,
|
TmdbGenresResult,
|
||||||
|
TmdbKeyword,
|
||||||
|
TmdbKeywordSearchResponse,
|
||||||
TmdbLanguage,
|
TmdbLanguage,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbNetwork,
|
TmdbNetwork,
|
||||||
@@ -19,6 +22,8 @@ import {
|
|||||||
TmdbSeasonWithEpisodes,
|
TmdbSeasonWithEpisodes,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
TmdbUpcomingMoviesResponse,
|
TmdbUpcomingMoviesResponse,
|
||||||
|
TmdbWatchProviderDetails,
|
||||||
|
TmdbWatchProviderRegion,
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
|
|
||||||
interface SearchOptions {
|
interface SearchOptions {
|
||||||
@@ -32,30 +37,43 @@ interface SingleSearchOptions extends SearchOptions {
|
|||||||
year?: number;
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SortOptions =
|
||||||
|
| 'popularity.asc'
|
||||||
|
| 'popularity.desc'
|
||||||
|
| 'release_date.asc'
|
||||||
|
| 'release_date.desc'
|
||||||
|
| 'revenue.asc'
|
||||||
|
| 'revenue.desc'
|
||||||
|
| 'primary_release_date.asc'
|
||||||
|
| 'primary_release_date.desc'
|
||||||
|
| 'original_title.asc'
|
||||||
|
| 'original_title.desc'
|
||||||
|
| 'vote_average.asc'
|
||||||
|
| 'vote_average.desc'
|
||||||
|
| 'vote_count.asc'
|
||||||
|
| 'vote_count.desc'
|
||||||
|
| 'first_air_date.asc'
|
||||||
|
| 'first_air_date.desc';
|
||||||
|
|
||||||
interface DiscoverMovieOptions {
|
interface DiscoverMovieOptions {
|
||||||
page?: number;
|
page?: number;
|
||||||
includeAdult?: boolean;
|
includeAdult?: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
primaryReleaseDateGte?: string;
|
primaryReleaseDateGte?: string;
|
||||||
primaryReleaseDateLte?: string;
|
primaryReleaseDateLte?: string;
|
||||||
|
withRuntimeGte?: string;
|
||||||
|
withRuntimeLte?: string;
|
||||||
|
voteAverageGte?: string;
|
||||||
|
voteAverageLte?: string;
|
||||||
|
voteCountGte?: string;
|
||||||
|
voteCountLte?: string;
|
||||||
originalLanguage?: string;
|
originalLanguage?: string;
|
||||||
genre?: number;
|
genre?: string;
|
||||||
studio?: number;
|
studio?: string;
|
||||||
sortBy?:
|
keywords?: string;
|
||||||
| 'popularity.asc'
|
sortBy?: SortOptions;
|
||||||
| 'popularity.desc'
|
watchRegion?: string;
|
||||||
| 'release_date.asc'
|
watchProviders?: string;
|
||||||
| 'release_date.desc'
|
|
||||||
| 'revenue.asc'
|
|
||||||
| 'revenue.desc'
|
|
||||||
| 'primary_release_date.asc'
|
|
||||||
| 'primary_release_date.desc'
|
|
||||||
| 'original_title.asc'
|
|
||||||
| 'original_title.desc'
|
|
||||||
| 'vote_average.asc'
|
|
||||||
| 'vote_average.desc'
|
|
||||||
| 'vote_count.asc'
|
|
||||||
| 'vote_count.desc';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscoverTvOptions {
|
interface DiscoverTvOptions {
|
||||||
@@ -63,19 +81,20 @@ interface DiscoverTvOptions {
|
|||||||
language?: string;
|
language?: string;
|
||||||
firstAirDateGte?: string;
|
firstAirDateGte?: string;
|
||||||
firstAirDateLte?: string;
|
firstAirDateLte?: string;
|
||||||
|
withRuntimeGte?: string;
|
||||||
|
withRuntimeLte?: string;
|
||||||
|
voteAverageGte?: string;
|
||||||
|
voteAverageLte?: string;
|
||||||
|
voteCountGte?: string;
|
||||||
|
voteCountLte?: string;
|
||||||
includeEmptyReleaseDate?: boolean;
|
includeEmptyReleaseDate?: boolean;
|
||||||
originalLanguage?: string;
|
originalLanguage?: string;
|
||||||
genre?: number;
|
genre?: string;
|
||||||
network?: number;
|
network?: number;
|
||||||
sortBy?:
|
keywords?: string;
|
||||||
| 'popularity.asc'
|
sortBy?: SortOptions;
|
||||||
| 'popularity.desc'
|
watchRegion?: string;
|
||||||
| 'vote_average.asc'
|
watchProviders?: string;
|
||||||
| 'vote_average.desc'
|
|
||||||
| 'vote_count.asc'
|
|
||||||
| 'vote_count.desc'
|
|
||||||
| 'first_air_date.asc'
|
|
||||||
| 'first_air_date.desc';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TheMovieDb extends ExternalAPI {
|
class TheMovieDb extends ExternalAPI {
|
||||||
@@ -92,6 +111,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
nodeCache: cacheManager.getCache('tmdb').data,
|
nodeCache: cacheManager.getCache('tmdb').data,
|
||||||
|
rateLimit: {
|
||||||
|
maxRequests: 20,
|
||||||
|
maxRPS: 50,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.region = region;
|
this.region = region;
|
||||||
@@ -192,7 +215,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,7 +237,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[TMDb] Failed to fetch person combined credits: ${e.message}`
|
`[TMDB] Failed to fetch person combined credits: ${e.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -233,7 +256,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
params: {
|
params: {
|
||||||
language,
|
language,
|
||||||
append_to_response:
|
append_to_response:
|
||||||
'credits,external_ids,videos,release_dates,watch/providers',
|
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
@@ -241,7 +264,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,7 +290,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -293,7 +316,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -319,7 +342,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +368,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +394,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +421,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[TMDb] Failed to fetch TV recommendations: ${e.message}`
|
`[TMDB] Failed to fetch TV recommendations: ${e.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,7 +445,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,8 +459,27 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
originalLanguage,
|
originalLanguage,
|
||||||
genre,
|
genre,
|
||||||
studio,
|
studio,
|
||||||
|
keywords,
|
||||||
|
withRuntimeGte,
|
||||||
|
withRuntimeLte,
|
||||||
|
voteAverageGte,
|
||||||
|
voteAverageLte,
|
||||||
|
voteCountGte,
|
||||||
|
voteCountLte,
|
||||||
|
watchProviders,
|
||||||
|
watchRegion,
|
||||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||||
try {
|
try {
|
||||||
|
const defaultFutureDate = new Date(
|
||||||
|
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
|
const defaultPastDate = new Date('1900-01-01')
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||||
params: {
|
params: {
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
@@ -445,17 +487,39 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
include_adult: includeAdult,
|
include_adult: includeAdult,
|
||||||
language,
|
language,
|
||||||
region: this.region,
|
region: this.region,
|
||||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
with_original_language:
|
||||||
'primary_release_date.gte': primaryReleaseDateGte,
|
originalLanguage && originalLanguage !== 'all'
|
||||||
'primary_release_date.lte': primaryReleaseDateLte,
|
? originalLanguage
|
||||||
|
: originalLanguage === 'all'
|
||||||
|
? undefined
|
||||||
|
: this.originalLanguage,
|
||||||
|
// Set our release date values, but check if one is set and not the other,
|
||||||
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
|
'primary_release_date.gte':
|
||||||
|
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||||
|
? defaultPastDate
|
||||||
|
: primaryReleaseDateGte,
|
||||||
|
'primary_release_date.lte':
|
||||||
|
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||||
|
? defaultFutureDate
|
||||||
|
: primaryReleaseDateLte,
|
||||||
with_genres: genre,
|
with_genres: genre,
|
||||||
with_companies: studio,
|
with_companies: studio,
|
||||||
|
with_keywords: keywords,
|
||||||
|
'with_runtime.gte': withRuntimeGte,
|
||||||
|
'with_runtime.lte': withRuntimeLte,
|
||||||
|
'vote_average.gte': voteAverageGte,
|
||||||
|
'vote_average.lte': voteAverageLte,
|
||||||
|
'vote_count.gte': voteCountGte,
|
||||||
|
'vote_count.lte': voteCountLte,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
with_watch_providers: watchProviders,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -469,26 +533,67 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
originalLanguage,
|
originalLanguage,
|
||||||
genre,
|
genre,
|
||||||
network,
|
network,
|
||||||
|
keywords,
|
||||||
|
withRuntimeGte,
|
||||||
|
withRuntimeLte,
|
||||||
|
voteAverageGte,
|
||||||
|
voteAverageLte,
|
||||||
|
voteCountGte,
|
||||||
|
voteCountLte,
|
||||||
|
watchProviders,
|
||||||
|
watchRegion,
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
|
const defaultFutureDate = new Date(
|
||||||
|
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
|
const defaultPastDate = new Date('1900-01-01')
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||||
params: {
|
params: {
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
page,
|
page,
|
||||||
language,
|
language,
|
||||||
region: this.region,
|
region: this.region,
|
||||||
'first_air_date.gte': firstAirDateGte,
|
// Set our release date values, but check if one is set and not the other,
|
||||||
'first_air_date.lte': firstAirDateLte,
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
'first_air_date.gte':
|
||||||
|
!firstAirDateGte && firstAirDateLte
|
||||||
|
? defaultPastDate
|
||||||
|
: firstAirDateGte,
|
||||||
|
'first_air_date.lte':
|
||||||
|
!firstAirDateLte && firstAirDateGte
|
||||||
|
? defaultFutureDate
|
||||||
|
: firstAirDateLte,
|
||||||
|
with_original_language:
|
||||||
|
originalLanguage && originalLanguage !== 'all'
|
||||||
|
? originalLanguage
|
||||||
|
: originalLanguage === 'all'
|
||||||
|
? undefined
|
||||||
|
: this.originalLanguage,
|
||||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||||
with_genres: genre,
|
with_genres: genre,
|
||||||
with_networks: network,
|
with_networks: network,
|
||||||
|
with_keywords: keywords,
|
||||||
|
'with_runtime.gte': withRuntimeGte,
|
||||||
|
'with_runtime.lte': withRuntimeLte,
|
||||||
|
'vote_average.gte': voteAverageGte,
|
||||||
|
'vote_average.lte': voteAverageLte,
|
||||||
|
'vote_count.gte': voteCountGte,
|
||||||
|
'vote_count.lte': voteCountLte,
|
||||||
|
with_watch_providers: watchProviders,
|
||||||
|
watch_region: watchRegion,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -514,7 +619,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -541,7 +646,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -564,7 +669,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -587,7 +692,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -619,7 +724,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`);
|
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,7 +762,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
throw new Error(`No movie or show returned from API for ID ${imdbId}`);
|
throw new Error(`No movie or show returned from API for ID ${imdbId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[TMDb] Failed to find media using external IMDb ID: ${e.message}`
|
`[TMDB] Failed to find media using external IMDb ID: ${e.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,7 +792,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
throw new Error(`No show returned from API for ID ${tvdbId}`);
|
throw new Error(`No show returned from API for ID ${tvdbId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`
|
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -711,7 +816,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,7 +832,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return regions;
|
return regions;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +848,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return languages;
|
return languages;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,7 +860,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +870,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,7 +921,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return movieGenres;
|
return movieGenres;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,7 +972,153 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return tvGenres;
|
return tvGenres;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getKeywordDetails({
|
||||||
|
keywordId,
|
||||||
|
}: {
|
||||||
|
keywordId: number;
|
||||||
|
}): Promise<TmdbKeyword> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbKeyword>(
|
||||||
|
`/keyword/${keywordId}`,
|
||||||
|
undefined,
|
||||||
|
604800 // 7 days
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchKeyword({
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
page?: number;
|
||||||
|
}): Promise<TmdbKeywordSearchResponse> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbKeywordSearchResponse>(
|
||||||
|
'/search/keyword',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchCompany({
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
page?: number;
|
||||||
|
}): Promise<TmdbCompanySearchResponse> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbCompanySearchResponse>(
|
||||||
|
'/search/company',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAvailableWatchProviderRegions({
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
||||||
|
'/watch/providers/regions',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[TMDB] Failed to fetch available watch regions: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMovieWatchProviders({
|
||||||
|
language,
|
||||||
|
watchRegion,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
watchRegion: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
|
'/watch/providers/movie',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTvWatchProviders({
|
||||||
|
language,
|
||||||
|
watchRegion,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
watchRegion: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
|
'/watch/providers/tv',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
|
|||||||
first_air_date: string;
|
first_air_date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbCollectionResult {
|
||||||
|
id: number;
|
||||||
|
media_type: 'collection';
|
||||||
|
title: string;
|
||||||
|
original_title: string;
|
||||||
|
adult: boolean;
|
||||||
|
poster_path?: string;
|
||||||
|
backdrop_path?: string;
|
||||||
|
overview: string;
|
||||||
|
original_language: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TmdbPersonResult {
|
export interface TmdbPersonResult {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
||||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
|
results: (
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
||||||
@@ -171,6 +188,9 @@ export interface TmdbMovieDetails {
|
|||||||
id: number;
|
id: number;
|
||||||
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
||||||
};
|
};
|
||||||
|
keywords: {
|
||||||
|
keywords: TmdbKeyword[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbVideo {
|
export interface TmdbVideo {
|
||||||
@@ -191,7 +211,7 @@ export interface TmdbVideo {
|
|||||||
|
|
||||||
export interface TmdbTvEpisodeResult {
|
export interface TmdbTvEpisodeResult {
|
||||||
id: number;
|
id: number;
|
||||||
air_date: string;
|
air_date: string | null;
|
||||||
episode_number: number;
|
episode_number: number;
|
||||||
name: string;
|
name: string;
|
||||||
overview: string;
|
overview: string;
|
||||||
@@ -372,7 +392,8 @@ export interface TmdbPersonCombinedCredits {
|
|||||||
crew: TmdbPersonCreditCrew[];
|
crew: TmdbPersonCreditCrew[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
|
export interface TmdbSeasonWithEpisodes
|
||||||
|
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
|
||||||
episodes: TmdbTvEpisodeResult[];
|
episodes: TmdbTvEpisodeResult[];
|
||||||
external_ids: TmdbExternalIds;
|
external_ids: TmdbExternalIds;
|
||||||
}
|
}
|
||||||
@@ -427,3 +448,24 @@ export interface TmdbWatchProviderDetails {
|
|||||||
provider_id: number;
|
provider_id: number;
|
||||||
provider_name: string;
|
provider_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbKeywordSearchResponse extends TmdbPaginatedResponse {
|
||||||
|
results: TmdbKeyword[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have production companies, but the company search results return less data
|
||||||
|
export interface TmdbCompany {
|
||||||
|
id: number;
|
||||||
|
logo_path?: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
||||||
|
results: TmdbCompany[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbWatchProviderRegion {
|
||||||
|
iso_3166_1: string;
|
||||||
|
english_name: string;
|
||||||
|
native_name: string;
|
||||||
|
}
|
||||||
|
|||||||
100
server/constants/discover.ts
Normal file
100
server/constants/discover.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
|
||||||
|
export enum DiscoverSliderType {
|
||||||
|
RECENTLY_ADDED = 1,
|
||||||
|
RECENT_REQUESTS,
|
||||||
|
PLEX_WATCHLIST,
|
||||||
|
TRENDING,
|
||||||
|
POPULAR_MOVIES,
|
||||||
|
MOVIE_GENRES,
|
||||||
|
UPCOMING_MOVIES,
|
||||||
|
STUDIOS,
|
||||||
|
POPULAR_TV,
|
||||||
|
TV_GENRES,
|
||||||
|
UPCOMING_TV,
|
||||||
|
NETWORKS,
|
||||||
|
TMDB_MOVIE_KEYWORD,
|
||||||
|
TMDB_MOVIE_GENRE,
|
||||||
|
TMDB_TV_KEYWORD,
|
||||||
|
TMDB_TV_GENRE,
|
||||||
|
TMDB_SEARCH,
|
||||||
|
TMDB_STUDIO,
|
||||||
|
TMDB_NETWORK,
|
||||||
|
TMDB_MOVIE_STREAMING_SERVICES,
|
||||||
|
TMDB_TV_STREAMING_SERVICES,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.RECENTLY_ADDED,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.RECENT_REQUESTS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.PLEX_WATCHLIST,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TRENDING,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.POPULAR_MOVIES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.MOVIE_GENRES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.UPCOMING_MOVIES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.STUDIOS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.POPULAR_TV,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TV_GENRES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.UPCOMING_TV,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.NETWORKS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 11,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -2,6 +2,7 @@ export enum MediaRequestStatus {
|
|||||||
PENDING = 1,
|
PENDING = 1,
|
||||||
APPROVED,
|
APPROVED,
|
||||||
DECLINED,
|
DECLINED,
|
||||||
|
FAILED,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MediaType {
|
export enum MediaType {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
const devConfig = {
|
import 'reflect-metadata';
|
||||||
|
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
const devConfig: DataSourceOptions = {
|
||||||
type: 'sqlite',
|
type: 'sqlite',
|
||||||
database: process.env.CONFIG_DIRECTORY
|
database: process.env.CONFIG_DIRECTORY
|
||||||
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
|
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
|
||||||
@@ -10,31 +14,30 @@ const devConfig = {
|
|||||||
entities: ['server/entity/**/*.ts'],
|
entities: ['server/entity/**/*.ts'],
|
||||||
migrations: ['server/migration/**/*.ts'],
|
migrations: ['server/migration/**/*.ts'],
|
||||||
subscribers: ['server/subscriber/**/*.ts'],
|
subscribers: ['server/subscriber/**/*.ts'],
|
||||||
cli: {
|
|
||||||
entitiesDir: 'server/entity',
|
|
||||||
migrationsDir: 'server/migration',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const prodConfig = {
|
const prodConfig: DataSourceOptions = {
|
||||||
type: 'sqlite',
|
type: 'sqlite',
|
||||||
database: process.env.CONFIG_DIRECTORY
|
database: process.env.CONFIG_DIRECTORY
|
||||||
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
|
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
|
||||||
: 'config/db/db.sqlite3',
|
: 'config/db/db.sqlite3',
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
migrationsRun: false,
|
||||||
logging: false,
|
logging: false,
|
||||||
enableWAL: true,
|
enableWAL: true,
|
||||||
entities: ['dist/entity/**/*.js'],
|
entities: ['dist/entity/**/*.js'],
|
||||||
migrations: ['dist/migration/**/*.js'],
|
migrations: ['dist/migration/**/*.js'],
|
||||||
migrationsRun: false,
|
|
||||||
subscribers: ['dist/subscriber/**/*.js'],
|
subscribers: ['dist/subscriber/**/*.js'],
|
||||||
cli: {
|
|
||||||
entitiesDir: 'dist/entity',
|
|
||||||
migrationsDir: 'dist/migration',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalConfig =
|
const dataSource = new DataSource(
|
||||||
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig;
|
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = finalConfig;
|
export const getRepository = <Entity extends object>(
|
||||||
|
target: EntityTarget<Entity>
|
||||||
|
): Repository<Entity> => {
|
||||||
|
return dataSource.getRepository(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default dataSource;
|
||||||
69
server/entity/DiscoverSlider.ts
Normal file
69
server/entity/DiscoverSlider.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { DiscoverSliderType } from '@server/constants/discover';
|
||||||
|
import { defaultSliders } from '@server/constants/discover';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
class DiscoverSlider {
|
||||||
|
public static async bootstrapSliders(): Promise<void> {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
for (const slider of defaultSliders) {
|
||||||
|
const existingSlider = await sliderRepository.findOne({
|
||||||
|
where: {
|
||||||
|
type: slider.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSlider) {
|
||||||
|
logger.info('Creating built-in discovery slider', {
|
||||||
|
label: 'Discover Slider',
|
||||||
|
slider,
|
||||||
|
});
|
||||||
|
await sliderRepository.save(new DiscoverSlider(slider));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
public type: DiscoverSliderType;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
public order: number;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
public isBuiltIn: boolean;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
public enabled: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
// Title is not required for built in sliders because we will
|
||||||
|
// use translations for them.
|
||||||
|
public title?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public data?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(init?: Partial<DiscoverSlider>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiscoverSlider;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { IssueType } from '@server/constants/issue';
|
||||||
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
@@ -7,7 +9,6 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { IssueStatus, IssueType } from '../constants/issue';
|
|
||||||
import IssueComment from './IssueComment';
|
import IssueComment from './IssueComment';
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import type { User } from '@server/entity/User';
|
||||||
|
import { Watchlist } from '@server/entity/Watchlist';
|
||||||
|
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||||
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
getRepository,
|
|
||||||
In,
|
|
||||||
Index,
|
Index,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import RadarrAPI from '../api/servarr/radarr';
|
|
||||||
import SonarrAPI from '../api/servarr/sonarr';
|
|
||||||
import { MediaStatus, MediaType } from '../constants/media';
|
|
||||||
import { MediaServerType } from '../constants/server';
|
|
||||||
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
|
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
import Season from './Season';
|
import Season from './Season';
|
||||||
@@ -24,6 +26,7 @@ import Season from './Season';
|
|||||||
@Entity()
|
@Entity()
|
||||||
class Media {
|
class Media {
|
||||||
public static async getRelatedMedia(
|
public static async getRelatedMedia(
|
||||||
|
user: User | undefined,
|
||||||
tmdbIds: number | number[]
|
tmdbIds: number | number[]
|
||||||
): Promise<Media[]> {
|
): Promise<Media[]> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
@@ -36,9 +39,16 @@ class Media {
|
|||||||
finalIds = tmdbIds;
|
finalIds = tmdbIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await mediaRepository.find({
|
const media = await mediaRepository
|
||||||
tmdbId: In(finalIds),
|
.createQueryBuilder('media')
|
||||||
});
|
.leftJoinAndSelect(
|
||||||
|
'media.watchlists',
|
||||||
|
'watchlist',
|
||||||
|
'media.id= watchlist.media and watchlist.requestedBy = :userId',
|
||||||
|
{ userId: user?.id }
|
||||||
|
) //,
|
||||||
|
.where(' media.tmdbId in (:...finalIds)', { finalIds })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
return media;
|
return media;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -56,10 +66,10 @@ class Media {
|
|||||||
try {
|
try {
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { tmdbId: id, mediaType },
|
where: { tmdbId: id, mediaType },
|
||||||
relations: ['requests', 'issues'],
|
relations: { requests: true, issues: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return media;
|
return media ?? undefined;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e.message);
|
logger.error(e.message);
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -93,6 +103,9 @@ class Media {
|
|||||||
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
||||||
public requests: MediaRequest[];
|
public requests: MediaRequest[];
|
||||||
|
|
||||||
|
@OneToMany(() => Watchlist, (watchlist) => watchlist.media)
|
||||||
|
public watchlists: null | Watchlist[];
|
||||||
|
|
||||||
@OneToMany(() => Season, (season) => season.media, {
|
@OneToMany(() => Season, (season) => season.media, {
|
||||||
cascade: true,
|
cascade: true,
|
||||||
eager: true,
|
eager: true,
|
||||||
@@ -114,29 +127,29 @@ class Media {
|
|||||||
@Column({ type: 'datetime', nullable: true })
|
@Column({ type: 'datetime', nullable: true })
|
||||||
public mediaAddedAt: Date;
|
public mediaAddedAt: Date;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public serviceId?: number;
|
public serviceId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public serviceId4k?: number;
|
public serviceId4k?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public externalServiceId?: number;
|
public externalServiceId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public externalServiceId4k?: number;
|
public externalServiceId4k?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public externalServiceSlug?: string;
|
public externalServiceSlug?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public externalServiceSlug4k?: string;
|
public externalServiceSlug4k?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public ratingKey?: string;
|
public ratingKey?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public ratingKey4k?: string;
|
public ratingKey4k?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public jellyfinMediaId?: string;
|
public jellyfinMediaId?: string;
|
||||||
@@ -152,6 +165,9 @@ class Media {
|
|||||||
public mediaUrl?: string;
|
public mediaUrl?: string;
|
||||||
public mediaUrl4k?: string;
|
public mediaUrl4k?: string;
|
||||||
|
|
||||||
|
public iOSPlexUrl?: string;
|
||||||
|
public iOSPlexUrl4k?: string;
|
||||||
|
|
||||||
public tautulliUrl?: string;
|
public tautulliUrl?: string;
|
||||||
public tautulliUrl4k?: string;
|
public tautulliUrl4k?: string;
|
||||||
|
|
||||||
@@ -172,35 +188,44 @@ class Media {
|
|||||||
this.ratingKey
|
this.ratingKey
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
this.iOSPlexUrl = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey}&server=${machineId}`;
|
||||||
|
|
||||||
if (tautulliUrl) {
|
if (tautulliUrl) {
|
||||||
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
|
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (this.ratingKey4k) {
|
if (this.ratingKey4k) {
|
||||||
this.mediaUrl4k = `${
|
this.mediaUrl4k = `${
|
||||||
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
|
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
|
||||||
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
|
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
|
||||||
this.ratingKey4k
|
this.ratingKey4k
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
if (tautulliUrl) {
|
this.iOSPlexUrl4k = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}&server=${machineId}`;
|
||||||
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
|
|
||||||
|
if (tautulliUrl) {
|
||||||
|
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pageName =
|
const pageName =
|
||||||
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||||
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
|
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
|
||||||
const jellyfinHost =
|
let jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: hostname;
|
||||||
|
|
||||||
|
jellyfinHost = jellyfinHost.endsWith('/')
|
||||||
|
? jellyfinHost.slice(0, -1)
|
||||||
|
: jellyfinHost;
|
||||||
|
|
||||||
if (this.jellyfinMediaId) {
|
if (this.jellyfinMediaId) {
|
||||||
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
}
|
}
|
||||||
if (this.jellyfinMediaId4k) {
|
if (this.jellyfinMediaId4k) {
|
||||||
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
|
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +300,9 @@ class Media {
|
|||||||
if (this.mediaType === MediaType.MOVIE) {
|
if (this.mediaType === MediaType.MOVIE) {
|
||||||
if (
|
if (
|
||||||
this.externalServiceId !== undefined &&
|
this.externalServiceId !== undefined &&
|
||||||
this.serviceId !== undefined
|
this.externalServiceId !== null &&
|
||||||
|
this.serviceId !== undefined &&
|
||||||
|
this.serviceId !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus = downloadTracker.getMovieProgress(
|
this.downloadStatus = downloadTracker.getMovieProgress(
|
||||||
this.serviceId,
|
this.serviceId,
|
||||||
@@ -285,7 +312,9 @@ class Media {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.externalServiceId4k !== undefined &&
|
this.externalServiceId4k !== undefined &&
|
||||||
this.serviceId4k !== undefined
|
this.externalServiceId4k !== null &&
|
||||||
|
this.serviceId4k !== undefined &&
|
||||||
|
this.serviceId4k !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
||||||
this.serviceId4k,
|
this.serviceId4k,
|
||||||
@@ -297,7 +326,9 @@ class Media {
|
|||||||
if (this.mediaType === MediaType.TV) {
|
if (this.mediaType === MediaType.TV) {
|
||||||
if (
|
if (
|
||||||
this.externalServiceId !== undefined &&
|
this.externalServiceId !== undefined &&
|
||||||
this.serviceId !== undefined
|
this.externalServiceId !== null &&
|
||||||
|
this.serviceId !== undefined &&
|
||||||
|
this.serviceId !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus = downloadTracker.getSeriesProgress(
|
this.downloadStatus = downloadTracker.getSeriesProgress(
|
||||||
this.serviceId,
|
this.serviceId,
|
||||||
@@ -307,7 +338,9 @@ class Media {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.externalServiceId4k !== undefined &&
|
this.externalServiceId4k !== undefined &&
|
||||||
this.serviceId4k !== undefined
|
this.externalServiceId4k !== null &&
|
||||||
|
this.serviceId4k !== undefined &&
|
||||||
|
this.serviceId4k !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
||||||
this.serviceId4k,
|
this.serviceId4k,
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import type {
|
||||||
|
AddSeriesOptions,
|
||||||
|
SonarrSeries,
|
||||||
|
} from '@server/api/servarr/sonarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
||||||
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import { isEqual, truncate } from 'lodash';
|
import { isEqual, truncate } from 'lodash';
|
||||||
import {
|
import {
|
||||||
AfterInsert,
|
AfterInsert,
|
||||||
@@ -6,30 +26,347 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
getRepository,
|
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
RelationCount,
|
RelationCount,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr';
|
|
||||||
import SonarrAPI, {
|
|
||||||
AddSeriesOptions,
|
|
||||||
SonarrSeries,
|
|
||||||
} from '../api/servarr/sonarr';
|
|
||||||
import TheMovieDb from '../api/themoviedb';
|
|
||||||
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
|
|
||||||
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
|
|
||||||
import notificationManager, { Notification } from '../lib/notifications';
|
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
import SeasonRequest from './SeasonRequest';
|
import SeasonRequest from './SeasonRequest';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
|
export class RequestPermissionError extends Error {}
|
||||||
|
export class QuotaRestrictedError extends Error {}
|
||||||
|
export class DuplicateMediaRequestError extends Error {}
|
||||||
|
export class NoSeasonsAvailableError extends Error {}
|
||||||
|
|
||||||
|
type MediaRequestOptions = {
|
||||||
|
isAutoRequest?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class MediaRequest {
|
export class MediaRequest {
|
||||||
|
public static async request(
|
||||||
|
requestBody: MediaRequestBody,
|
||||||
|
user: User,
|
||||||
|
options: MediaRequestOptions = {}
|
||||||
|
): Promise<MediaRequest> {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
let requestUser = user;
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestBody.userId &&
|
||||||
|
!requestUser.hasPermission([
|
||||||
|
Permission.MANAGE_USERS,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
throw new RequestPermissionError(
|
||||||
|
'You do not have permission to modify the request user.'
|
||||||
|
);
|
||||||
|
} else if (requestBody.userId) {
|
||||||
|
requestUser = await userRepository.findOneOrFail({
|
||||||
|
where: { id: requestBody.userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestUser) {
|
||||||
|
throw new Error('User missing from request context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestBody.mediaType === MediaType.MOVIE &&
|
||||||
|
!requestUser.hasPermission(
|
||||||
|
requestBody.is4k
|
||||||
|
? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
|
||||||
|
: [Permission.REQUEST, Permission.REQUEST_MOVIE],
|
||||||
|
{
|
||||||
|
type: 'or',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new RequestPermissionError(
|
||||||
|
`You do not have permission to make ${
|
||||||
|
requestBody.is4k ? '4K ' : ''
|
||||||
|
}movie requests.`
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
requestBody.mediaType === MediaType.TV &&
|
||||||
|
!requestUser.hasPermission(
|
||||||
|
requestBody.is4k
|
||||||
|
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
|
||||||
|
: [Permission.REQUEST, Permission.REQUEST_TV],
|
||||||
|
{
|
||||||
|
type: 'or',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new RequestPermissionError(
|
||||||
|
`You do not have permission to make ${
|
||||||
|
requestBody.is4k ? '4K ' : ''
|
||||||
|
}series requests.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotas = await requestUser.getQuota();
|
||||||
|
|
||||||
|
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
|
||||||
|
throw new QuotaRestrictedError('Movie Quota exceeded.');
|
||||||
|
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
|
||||||
|
throw new QuotaRestrictedError('Series Quota exceeded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdbMedia =
|
||||||
|
requestBody.mediaType === MediaType.MOVIE
|
||||||
|
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
||||||
|
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
|
||||||
|
|
||||||
|
let media = await mediaRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tmdbId: requestBody.mediaId,
|
||||||
|
mediaType: requestBody.mediaType,
|
||||||
|
},
|
||||||
|
relations: ['requests'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
media = new Media({
|
||||||
|
tmdbId: tmdbMedia.id,
|
||||||
|
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
|
||||||
|
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||||
|
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||||
|
mediaType: requestBody.mediaType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
||||||
|
media.status = MediaStatus.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
|
||||||
|
media.status4k = MediaStatus.PENDING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await requestRepository
|
||||||
|
.createQueryBuilder('request')
|
||||||
|
.leftJoin('request.media', 'media')
|
||||||
|
.leftJoinAndSelect('request.requestedBy', 'user')
|
||||||
|
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
|
||||||
|
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
|
||||||
|
.andWhere('media.mediaType = :mediaType', {
|
||||||
|
mediaType: requestBody.mediaType,
|
||||||
|
})
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||||
|
if (
|
||||||
|
requestBody.mediaType === MediaType.MOVIE &&
|
||||||
|
existing[0].status !== MediaRequestStatus.DECLINED
|
||||||
|
) {
|
||||||
|
logger.warn('Duplicate request for media blocked', {
|
||||||
|
tmdbId: tmdbMedia.id,
|
||||||
|
mediaType: requestBody.mediaType,
|
||||||
|
is4k: requestBody.is4k,
|
||||||
|
label: 'Media Request',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new DuplicateMediaRequestError(
|
||||||
|
'Request for this media already exists.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If an existing auto-request for this media exists from the same user,
|
||||||
|
// don't allow a new one.
|
||||||
|
if (
|
||||||
|
existing.find(
|
||||||
|
(r) => r.requestedBy.id === requestUser.id && r.isAutoRequest
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new DuplicateMediaRequestError(
|
||||||
|
'Auto-request for this media and user already exists.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestBody.mediaType === MediaType.MOVIE) {
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
|
||||||
|
const request = new MediaRequest({
|
||||||
|
type: MediaType.MOVIE,
|
||||||
|
media,
|
||||||
|
requestedBy: requestUser,
|
||||||
|
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||||
|
status: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||||
|
: Permission.AUTO_APPROVE_MOVIE,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? MediaRequestStatus.APPROVED
|
||||||
|
: MediaRequestStatus.PENDING,
|
||||||
|
modifiedBy: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||||
|
: Permission.AUTO_APPROVE_MOVIE,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? user
|
||||||
|
: undefined,
|
||||||
|
is4k: requestBody.is4k,
|
||||||
|
serverId: requestBody.serverId,
|
||||||
|
profileId: requestBody.profileId,
|
||||||
|
rootFolder: requestBody.rootFolder,
|
||||||
|
tags: requestBody.tags,
|
||||||
|
isAutoRequest: options.isAutoRequest ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestRepository.save(request);
|
||||||
|
return request;
|
||||||
|
} else {
|
||||||
|
const tmdbMediaShow = tmdbMedia as Awaited<
|
||||||
|
ReturnType<typeof tmdb.getTvShow>
|
||||||
|
>;
|
||||||
|
const requestedSeasons =
|
||||||
|
requestBody.seasons === 'all'
|
||||||
|
? tmdbMediaShow.seasons
|
||||||
|
.map((season) => season.season_number)
|
||||||
|
.filter((sn) => sn > 0)
|
||||||
|
: (requestBody.seasons as number[]);
|
||||||
|
let existingSeasons: number[] = [];
|
||||||
|
|
||||||
|
// We need to check existing requests on this title to make sure we don't double up on seasons that were
|
||||||
|
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
|
||||||
|
// (Unless there are no seasons, in which case we abort)
|
||||||
|
if (media.requests) {
|
||||||
|
existingSeasons = media.requests
|
||||||
|
.filter(
|
||||||
|
(request) =>
|
||||||
|
request.is4k === requestBody.is4k &&
|
||||||
|
request.status !== MediaRequestStatus.DECLINED
|
||||||
|
)
|
||||||
|
.reduce((seasons, request) => {
|
||||||
|
const combinedSeasons = request.seasons.map(
|
||||||
|
(season) => season.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...seasons, ...combinedSeasons];
|
||||||
|
}, [] as number[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should also check seasons that are available/partially available but don't have existing requests
|
||||||
|
if (media.seasons) {
|
||||||
|
existingSeasons = [
|
||||||
|
...existingSeasons,
|
||||||
|
...media.seasons
|
||||||
|
.filter(
|
||||||
|
(season) =>
|
||||||
|
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||||
|
MediaStatus.UNKNOWN
|
||||||
|
)
|
||||||
|
.map((season) => season.seasonNumber),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSeasons = requestedSeasons.filter(
|
||||||
|
(rs) => !existingSeasons.includes(rs)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (finalSeasons.length === 0) {
|
||||||
|
throw new NoSeasonsAvailableError('No seasons available to request');
|
||||||
|
} else if (
|
||||||
|
quotas.tv.limit &&
|
||||||
|
finalSeasons.length > (quotas.tv.remaining ?? 0)
|
||||||
|
) {
|
||||||
|
throw new QuotaRestrictedError('Series Quota exceeded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
|
||||||
|
const request = new MediaRequest({
|
||||||
|
type: MediaType.TV,
|
||||||
|
media,
|
||||||
|
requestedBy: requestUser,
|
||||||
|
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||||
|
status: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_TV
|
||||||
|
: Permission.AUTO_APPROVE_TV,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? MediaRequestStatus.APPROVED
|
||||||
|
: MediaRequestStatus.PENDING,
|
||||||
|
modifiedBy: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_TV
|
||||||
|
: Permission.AUTO_APPROVE_TV,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? user
|
||||||
|
: undefined,
|
||||||
|
is4k: requestBody.is4k,
|
||||||
|
serverId: requestBody.serverId,
|
||||||
|
profileId: requestBody.profileId,
|
||||||
|
rootFolder: requestBody.rootFolder,
|
||||||
|
languageProfileId: requestBody.languageProfileId,
|
||||||
|
tags: requestBody.tags,
|
||||||
|
seasons: finalSeasons.map(
|
||||||
|
(sn) =>
|
||||||
|
new SeasonRequest({
|
||||||
|
seasonNumber: sn,
|
||||||
|
status: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_TV
|
||||||
|
: Permission.AUTO_APPROVE_TV,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? MediaRequestStatus.APPROVED
|
||||||
|
: MediaRequestStatus.PENDING,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
isAutoRequest: options.isAutoRequest ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestRepository.save(request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
public id: number;
|
public id: number;
|
||||||
|
|
||||||
@@ -120,6 +457,9 @@ export class MediaRequest {
|
|||||||
})
|
})
|
||||||
public tags?: number[];
|
public tags?: number[];
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
public isAutoRequest: boolean;
|
||||||
|
|
||||||
constructor(init?: Partial<MediaRequest>) {
|
constructor(init?: Partial<MediaRequest>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
@@ -147,6 +487,10 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.sendNotification(media, Notification.MEDIA_PENDING);
|
this.sendNotification(media, Notification.MEDIA_PENDING);
|
||||||
|
|
||||||
|
if (this.isAutoRequest) {
|
||||||
|
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +535,14 @@ export class MediaRequest {
|
|||||||
: Notification.MEDIA_APPROVED
|
: Notification.MEDIA_APPROVED
|
||||||
: Notification.MEDIA_DECLINED
|
: Notification.MEDIA_DECLINED
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.status === MediaRequestStatus.APPROVED &&
|
||||||
|
autoApproved &&
|
||||||
|
this.isAutoRequest
|
||||||
|
) {
|
||||||
|
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +559,7 @@ export class MediaRequest {
|
|||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { id: this.media.id },
|
where: { id: this.media.id },
|
||||||
relations: ['requests'],
|
relations: { requests: true },
|
||||||
});
|
});
|
||||||
if (!media) {
|
if (!media) {
|
||||||
logger.error('Media data not found', {
|
logger.error('Media data not found', {
|
||||||
@@ -272,7 +624,7 @@ export class MediaRequest {
|
|||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const fullMedia = await mediaRepository.findOneOrFail({
|
const fullMedia = await mediaRepository.findOneOrFail({
|
||||||
where: { id: this.media.id },
|
where: { id: this.media.id },
|
||||||
relations: ['requests'],
|
relations: { requests: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -352,7 +704,7 @@ export class MediaRequest {
|
|||||||
|
|
||||||
let rootFolder = radarrSettings.activeDirectory;
|
let rootFolder = radarrSettings.activeDirectory;
|
||||||
let qualityProfile = radarrSettings.activeProfileId;
|
let qualityProfile = radarrSettings.activeProfileId;
|
||||||
let tags = radarrSettings.tags;
|
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.rootFolder &&
|
this.rootFolder &&
|
||||||
@@ -412,10 +764,51 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (radarrSettings.tagRequests) {
|
||||||
|
let userTag = (await radarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await radarr.createTag({
|
||||||
|
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
throw new Error('Media already available');
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(this);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const radarrMovieOptions: RadarrMovieOptions = {
|
const radarrMovieOptions: RadarrMovieOptions = {
|
||||||
@@ -452,10 +845,13 @@ export class MediaRequest {
|
|||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
const requestRepository = getRepository(MediaRequest);
|
||||||
await mediaRepository.save(media);
|
|
||||||
|
this.status = MediaRequestStatus.FAILED;
|
||||||
|
requestRepository.save(this);
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending movie request to Radarr, marking status as UNKNOWN',
|
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||||
{
|
{
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
requestId: this.id,
|
requestId: this.id,
|
||||||
@@ -543,7 +939,7 @@ export class MediaRequest {
|
|||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { id: this.media.id },
|
where: { id: this.media.id },
|
||||||
relations: ['requests'],
|
relations: { requests: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
@@ -553,7 +949,16 @@ export class MediaRequest {
|
|||||||
if (
|
if (
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
throw new Error('Media already available');
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(this);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
@@ -579,7 +984,7 @@ export class MediaRequest {
|
|||||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
seriesType = 'anime';
|
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootFolder =
|
let rootFolder =
|
||||||
@@ -597,7 +1002,11 @@ export class MediaRequest {
|
|||||||
let tags =
|
let tags =
|
||||||
seriesType === 'anime'
|
seriesType === 'anime'
|
||||||
? sonarrSettings.animeTags
|
? sonarrSettings.animeTags
|
||||||
: sonarrSettings.tags;
|
? [...sonarrSettings.animeTags]
|
||||||
|
: []
|
||||||
|
: sonarrSettings.tags
|
||||||
|
? [...sonarrSettings.tags]
|
||||||
|
: [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.rootFolder &&
|
this.rootFolder &&
|
||||||
@@ -649,6 +1058,38 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sonarrSettings.tagRequests) {
|
||||||
|
let userTag = (await sonarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await sonarr.createTag({
|
||||||
|
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||||
profileId: qualityProfile,
|
profileId: qualityProfile,
|
||||||
languageProfileId: languageProfile,
|
languageProfileId: languageProfile,
|
||||||
@@ -670,7 +1111,7 @@ export class MediaRequest {
|
|||||||
// We grab media again here to make sure we have the latest version of it
|
// We grab media again here to make sure we have the latest version of it
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { id: this.media.id },
|
where: { id: this.media.id },
|
||||||
relations: ['requests'],
|
relations: { requests: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
@@ -685,10 +1126,13 @@ export class MediaRequest {
|
|||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
const requestRepository = getRepository(MediaRequest);
|
||||||
await mediaRepository.save(media);
|
|
||||||
|
this.status = MediaRequestStatus.FAILED;
|
||||||
|
requestRepository.save(this);
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending series request to Sonarr, marking status as UNKNOWN',
|
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||||
{
|
{
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
requestId: this.id,
|
requestId: this.id,
|
||||||
@@ -723,6 +1167,7 @@ export class MediaRequest {
|
|||||||
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||||
let event: string | undefined;
|
let event: string | undefined;
|
||||||
let notifyAdmin = true;
|
let notifyAdmin = true;
|
||||||
|
let notifySystem = true;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
@@ -736,6 +1181,13 @@ export class MediaRequest {
|
|||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
||||||
break;
|
break;
|
||||||
|
case Notification.MEDIA_AUTO_REQUESTED:
|
||||||
|
event = `${
|
||||||
|
this.is4k ? '4K ' : ''
|
||||||
|
}${mediaType} Request Automatically Submitted`;
|
||||||
|
notifyAdmin = false;
|
||||||
|
notifySystem = false;
|
||||||
|
break;
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
event = `${
|
event = `${
|
||||||
this.is4k ? '4K ' : ''
|
this.is4k ? '4K ' : ''
|
||||||
@@ -752,6 +1204,7 @@ export class MediaRequest {
|
|||||||
media,
|
media,
|
||||||
request: this,
|
request: this,
|
||||||
notifyAdmin,
|
notifyAdmin,
|
||||||
|
notifySystem,
|
||||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||||
event,
|
event,
|
||||||
subject: `${movie.title}${
|
subject: `${movie.title}${
|
||||||
@@ -770,6 +1223,7 @@ export class MediaRequest {
|
|||||||
media,
|
media,
|
||||||
request: this,
|
request: this,
|
||||||
notifyAdmin,
|
notifyAdmin,
|
||||||
|
notifySystem,
|
||||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||||
event,
|
event,
|
||||||
subject: `${tv.name}${
|
subject: `${tv.name}${
|
||||||
@@ -801,3 +1255,5 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default MediaRequest;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import {
|
import {
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
Column,
|
||||||
ManyToOne,
|
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { MediaStatus } from '../constants/media';
|
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
import {
|
import {
|
||||||
Entity,
|
AfterRemove,
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
Entity,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { MediaRequestStatus } from '../constants/media';
|
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@@ -34,6 +36,18 @@ class SeasonRequest {
|
|||||||
constructor(init?: Partial<SeasonRequest>) {
|
constructor(init?: Partial<SeasonRequest>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterRemove()
|
||||||
|
public async handleRemoveParent(): Promise<void> {
|
||||||
|
const mediaRequestRepository = getRepository(MediaRequest);
|
||||||
|
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
||||||
|
where: { id: this.request.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestToBeDeleted.seasons.length === 0) {
|
||||||
|
await mediaRequestRepository.delete({ id: this.request.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SeasonRequest;
|
export default SeasonRequest;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ISession } from 'connect-typeorm';
|
import type { ISession } from 'connect-typeorm';
|
||||||
import { Index, Column, PrimaryColumn, Entity } from 'typeorm';
|
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Session implements ISession {
|
export class Session implements ISession {
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
import { MediaRequestStatus, MediaType } from '@server/constants/media';
|
||||||
|
import { UserType } from '@server/constants/user';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { Watchlist } from '@server/entity/Watchlist';
|
||||||
|
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
|
import PreparedEmail from '@server/lib/email';
|
||||||
|
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||||
|
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { AfterDate } from '@server/utils/dateHelpers';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -7,8 +18,6 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
getRepository,
|
|
||||||
MoreThan,
|
|
||||||
Not,
|
Not,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
OneToOne,
|
OneToOne,
|
||||||
@@ -16,17 +25,6 @@ import {
|
|||||||
RelationCount,
|
RelationCount,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { MediaRequestStatus, MediaType } from '../constants/media';
|
|
||||||
import { UserType } from '../constants/user';
|
|
||||||
import { QuotaResponse } from '../interfaces/api/userInterfaces';
|
|
||||||
import PreparedEmail from '../lib/email';
|
|
||||||
import {
|
|
||||||
hasPermission,
|
|
||||||
Permission,
|
|
||||||
PermissionCheckOptions,
|
|
||||||
} from '../lib/permissions';
|
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
import SeasonRequest from './SeasonRequest';
|
import SeasonRequest from './SeasonRequest';
|
||||||
@@ -42,7 +40,7 @@ export class User {
|
|||||||
return users.map((u) => u.filter(showFiltered));
|
return users.map((u) => u.filter(showFiltered));
|
||||||
}
|
}
|
||||||
|
|
||||||
static readonly filteredFields: string[] = ['email'];
|
static readonly filteredFields: string[] = ['email', 'plexId'];
|
||||||
|
|
||||||
public displayName: string;
|
public displayName: string;
|
||||||
|
|
||||||
@@ -79,7 +77,7 @@ export class User {
|
|||||||
@Column({ type: 'integer', default: UserType.PLEX })
|
@Column({ type: 'integer', default: UserType.PLEX })
|
||||||
public userType: UserType;
|
public userType: UserType;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, select: true })
|
||||||
public plexId?: number;
|
public plexId?: number;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
@@ -106,6 +104,9 @@ export class User {
|
|||||||
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
|
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
|
||||||
public requests: MediaRequest[];
|
public requests: MediaRequest[];
|
||||||
|
|
||||||
|
@OneToMany(() => Watchlist, (watchlist) => watchlist.requestedBy)
|
||||||
|
public watchlists: Watchlist[];
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public movieQuotaLimit?: number;
|
public movieQuotaLimit?: number;
|
||||||
|
|
||||||
@@ -270,13 +271,14 @@ export class User {
|
|||||||
if (movieQuotaDays) {
|
if (movieQuotaDays) {
|
||||||
movieDate.setDate(movieDate.getDate() - movieQuotaDays);
|
movieDate.setDate(movieDate.getDate() - movieQuotaDays);
|
||||||
}
|
}
|
||||||
const movieQuotaStartDate = movieDate.toJSON();
|
|
||||||
|
|
||||||
const movieQuotaUsed = movieQuotaLimit
|
const movieQuotaUsed = movieQuotaLimit
|
||||||
? await requestRepository.count({
|
? await requestRepository.count({
|
||||||
where: {
|
where: {
|
||||||
requestedBy: this,
|
requestedBy: {
|
||||||
createdAt: MoreThan(movieQuotaStartDate),
|
id: this.id,
|
||||||
|
},
|
||||||
|
createdAt: AfterDate(movieDate),
|
||||||
type: MediaType.MOVIE,
|
type: MediaType.MOVIE,
|
||||||
status: Not(MediaRequestStatus.DECLINED),
|
status: Not(MediaRequestStatus.DECLINED),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { NotificationAgentTypes } from '@server/interfaces/api/userSettingsInterfaces';
|
||||||
|
import { hasNotificationType, Notification } from '@server/lib/notifications';
|
||||||
|
import { NotificationAgentKey } from '@server/lib/settings';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
Entity,
|
Entity,
|
||||||
@@ -5,9 +8,6 @@ import {
|
|||||||
OneToOne,
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
|
|
||||||
import { hasNotificationType, Notification } from '../lib/notifications';
|
|
||||||
import { NotificationAgentKey } from '../lib/settings';
|
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
||||||
@@ -51,12 +51,21 @@ export class UserSettings {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public pushoverUserKey?: string;
|
public pushoverUserKey?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public pushoverSound?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public telegramChatId?: string;
|
public telegramChatId?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public telegramSendSilently?: boolean;
|
public telegramSendSilently?: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public watchlistSyncMovies?: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public watchlistSyncTv?: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|||||||
157
server/entity/Watchlist.ts
Normal file
157
server/entity/Watchlist.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||||
|
|
||||||
|
export class DuplicateWatchlistRequestError extends Error {}
|
||||||
|
export class NotFoundError extends Error {
|
||||||
|
constructor(message = 'Not found') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'NotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy'])
|
||||||
|
export class Watchlist implements WatchlistItem {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar' })
|
||||||
|
public ratingKey = '';
|
||||||
|
|
||||||
|
@Column({ type: 'varchar' })
|
||||||
|
public mediaType: MediaType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar' })
|
||||||
|
title = '';
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
@Index()
|
||||||
|
public tmdbId: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, (user) => user.watchlists, {
|
||||||
|
eager: true,
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
public requestedBy: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||||
|
eager: true,
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
public media: Media;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(init?: Partial<Watchlist>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async createWatchlist({
|
||||||
|
watchlistRequest,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
watchlistRequest: {
|
||||||
|
mediaType: MediaType;
|
||||||
|
ratingKey?: ZodOptional<ZodString>['_output'];
|
||||||
|
title?: ZodOptional<ZodString>['_output'];
|
||||||
|
tmdbId: ZodNumber['_output'];
|
||||||
|
};
|
||||||
|
user: User;
|
||||||
|
}): Promise<Watchlist> {
|
||||||
|
const watchlistRepository = getRepository(this);
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
const tmdbMedia =
|
||||||
|
watchlistRequest.mediaType === MediaType.MOVIE
|
||||||
|
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
|
||||||
|
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
|
||||||
|
|
||||||
|
const existing = await watchlistRepository
|
||||||
|
.createQueryBuilder('watchlist')
|
||||||
|
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||||
|
.where('user.id = :userId', { userId: user.id })
|
||||||
|
.andWhere('watchlist.tmdbId = :tmdbId', {
|
||||||
|
tmdbId: watchlistRequest.tmdbId,
|
||||||
|
})
|
||||||
|
.andWhere('watchlist.mediaType = :mediaType', {
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
})
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
logger.warn('Duplicate request for watchlist blocked', {
|
||||||
|
tmdbId: watchlistRequest.tmdbId,
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
label: 'Watchlist',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new DuplicateWatchlistRequestError();
|
||||||
|
}
|
||||||
|
|
||||||
|
let media = await mediaRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tmdbId: watchlistRequest.tmdbId,
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
media = new Media({
|
||||||
|
tmdbId: tmdbMedia.id,
|
||||||
|
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
||||||
|
mediaType: watchlistRequest.mediaType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchlist = new this({
|
||||||
|
...watchlistRequest,
|
||||||
|
requestedBy: user,
|
||||||
|
media,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
await watchlistRepository.save(watchlist);
|
||||||
|
return watchlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async deleteWatchlist(
|
||||||
|
tmdbId: Watchlist['tmdbId'],
|
||||||
|
user: User
|
||||||
|
): Promise<Watchlist | null> {
|
||||||
|
const watchlistRepository = getRepository(this);
|
||||||
|
const watchlist = await watchlistRepository.findOneBy({
|
||||||
|
tmdbId,
|
||||||
|
requestedBy: { id: user.id },
|
||||||
|
});
|
||||||
|
if (!watchlist) {
|
||||||
|
throw new NotFoundError('not Found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watchlist) {
|
||||||
|
await watchlistRepository.delete(watchlist.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return watchlist;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,40 @@
|
|||||||
|
import PlexAPI from '@server/api/plexapi';
|
||||||
|
import dataSource, { getRepository } from '@server/datasource';
|
||||||
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
import { Session } from '@server/entity/Session';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import { startJobs } from '@server/job/schedule';
|
||||||
|
import notificationManager from '@server/lib/notifications';
|
||||||
|
import DiscordAgent from '@server/lib/notifications/agents/discord';
|
||||||
|
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||||
|
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||||
|
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||||
|
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||||
|
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||||
|
import SlackAgent from '@server/lib/notifications/agents/slack';
|
||||||
|
import TelegramAgent from '@server/lib/notifications/agents/telegram';
|
||||||
|
import WebhookAgent from '@server/lib/notifications/agents/webhook';
|
||||||
|
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import clearCookies from '@server/middleware/clearcookies';
|
||||||
|
import routes from '@server/routes';
|
||||||
|
import imageproxy from '@server/routes/imageproxy';
|
||||||
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
|
import restartFlag from '@server/utils/restartFlag';
|
||||||
import { getClientIp } from '@supercharge/request-ip';
|
import { getClientIp } from '@supercharge/request-ip';
|
||||||
import { TypeormStore } from 'connect-typeorm/out';
|
import { TypeormStore } from 'connect-typeorm/out';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import csurf from 'csurf';
|
import csurf from 'csurf';
|
||||||
import express, { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import express from 'express';
|
||||||
import * as OpenApiValidator from 'express-openapi-validator';
|
import * as OpenApiValidator from 'express-openapi-validator';
|
||||||
import session, { Store } from 'express-session';
|
import type { Store } from 'express-session';
|
||||||
|
import session from 'express-session';
|
||||||
import next from 'next';
|
import next from 'next';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import { createConnection, getRepository } from 'typeorm';
|
|
||||||
import YAML from 'yamljs';
|
import YAML from 'yamljs';
|
||||||
import PlexAPI from './api/plexapi';
|
|
||||||
import { Session } from './entity/Session';
|
|
||||||
import { User } from './entity/User';
|
|
||||||
import { startJobs } from './job/schedule';
|
|
||||||
import notificationManager from './lib/notifications';
|
|
||||||
import DiscordAgent from './lib/notifications/agents/discord';
|
|
||||||
import EmailAgent from './lib/notifications/agents/email';
|
|
||||||
import GotifyAgent from './lib/notifications/agents/gotify';
|
|
||||||
import LunaSeaAgent from './lib/notifications/agents/lunasea';
|
|
||||||
import PushbulletAgent from './lib/notifications/agents/pushbullet';
|
|
||||||
import PushoverAgent from './lib/notifications/agents/pushover';
|
|
||||||
import SlackAgent from './lib/notifications/agents/slack';
|
|
||||||
import TelegramAgent from './lib/notifications/agents/telegram';
|
|
||||||
import WebhookAgent from './lib/notifications/agents/webhook';
|
|
||||||
import WebPushAgent from './lib/notifications/agents/webpush';
|
|
||||||
import { getSettings } from './lib/settings';
|
|
||||||
import logger from './logger';
|
|
||||||
import routes from './routes';
|
|
||||||
import { getAppVersion } from './utils/appVersion';
|
|
||||||
|
|
||||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||||
|
|
||||||
@@ -40,7 +46,7 @@ const handle = app.getRequestHandler();
|
|||||||
app
|
app
|
||||||
.prepare()
|
.prepare()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
const dbConnection = await createConnection();
|
const dbConnection = await dataSource.initialize();
|
||||||
|
|
||||||
// Run migrations in production
|
// Run migrations in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
@@ -51,6 +57,7 @@ app
|
|||||||
|
|
||||||
// Load Settings
|
// Load Settings
|
||||||
const settings = getSettings().load();
|
const settings = getSettings().load();
|
||||||
|
restartFlag.initializeSettings(settings.main);
|
||||||
|
|
||||||
// Migrate library types
|
// Migrate library types
|
||||||
if (
|
if (
|
||||||
@@ -59,8 +66,8 @@ app
|
|||||||
) {
|
) {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOne({
|
const admin = await userRepository.findOne({
|
||||||
select: ['id', 'plexToken'],
|
select: { id: true, plexToken: true },
|
||||||
order: { id: 'ASC' },
|
where: { id: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (admin) {
|
if (admin) {
|
||||||
@@ -87,8 +94,21 @@ app
|
|||||||
new WebPushAgent(),
|
new WebPushAgent(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Start Jobs
|
const userRepository = getRepository(User);
|
||||||
startJobs();
|
const totalUsers = await userRepository.count();
|
||||||
|
if (totalUsers > 0) {
|
||||||
|
startJobs();
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`Skipping starting the scheduled jobs as we have no Plex/Jellyfin/Emby servers setup yet`,
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap Discovery Sliders
|
||||||
|
await DiscoverSlider.bootstrapSliders();
|
||||||
|
|
||||||
const server = express();
|
const server = express();
|
||||||
if (settings.main.trustProxy) {
|
if (settings.main.trustProxy) {
|
||||||
@@ -142,7 +162,7 @@ app
|
|||||||
cookie: {
|
cookie: {
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: true,
|
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
|
||||||
secure: 'auto',
|
secure: 'auto',
|
||||||
},
|
},
|
||||||
store: new TypeormStore({
|
store: new TypeormStore({
|
||||||
@@ -172,6 +192,10 @@ app
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
server.use('/api/v1', routes);
|
server.use('/api/v1', routes);
|
||||||
|
|
||||||
|
// Do not set cookies so CDNs can cache them
|
||||||
|
server.use('/imageproxy', clearCookies, imageproxy);
|
||||||
|
|
||||||
server.get('*', (req, res) => handle(req, res));
|
server.get('*', (req, res) => handle(req, res));
|
||||||
server.use(
|
server.use(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -3,3 +3,17 @@ export interface GenreSliderItem {
|
|||||||
name: string;
|
name: string;
|
||||||
backdrops: string[];
|
backdrops: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchlistItem {
|
||||||
|
ratingKey: string;
|
||||||
|
tmdbId: number;
|
||||||
|
mediaType: 'movie' | 'tv';
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchlistResponse {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalResults: number;
|
||||||
|
results: WatchlistItem[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Issue from '../../entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
import { PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface IssueResultsResponse extends PaginatedResponse {
|
export interface IssueResultsResponse extends PaginatedResponse {
|
||||||
results: Issue[];
|
results: Issue[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type Media from '../../entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
import { User } from '../../entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import { PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface MediaResultsResponse extends PaginatedResponse {
|
export interface MediaResultsResponse extends PaginatedResponse {
|
||||||
results: Media[];
|
results: Media[];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
|
import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person';
|
||||||
|
|
||||||
export interface PersonCombinedCreditsResponse {
|
export interface PersonCombinedCreditsResponse {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PlexSettings } from '../../lib/settings';
|
import type { PlexSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
export interface PlexStatus {
|
export interface PlexStatus {
|
||||||
settings: PlexSettings;
|
settings: PlexSettings;
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
|
import type { MediaType } from '@server/constants/media';
|
||||||
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
import type { MediaRequest } from '../../entity/MediaRequest';
|
|
||||||
|
|
||||||
export interface RequestResultsResponse extends PaginatedResponse {
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
results: MediaRequest[];
|
results: MediaRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaRequestBody = {
|
||||||
|
mediaType: MediaType;
|
||||||
|
mediaId: number;
|
||||||
|
tvdbId?: number;
|
||||||
|
seasons?: number[] | 'all';
|
||||||
|
is4k?: boolean;
|
||||||
|
serverId?: number;
|
||||||
|
profileId?: number;
|
||||||
|
rootFolder?: string;
|
||||||
|
languageProfileId?: number;
|
||||||
|
userId?: number;
|
||||||
|
tags?: number[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
|
import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base';
|
||||||
import { LanguageProfile } from '../../api/servarr/sonarr';
|
import type { LanguageProfile } from '@server/api/servarr/sonarr';
|
||||||
|
|
||||||
export interface ServiceCommonServer {
|
export interface ServiceCommonServer {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -54,9 +54,15 @@ export interface CacheItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CacheResponse {
|
||||||
|
apiCaches: CacheItem[];
|
||||||
|
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatusResponse {
|
export interface StatusResponse {
|
||||||
version: string;
|
version: string;
|
||||||
commitTag: string;
|
commitTag: string;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
commitsBehind: number;
|
commitsBehind: number;
|
||||||
|
restartRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Media from '../../entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
import { MediaRequest } from '../../entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { User } from '../../entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import { PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface UserResultsResponse extends PaginatedResponse {
|
export interface UserResultsResponse extends PaginatedResponse {
|
||||||
results: User[];
|
results: User[];
|
||||||
@@ -23,6 +23,7 @@ export interface QuotaResponse {
|
|||||||
movie: QuotaStatus;
|
movie: QuotaStatus;
|
||||||
tv: QuotaStatus;
|
tv: QuotaStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserWatchDataResponse {
|
export interface UserWatchDataResponse {
|
||||||
recentlyWatched: Media[];
|
recentlyWatched: Media[];
|
||||||
playCount: number;
|
playCount: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NotificationAgentKey } from '../../lib/settings';
|
import type { NotificationAgentKey } from '@server/lib/settings';
|
||||||
|
|
||||||
export interface UserSettingsGeneralResponse {
|
export interface UserSettingsGeneralResponse {
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -15,6 +15,8 @@ export interface UserSettingsGeneralResponse {
|
|||||||
globalMovieQuotaLimit?: number;
|
globalMovieQuotaLimit?: number;
|
||||||
globalTvQuotaLimit?: number;
|
globalTvQuotaLimit?: number;
|
||||||
globalTvQuotaDays?: number;
|
globalTvQuotaDays?: number;
|
||||||
|
watchlistSyncMovies?: boolean;
|
||||||
|
watchlistSyncTv?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
|
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
|
||||||
@@ -27,6 +29,7 @@ export interface UserSettingsNotificationsResponse {
|
|||||||
pushbulletAccessToken?: string;
|
pushbulletAccessToken?: string;
|
||||||
pushoverApplicationToken?: string;
|
pushoverApplicationToken?: string;
|
||||||
pushoverUserKey?: string;
|
pushoverUserKey?: string;
|
||||||
|
pushoverSound?: string;
|
||||||
telegramEnabled?: boolean;
|
telegramEnabled?: boolean;
|
||||||
telegramBotUsername?: string;
|
telegramBotUsername?: string;
|
||||||
telegramChatId?: string;
|
telegramChatId?: string;
|
||||||
|
|||||||
9
server/interfaces/api/watchlistCreate.ts
Normal file
9
server/interfaces/api/watchlistCreate.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const watchlistCreate = z.object({
|
||||||
|
ratingKey: z.coerce.string().optional(),
|
||||||
|
tmdbId: z.coerce.number(),
|
||||||
|
mediaType: z.nativeEnum(MediaType),
|
||||||
|
title: z.coerce.string().optional(),
|
||||||
|
});
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
|
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||||
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
||||||
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import Season from '@server/entity/Season';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { Library } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import AsyncLock from '@server/utils/asyncLock';
|
||||||
import { randomUUID as uuid } from 'crypto';
|
import { randomUUID as uuid } from 'crypto';
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
import { getRepository } from 'typeorm';
|
|
||||||
import JellyfinAPI, { JellyfinLibraryItem } from '../../api/jellyfin';
|
|
||||||
import TheMovieDb from '../../api/themoviedb';
|
|
||||||
import { TmdbTvDetails } from '../../api/themoviedb/interfaces';
|
|
||||||
import { MediaStatus, MediaType } from '../../constants/media';
|
|
||||||
import { MediaServerType } from '../../constants/server';
|
|
||||||
import Media from '../../entity/Media';
|
|
||||||
import Season from '../../entity/Season';
|
|
||||||
import { User } from '../../entity/User';
|
|
||||||
import { getSettings, Library } from '../../lib/settings';
|
|
||||||
import logger from '../../logger';
|
|
||||||
import AsyncLock from '../../utils/asyncLock';
|
|
||||||
|
|
||||||
const BUNDLE_SIZE = 20;
|
const BUNDLE_SIZE = 20;
|
||||||
const UPDATE_RATE = 4 * 1000;
|
const UPDATE_RATE = 4 * 1000;
|
||||||
@@ -255,8 +257,19 @@ class JobJellyfinSync {
|
|||||||
//use for loop to make sure this loop _completes_ in full
|
//use for loop to make sure this loop _completes_ in full
|
||||||
//before the next section
|
//before the next section
|
||||||
for (const episode of episodes) {
|
for (const episode of episodes) {
|
||||||
|
let episodeCount = 1;
|
||||||
|
|
||||||
|
// count number of combined episodes
|
||||||
|
if (
|
||||||
|
episode.IndexNumber !== undefined &&
|
||||||
|
episode.IndexNumberEnd !== undefined
|
||||||
|
) {
|
||||||
|
episodeCount =
|
||||||
|
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.enable4kShow) {
|
if (!this.enable4kShow) {
|
||||||
totalStandard++;
|
totalStandard += episodeCount;
|
||||||
} else {
|
} else {
|
||||||
const ExtendedEpisodeData = await this.jfClient.getItemData(
|
const ExtendedEpisodeData = await this.jfClient.getItemData(
|
||||||
episode.Id
|
episode.Id
|
||||||
@@ -265,11 +278,11 @@ class JobJellyfinSync {
|
|||||||
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
||||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||||
if (MediaStream.Type === 'Video') {
|
if (MediaStream.Type === 'Video') {
|
||||||
if (MediaStream.Width ?? 0 < 2000) {
|
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||||
totalStandard++;
|
total4k += episodeCount;
|
||||||
|
} else {
|
||||||
|
totalStandard += episodeCount;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
total4k++;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -298,13 +311,15 @@ class JobJellyfinSync {
|
|||||||
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
||||||
// and then not modifying the status if there are 0 items
|
// and then not modifying the status if there are 0 items
|
||||||
existingSeason.status =
|
existingSeason.status =
|
||||||
totalStandard === season.episode_count
|
totalStandard >= season.episode_count ||
|
||||||
|
existingSeason.status === MediaStatus.AVAILABLE
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: totalStandard > 0
|
: totalStandard > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: existingSeason.status;
|
: existingSeason.status;
|
||||||
existingSeason.status4k =
|
existingSeason.status4k =
|
||||||
this.enable4kShow && total4k === season.episode_count
|
(this.enable4kShow && total4k >= season.episode_count) ||
|
||||||
|
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: this.enable4kShow && total4k > 0
|
: this.enable4kShow && total4k > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
@@ -316,13 +331,13 @@ class JobJellyfinSync {
|
|||||||
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
||||||
// if we dont have any items for the season
|
// if we dont have any items for the season
|
||||||
status:
|
status:
|
||||||
totalStandard === season.episode_count
|
totalStandard >= season.episode_count
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: totalStandard > 0
|
: totalStandard > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: MediaStatus.UNKNOWN,
|
: MediaStatus.UNKNOWN,
|
||||||
status4k:
|
status4k:
|
||||||
this.enable4kShow && total4k === season.episode_count
|
this.enable4kShow && total4k >= season.episode_count
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: this.enable4kShow && total4k > 0
|
: this.enable4kShow && total4k > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
@@ -552,6 +567,7 @@ class JobJellyfinSync {
|
|||||||
this.running = true;
|
this.running = true;
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOne({
|
const admin = await userRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
select: [
|
select: [
|
||||||
'id',
|
'id',
|
||||||
'jellyfinAuthToken',
|
'jellyfinAuthToken',
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
|
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||||
|
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||||
|
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||||
|
import type { JobId } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import watchlistSync from '@server/lib/watchlistsync';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import random from 'lodash/random';
|
||||||
import schedule from 'node-schedule';
|
import schedule from 'node-schedule';
|
||||||
import { MediaServerType } from '../constants/server';
|
|
||||||
import downloadTracker from '../lib/downloadtracker';
|
|
||||||
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
|
|
||||||
import { radarrScanner } from '../lib/scanners/radarr';
|
|
||||||
import { sonarrScanner } from '../lib/scanners/sonarr';
|
|
||||||
import { getSettings, JobId } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
|
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
|
||||||
|
|
||||||
interface ScheduledJob {
|
interface ScheduledJob {
|
||||||
@@ -13,7 +17,8 @@ interface ScheduledJob {
|
|||||||
job: schedule.Job;
|
job: schedule.Job;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
interval: 'short' | 'long' | 'fixed';
|
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||||
|
cronSchedule: string;
|
||||||
running?: () => boolean;
|
running?: () => boolean;
|
||||||
cancelFn?: () => void;
|
cancelFn?: () => void;
|
||||||
}
|
}
|
||||||
@@ -30,7 +35,8 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-recently-added-scan',
|
id: 'plex-recently-added-scan',
|
||||||
name: 'Plex Recently Added Scan',
|
name: 'Plex Recently Added Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'short',
|
interval: 'minutes',
|
||||||
|
cronSchedule: jobs['plex-recently-added-scan'].schedule,
|
||||||
job: schedule.scheduleJob(
|
job: schedule.scheduleJob(
|
||||||
jobs['plex-recently-added-scan'].schedule,
|
jobs['plex-recently-added-scan'].schedule,
|
||||||
() => {
|
() => {
|
||||||
@@ -49,7 +55,8 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-full-scan',
|
id: 'plex-full-scan',
|
||||||
name: 'Plex Full Library Scan',
|
name: 'Plex Full Library Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['plex-full-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
@@ -65,12 +72,13 @@ export const startJobs = (): void => {
|
|||||||
) {
|
) {
|
||||||
// Run recently added jellyfin sync every 5 minutes
|
// Run recently added jellyfin sync every 5 minutes
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'jellyfin-recently-added-sync',
|
id: 'jellyfin-recently-added-scan',
|
||||||
name: 'Jellyfin Recently Added Sync',
|
name: 'Jellyfin Recently Added Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'minutes',
|
||||||
|
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
|
||||||
job: schedule.scheduleJob(
|
job: schedule.scheduleJob(
|
||||||
jobs['jellyfin-recently-added-sync'].schedule,
|
jobs['jellyfin-recently-added-scan'].schedule,
|
||||||
() => {
|
() => {
|
||||||
logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
|
logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
@@ -84,11 +92,12 @@ export const startJobs = (): void => {
|
|||||||
|
|
||||||
// Run full jellyfin sync every 24 hours
|
// Run full jellyfin sync every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'jellyfin-full-sync',
|
id: 'jellyfin-full-scan',
|
||||||
name: 'Jellyfin Full Library Sync',
|
name: 'Jellyfin Full Library Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
|
cronSchedule: jobs['jellyfin-full-scan'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
@@ -99,12 +108,38 @@ export const startJobs = (): void => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watchlist Sync
|
||||||
|
const watchlistSyncJob: ScheduledJob = {
|
||||||
|
id: 'plex-watchlist-sync',
|
||||||
|
name: 'Plex Watchlist Sync',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'fixed',
|
||||||
|
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||||
|
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
|
||||||
|
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
watchlistSync.syncWatchlist();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
|
||||||
|
// after each run
|
||||||
|
watchlistSyncJob.job.on('run', () => {
|
||||||
|
watchlistSyncJob.job.schedule(
|
||||||
|
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduledJobs.push(watchlistSyncJob);
|
||||||
|
|
||||||
// Run full radarr scan every 24 hours
|
// Run full radarr scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'radarr-scan',
|
id: 'radarr-scan',
|
||||||
name: 'Radarr Scan',
|
name: 'Radarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['radarr-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||||
radarrScanner.run();
|
radarrScanner.run();
|
||||||
@@ -118,7 +153,8 @@ export const startJobs = (): void => {
|
|||||||
id: 'sonarr-scan',
|
id: 'sonarr-scan',
|
||||||
name: 'Sonarr Scan',
|
name: 'Sonarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['sonarr-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||||
sonarrScanner.run();
|
sonarrScanner.run();
|
||||||
@@ -127,12 +163,31 @@ export const startJobs = (): void => {
|
|||||||
cancelFn: () => sonarrScanner.cancel(),
|
cancelFn: () => sonarrScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Checks if media is still available in plex/sonarr/radarr libs
|
||||||
|
/* scheduledJobs.push({
|
||||||
|
id: 'availability-sync',
|
||||||
|
name: 'Media Availability Sync',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['availability-sync'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: Media Availability Sync', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
availabilitySync.run();
|
||||||
|
}),
|
||||||
|
running: () => availabilitySync.running,
|
||||||
|
cancelFn: () => availabilitySync.cancel(),
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
// Run download sync every minute
|
// Run download sync every minute
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'download-sync',
|
id: 'download-sync',
|
||||||
name: 'Download Sync',
|
name: 'Download Sync',
|
||||||
type: 'command',
|
type: 'command',
|
||||||
interval: 'fixed',
|
interval: 'seconds',
|
||||||
|
cronSchedule: jobs['download-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
||||||
logger.debug('Starting scheduled job: Download Sync', {
|
logger.debug('Starting scheduled job: Download Sync', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
@@ -146,7 +201,8 @@ export const startJobs = (): void => {
|
|||||||
id: 'download-sync-reset',
|
id: 'download-sync-reset',
|
||||||
name: 'Download Sync Reset',
|
name: 'Download Sync Reset',
|
||||||
type: 'command',
|
type: 'command',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['download-sync-reset'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Download Sync Reset', {
|
logger.info('Starting scheduled job: Download Sync Reset', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
@@ -155,5 +211,21 @@ export const startJobs = (): void => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Run image cache cleanup every 24 hours
|
||||||
|
scheduledJobs.push({
|
||||||
|
id: 'image-cache-cleanup',
|
||||||
|
name: 'Image Cache Cleanup',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
// Clean TMDB image cache
|
||||||
|
ImageProxy.clearCache('tmdb');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||||
};
|
};
|
||||||
|
|||||||
725
server/lib/availabilitySync.ts
Normal file
725
server/lib/availabilitySync.ts
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
import type { PlexMetadata } from '@server/api/plexapi';
|
||||||
|
import PlexAPI from '@server/api/plexapi';
|
||||||
|
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
||||||
|
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
|
import type Season from '@server/entity/Season';
|
||||||
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
|
||||||
|
class AvailabilitySync {
|
||||||
|
public running = false;
|
||||||
|
private plexClient: PlexAPI;
|
||||||
|
private plexSeasonsCache: Record<string, PlexMetadata[]>;
|
||||||
|
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
||||||
|
private radarrServers: RadarrSettings[];
|
||||||
|
private sonarrServers: SonarrSettings[];
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const settings = getSettings();
|
||||||
|
this.running = true;
|
||||||
|
this.plexSeasonsCache = {};
|
||||||
|
this.sonarrSeasonsCache = {};
|
||||||
|
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||||
|
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Starting availability sync...`, {
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
const admin = await userRepository.findOne({
|
||||||
|
select: { id: true, plexToken: true },
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||||
|
} else {
|
||||||
|
logger.error('An admin is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
|
||||||
|
if (!this.running) {
|
||||||
|
throw new Error('Job aborted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check plex, radarr, and sonarr for that specific media and
|
||||||
|
// if unavailable, then we change the status accordingly.
|
||||||
|
// If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
|
||||||
|
if (media.mediaType === 'movie') {
|
||||||
|
let movieExists = false;
|
||||||
|
let movieExists4k = false;
|
||||||
|
|
||||||
|
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
|
||||||
|
const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex(
|
||||||
|
media,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const existsInRadarr = await this.mediaExistsInRadarr(media, false);
|
||||||
|
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
|
||||||
|
|
||||||
|
if (existsInPlex || existsInRadarr) {
|
||||||
|
movieExists = true;
|
||||||
|
logger.info(
|
||||||
|
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInPlex4k || existsInRadarr4k) {
|
||||||
|
movieExists4k = true;
|
||||||
|
logger.info(
|
||||||
|
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!movieExists && media.status === MediaStatus.AVAILABLE) {
|
||||||
|
await this.mediaUpdater(media, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
|
||||||
|
await this.mediaUpdater(media, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both versions still exist in plex, we still need
|
||||||
|
// to check through sonarr to verify season availability
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
let showExists = false;
|
||||||
|
let showExists4k = false;
|
||||||
|
|
||||||
|
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
|
||||||
|
await this.mediaExistsInPlex(media, false);
|
||||||
|
const {
|
||||||
|
existsInPlex: existsInPlex4k,
|
||||||
|
seasonsMap: plexSeasonsMap4k = new Map(),
|
||||||
|
} = await this.mediaExistsInPlex(media, true);
|
||||||
|
|
||||||
|
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
|
||||||
|
await this.mediaExistsInSonarr(media, false);
|
||||||
|
const {
|
||||||
|
existsInSonarr: existsInSonarr4k,
|
||||||
|
seasonsMap: sonarrSeasonsMap4k,
|
||||||
|
} = await this.mediaExistsInSonarr(media, true);
|
||||||
|
|
||||||
|
if (existsInPlex || existsInSonarr) {
|
||||||
|
showExists = true;
|
||||||
|
logger.info(
|
||||||
|
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInPlex4k || existsInSonarr4k) {
|
||||||
|
showExists4k = true;
|
||||||
|
logger.info(
|
||||||
|
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we will create a final map that will cross compare
|
||||||
|
// with plex and sonarr. Filtered seasons will go through
|
||||||
|
// each season and assume the season does not exist. If Plex or
|
||||||
|
// Sonarr finds that season, we will change the final seasons value
|
||||||
|
// to true.
|
||||||
|
const filteredSeasonsMap: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
|
media.seasons
|
||||||
|
.filter(
|
||||||
|
(season) =>
|
||||||
|
season.status === MediaStatus.AVAILABLE ||
|
||||||
|
season.status === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
)
|
||||||
|
.forEach((season) =>
|
||||||
|
filteredSeasonsMap.set(season.seasonNumber, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalSeasons = new Map([
|
||||||
|
...filteredSeasonsMap,
|
||||||
|
...plexSeasonsMap,
|
||||||
|
...sonarrSeasonsMap,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
|
media.seasons
|
||||||
|
.filter(
|
||||||
|
(season) =>
|
||||||
|
season.status4k === MediaStatus.AVAILABLE ||
|
||||||
|
season.status4k === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
)
|
||||||
|
.forEach((season) =>
|
||||||
|
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalSeasons4k = new Map([
|
||||||
|
...filteredSeasonsMap4k,
|
||||||
|
...plexSeasonsMap4k,
|
||||||
|
...sonarrSeasonsMap4k,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ([...finalSeasons.values()].includes(false)) {
|
||||||
|
await this.seasonUpdater(media, finalSeasons, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([...finalSeasons4k.values()].includes(false)) {
|
||||||
|
await this.seasonUpdater(media, finalSeasons4k, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists &&
|
||||||
|
(media.status === MediaStatus.AVAILABLE ||
|
||||||
|
media.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists4k &&
|
||||||
|
(media.status4k === MediaStatus.AVAILABLE ||
|
||||||
|
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
logger.error('Failed to complete availability sync.', {
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
logger.info(`Availability sync complete.`, {
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel() {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *loadAvailableMediaPaginated(pageSize: number) {
|
||||||
|
let offset = 0;
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const whereOptions = [
|
||||||
|
{ status: MediaStatus.AVAILABLE },
|
||||||
|
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
{ status4k: MediaStatus.AVAILABLE },
|
||||||
|
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
];
|
||||||
|
|
||||||
|
let mediaPage: Media[];
|
||||||
|
|
||||||
|
do {
|
||||||
|
yield* (mediaPage = await mediaRepository.find({
|
||||||
|
where: whereOptions,
|
||||||
|
skip: offset,
|
||||||
|
take: pageSize,
|
||||||
|
}));
|
||||||
|
offset += pageSize;
|
||||||
|
} while (mediaPage.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findMediaStatus(
|
||||||
|
requests: MediaRequest[],
|
||||||
|
is4k: boolean
|
||||||
|
): MediaStatus {
|
||||||
|
const filteredRequests = requests.filter(
|
||||||
|
(request) => request.is4k === is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
let mediaStatus: MediaStatus;
|
||||||
|
|
||||||
|
if (
|
||||||
|
filteredRequests.some(
|
||||||
|
(request) => request.status === MediaRequestStatus.APPROVED
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mediaStatus = MediaStatus.PROCESSING;
|
||||||
|
} else if (
|
||||||
|
filteredRequests.some(
|
||||||
|
(request) => request.status === MediaRequestStatus.PENDING
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mediaStatus = MediaStatus.PENDING;
|
||||||
|
} else {
|
||||||
|
mediaStatus = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find all related requests only if
|
||||||
|
// the related media has an available status
|
||||||
|
const requests = await requestRepository
|
||||||
|
.createQueryBuilder('request')
|
||||||
|
.leftJoinAndSelect('request.media', 'media')
|
||||||
|
.where('(media.id = :id)', {
|
||||||
|
id: media.id,
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
`(request.is4k = :is4k AND media.${
|
||||||
|
is4k ? 'status4k' : 'status'
|
||||||
|
} IN (:...mediaStatus))`,
|
||||||
|
{
|
||||||
|
mediaStatus: [
|
||||||
|
MediaStatus.AVAILABLE,
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
],
|
||||||
|
is4k: is4k,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Check if a season is processing or pending to
|
||||||
|
// make sure we set the media to the correct status
|
||||||
|
let mediaStatus = MediaStatus.UNKNOWN;
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
mediaStatus = this.findMediaStatus(requests, is4k);
|
||||||
|
}
|
||||||
|
|
||||||
|
media[is4k ? 'status4k' : 'status'] = mediaStatus;
|
||||||
|
media[is4k ? 'serviceId4k' : 'serviceId'] =
|
||||||
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
|
? media[is4k ? 'serviceId4k' : 'serviceId']
|
||||||
|
: null;
|
||||||
|
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
|
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
||||||
|
: null;
|
||||||
|
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||||
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
|
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
||||||
|
: null;
|
||||||
|
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
||||||
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
|
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`The ${is4k ? '4K' : 'non-4K'} ${
|
||||||
|
media.mediaType === 'movie' ? 'movie' : 'show'
|
||||||
|
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
||||||
|
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
||||||
|
} and Plex instance. Status will be changed to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await mediaRepository.save({ media, ...media });
|
||||||
|
|
||||||
|
// Only delete media request if type is movie.
|
||||||
|
// Type tv request deletion is handled
|
||||||
|
// in the season request entity
|
||||||
|
if (requests.length > 0 && media.mediaType === 'movie') {
|
||||||
|
await requestRepository.remove(requests);
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
logger.debug(
|
||||||
|
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
|
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||||
|
} [TMDB ID ${media.tmdbId}].`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonUpdater(
|
||||||
|
media: Media,
|
||||||
|
seasons: Map<number, boolean>,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
|
||||||
|
const seasonsPendingRemoval = new Map(
|
||||||
|
// Disabled linter as only the value is needed from the filter
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
[...seasons].filter(([_, exists]) => !exists)
|
||||||
|
);
|
||||||
|
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Need to check and see if there are any related season
|
||||||
|
// requests. If they are, we will need to delete them.
|
||||||
|
const seasonRequests = await seasonRequestRepository
|
||||||
|
.createQueryBuilder('seasonRequest')
|
||||||
|
.leftJoinAndSelect('seasonRequest.request', 'request')
|
||||||
|
.leftJoinAndSelect('request.media', 'media')
|
||||||
|
.where('(media.id = :id)', { id: media.id })
|
||||||
|
.andWhere(
|
||||||
|
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
|
||||||
|
{
|
||||||
|
seasonNumbers: seasonKeys,
|
||||||
|
is4k: is4k,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
for (const mediaSeason of media.seasons) {
|
||||||
|
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
||||||
|
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status === MediaStatus.AVAILABLE) {
|
||||||
|
media.status = MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
logger.info(
|
||||||
|
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||||
|
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
logger.info(
|
||||||
|
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mediaRepository.save({ media, ...media });
|
||||||
|
|
||||||
|
if (seasonRequests.length > 0) {
|
||||||
|
await seasonRequestRepository.remove(seasonRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
||||||
|
media.tmdbId
|
||||||
|
}] was not found in any ${
|
||||||
|
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
|
||||||
|
} and Plex instance. Status will be changed to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
logger.debug(
|
||||||
|
`Failure updating the ${
|
||||||
|
is4k ? '4K' : 'non-4K'
|
||||||
|
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInRadarr(
|
||||||
|
media: Media,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
let existsInRadarr = false;
|
||||||
|
|
||||||
|
// Check for availability in all of the available radarr servers
|
||||||
|
// If any find the media, we will assume the media exists
|
||||||
|
for (const server of this.radarrServers) {
|
||||||
|
const radarrAPI = new RadarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let radarr: RadarrMovie | undefined;
|
||||||
|
|
||||||
|
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||||
|
radarr = await radarrAPI.getMovie({
|
||||||
|
id: media.externalServiceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||||
|
radarr = await radarrAPI.getMovie({
|
||||||
|
id: media.externalServiceId4k,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radarr && radarr.hasFile) {
|
||||||
|
existsInRadarr = true;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (!ex.message.includes('404')) {
|
||||||
|
existsInRadarr = true;
|
||||||
|
logger.debug(
|
||||||
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
|
||||||
|
media.tmdbId
|
||||||
|
}] from Radarr.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return existsInRadarr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInSonarr(
|
||||||
|
media: Media,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<{ existsInSonarr: boolean; seasonsMap: Map<number, boolean> }> {
|
||||||
|
let existsInSonarr = false;
|
||||||
|
let preventSeasonSearch = false;
|
||||||
|
|
||||||
|
// Check for availability in all of the available sonarr servers
|
||||||
|
// If any find the media, we will assume the media exists
|
||||||
|
for (const server of this.sonarrServers) {
|
||||||
|
const sonarrAPI = new SonarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let sonarr: SonarrSeries | undefined;
|
||||||
|
|
||||||
|
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||||
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
||||||
|
sonarr.seasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||||
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
||||||
|
sonarr.seasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sonarr && sonarr.statistics.episodeFileCount > 0) {
|
||||||
|
existsInSonarr = true;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (!ex.message.includes('404')) {
|
||||||
|
existsInSonarr = true;
|
||||||
|
preventSeasonSearch = true;
|
||||||
|
logger.debug(
|
||||||
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
|
||||||
|
media.tmdbId
|
||||||
|
}] from Sonarr.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we check each season for availability
|
||||||
|
// If the API returns an error other than a 404,
|
||||||
|
// we will have to prevent the season check from happening
|
||||||
|
const seasonsMap: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
|
if (!preventSeasonSearch) {
|
||||||
|
const filteredSeasons = media.seasons.filter(
|
||||||
|
(season) =>
|
||||||
|
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||||
|
season[is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const season of filteredSeasons) {
|
||||||
|
const seasonExists = await this.seasonExistsInSonarr(
|
||||||
|
media,
|
||||||
|
season,
|
||||||
|
is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonExists) {
|
||||||
|
seasonsMap.set(season.seasonNumber, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { existsInSonarr, seasonsMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonExistsInSonarr(
|
||||||
|
media: Media,
|
||||||
|
season: Season,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
let seasonExists = false;
|
||||||
|
|
||||||
|
// Check each sonarr instance to see if the media still exists
|
||||||
|
// If found, we will assume the media exists and prevent removal
|
||||||
|
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
|
||||||
|
for (const server of this.sonarrServers) {
|
||||||
|
let sonarrSeasons: SonarrSeason[] | undefined;
|
||||||
|
|
||||||
|
if (media.externalServiceId && !is4k) {
|
||||||
|
sonarrSeasons =
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.externalServiceId4k && is4k) {
|
||||||
|
sonarrSeasons =
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonIsAvailable = sonarrSeasons?.find(
|
||||||
|
({ seasonNumber, statistics }) =>
|
||||||
|
season.seasonNumber === seasonNumber &&
|
||||||
|
statistics?.episodeFileCount &&
|
||||||
|
statistics?.episodeFileCount > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonIsAvailable && sonarrSeasons) {
|
||||||
|
seasonExists = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seasonExists;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInPlex(
|
||||||
|
media: Media,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<{ existsInPlex: boolean; seasonsMap?: Map<number, boolean> }> {
|
||||||
|
const ratingKey = media.ratingKey;
|
||||||
|
const ratingKey4k = media.ratingKey4k;
|
||||||
|
let existsInPlex = false;
|
||||||
|
let preventSeasonSearch = false;
|
||||||
|
|
||||||
|
// Check each plex instance to see if the media still exists
|
||||||
|
// If found, we will assume the media exists and prevent removal
|
||||||
|
// We can use the cache we built when we fetched the series with mediaExistsInPlex
|
||||||
|
try {
|
||||||
|
let plexMedia: PlexMetadata | undefined;
|
||||||
|
|
||||||
|
if (ratingKey && !is4k) {
|
||||||
|
plexMedia = await this.plexClient?.getMetadata(ratingKey);
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
this.plexSeasonsCache[ratingKey] =
|
||||||
|
await this.plexClient?.getChildrenMetadata(ratingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingKey4k && is4k) {
|
||||||
|
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
this.plexSeasonsCache[ratingKey4k] =
|
||||||
|
await this.plexClient?.getChildrenMetadata(ratingKey4k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plexMedia) {
|
||||||
|
existsInPlex = true;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (!ex.message.includes('404')) {
|
||||||
|
existsInPlex = true;
|
||||||
|
preventSeasonSearch = true;
|
||||||
|
logger.debug(
|
||||||
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
|
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||||
|
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we check each season in plex for availability
|
||||||
|
// If the API returns an error other than a 404,
|
||||||
|
// we will have to prevent the season check from happening
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
const seasonsMap: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
|
if (!preventSeasonSearch) {
|
||||||
|
const filteredSeasons = media.seasons.filter(
|
||||||
|
(season) =>
|
||||||
|
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||||
|
season[is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const season of filteredSeasons) {
|
||||||
|
const seasonExists = await this.seasonExistsInPlex(
|
||||||
|
media,
|
||||||
|
season,
|
||||||
|
is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonExists) {
|
||||||
|
seasonsMap.set(season.seasonNumber, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { existsInPlex, seasonsMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { existsInPlex };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonExistsInPlex(
|
||||||
|
media: Media,
|
||||||
|
season: Season,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
const ratingKey = media.ratingKey;
|
||||||
|
const ratingKey4k = media.ratingKey4k;
|
||||||
|
let seasonExistsInPlex = false;
|
||||||
|
|
||||||
|
// Check each plex instance to see if the season exists
|
||||||
|
let plexSeasons: PlexMetadata[] | undefined;
|
||||||
|
|
||||||
|
if (ratingKey && !is4k) {
|
||||||
|
plexSeasons = this.plexSeasonsCache[ratingKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingKey4k && is4k) {
|
||||||
|
plexSeasons = this.plexSeasonsCache[ratingKey4k];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonIsAvailable = plexSeasons?.find(
|
||||||
|
(plexSeason) => plexSeason.index === season.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonIsAvailable) {
|
||||||
|
seasonExistsInPlex = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return seasonExistsInPlex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilitySync = new AvailabilitySync();
|
||||||
|
export default availabilitySync;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user