mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-05 13:20:20 -05:00
Compare commits
585 Commits
v4.0.16.29
...
api-docs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed0d9779db | ||
|
|
7f971d47ac | ||
|
|
db9ef92a80 | ||
|
|
147f11dece | ||
|
|
f91ebd4c07 | ||
|
|
d99f8b5685 | ||
|
|
6c329e8a6f | ||
|
|
2a5667e634 | ||
|
|
da6340e421 | ||
|
|
965b6144e3 | ||
|
|
7add5aafad | ||
|
|
d543427012 | ||
|
|
1c805bded0 | ||
|
|
c4c0ec25ac | ||
|
|
bcceb22512 | ||
|
|
6764cf1c22 | ||
|
|
dd6533c18a | ||
|
|
ac1c74105f | ||
|
|
33fb0a4e88 | ||
|
|
f93bc57426 | ||
|
|
eda676f9a2 | ||
|
|
c8f15ae198 | ||
|
|
fcfdac99d5 | ||
|
|
5bac016f0c | ||
|
|
236978a9b1 | ||
|
|
d8698d1c28 | ||
|
|
b94cf9b3b9 | ||
|
|
2335657fe4 | ||
|
|
8fa16e3542 | ||
|
|
93713c3827 | ||
|
|
39573ea17b | ||
|
|
944e33f24b | ||
|
|
06aba5fe18 | ||
|
|
49f6117d54 | ||
|
|
9c61a5c286 | ||
|
|
fdda9abcbb | ||
|
|
dfbd54308f | ||
|
|
bbb4c6714c | ||
|
|
1d8da79172 | ||
|
|
3e22ad59c3 | ||
|
|
f2fc1e9332 | ||
|
|
e8020b7289 | ||
|
|
1033849d39 | ||
|
|
70678510ee | ||
|
|
54dafdb8d3 | ||
|
|
c0a565861e | ||
|
|
8b8cd14834 | ||
|
|
ee83b81be9 | ||
|
|
302b0f356e | ||
|
|
15fb999597 | ||
|
|
b88b7e15c1 | ||
|
|
da6985b40c | ||
|
|
0d521c078b | ||
|
|
52ce33a97a | ||
|
|
1ef034af35 | ||
|
|
7ea20335ed | ||
|
|
41b0ecb08c | ||
|
|
5d29c16bf3 | ||
|
|
210c20f8e9 | ||
|
|
0ed53874c6 | ||
|
|
97d3a4940d | ||
|
|
98fbe694e4 | ||
|
|
677c588a3b | ||
|
|
0ddc2a34e5 | ||
|
|
f3b39ba4f7 | ||
|
|
c409ee81bd | ||
|
|
6cd1ab3764 | ||
|
|
5d8d2d66a4 | ||
|
|
164441965c | ||
|
|
5487aa74f5 | ||
|
|
bee7e4325f | ||
|
|
7b0db46c25 | ||
|
|
b16743bdda | ||
|
|
399ca1661f | ||
|
|
28300ffb2b | ||
|
|
ae13ce4ac6 | ||
|
|
276e67b5fa | ||
|
|
e20b4c4f5d | ||
|
|
6d49b41dd2 | ||
|
|
0d80c093ff | ||
|
|
06c6062531 | ||
|
|
3e8a85ad26 | ||
|
|
107e474f9c | ||
|
|
0149886b68 | ||
|
|
77612810d8 | ||
|
|
12ed8cdc26 | ||
|
|
63431917fe | ||
|
|
e06c6ba649 | ||
|
|
e747ec8f5c | ||
|
|
6a6639105e | ||
|
|
8f5f3070ac | ||
|
|
1839aa7907 | ||
|
|
a79234de48 | ||
|
|
ac5b9b14ee | ||
|
|
e0abca8360 | ||
|
|
b2f2c21a61 | ||
|
|
4bf02221e6 | ||
|
|
c70de927a5 | ||
|
|
c60a978eb8 | ||
|
|
cf593b1f5d | ||
|
|
243a3057ae | ||
|
|
8b438c2197 | ||
|
|
21ca65a015 | ||
|
|
f4b9b30978 | ||
|
|
4713615b17 | ||
|
|
f963a0d972 | ||
|
|
3977d8766c | ||
|
|
91f1b672c5 | ||
|
|
c3706c3c92 | ||
|
|
8fcab2d321 | ||
|
|
1114cc7f7a | ||
|
|
74e6ce4305 | ||
|
|
e9011011ed | ||
|
|
7e70238005 | ||
|
|
ad57cf4b5d | ||
|
|
25fb4c4d7a | ||
|
|
2d071eca9b | ||
|
|
a466a94d4d | ||
|
|
763c9c838f | ||
|
|
3f098c601b | ||
|
|
c5ff89c69b | ||
|
|
cd7adba17c | ||
|
|
5f8297da6c | ||
|
|
ee875ae654 | ||
|
|
d70dcfed56 | ||
|
|
47fa1f3ae6 | ||
|
|
6bb694a6f7 | ||
|
|
3c77c4b989 | ||
|
|
fe61545716 | ||
|
|
dbb841f027 | ||
|
|
8ee10adbd4 | ||
|
|
03cb2e7f63 | ||
|
|
65cd80a9d5 | ||
|
|
869269ddf3 | ||
|
|
ff36fb017a | ||
|
|
1043e7f43f | ||
|
|
ce8a5d8a6b | ||
|
|
ec44e1c513 | ||
|
|
8da611ea58 | ||
|
|
4c13a01c8e | ||
|
|
10c0e18a42 | ||
|
|
bc099f27cb | ||
|
|
f94b3d8560 | ||
|
|
6b479a5a10 | ||
|
|
5eb18fe274 | ||
|
|
7d59b466e7 | ||
|
|
21083d9041 | ||
|
|
2453de80aa | ||
|
|
f722e51377 | ||
|
|
db95d7e42f | ||
|
|
9b7f2a5adf | ||
|
|
bb090a7c42 | ||
|
|
dec6f4b5f2 | ||
|
|
dd12b9e076 | ||
|
|
227db9ef39 | ||
|
|
5f9f8fddad | ||
|
|
cbade29c7a | ||
|
|
87892a1d0c | ||
|
|
4e52b3c93e | ||
|
|
aee6158ec5 | ||
|
|
878f879c40 | ||
|
|
66efb904f2 | ||
|
|
e73712bd8f | ||
|
|
5542187f17 | ||
|
|
de0f1d461d | ||
|
|
c749e2d1a3 | ||
|
|
b612888ed4 | ||
|
|
0ade5b4c22 | ||
|
|
3c0fa4a3af | ||
|
|
0521a6c390 | ||
|
|
49db4a1d76 | ||
|
|
1df3b116c1 | ||
|
|
5d655f98f2 | ||
|
|
5f846ab51e | ||
|
|
bef2986357 | ||
|
|
6f287cb1de | ||
|
|
1178c98341 | ||
|
|
a97f2c016b | ||
|
|
4959ef4321 | ||
|
|
d252fa8ed6 | ||
|
|
7d2e01d516 | ||
|
|
91b242902d | ||
|
|
2f119fefd1 | ||
|
|
1ca148a7a9 | ||
|
|
9db17883df | ||
|
|
4c556eacc1 | ||
|
|
e4298d0135 | ||
|
|
8f95849e9b | ||
|
|
9b756df4bf | ||
|
|
8e537cb626 | ||
|
|
f76ae1cce9 | ||
|
|
7a5157df29 | ||
|
|
449caa12e3 | ||
|
|
dd84bcd919 | ||
|
|
26934a5d81 | ||
|
|
84fbab6ab4 | ||
|
|
a047053fae | ||
|
|
6629ebc6e0 | ||
|
|
44fc1e0e85 | ||
|
|
9cdf1bf721 | ||
|
|
d2107e92f3 | ||
|
|
7284898c7d | ||
|
|
d4f14246f1 | ||
|
|
ae18ad61bd | ||
|
|
aac4760d30 | ||
|
|
21592f3d69 | ||
|
|
e1cfc5f550 | ||
|
|
0809a72ce5 | ||
|
|
20ad1b4410 | ||
|
|
e89d58985a | ||
|
|
4071278183 | ||
|
|
5a702dec12 | ||
|
|
317cdf1558 | ||
|
|
f104268256 | ||
|
|
263f4839ab | ||
|
|
19a65d672f | ||
|
|
2098303e25 | ||
|
|
ccb7f07c26 | ||
|
|
6a3e1278a5 | ||
|
|
5867cd5f47 | ||
|
|
c295e24fc6 | ||
|
|
29170f17d2 | ||
|
|
3091f40ca8 | ||
|
|
565f967f7a | ||
|
|
7960bb8c7d | ||
|
|
b5967425f1 | ||
|
|
910b85f37d | ||
|
|
08f0a5a960 | ||
|
|
0df0212b3a | ||
|
|
b64cc65579 | ||
|
|
871ae9555b | ||
|
|
2bc30037bd | ||
|
|
0552a81180 | ||
|
|
9bc9d6d400 | ||
|
|
beadc687ae | ||
|
|
4f149257c0 | ||
|
|
255f1c37c3 | ||
|
|
0ff644e842 | ||
|
|
8718f28fbd | ||
|
|
b803635b6c | ||
|
|
dacd469627 | ||
|
|
49c52c2e1a | ||
|
|
fc0c26c2b3 | ||
|
|
4f7425086e | ||
|
|
a85419df15 | ||
|
|
7ef3b6bd0a | ||
|
|
79f1d2e38c | ||
|
|
52ba6f4593 | ||
|
|
81c6f3ac75 | ||
|
|
550cf8d399 | ||
|
|
37dfad11f2 | ||
|
|
ff5e73273b | ||
|
|
fce8e780cb | ||
|
|
4fb89252b5 | ||
|
|
5a3f41263a | ||
|
|
52d7f67627 | ||
|
|
a1db23353c | ||
|
|
79b56c3ff6 | ||
|
|
5568746ef8 | ||
|
|
813d7df643 | ||
|
|
b04b9f900f | ||
|
|
a45b077625 | ||
|
|
74ce132556 | ||
|
|
ca364724cf | ||
|
|
a4f210855e | ||
|
|
bc4ad574fc | ||
|
|
a5ea19ddfb | ||
|
|
858c690543 | ||
|
|
8e169561f2 | ||
|
|
994faa60c6 | ||
|
|
a4a18d6121 | ||
|
|
0407564784 | ||
|
|
0a61e66ef1 | ||
|
|
051451eb2a | ||
|
|
a4e3be721d | ||
|
|
224e74605b | ||
|
|
6c581b7e3c | ||
|
|
6588ba8435 | ||
|
|
ce4c2e4fcc | ||
|
|
b1f77007dc | ||
|
|
8bab0a06dd | ||
|
|
5b135addaa | ||
|
|
4904e85887 | ||
|
|
c4978022eb | ||
|
|
15e9350601 | ||
|
|
2e1289b924 | ||
|
|
7dac00d5aa | ||
|
|
3796c9e30f | ||
|
|
f2f4edad0c | ||
|
|
b0b15c78ff | ||
|
|
64c421c187 | ||
|
|
6440151053 | ||
|
|
cf6b21aef6 | ||
|
|
1610e54650 | ||
|
|
c40fbeed50 | ||
|
|
b57e7e2db0 | ||
|
|
478866b2bb | ||
|
|
ae201f5299 | ||
|
|
642f4f97bc | ||
|
|
37cb978f18 | ||
|
|
7fdc4d6638 | ||
|
|
309b55fe38 | ||
|
|
d6f265c7b5 | ||
|
|
e757dca038 | ||
|
|
9ebe043bd9 | ||
|
|
f055e8a3e5 | ||
|
|
8c697afa67 | ||
|
|
8d68879edd | ||
|
|
e9c82078da | ||
|
|
f0798550af | ||
|
|
d9c7838329 | ||
|
|
b00229e53c | ||
|
|
880628fb68 | ||
|
|
b09c6f0811 | ||
|
|
b376b63c9e | ||
|
|
99feaa34d2 | ||
|
|
d7f82a72c2 | ||
|
|
bd20ebfad7 | ||
|
|
71553ad67b | ||
|
|
41c39f1f28 | ||
|
|
d0066358eb | ||
|
|
6f1d461dad | ||
|
|
6ccab3cfc8 | ||
|
|
5e47cc3baa | ||
|
|
78ca30d1f8 | ||
|
|
f9d0abada3 | ||
|
|
4bdb0408f1 | ||
|
|
40ea6ce4e5 | ||
|
|
ccf33033dc | ||
|
|
996c0e9f50 | ||
|
|
8b7f9daab0 | ||
|
|
dfb6fdfbeb | ||
|
|
29d0073ee6 | ||
|
|
9cf6be32fa | ||
|
|
fee3f8150e | ||
|
|
010bbbd222 | ||
|
|
d3c3a6ebce | ||
|
|
f26344ae75 | ||
|
|
034f731308 | ||
|
|
4b50861a6b | ||
|
|
f977b8ba1b | ||
|
|
8374ebc25b | ||
|
|
71851d038c | ||
|
|
9ffcd141a5 | ||
|
|
a6f50408f2 | ||
|
|
6e43b08dab | ||
|
|
90c4791d5f | ||
|
|
030910babc | ||
|
|
59af86cea4 | ||
|
|
4cb25228b6 | ||
|
|
bf34b43094 | ||
|
|
1cdca8ef3e | ||
|
|
103b1335b9 | ||
|
|
b3d830c475 | ||
|
|
a279240335 | ||
|
|
3eed84c679 | ||
|
|
51c17fd312 | ||
|
|
70c74fc176 | ||
|
|
cfda24536c | ||
|
|
14e324ee30 | ||
|
|
32ba06ecd0 | ||
|
|
61807fede0 | ||
|
|
2a1efe5f59 | ||
|
|
0f43f8c9f6 | ||
|
|
a853c537db | ||
|
|
f9dccd6ec7 | ||
|
|
72b3b825eb | ||
|
|
818ae02a7a | ||
|
|
5ba3ff5987 | ||
|
|
e38deb3422 | ||
|
|
f35888e053 | ||
|
|
a50d256264 | ||
|
|
70165bddc8 | ||
|
|
4258e94e90 | ||
|
|
066b39032b | ||
|
|
728df146ad | ||
|
|
751a07bb40 | ||
|
|
d4ce60bd41 | ||
|
|
4b868d3f06 | ||
|
|
817d13e85c | ||
|
|
fae014c8be | ||
|
|
2fa02472ee | ||
|
|
7c3c577811 | ||
|
|
9fdf545f47 | ||
|
|
e537a2dc8f | ||
|
|
1047e71b7d | ||
|
|
415498efb3 | ||
|
|
cf08e947c4 | ||
|
|
bb872ee35b | ||
|
|
ab0d8352e8 | ||
|
|
9683b0af35 | ||
|
|
76b1130b68 | ||
|
|
5be58249f8 | ||
|
|
4d67b8ae2b | ||
|
|
66633b9b07 | ||
|
|
4728fa29ef | ||
|
|
9cb9c711be | ||
|
|
d62eea604a | ||
|
|
3185315343 | ||
|
|
e52b68ee7d | ||
|
|
f7eece32e7 | ||
|
|
c96c47af9e | ||
|
|
a5999b1410 | ||
|
|
ac1bb497ef | ||
|
|
9bd619ccfe | ||
|
|
dfbf12b711 | ||
|
|
0ae07898ba | ||
|
|
2314d0b506 | ||
|
|
2093f08a57 | ||
|
|
0a7ffb64f0 | ||
|
|
41b65abd1d | ||
|
|
0f904e0917 | ||
|
|
f8e57b0985 | ||
|
|
9e774f4026 | ||
|
|
2acc4c8865 | ||
|
|
0fcd92e441 | ||
|
|
b103005aa2 | ||
|
|
41b5118938 | ||
|
|
c84699ed5d | ||
|
|
bdd975da0f | ||
|
|
08d1bcb351 | ||
|
|
5fb632eb46 | ||
|
|
da29de4cfe | ||
|
|
83b2c9e97a | ||
|
|
095126bfe8 | ||
|
|
6aee9c7fd5 | ||
|
|
20c2d59e9a | ||
|
|
7ee90fb05d | ||
|
|
1a8ba51260 | ||
|
|
2e66cd2a1e | ||
|
|
6115236d38 | ||
|
|
7ff8c9e18d | ||
|
|
f0e320f3aa | ||
|
|
9f29b06ca4 | ||
|
|
08c0c5aa30 | ||
|
|
64956d7be7 | ||
|
|
b598795262 | ||
|
|
9756a3df38 | ||
|
|
94f64435f5 | ||
|
|
a324052deb | ||
|
|
e08c9d5501 | ||
|
|
1449941471 | ||
|
|
28c4f0cef2 | ||
|
|
e199710c15 | ||
|
|
e3a048790d | ||
|
|
6dc47755ec | ||
|
|
01f7783519 | ||
|
|
e9f59188b1 | ||
|
|
a6e6b7518d | ||
|
|
38cd63ec04 | ||
|
|
71f1593fd9 | ||
|
|
3d951f6db8 | ||
|
|
0f16837b59 | ||
|
|
f0d0eb9a7a | ||
|
|
608fc29086 | ||
|
|
8acd154206 | ||
|
|
6f451b2206 | ||
|
|
3cb6f866cc | ||
|
|
649ed04f8a | ||
|
|
fef00dccf8 | ||
|
|
cbc5127a14 | ||
|
|
38746fc95b | ||
|
|
24ce10006e | ||
|
|
c034282f45 | ||
|
|
b2214fd912 | ||
|
|
4db4388236 | ||
|
|
093ee5b88d | ||
|
|
f58dfc5605 | ||
|
|
1c9a0232ad | ||
|
|
c86822b114 | ||
|
|
5342416659 | ||
|
|
5d7c94f8e9 | ||
|
|
6d8c3f15b3 | ||
|
|
efef9f88ff | ||
|
|
8748cdb1bf | ||
|
|
7a9b6d3262 | ||
|
|
c902735927 | ||
|
|
156d306334 | ||
|
|
d09e7893b3 | ||
|
|
3e2e8e9388 | ||
|
|
99fc61e636 | ||
|
|
271979d637 | ||
|
|
b572a6f759 | ||
|
|
950330b091 | ||
|
|
8940ef8b81 | ||
|
|
91bdf06214 | ||
|
|
5678f98344 | ||
|
|
05d57aa913 | ||
|
|
b22d598ebf | ||
|
|
e22dd15443 | ||
|
|
1fa532dd3e | ||
|
|
350dea10dd | ||
|
|
d3777dd43c | ||
|
|
a72288a14e | ||
|
|
e506eb6d03 | ||
|
|
591b569bdd | ||
|
|
094df71301 | ||
|
|
31e02bdead | ||
|
|
b122ee9670 | ||
|
|
0f9e063e21 | ||
|
|
609e964794 | ||
|
|
5cebec6ae4 | ||
|
|
33da537a63 | ||
|
|
11c945c2dc | ||
|
|
1b0a20535c | ||
|
|
1734fcaa8f | ||
|
|
b99e06acc0 | ||
|
|
5d16169e52 | ||
|
|
a2670a5804 | ||
|
|
9e5ebdc624 | ||
|
|
e62aa5e041 | ||
|
|
80a8176c58 | ||
|
|
39f1c669b8 | ||
|
|
5208f5e966 | ||
|
|
bb0ad312f1 | ||
|
|
413d6b996b | ||
|
|
79474f26e9 | ||
|
|
18bbb8bbd1 | ||
|
|
374c6d13f6 | ||
|
|
92f0aa4e8f | ||
|
|
7345c06003 | ||
|
|
ff08b914f3 | ||
|
|
e40c8f3e3e | ||
|
|
2fd1fea4cb | ||
|
|
a72bc164a9 | ||
|
|
d9a86bcb31 | ||
|
|
553c4aeae1 | ||
|
|
5a47f34ef9 | ||
|
|
15b070119d | ||
|
|
7b133bd80d | ||
|
|
ce6536f8ab | ||
|
|
c6eb6c3cd8 | ||
|
|
f4f3fdfb0b | ||
|
|
6b92627004 | ||
|
|
c3f9cd12af | ||
|
|
6f871a1bfb | ||
|
|
ab1f8bdbd9 | ||
|
|
b72b1d5e5c | ||
|
|
87c840974b | ||
|
|
ee46e6378a | ||
|
|
7644cec376 | ||
|
|
d3153685ac | ||
|
|
7035bb2944 | ||
|
|
6dc16f3ddd | ||
|
|
0e1474579a | ||
|
|
7b4bd50f18 | ||
|
|
4849d1da10 | ||
|
|
8482f3da1a | ||
|
|
782af002d6 | ||
|
|
8f6d9f3bf4 | ||
|
|
699120a8fd | ||
|
|
0fdeb05663 | ||
|
|
7c64911b6b | ||
|
|
3035521b93 | ||
|
|
45c53bea86 | ||
|
|
4c6d6b726e | ||
|
|
1765feac03 | ||
|
|
92db4769be | ||
|
|
6838f068bc | ||
|
|
b218461678 | ||
|
|
10e3a237ef | ||
|
|
6e008a8e85 | ||
|
|
27f81117ed | ||
|
|
839658a698 | ||
|
|
1bc1b080d1 | ||
|
|
572bdc979c | ||
|
|
cde0a31ff0 | ||
|
|
60529f0bac | ||
|
|
5ed7780ed7 | ||
|
|
89c8a10e0d | ||
|
|
fd09ca6e71 | ||
|
|
95929dd9c2 | ||
|
|
23bc6a157c | ||
|
|
756e985b66 | ||
|
|
a2fd23c84d | ||
|
|
32ce09648c | ||
|
|
20e1a8d116 | ||
|
|
12a1ef0387 | ||
|
|
2935d148a8 | ||
|
|
a90c13e86f | ||
|
|
9a7ddd751e | ||
|
|
a1d4bb5399 | ||
|
|
9d0acba000 | ||
|
|
ee1a0a1f71 | ||
|
|
f35a27449d |
@@ -2,11 +2,11 @@
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
|
||||
{
|
||||
"name": "Sonarr",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "16",
|
||||
"version": "20",
|
||||
"nvmVersion": "latest"
|
||||
}
|
||||
},
|
||||
|
||||
194
.github/actions/build/action.yml
vendored
Normal file
194
.github/actions/build/action.yml
vendored
Normal file
@@ -0,0 +1,194 @@
|
||||
name: Build Backend
|
||||
description: Builds the backend and packages it
|
||||
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch name for this build"
|
||||
required: true
|
||||
version:
|
||||
description: "Version number to build"
|
||||
required: true
|
||||
framework:
|
||||
description: ".net framework used for the build"
|
||||
required: true
|
||||
runtime:
|
||||
description: "Run time to build for"
|
||||
required: true
|
||||
package_tests:
|
||||
description: "True if tests should be packaged for later testing steps"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Setup NuGet registry source
|
||||
shell: bash
|
||||
if: ${{ startsWith(inputs.runtime, 'freebsd') }}
|
||||
run:
|
||||
dotnet nuget add source --configfile src/NuGet.Config --name gh-openur https://nuget.pkg.github.com/openur/index.json --username ${{ github.repository_owner }} --password ${{ github.token }} --store-password-in-clear-text
|
||||
|
||||
- name: Setup Environment Variables
|
||||
id: variables
|
||||
shell: bash
|
||||
run: |
|
||||
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
|
||||
|
||||
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "SONARR_VERSION=${{ inputs.version }}" >> "$GITHUB_ENV"
|
||||
echo "BRANCH=${{ inputs.branch }}" >> "$GITHUB_ENV"
|
||||
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
echo "NUGET_PACKAGES=D:\nuget\packages" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Enable Extra Platforms In SDK
|
||||
if: ${{ inputs.runtime == 'freebsd-x64' }}
|
||||
shell: bash
|
||||
run: |
|
||||
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
|
||||
|
||||
if grep -q freebsd-x64 "$BUNDLEDVERSIONS"; then
|
||||
echo "Extra platforms already enabled"
|
||||
else
|
||||
echo "Enabling extra platform support"
|
||||
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
|
||||
fi
|
||||
|
||||
if grep -qv freebsd-x64 src/Directory.Build.props; then
|
||||
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
|
||||
fi
|
||||
|
||||
- name: Update Version Number
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$SONARR_VERSION" != "" ]; then
|
||||
echo "Updating version info to: $SONARR_VERSION"
|
||||
sed -i'' -e "s/<AssemblyVersion>[0-9.*]\+<\/AssemblyVersion>/<AssemblyVersion>$SONARR_VERSION<\/AssemblyVersion>/g" src/Directory.Build.props
|
||||
sed -i'' -e "s/<AssemblyConfiguration>[\$()A-Za-z-]\+<\/AssemblyConfiguration>/<AssemblyConfiguration>${BRANCH}<\/AssemblyConfiguration>/g" src/Directory.Build.props
|
||||
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$SONARR_VERSION<\/string>/g" distribution/macOS/Sonarr.app/Contents/Info.plist
|
||||
fi
|
||||
|
||||
- name: Build Backend
|
||||
shell: bash
|
||||
run: |
|
||||
runtime="${{ inputs.runtime }}"
|
||||
platform=Windows
|
||||
slnFile=src/Sonarr.sln
|
||||
targetingWindows=false
|
||||
|
||||
IFS='-' read -ra SPLIT <<< "$runtime"
|
||||
|
||||
if [ "${SPLIT[0]}" == "win" ]; then
|
||||
platform=Windows
|
||||
targetingWindows=true
|
||||
else
|
||||
platform=Posix
|
||||
fi
|
||||
|
||||
rm -rf _output
|
||||
rm -rf _tests
|
||||
|
||||
echo "Building Sonarr for $runtime, Platform: $platform"
|
||||
|
||||
dotnet msbuild -restore $slnFile -p:SelfContained=true -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$runtime -p:EnableWindowsTargeting=true -t:PublishAllRids
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
framework="${{ inputs.framework }}"
|
||||
runtime="${{ inputs.runtime }}"
|
||||
|
||||
IFS='-' read -ra SPLIT <<< "$runtime"
|
||||
|
||||
case "${SPLIT[0]}" in
|
||||
linux|freebsd*)
|
||||
folder=_artifacts/$runtime/$framework/Sonarr
|
||||
|
||||
echo "Packaging files"
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r _output/$framework/$runtime/publish/* $folder
|
||||
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
||||
cp LICENSE.md $folder
|
||||
|
||||
echo "Removing Service helpers"
|
||||
rm -f $folder/ServiceUninstall.*
|
||||
rm -f $folder/ServiceInstall.*
|
||||
|
||||
echo "Removing Sonarr.Windows"
|
||||
rm $folder/Sonarr.Windows.*
|
||||
|
||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||
;;
|
||||
win)
|
||||
folder=_artifacts/$runtime/$framework/Sonarr
|
||||
|
||||
echo "Packaging files"
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r _output/$framework/$runtime/publish/* $folder
|
||||
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
||||
cp LICENSE.md $folder
|
||||
cp -r _output/$framework-windows/$runtime/publish/* $folder
|
||||
|
||||
echo "Removing Sonarr.Mono"
|
||||
rm -f $folder/Sonarr.Mono.*
|
||||
rm -f $folder/Mono.Posix.NETStandard.*
|
||||
rm -f $folder/libMonoPosixHelper.*
|
||||
|
||||
echo "Adding Sonarr.Windows to UpdatePackage"
|
||||
cp $folder/Sonarr.Windows.* $folder/Sonarr.Update
|
||||
|
||||
;;
|
||||
osx)
|
||||
folder=_artifacts/$runtime/$framework/Sonarr
|
||||
|
||||
echo "Packaging files"
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r _output/$framework/$runtime/publish/* $folder
|
||||
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
||||
cp LICENSE.md $folder
|
||||
|
||||
echo "Removing Service helpers"
|
||||
rm -f $folder/ServiceUninstall.*
|
||||
rm -f $folder/ServiceInstall.*
|
||||
|
||||
echo "Removing Sonarr.Windows"
|
||||
rm $folder/Sonarr.Windows.*
|
||||
|
||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Package Tests
|
||||
if: ${{ inputs.package_tests }}
|
||||
shell: bash
|
||||
run: |
|
||||
framework="${{ inputs.framework }}"
|
||||
runtime="${{ inputs.runtime }}"
|
||||
|
||||
cp scripts/test.sh "_tests/$framework/$runtime/publish"
|
||||
|
||||
rm -f _tests/$framework/$runtime/*.log.config
|
||||
|
||||
- name: Upload Test Artifacts
|
||||
if: ${{ inputs.package_tests }}
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ inputs.framework }}
|
||||
runtime: ${{ inputs.runtime }}
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ inputs.runtime }}
|
||||
path: _artifacts/**/*
|
||||
26
.github/actions/package/action.yml
vendored
26
.github/actions/package/action.yml
vendored
@@ -2,34 +2,34 @@ name: Package
|
||||
description: Packages binaries for deployment
|
||||
|
||||
inputs:
|
||||
platform:
|
||||
description: 'Binary platform'
|
||||
runtime:
|
||||
description: "Binary runtime"
|
||||
required: true
|
||||
framework:
|
||||
description: '.net framework'
|
||||
framework:
|
||||
description: ".net framework"
|
||||
required: true
|
||||
artifact:
|
||||
description: 'Binary artifact'
|
||||
description: "Binary artifact"
|
||||
required: true
|
||||
branch:
|
||||
description: 'Git branch used for this build'
|
||||
description: "Git branch used for this build"
|
||||
required: true
|
||||
major_version:
|
||||
description: 'Sonarr major version'
|
||||
description: "Sonarr major version"
|
||||
required: true
|
||||
version:
|
||||
description: 'Sonarr version'
|
||||
description: "Sonarr version"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
path: _output
|
||||
|
||||
|
||||
- name: Download UI Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -49,7 +49,7 @@ runs:
|
||||
run: $GITHUB_ACTION_PATH/package.sh
|
||||
|
||||
- name: Create Windows Installer (x64)
|
||||
if: ${{ inputs.platform == 'windows' }}
|
||||
if: ${{ inputs.runtime == 'win-x64' }}
|
||||
working-directory: distribution/windows/setup
|
||||
shell: cmd
|
||||
run: |
|
||||
@@ -58,7 +58,7 @@ runs:
|
||||
build.bat
|
||||
|
||||
- name: Create Windows Installer (x86)
|
||||
if: ${{ inputs.platform == 'windows' }}
|
||||
if: ${{ inputs.runtime == 'win-x86' }}
|
||||
working-directory: distribution/windows/setup
|
||||
shell: cmd
|
||||
run: |
|
||||
@@ -69,7 +69,7 @@ runs:
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release_${{ inputs.platform }}
|
||||
name: release-${{ inputs.runtime }}
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
|
||||
2
.github/actions/package/package.sh
vendored
2
.github/actions/package/package.sh
vendored
@@ -3,7 +3,7 @@
|
||||
outputFolder=_output
|
||||
artifactsFolder=_artifacts
|
||||
uiFolder="$outputFolder/UI"
|
||||
framework="${FRAMEWORK:=net6.0}"
|
||||
framework="${FRAMEWORK:=net10.0}"
|
||||
|
||||
rm -rf $artifactsFolder
|
||||
mkdir $artifactsFolder
|
||||
|
||||
14
.github/actions/test/action.yml
vendored
14
.github/actions/test/action.yml
vendored
@@ -4,6 +4,8 @@ description: Runs unit/integration tests
|
||||
inputs:
|
||||
use_postgres:
|
||||
description: 'Whether postgres should be used for the database'
|
||||
postgres-version:
|
||||
description: 'Which postgres version should be used for the database'
|
||||
os:
|
||||
description: 'OS that the tests are running on'
|
||||
required: true
|
||||
@@ -27,16 +29,18 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Setup Postgres
|
||||
if: ${{ inputs.use_postgres }}
|
||||
uses: ikalnytskyi/action-setup-postgres@v4
|
||||
uses: ikalnytskyi/action-setup-postgres@v8
|
||||
with:
|
||||
postgres-version: ${{ inputs.postgres-version }}
|
||||
|
||||
- name: Setup Test Variables
|
||||
shell: bash
|
||||
run: |
|
||||
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}" >> "$GITHUB_ENV"
|
||||
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}${{ inputs.use_postgres && inputs.postgres-version && inputs.postgres-version }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Postgres Environment Variables
|
||||
if: ${{ inputs.use_postgres }}
|
||||
@@ -48,14 +52,14 @@ runs:
|
||||
echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
path: _tests
|
||||
|
||||
- name: Download Binary Artifact
|
||||
if: ${{ inputs.integration_tests }}
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: ${{ inputs.binary_artifact }}
|
||||
path: _output
|
||||
|
||||
23
.github/workflows/api_docs.yml
vendored
23
.github/workflows/api_docs.yml
vendored
@@ -1,12 +1,12 @@
|
||||
name: 'API Docs'
|
||||
name: "API Docs"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
- cron: "0 0 * * 1"
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- v5-develop
|
||||
paths:
|
||||
- ".github/workflows/api_docs.yml"
|
||||
- "docs.sh"
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
id: setup-dotnet
|
||||
|
||||
- name: Create openapi.json
|
||||
run: ./docs.sh Linux
|
||||
run: ./scripts/docs.sh Linux x64
|
||||
|
||||
- name: Commit API Docs Change
|
||||
continue-on-error: true
|
||||
@@ -46,7 +46,20 @@ jobs:
|
||||
then
|
||||
git commit -am 'Automated API Docs update' -m "ignore-downstream"
|
||||
git push -f --set-upstream origin api-docs
|
||||
curl -X POST -H "Authorization: Bearer ${{ secrets.OPENAPI_PAT }}" -H "Accept: application/vnd.github+json" https://api.github.com/repos/sonarr/sonarr/pulls -d '{"head":"api-docs","base":"develop","title":"Update API docs"}'
|
||||
curl -X POST -H "Authorization: Bearer ${{ secrets.OPENAPI_PAT }}" -H "Accept: application/vnd.github+json" https://api.github.com/repos/sonarr/sonarr/pulls -d '{"head":"api-docs","base":"v5-develop","title":"Update API docs"}'
|
||||
else
|
||||
echo "No changes since last run"
|
||||
fi
|
||||
|
||||
- name: Notify
|
||||
if: failure()
|
||||
uses: tsickert/discord-webhook@v6.0.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
username: "GitHub Actions"
|
||||
avatar-url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
|
||||
embed-title: "${{ github.workflow }}: Failure"
|
||||
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
embed-description: |
|
||||
Failed to update API docs
|
||||
embed-color: "15158332"
|
||||
|
||||
@@ -3,13 +3,13 @@ name: Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
- v5-develop
|
||||
- v5-main
|
||||
paths-ignore:
|
||||
- "src/Sonarr.Api.*/openapi.json"
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- v5-develop
|
||||
paths-ignore:
|
||||
- "src/NzbDrone.Core/Localization/Core/**"
|
||||
- "src/Sonarr.Api.*/openapi.json"
|
||||
@@ -19,97 +19,85 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FRAMEWORK: net6.0
|
||||
FRAMEWORK: net10.0
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.16
|
||||
SONARR_MAJOR_VERSION: 5
|
||||
VERSION: 5.0.0
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: windows-latest
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
framework: ${{ steps.variables.outputs.framework }}
|
||||
major_version: ${{ steps.variables.outputs.major_version }}
|
||||
version: ${{ steps.variables.outputs.version }}
|
||||
branch: ${{ steps.variables.outputs.branch }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Setup Environment Variables
|
||||
id: variables
|
||||
shell: bash
|
||||
run: |
|
||||
# Add 800 to the build number because GitHub won't let us pick an arbitrary starting point
|
||||
SONARR_VERSION="${{ env.VERSION }}.$((${{ github.run_number }}+800))"
|
||||
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
|
||||
|
||||
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "SONARR_VERSION=$SONARR_VERSION" >> "$GITHUB_ENV"
|
||||
echo "BRANCH=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
|
||||
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${{ env.VERSION }}.$((${{ github.run_number }}))" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Enable Extra Platforms In SDK
|
||||
shell: bash
|
||||
run: ./build.sh --enable-extra-platforms-in-sdk
|
||||
backend:
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: freebsd-x64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-arm
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-arm64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-musl-arm64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-musl-x64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-x64
|
||||
package_tests: true
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-arm64
|
||||
package_tests: true
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-x64
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
- runtime: win-x64
|
||||
package_tests: true
|
||||
os: ubuntu-latest
|
||||
- runtime: win-x86
|
||||
package_tests: false
|
||||
os: ubuntu-latest
|
||||
|
||||
- name: Build Backend
|
||||
shell: bash
|
||||
run: ./build.sh --backend --enable-extra-platforms --packages
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Test Artifacts
|
||||
|
||||
- name: Publish win-x64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
- name: Build
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: win-x64
|
||||
|
||||
- name: Publish linux-x64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: linux-x64
|
||||
|
||||
- name: Publish osx-arm64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: osx-arm64
|
||||
|
||||
# Build Artifacts (grouped by OS)
|
||||
|
||||
- name: Publish FreeBSD Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_freebsd
|
||||
path: _artifacts/freebsd-*/**/*
|
||||
- name: Publish Linux Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_linux
|
||||
path: _artifacts/linux-*/**/*
|
||||
- name: Publish macOS Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_macos
|
||||
path: _artifacts/osx-*/**/*
|
||||
- name: Publish Windows Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_windows
|
||||
path: _artifacts/win-*/**/*
|
||||
branch: ${{ needs.prepare.outputs.branch }}
|
||||
version: ${{ needs.prepare.outputs.version }}
|
||||
framework: ${{ needs.prepare.outputs.framework }}
|
||||
runtime: ${{ matrix.runtime }}
|
||||
package_tests: ${{ matrix.package_tests }}
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Volta
|
||||
uses: volta-cli/action@v4
|
||||
@@ -151,7 +139,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
@@ -164,9 +152,13 @@ jobs:
|
||||
unit_test_postgres:
|
||||
needs: backend
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
postgres-version: [16, 17, 18]
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
@@ -176,9 +168,10 @@ jobs:
|
||||
pattern: Sonarr.*.Test.dll
|
||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
|
||||
use_postgres: true
|
||||
postgres-version: ${{ matrix.postgres-version }}
|
||||
|
||||
integration_test:
|
||||
needs: backend
|
||||
needs: [prepare, backend]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -187,22 +180,22 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
artifact: tests-linux-x64
|
||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
|
||||
binary_artifact: build_linux
|
||||
binary_path: linux-x64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||
binary_artifact: build-linux-x64
|
||||
binary_path: linux-x64/${{ needs.prepare.outputs.framework }}/Sonarr
|
||||
- os: macos-latest
|
||||
artifact: tests-osx-arm64
|
||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
|
||||
binary_artifact: build_macos
|
||||
binary_path: osx-arm64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||
binary_artifact: build-osx-arm64
|
||||
binary_path: osx-arm64/${{ needs.prepare.outputs.framework }}/Sonarr
|
||||
- os: windows-latest
|
||||
artifact: tests-win-x64
|
||||
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest
|
||||
binary_artifact: build_windows
|
||||
binary_path: win-x64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||
binary_artifact: build-win-x64
|
||||
binary_path: win-x64/${{ needs.prepare.outputs.framework }}/Sonarr
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
@@ -216,20 +209,29 @@ jobs:
|
||||
binary_path: ${{ matrix.binary_path }}
|
||||
|
||||
deploy:
|
||||
if: ${{ github.ref_name == 'develop' || github.ref_name == 'main' }}
|
||||
needs: [backend, frontend, unit_test, unit_test_postgres, integration_test]
|
||||
if: ${{ github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final' }}
|
||||
needs:
|
||||
[
|
||||
prepare,
|
||||
backend,
|
||||
frontend,
|
||||
unit_test,
|
||||
unit_test_postgres,
|
||||
integration_test,
|
||||
]
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/deploy.yml
|
||||
with:
|
||||
framework: ${{ needs.backend.outputs.framework }}
|
||||
framework: ${{ needs.prepare.outputs.framework }}
|
||||
branch: ${{ github.ref_name }}
|
||||
major_version: ${{ needs.backend.outputs.major_version }}
|
||||
version: ${{ needs.backend.outputs.version }}
|
||||
major_version: ${{ needs.prepare.outputs.major_version }}
|
||||
version: ${{ needs.prepare.outputs.version }}
|
||||
|
||||
notify:
|
||||
name: Discord Notification
|
||||
needs:
|
||||
[
|
||||
prepare,
|
||||
backend,
|
||||
frontend,
|
||||
unit_test,
|
||||
@@ -237,7 +239,7 @@ jobs:
|
||||
integration_test,
|
||||
deploy,
|
||||
]
|
||||
if: ${{ !cancelled() && (github.ref_name == 'develop' || github.ref_name == 'main') }}
|
||||
if: ${{ !cancelled() && (github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final') }}
|
||||
env:
|
||||
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -253,5 +255,5 @@ jobs:
|
||||
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
embed-description: |
|
||||
**Branch** ${{ github.ref }}
|
||||
**Build** ${{ needs.backend.outputs.version }}
|
||||
**Build** ${{ needs.prepare.outputs.version }}
|
||||
embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }}
|
||||
26
.github/workflows/close_invalid_issues.yml
vendored
Normal file
26
.github/workflows/close_invalid_issues.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Close issues without labels
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
close-issue:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: Close issue if no labels found
|
||||
if: join(github.event.issue.labels) == ''
|
||||
run: |
|
||||
gh issue comment ${{ github.event.issue.number }} --body ":wave: @${{ github.event.issue.user.login }}, this issue was closed automatically because it was created without following an issue template. Please update the issue following the correct template for this issue. Once updated please reply to this issue so we can review and re-open. In the future, use the [issue templates](https://github.com/${{ github.repository }}/issues/new/choose) instead of creating your own."
|
||||
gh issue close ${{ github.event.issue.number }} --reason "not planned"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
6
.github/workflows/conflict_labeler.yml
vendored
6
.github/workflows/conflict_labeler.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- develop
|
||||
- v5-develop
|
||||
types: [synchronize]
|
||||
|
||||
jobs:
|
||||
@@ -21,6 +22,5 @@ jobs:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@v3
|
||||
with:
|
||||
dirtyLabel: 'merge-conflict'
|
||||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||
|
||||
dirtyLabel: "merge-conflict"
|
||||
repoToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
241
.github/workflows/deploy.yml
vendored
241
.github/workflows/deploy.yml
vendored
@@ -3,20 +3,20 @@ name: Deploy
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
framework:
|
||||
description: '.net framework'
|
||||
framework:
|
||||
description: ".net framework"
|
||||
type: string
|
||||
required: true
|
||||
branch:
|
||||
description: 'Git branch used for this build'
|
||||
description: "Git branch used for this build"
|
||||
type: string
|
||||
required: true
|
||||
major_version:
|
||||
description: 'Sonarr major version'
|
||||
description: "Sonarr major version"
|
||||
type: string
|
||||
required: true
|
||||
version:
|
||||
description: 'Sonarr version'
|
||||
description: "Sonarr version"
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
@@ -27,31 +27,42 @@ jobs:
|
||||
package:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [freebsd, linux, macos, windows]
|
||||
include:
|
||||
- platform: freebsd
|
||||
- runtime: freebsd-x64
|
||||
os: ubuntu-latest
|
||||
- platform: linux
|
||||
- runtime: linux-arm
|
||||
os: ubuntu-latest
|
||||
- platform: macos
|
||||
- runtime: linux-arm64
|
||||
os: ubuntu-latest
|
||||
- platform: windows
|
||||
- runtime: linux-musl-arm64
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-musl-x64
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-x64
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-arm64
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-x64
|
||||
os: ubuntu-latest
|
||||
- runtime: win-x64
|
||||
os: windows-latest
|
||||
- runtime: win-x86
|
||||
os: windows-latest
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Package
|
||||
uses: ./.github/actions/package
|
||||
with:
|
||||
framework: ${{ inputs.framework }}
|
||||
platform: ${{ matrix.platform }}
|
||||
artifact: build_${{ matrix.platform }}
|
||||
branch: ${{ inputs.branch }}
|
||||
major_version: ${{ inputs.major_version }}
|
||||
version: ${{ inputs.version }}
|
||||
- name: Package
|
||||
uses: ./.github/actions/package
|
||||
with:
|
||||
framework: ${{ inputs.framework }}
|
||||
runtime: ${{ matrix.runtime }}
|
||||
artifact: build-${{ matrix.runtime }}
|
||||
branch: ${{ inputs.branch }}
|
||||
major_version: ${{ inputs.major_version }}
|
||||
version: ${{ inputs.version }}
|
||||
|
||||
release:
|
||||
needs: package
|
||||
@@ -59,102 +70,102 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: _artifacts
|
||||
pattern: release_*
|
||||
merge-multiple: true
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: _artifacts
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Get Previous Release
|
||||
id: previous-release
|
||||
uses: cardinalby/git-get-release-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
latest: true
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
- name: Get Previous Release
|
||||
id: previous-release
|
||||
uses: cardinalby/git-get-release-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
latest: true
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
|
||||
- name: Generate Release Notes
|
||||
id: generate-release-notes
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: 'v${{ inputs.version }}',
|
||||
target_commitish: '${{ github.sha }}',
|
||||
previous_tag_name: '${{ steps.previous-release.outputs.tag_name }}',
|
||||
})
|
||||
return data.body
|
||||
- name: Generate Release Notes
|
||||
id: generate-release-notes
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: 'v${{ inputs.version }}',
|
||||
target_commitish: '${{ github.sha }}',
|
||||
previous_tag_name: '${{ steps.previous-release.outputs.tag_name }}',
|
||||
})
|
||||
return data.body
|
||||
|
||||
- name: Create release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: _artifacts/Sonarr.*
|
||||
commit: ${{ github.sha }}
|
||||
generateReleaseNotes: false
|
||||
body: ${{ steps.generate-release-notes.outputs.result }}
|
||||
name: ${{ inputs.version }}
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
skipIfReleaseExists: true
|
||||
tag: v${{ inputs.version }}
|
||||
- name: Create release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: _artifacts/Sonarr.*
|
||||
commit: ${{ github.sha }}
|
||||
generateReleaseNotes: false
|
||||
body: ${{ steps.generate-release-notes.outputs.result }}
|
||||
name: ${{ inputs.version }}
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
skipIfReleaseExists: true
|
||||
tag: v${{ inputs.version }}
|
||||
|
||||
- name: Publish to Services
|
||||
shell: bash
|
||||
working-directory: _artifacts
|
||||
run: |
|
||||
branch=${{ inputs.branch }}
|
||||
version=${{ inputs.version }}
|
||||
lastCommit=${{ github.sha }}
|
||||
|
||||
hashes="["
|
||||
|
||||
addHash() {
|
||||
path=$1
|
||||
os=$2
|
||||
arch=$3
|
||||
type=$4
|
||||
|
||||
local hash=$(sha256sum *.$version.$path | awk '{ print $1; }')
|
||||
echo "{ \""Os\"": \""$os\"", \""Arch\"": \""$arch\"", \""Type\"": \""$type\"", \""Hash\"": \""$hash\"" }"
|
||||
}
|
||||
|
||||
hashes="$hashes $(addHash "linux-arm.tar.gz" "linux" "arm" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-arm64.tar.gz" "linux" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-x64.tar.gz" "linux" "x64" "archive")"
|
||||
# hashes="$hashes, $(addHash "linux-x86.tar.gz" "linux" "x86" "archive")"
|
||||
|
||||
# hashes="$hashes, $(addHash "linux-musl-arm.tar.gz" "linuxmusl" "arm" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-musl-arm64.tar.gz" "linuxmusl" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-musl-x64.tar.gz" "linuxmusl" "x64" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "osx-arm64.tar.gz" "osx" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "osx-x64.tar.gz" "osx" "x64" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "osx-arm64-app.zip" "osx" "arm64" "installer")"
|
||||
hashes="$hashes, $(addHash "osx-x64-app.zip" "osx" "x64" "installer")"
|
||||
|
||||
hashes="$hashes, $(addHash "win-x64.zip" "windows" "x64" "archive")"
|
||||
hashes="$hashes, $(addHash "win-x86.zip" "windows" "x86" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "win-x64-installer.exe" "windows" "x64" "installer")"
|
||||
hashes="$hashes, $(addHash "win-x86-installer.exe" "windows" "x86" "installer")"
|
||||
|
||||
hashes="$hashes, $(addHash "freebsd-x64.tar.gz" "freebsd" "x64" "archive")"
|
||||
|
||||
hashes="$hashes ]"
|
||||
|
||||
json="{\""branch\"":\""$branch\"", \""version\"":\""$version\"", \""lastCommit\"":\""$lastCommit\"", \""hashes\"":$hashes, \""gitHubRelease\"":true}"
|
||||
url="https://services.sonarr.tv/v1/update"
|
||||
|
||||
echo "Publishing update $version ($branch) to: $url"
|
||||
echo "$json"
|
||||
|
||||
curl -H "Content-Type: application/json" -H "X-Api-Key: ${{ secrets.SERVICES_API_KEY }}" -X POST -d "$json" --fail-with-body $url
|
||||
- name: Publish to Services
|
||||
shell: bash
|
||||
working-directory: _artifacts
|
||||
run: |
|
||||
branch=${{ inputs.branch }}
|
||||
version=${{ inputs.version }}
|
||||
lastCommit=${{ github.sha }}
|
||||
|
||||
hashes="["
|
||||
|
||||
addHash() {
|
||||
path=$1
|
||||
os=$2
|
||||
arch=$3
|
||||
type=$4
|
||||
|
||||
local hash=$(sha256sum *.$version.$path | awk '{ print $1; }')
|
||||
echo "{ \""Os\"": \""$os\"", \""Arch\"": \""$arch\"", \""Type\"": \""$type\"", \""Hash\"": \""$hash\"" }"
|
||||
}
|
||||
|
||||
hashes="$hashes $(addHash "linux-arm.tar.gz" "linux" "arm" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-arm64.tar.gz" "linux" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-x64.tar.gz" "linux" "x64" "archive")"
|
||||
# hashes="$hashes, $(addHash "linux-x86.tar.gz" "linux" "x86" "archive")"
|
||||
|
||||
# hashes="$hashes, $(addHash "linux-musl-arm.tar.gz" "linuxmusl" "arm" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-musl-arm64.tar.gz" "linuxmusl" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "linux-musl-x64.tar.gz" "linuxmusl" "x64" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "osx-arm64.tar.gz" "osx" "arm64" "archive")"
|
||||
hashes="$hashes, $(addHash "osx-x64.tar.gz" "osx" "x64" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "osx-arm64-app.zip" "osx" "arm64" "installer")"
|
||||
hashes="$hashes, $(addHash "osx-x64-app.zip" "osx" "x64" "installer")"
|
||||
|
||||
hashes="$hashes, $(addHash "win-x64.zip" "windows" "x64" "archive")"
|
||||
hashes="$hashes, $(addHash "win-x86.zip" "windows" "x86" "archive")"
|
||||
|
||||
hashes="$hashes, $(addHash "win-x64-installer.exe" "windows" "x64" "installer")"
|
||||
hashes="$hashes, $(addHash "win-x86-installer.exe" "windows" "x86" "installer")"
|
||||
|
||||
hashes="$hashes, $(addHash "freebsd-x64.tar.gz" "freebsd" "x64" "archive")"
|
||||
|
||||
hashes="$hashes ]"
|
||||
|
||||
json="{\""branch\"":\""$branch\"", \""version\"":\""$version\"", \""lastCommit\"":\""$lastCommit\"", \""hashes\"":$hashes, \""gitHubRelease\"":true}"
|
||||
url="https://services.sonarr.tv/v1/update"
|
||||
|
||||
echo "Publishing update $version ($branch) to: $url"
|
||||
echo "$json"
|
||||
|
||||
curl -H "Content-Type: application/json" -H "X-Api-Key: ${{ secrets.SERVICES_API_KEY }}" -X POST -d "$json" --fail-with-body $url
|
||||
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -10,7 +10,7 @@
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build dotnet",
|
||||
// If you have changed target frameworks, make sure to update the program path.
|
||||
"program": "${workspaceFolder}/_output/net6.0/Sonarr",
|
||||
"program": "${workspaceFolder}/_output/net10.0/Sonarr",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
# How to Contribute #
|
||||
# How to Contribute
|
||||
|
||||
We're always looking for people to help make Sonarr even better, there are a number of ways to contribute.
|
||||
|
||||
## Documentation ##
|
||||
## Documentation
|
||||
|
||||
Setup guides, [FAQ](https://wiki.servarr.com/sonarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/sonarr) the better.
|
||||
|
||||
## Development ##
|
||||
## Development
|
||||
|
||||
### Tools required ###
|
||||
- Visual Studio 2019 or higher (https://www.visualstudio.com/vs/). The community version is free and works (https://www.visualstudio.com/downloads/).
|
||||
### Tools required
|
||||
|
||||
- Visual Studio 2019 or higher (https://www.visualstudio.com/vs/). The community version is free and works (https://www.visualstudio.com/downloads/).
|
||||
- HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 10.X.X or higher)
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
|
||||
### Getting started ###
|
||||
### Getting started
|
||||
|
||||
1. Fork Sonarr
|
||||
2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
|
||||
2. Clone the repository into your development machine. [_info_](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
|
||||
3. Install the required Node Packages `yarn install`
|
||||
4. Start webpack to monitor your dev environment for any frontend changes that need post processing using `yarn start` command.
|
||||
5. Build the project in Visual Studio, Setting startup project to `Sonarr.Console` and framework to `x86`
|
||||
6. Debug the project in Visual Studio
|
||||
7. Open http://localhost:8989
|
||||
|
||||
### Contributing Code ###
|
||||
### Contributing Code
|
||||
|
||||
- If you're adding a new, already requested feature, please comment on [Github Issues](https://github.com/Sonarr/Sonarr/issues "Github Issues") so work is not duplicated (If you want to add something not already on there, please talk to us first)
|
||||
- Rebase from Sonarr's `develop` branch, don't merge
|
||||
- Rebase from Sonarr's `v5-develop` branch, don't merge
|
||||
- Make meaningful commits, or squash them
|
||||
- Feel free to make a pull request before work is complete, this will let us see where its at and make comments/suggest improvements
|
||||
- Reach out to us on our [forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/), [discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr) if you have any questions
|
||||
@@ -35,8 +38,9 @@ Setup guides, [FAQ](https://wiki.servarr.com/sonarr/faq), the more information w
|
||||
- One feature/bug fix per pull request to keep things clean and easy to understand
|
||||
- Use 4 spaces instead of tabs, this should be the default for VS 2019 and WebStorm
|
||||
|
||||
### Pull Requesting ###
|
||||
- Only make pull requests to develop (currently `develop`), never `main`, if you make a PR to master we'll comment on it and close it
|
||||
### Pull Requesting
|
||||
|
||||
- Only make pull requests to the default branch (currently `v5-develop`), never `main`, if you make a PR to main we'll comment on it and close it
|
||||
- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability
|
||||
- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it
|
||||
- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed)
|
||||
|
||||
@@ -12,7 +12,7 @@ Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS fee
|
||||
- [Download/Installation](https://sonarr.tv/#downloads-v3)
|
||||
- [FAQ](https://wiki.servarr.com/sonarr/faq)
|
||||
- [Wiki](https://wiki.servarr.com/Sonarr)
|
||||
- [v4 Beta API Documentation](https://sonarr.tv/docs/api)
|
||||
- [API Documentation](https://sonarr.tv/docs/api)
|
||||
- [Donate](https://sonarr.tv/donate)
|
||||
|
||||
## Support
|
||||
@@ -82,4 +82,4 @@ Thank you to [<img src="https://resources.jetbrains.com/storage/products/company
|
||||
### Licenses
|
||||
|
||||
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
|
||||
- Copyright 2010-2024
|
||||
- Copyright 2010-2025
|
||||
|
||||
455
build.sh
455
build.sh
@@ -1,455 +0,0 @@
|
||||
#! /usr/bin/env bash
|
||||
set -e
|
||||
|
||||
outputFolder='_output'
|
||||
testPackageFolder='_tests'
|
||||
artifactsFolder="_artifacts";
|
||||
framework="${FRAMEWORK:=net6.0}"
|
||||
|
||||
ProgressStart()
|
||||
{
|
||||
echo "::group::$1"
|
||||
echo "Start '$1'"
|
||||
}
|
||||
|
||||
ProgressEnd()
|
||||
{
|
||||
echo "Finish '$1'"
|
||||
echo "::endgroup::"
|
||||
}
|
||||
|
||||
UpdateVersionNumber()
|
||||
{
|
||||
if [ "$SONARR_VERSION" != "" ]; then
|
||||
echo "Updating version info to: $SONARR_VERSION"
|
||||
sed -i'' -e "s/<AssemblyVersion>[0-9.*]\+<\/AssemblyVersion>/<AssemblyVersion>$SONARR_VERSION<\/AssemblyVersion>/g" src/Directory.Build.props
|
||||
sed -i'' -e "s/<AssemblyConfiguration>[\$()A-Za-z-]\+<\/AssemblyConfiguration>/<AssemblyConfiguration>${BRANCH}<\/AssemblyConfiguration>/g" src/Directory.Build.props
|
||||
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$SONARR_VERSION<\/string>/g" distribution/macOS/Sonarr.app/Contents/Info.plist
|
||||
fi
|
||||
}
|
||||
|
||||
EnableExtraPlatformsInSDK()
|
||||
{
|
||||
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
|
||||
if grep -q freebsd-x64 "$BUNDLEDVERSIONS"; then
|
||||
echo "Extra platforms already enabled"
|
||||
else
|
||||
echo "Enabling extra platform support"
|
||||
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
|
||||
fi
|
||||
}
|
||||
|
||||
EnableExtraPlatforms()
|
||||
{
|
||||
if grep -qv freebsd-x64 src/Directory.Build.props; then
|
||||
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
|
||||
fi
|
||||
}
|
||||
|
||||
LintUI()
|
||||
{
|
||||
ProgressStart 'ESLint'
|
||||
yarn lint
|
||||
ProgressEnd 'ESLint'
|
||||
|
||||
ProgressStart 'Stylelint'
|
||||
yarn stylelint
|
||||
ProgressEnd 'Stylelint'
|
||||
}
|
||||
|
||||
Build()
|
||||
{
|
||||
ProgressStart 'Build'
|
||||
|
||||
rm -rf $outputFolder
|
||||
rm -rf $testPackageFolder
|
||||
|
||||
slnFile=src/Sonarr.sln
|
||||
|
||||
if [ $os = "windows" ]; then
|
||||
platform=Windows
|
||||
else
|
||||
platform=Posix
|
||||
fi
|
||||
|
||||
dotnet clean $slnFile -c Debug
|
||||
dotnet clean $slnFile -c Release
|
||||
|
||||
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||
then
|
||||
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -t:PublishAllRids
|
||||
else
|
||||
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$RID -t:PublishAllRids
|
||||
fi
|
||||
|
||||
ProgressEnd 'Build'
|
||||
}
|
||||
|
||||
YarnInstall()
|
||||
{
|
||||
ProgressStart 'yarn install'
|
||||
yarn install --frozen-lockfile --network-timeout 120000
|
||||
ProgressEnd 'yarn install'
|
||||
}
|
||||
|
||||
RunWebpack()
|
||||
{
|
||||
ProgressStart 'Running webpack'
|
||||
yarn run build --env production
|
||||
ProgressEnd 'Running webpack'
|
||||
}
|
||||
|
||||
PackageFiles()
|
||||
{
|
||||
local folder="$1"
|
||||
local framework="$2"
|
||||
local runtime="$3"
|
||||
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r $outputFolder/$framework/$runtime/publish/* $folder
|
||||
cp -r $outputFolder/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
||||
|
||||
if [ "$FRONTEND" = "YES" ];
|
||||
then
|
||||
cp -r $outputFolder/UI $folder
|
||||
fi
|
||||
|
||||
echo "Adding LICENSE"
|
||||
cp LICENSE.md $folder
|
||||
}
|
||||
|
||||
PackageLinux()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating $runtime Package for $framework"
|
||||
|
||||
local folder=$artifactsFolder/$runtime/$framework/Sonarr
|
||||
|
||||
PackageFiles "$folder" "$framework" "$runtime"
|
||||
|
||||
echo "Removing Service helpers"
|
||||
rm -f $folder/ServiceUninstall.*
|
||||
rm -f $folder/ServiceInstall.*
|
||||
|
||||
echo "Removing Sonarr.Windows"
|
||||
rm $folder/Sonarr.Windows.*
|
||||
|
||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||
if [ "$framework" = "$framework" ]; then
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||
fi
|
||||
|
||||
ProgressEnd "Creating $runtime Package for $framework"
|
||||
}
|
||||
|
||||
PackageMacOS()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating $runtime Package for $framework"
|
||||
|
||||
local folder=$artifactsFolder/$runtime/$framework/Sonarr
|
||||
|
||||
PackageFiles "$folder" "$framework" "$runtime"
|
||||
|
||||
echo "Removing Service helpers"
|
||||
rm -f $folder/ServiceUninstall.*
|
||||
rm -f $folder/ServiceInstall.*
|
||||
|
||||
echo "Removing Sonarr.Windows"
|
||||
rm $folder/Sonarr.Windows.*
|
||||
|
||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||
if [ "$framework" = "$framework" ]; then
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||
fi
|
||||
|
||||
ProgressEnd "Creating $runtime Package for $framework"
|
||||
}
|
||||
|
||||
PackageMacOSApp()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating $runtime App Package for $framework"
|
||||
|
||||
local folder=$artifactsFolder/$runtime-app/$framework
|
||||
|
||||
rm -rf $folder
|
||||
mkdir -p $folder
|
||||
cp -r distribution/macOS/Sonarr.app $folder
|
||||
mkdir -p $folder/Sonarr.app/Contents/MacOS
|
||||
|
||||
echo "Copying Binaries"
|
||||
cp -r $artifactsFolder/$runtime/$framework/Sonarr/* $folder/Sonarr.app/Contents/MacOS
|
||||
|
||||
echo "Removing Update Folder"
|
||||
rm -r $folder/Sonarr.app/Contents/MacOS/Sonarr.Update
|
||||
|
||||
ProgressEnd "Creating $runtime App Package for $framework"
|
||||
}
|
||||
|
||||
PackageWindows()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating Windows Package for $framework"
|
||||
|
||||
local folder=$artifactsFolder/$runtime/$framework/Sonarr
|
||||
|
||||
PackageFiles "$folder" "$framework" "$runtime"
|
||||
cp -r $outputFolder/$framework-windows/$runtime/publish/* $folder
|
||||
|
||||
echo "Removing Sonarr.Mono"
|
||||
rm -f $folder/Sonarr.Mono.*
|
||||
rm -f $folder/Mono.Posix.NETStandard.*
|
||||
rm -f $folder/libMonoPosixHelper.*
|
||||
|
||||
echo "Adding Sonarr.Windows to UpdatePackage"
|
||||
cp $folder/Sonarr.Windows.* $folder/Sonarr.Update
|
||||
|
||||
ProgressEnd "Creating Windows Package for $framework"
|
||||
}
|
||||
|
||||
Package()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
local SPLIT
|
||||
|
||||
IFS='-' read -ra SPLIT <<< "$runtime"
|
||||
|
||||
case "${SPLIT[0]}" in
|
||||
linux|freebsd*)
|
||||
PackageLinux "$framework" "$runtime"
|
||||
;;
|
||||
win)
|
||||
PackageWindows "$framework" "$runtime"
|
||||
;;
|
||||
osx)
|
||||
PackageMacOS "$framework" "$runtime"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
PackageTests()
|
||||
{
|
||||
local framework="$1"
|
||||
local runtime="$2"
|
||||
|
||||
ProgressStart "Creating $runtime Test Package for $framework"
|
||||
|
||||
cp test.sh "$testPackageFolder/$framework/$runtime/publish"
|
||||
|
||||
rm -f $testPackageFolder/$framework/$runtime/*.log.config
|
||||
|
||||
ProgressEnd "Creating $runtime Test Package for $framework"
|
||||
}
|
||||
|
||||
UploadTestArtifacts()
|
||||
{
|
||||
local framework="$1"
|
||||
|
||||
ProgressStart 'Publishing Test Artifacts'
|
||||
|
||||
# Tests
|
||||
for dir in $testPackageFolder/$framework/*
|
||||
do
|
||||
local runtime=$(basename "$dir")
|
||||
echo "##teamcity[publishArtifacts '$testPackageFolder/$framework/$runtime/publish/** => tests.$runtime.zip']"
|
||||
done
|
||||
|
||||
ProgressEnd 'Publishing Test Artifacts'
|
||||
}
|
||||
|
||||
UploadArtifacts()
|
||||
{
|
||||
local framework="$1"
|
||||
|
||||
ProgressStart 'Publishing Artifacts'
|
||||
|
||||
# Releases
|
||||
for dir in $artifactsFolder/*
|
||||
do
|
||||
local runtime=$(basename "$dir")
|
||||
|
||||
echo "##teamcity[publishArtifacts '$artifactsFolder/$runtime/$framework/** => Sonarr.$BRANCH.$SONARR_VERSION.$runtime.zip']"
|
||||
done
|
||||
|
||||
# Debian Package / Windows installer / macOS app
|
||||
echo "##teamcity[publishArtifacts 'distribution/** => distribution.zip']"
|
||||
|
||||
ProgressEnd 'Publishing Artifacts'
|
||||
}
|
||||
|
||||
UploadUIArtifacts()
|
||||
{
|
||||
local framework="$1"
|
||||
|
||||
ProgressStart 'Publishing UI Artifacts'
|
||||
|
||||
# UI folder
|
||||
echo "##teamcity[publishArtifacts '$outputFolder/UI/** => UI.zip']"
|
||||
|
||||
ProgressEnd 'Publishing UI Artifacts'
|
||||
}
|
||||
|
||||
# Use mono or .net depending on OS
|
||||
case "$(uname -s)" in
|
||||
CYGWIN*|MINGW32*|MINGW64*|MSYS*)
|
||||
# on windows, use dotnet
|
||||
os="windows"
|
||||
;;
|
||||
*)
|
||||
# otherwise use mono
|
||||
os="posix"
|
||||
;;
|
||||
esac
|
||||
|
||||
POSITIONAL=()
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "No arguments provided, building everything"
|
||||
BACKEND=YES
|
||||
FRONTEND=YES
|
||||
PACKAGES=YES
|
||||
LINT=YES
|
||||
ENABLE_EXTRA_PLATFORMS=NO
|
||||
ENABLE_EXTRA_PLATFORMS_IN_SDK=NO
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]
|
||||
do
|
||||
key="$1"
|
||||
|
||||
case $key in
|
||||
--backend)
|
||||
BACKEND=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--enable-bsd|--enable-extra-platforms)
|
||||
ENABLE_EXTRA_PLATFORMS=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--enable-extra-platforms-in-sdk)
|
||||
ENABLE_EXTRA_PLATFORMS_IN_SDK=YES
|
||||
shift # past argument
|
||||
;;
|
||||
-r|--runtime)
|
||||
RID="$2"
|
||||
shift # past argument
|
||||
shift # past value
|
||||
;;
|
||||
-f|--framework)
|
||||
FRAMEWORK="$2"
|
||||
shift # past argument
|
||||
shift # past value
|
||||
;;
|
||||
--frontend)
|
||||
FRONTEND=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--packages)
|
||||
PACKAGES=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--lint)
|
||||
LINT=YES
|
||||
shift # past argument
|
||||
;;
|
||||
--all)
|
||||
BACKEND=YES
|
||||
FRONTEND=YES
|
||||
PACKAGES=YES
|
||||
LINT=YES
|
||||
shift # past argument
|
||||
;;
|
||||
*) # unknown option
|
||||
POSITIONAL+=("$1") # save it in an array for later
|
||||
shift # past argument
|
||||
;;
|
||||
esac
|
||||
done
|
||||
set -- "${POSITIONAL[@]}" # restore positional parameters
|
||||
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS_IN_SDK" = "YES" ];
|
||||
then
|
||||
EnableExtraPlatformsInSDK
|
||||
fi
|
||||
|
||||
if [ "$BACKEND" = "YES" ];
|
||||
then
|
||||
UpdateVersionNumber
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||
then
|
||||
EnableExtraPlatforms
|
||||
fi
|
||||
|
||||
Build
|
||||
|
||||
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||
then
|
||||
PackageTests "$framework" "win-x64"
|
||||
PackageTests "$framework" "win-x86"
|
||||
PackageTests "$framework" "linux-x64"
|
||||
PackageTests "$framework" "linux-musl-x64"
|
||||
PackageTests "$framework" "osx-x64"
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||
then
|
||||
PackageTests "$framework" "freebsd-x64"
|
||||
fi
|
||||
else
|
||||
PackageTests "$FRAMEWORK" "$RID"
|
||||
fi
|
||||
|
||||
UploadTestArtifacts "$framework"
|
||||
fi
|
||||
|
||||
if [ "$FRONTEND" = "YES" ];
|
||||
then
|
||||
YarnInstall
|
||||
|
||||
if [ "$LINT" = "YES" ];
|
||||
then
|
||||
LintUI
|
||||
fi
|
||||
|
||||
RunWebpack
|
||||
UploadUIArtifacts
|
||||
fi
|
||||
|
||||
if [ "$PACKAGES" = "YES" ];
|
||||
then
|
||||
UpdateVersionNumber
|
||||
|
||||
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||
then
|
||||
Package "$framework" "win-x64"
|
||||
Package "$framework" "win-x86"
|
||||
Package "$framework" "linux-x64"
|
||||
Package "$framework" "linux-musl-x64"
|
||||
Package "$framework" "linux-arm64"
|
||||
Package "$framework" "linux-musl-arm64"
|
||||
Package "$framework" "linux-arm"
|
||||
Package "$framework" "osx-x64"
|
||||
Package "$framework" "osx-arm64"
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||
then
|
||||
Package "$framework" "freebsd-x64"
|
||||
fi
|
||||
else
|
||||
Package "$FRAMEWORK" "$RID"
|
||||
fi
|
||||
|
||||
UploadArtifacts "$framework"
|
||||
fi
|
||||
113
distribution/debian/install.sh
Normal file → Executable file
113
distribution/debian/install.sh
Normal file → Executable file
@@ -6,6 +6,8 @@
|
||||
### Version V1.0.1 2024-01-02 - StevieTV - remove UTF8-BOM
|
||||
### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty
|
||||
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
|
||||
### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode
|
||||
### Version V1.0.5 2025-07-08 - bparkin1283 - use systemctl instead of service for stopping app
|
||||
|
||||
### Boilerplate Warning
|
||||
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
@@ -16,8 +18,8 @@
|
||||
#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
scriptversion="1.0.3"
|
||||
scriptdate="2024-01-06"
|
||||
scriptversion="1.0.4"
|
||||
scriptdate="2025-04-05"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -49,18 +51,106 @@ if [ "$installdir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ] || [ "$bindi
|
||||
exit
|
||||
fi
|
||||
|
||||
# Prompt User
|
||||
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
|
||||
show_help() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Options:
|
||||
--user <name> What user will $app run under?
|
||||
User will be created if it doesn't already exist.
|
||||
|
||||
--group <name> What group will $app run under?
|
||||
Group will be created if it doesn't already exist.
|
||||
|
||||
-u Unattended mode
|
||||
The installer will not prompt or pause, making it suitable for automated installations.
|
||||
This option requires the use of --user and --group to supply those inputs for the script.
|
||||
|
||||
-h, --help Show this help message and exit
|
||||
EOF
|
||||
}
|
||||
|
||||
# Default values for command-line arguments
|
||||
arg_user=""
|
||||
arg_group=""
|
||||
arg_unattended=false
|
||||
|
||||
# Parse command-line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user=*)
|
||||
arg_user="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--user)
|
||||
if [[ -n "$2" && "$2" != -* ]]; then
|
||||
arg_user="$2"
|
||||
shift 2
|
||||
else
|
||||
echo "Error: --user requires a value." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--group=*)
|
||||
arg_group="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--group)
|
||||
if [[ -n "$2" && "$2" != -* ]]; then
|
||||
arg_group="$2"
|
||||
shift 2
|
||||
else
|
||||
echo "Error: --group requires a value." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
-u)
|
||||
arg_unattended=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
echo "Use --help to see valid options." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# If unattended mode is requested, require user and group
|
||||
if $arg_unattended; then
|
||||
if [[ -z "$arg_user" || -z "$arg_group" ]]; then
|
||||
echo "Error: --user and --group are required when using -u (unattended mode)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prompt User if necessary
|
||||
if [ -n "$arg_user" ]; then
|
||||
app_uid="$arg_user"
|
||||
else
|
||||
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
|
||||
fi
|
||||
app_uid=$(echo "$app_uid" | tr -d ' ')
|
||||
app_uid=${app_uid:-$app}
|
||||
# Prompt Group
|
||||
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
|
||||
|
||||
# Prompt Group if necessary
|
||||
if [ -n "$arg_group" ]; then
|
||||
app_guid="$arg_group"
|
||||
else
|
||||
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
|
||||
fi
|
||||
app_guid=$(echo "$app_guid" | tr -d ' ')
|
||||
app_guid=${app_guid:-media}
|
||||
|
||||
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
|
||||
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
|
||||
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
|
||||
if ! $arg_unattended; then
|
||||
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
|
||||
fi
|
||||
|
||||
# Create User / Group as needed
|
||||
if [ "$app_guid" != "$app_uid" ]; then
|
||||
@@ -78,11 +168,10 @@ if ! getent group "$app_guid" | grep -qw "$app_uid"; then
|
||||
echo "Added User [$app_uid] to Group [$app_guid]"
|
||||
fi
|
||||
|
||||
# Stop the App if running
|
||||
if service --status-all | grep -Fq "$app"; then
|
||||
systemctl stop "$app"
|
||||
systemctl disable "$app".service
|
||||
echo "Stopped existing $app"
|
||||
# Stop and disable the App if running
|
||||
if [ $(systemctl is-active "$app") = "active" ]; then
|
||||
systemctl disable --now -q "$app"
|
||||
echo "Stopped and disabled existing $app"
|
||||
fi
|
||||
|
||||
# Create Appdata Directory
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@REM SET SONARR_MAJOR_VERSION=4
|
||||
@REM SET SONARR_VERSION=4.0.0.5
|
||||
@REM SET BRANCH=develop
|
||||
@REM SET FRAMEWORK=net6.0
|
||||
@REM SET FRAMEWORK=net8.0
|
||||
@REM SET RUNTIME=win-x64
|
||||
|
||||
inno\ISCC.exe sonarr.iss
|
||||
|
||||
@@ -7,9 +7,9 @@ cd /data/test
|
||||
|
||||
runTest()
|
||||
{
|
||||
bash test.sh Linux $1
|
||||
bash scripts/test.sh Linux $1
|
||||
cp TestResult.xml /data/_tests_results/TestResult_$1.xml
|
||||
}
|
||||
|
||||
runTest Integration
|
||||
runTest Unit
|
||||
runTest Unit
|
||||
|
||||
@@ -14,7 +14,6 @@ module.exports = (env) => {
|
||||
const srcFolder = path.join(frontendFolder, 'src');
|
||||
const isProduction = !!env.production;
|
||||
const isProfiling = isProduction && !!env.profile;
|
||||
const inlineWebWorkers = 'no-fallback';
|
||||
|
||||
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
||||
|
||||
@@ -66,7 +65,7 @@ module.exports = (env) => {
|
||||
|
||||
output: {
|
||||
path: distFolder,
|
||||
publicPath: '/',
|
||||
publicPath: 'auto',
|
||||
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
|
||||
sourceMapFilename: '[file].map'
|
||||
},
|
||||
@@ -160,16 +159,6 @@ module.exports = (env) => {
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.worker\.js$/,
|
||||
use: {
|
||||
loader: 'worker-loader',
|
||||
options: {
|
||||
filename: '[name].js',
|
||||
inline: inlineWebWorkers
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: [/\.jsx?$/, /\.tsx?$/],
|
||||
exclude: /(node_modules|JsLibraries)/,
|
||||
@@ -187,7 +176,7 @@ module.exports = (env) => {
|
||||
loose: true,
|
||||
debug: false,
|
||||
useBuiltIns: 'entry',
|
||||
corejs: '3.39'
|
||||
corejs: '3.42'
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore';
|
||||
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
|
||||
import CommandNames from 'Commands/CommandNames';
|
||||
import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
@@ -16,94 +16,77 @@ import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
clearBlocklist,
|
||||
fetchBlocklist,
|
||||
gotoBlocklistPage,
|
||||
removeBlocklistItems,
|
||||
setBlocklistFilter,
|
||||
setBlocklistSort,
|
||||
setBlocklistTableOption,
|
||||
} from 'Store/Actions/blocklistActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import BlockListModel from 'typings/Blocklist';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import {
|
||||
registerPagePopulator,
|
||||
unregisterPagePopulator,
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import BlocklistFilterModal from './BlocklistFilterModal';
|
||||
import {
|
||||
setBlocklistOption,
|
||||
setBlocklistSort,
|
||||
useBlocklistOptions,
|
||||
} from './blocklistOptionsStore';
|
||||
import BlocklistRow from './BlocklistRow';
|
||||
import useBlocklist, {
|
||||
useFilters,
|
||||
useRemoveBlocklistItems,
|
||||
} from './useBlocklist';
|
||||
|
||||
function Blocklist() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
|
||||
function BlocklistContent() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
isRemoving,
|
||||
} = useSelector((state: AppState) => state.blocklist);
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
error,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useBlocklist();
|
||||
|
||||
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
|
||||
const isClearingBlocklistExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST)
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useBlocklistOptions();
|
||||
|
||||
const filters = useFilters();
|
||||
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
|
||||
|
||||
const customFilters = useCustomFiltersList('blocklist');
|
||||
const executeCommand = useExecuteCommand();
|
||||
const isClearingBlocklistExecuting = useCommandExecuting(
|
||||
CommandNames.ClearBlocklist
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
||||
useState(false);
|
||||
const [isConfirmClearModalOpen, setIsConfirmClearModalOpen] = useState(false);
|
||||
|
||||
const [selectState, setSelectState] = useSelectState();
|
||||
const { allSelected, allUnselected, selectedState } = selectState;
|
||||
|
||||
const selectedIds = useMemo(() => {
|
||||
return getSelectedIds(selectedState);
|
||||
}, [selectedState]);
|
||||
|
||||
const wasClearingBlocklistExecuting = usePrevious(
|
||||
isClearingBlocklistExecuting
|
||||
);
|
||||
const {
|
||||
allSelected,
|
||||
allUnselected,
|
||||
anySelected,
|
||||
getSelectedIds,
|
||||
selectAll,
|
||||
unselectAll,
|
||||
} = useSelect<BlockListModel>();
|
||||
|
||||
const handleSelectAllChange = useCallback(
|
||||
({ value }: CheckInputChanged) => {
|
||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||
if (value) {
|
||||
selectAll();
|
||||
} else {
|
||||
unselectAll();
|
||||
}
|
||||
},
|
||||
[items, setSelectState]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||
setSelectState({
|
||||
type: 'toggleSelected',
|
||||
items,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[items, setSelectState]
|
||||
[selectAll, unselectAll]
|
||||
);
|
||||
|
||||
const handleRemoveSelectedPress = useCallback(() => {
|
||||
@@ -111,9 +94,9 @@ function Blocklist() {
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||
dispatch(removeBlocklistItems({ ids: selectedIds }));
|
||||
removeBlocklistItems({ ids: getSelectedIds() });
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
|
||||
}, [getSelectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
|
||||
|
||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
@@ -124,66 +107,47 @@ function Blocklist() {
|
||||
}, [setIsConfirmClearModalOpen]);
|
||||
|
||||
const handleClearBlocklistConfirmed = useCallback(() => {
|
||||
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
|
||||
executeCommand({ name: CommandNames.ClearBlocklist }, () => {
|
||||
goToPage(1);
|
||||
});
|
||||
setIsConfirmClearModalOpen(false);
|
||||
}, [setIsConfirmClearModalOpen, dispatch]);
|
||||
}, [setIsConfirmClearModalOpen, goToPage, executeCommand]);
|
||||
|
||||
const handleConfirmClearModalClose = useCallback(() => {
|
||||
setIsConfirmClearModalOpen(false);
|
||||
}, [setIsConfirmClearModalOpen]);
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoBlocklistPage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
dispatch(setBlocklistFilter({ selectedFilterKey }));
|
||||
(selectedFilterKey: string | number) => {
|
||||
setBlocklistOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setBlocklistSort({ sortKey }));
|
||||
(sortKey: string, sortDirection?: SortDirection) => {
|
||||
setBlocklistSort({
|
||||
sortKey,
|
||||
sortDirection,
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setBlocklistTableOption(payload));
|
||||
setQueueOptions(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchBlocklist());
|
||||
} else {
|
||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearBlocklist());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchBlocklist());
|
||||
refetch();
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -191,137 +155,129 @@ function Blocklist() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
|
||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
||||
}
|
||||
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<SelectProvider items={items}>
|
||||
<PageContent title={translate('Blocklist')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RemoveSelected')}
|
||||
iconName={icons.REMOVE}
|
||||
isDisabled={!selectedIds.length}
|
||||
isSpinning={isRemoving}
|
||||
onPress={handleRemoveSelectedPress}
|
||||
/>
|
||||
<PageContent title={translate('Blocklist')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RemoveSelected')}
|
||||
iconName={icons.REMOVE}
|
||||
isDisabled={!anySelected}
|
||||
isSpinning={isRemoving}
|
||||
onPress={handleRemoveSelectedPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Clear')}
|
||||
iconName={icons.CLEAR}
|
||||
isDisabled={!items.length}
|
||||
isSpinning={isClearingBlocklistExecuting}
|
||||
onPress={handleClearBlocklistPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('Clear')}
|
||||
iconName={icons.CLEAR}
|
||||
isDisabled={!records.length}
|
||||
isSpinning={isClearingBlocklistExecuting}
|
||||
onPress={handleClearBlocklistPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={BlocklistFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody>
|
||||
{isLoading && !isFetched ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isLoading && !!error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{isFetched && !error && !records.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{selectedFilterKey === 'all'
|
||||
? translate('NoBlocklistItems')
|
||||
: translate('BlocklistFilterHasNoItems')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isFetched && !error && !!records.length ? (
|
||||
<div>
|
||||
<Table
|
||||
selectAll={true}
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
onSelectAllChange={handleSelectAllChange}
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={BlocklistFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
<TableBody>
|
||||
{records.map((item) => {
|
||||
return (
|
||||
<BlocklistRow key={item.id} columns={columns} {...item} />
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePager
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onPageSelect={goToPage}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
</div>
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
|
||||
<PageContentBody>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmRemoveModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('RemoveSelected')}
|
||||
message={translate('RemoveSelectedBlocklistMessageText')}
|
||||
confirmLabel={translate('RemoveSelected')}
|
||||
onConfirm={handleRemoveSelectedConfirmed}
|
||||
onCancel={handleConfirmRemoveModalClose}
|
||||
/>
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
|
||||
) : null}
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmClearModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('ClearBlocklist')}
|
||||
message={translate('ClearBlocklistMessageText')}
|
||||
confirmLabel={translate('Clear')}
|
||||
onConfirm={handleClearBlocklistConfirmed}
|
||||
onCancel={handleConfirmClearModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
{isPopulated && !error && !items.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{selectedFilterKey === 'all'
|
||||
? translate('NoBlocklistItems')
|
||||
: translate('BlocklistFilterHasNoItems')}
|
||||
</Alert>
|
||||
) : null}
|
||||
function Blocklist() {
|
||||
const { records } = useBlocklist();
|
||||
|
||||
{isPopulated && !error && !!items.length ? (
|
||||
<div>
|
||||
<Table
|
||||
selectAll={true}
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
onSelectAllChange={handleSelectAllChange}
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<BlocklistRow
|
||||
key={item.id}
|
||||
isSelected={selectedState[item.id] || false}
|
||||
columns={columns}
|
||||
{...item}
|
||||
onSelectedChange={handleSelectedChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePager
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmRemoveModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('RemoveSelected')}
|
||||
message={translate('RemoveSelectedBlocklistMessageText')}
|
||||
confirmLabel={translate('RemoveSelected')}
|
||||
onConfirm={handleRemoveSelectedConfirmed}
|
||||
onCancel={handleConfirmRemoveModalClose}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmClearModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('ClearBlocklist')}
|
||||
message={translate('ClearBlocklistMessageText')}
|
||||
confirmLabel={translate('Clear')}
|
||||
onConfirm={handleClearBlocklistConfirmed}
|
||||
onCancel={handleConfirmClearModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
return (
|
||||
<SelectProvider<BlockListModel> items={records}>
|
||||
<BlocklistContent />
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,19 @@ interface BlocklistDetailsModalProps {
|
||||
protocol: DownloadProtocol;
|
||||
indexer?: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
|
||||
const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } =
|
||||
props;
|
||||
|
||||
function BlocklistDetailsModal({
|
||||
isOpen,
|
||||
sourceTitle,
|
||||
protocol,
|
||||
indexer,
|
||||
message,
|
||||
source,
|
||||
onModalClose,
|
||||
}: BlocklistDetailsModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
@@ -50,6 +56,9 @@ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
|
||||
data={message}
|
||||
/>
|
||||
) : null}
|
||||
{source ? (
|
||||
<DescriptionListItem title={translate('Source')} data={source} />
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
</ModalBody>
|
||||
|
||||
|
||||
@@ -1,53 +1,26 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setBlocklistOption } from './blocklistOptionsStore';
|
||||
import useBlocklist, { FILTER_BUILDER } from './useBlocklist';
|
||||
|
||||
function createBlocklistSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.blocklist.items,
|
||||
(blocklistItems) => {
|
||||
return blocklistItems;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.blocklist.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface BlocklistFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
type BlocklistFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
||||
const sectionItems = useSelector(createBlocklistSelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'blocklist';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { records } = useBlocklist();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: unknown) => {
|
||||
dispatch(setBlocklistFilter(payload));
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
setBlocklistOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
sectionItems={records}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
customFilterType="blocklist"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useSelect } from 'App/Select/SelectContext';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
@@ -11,40 +11,44 @@ import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { removeBlocklistItem } from 'Store/Actions/blocklistActions';
|
||||
import { useSingleSeries } from 'Series/useSeries';
|
||||
import Blocklist from 'typings/Blocklist';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import BlocklistDetailsModal from './BlocklistDetailsModal';
|
||||
import { useRemoveBlocklistItem } from './useBlocklist';
|
||||
import styles from './BlocklistRow.css';
|
||||
|
||||
interface BlocklistRowProps extends Blocklist {
|
||||
isSelected: boolean;
|
||||
columns: Column[];
|
||||
onSelectedChange: (options: SelectStateInputProps) => void;
|
||||
}
|
||||
|
||||
function BlocklistRow(props: BlocklistRowProps) {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
sourceTitle,
|
||||
languages,
|
||||
quality,
|
||||
customFormats,
|
||||
date,
|
||||
protocol,
|
||||
indexer,
|
||||
message,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange,
|
||||
} = props;
|
||||
|
||||
const series = useSeries(seriesId);
|
||||
const dispatch = useDispatch();
|
||||
function BlocklistRow({
|
||||
id,
|
||||
seriesId,
|
||||
sourceTitle,
|
||||
languages,
|
||||
quality,
|
||||
customFormats,
|
||||
date,
|
||||
protocol,
|
||||
indexer,
|
||||
message,
|
||||
source,
|
||||
columns,
|
||||
}: BlocklistRowProps) {
|
||||
const series = useSingleSeries(seriesId);
|
||||
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
const { toggleSelected, useIsSelected } = useSelect<Blocklist>();
|
||||
const isSelected = useIsSelected(id);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||
toggleSelected({ id, isSelected: value, shiftKey });
|
||||
},
|
||||
[toggleSelected]
|
||||
);
|
||||
|
||||
const handleDetailsPress = useCallback(() => {
|
||||
setIsDetailsModalOpen(true);
|
||||
@@ -55,8 +59,8 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
}, [setIsDetailsModalOpen]);
|
||||
|
||||
const handleRemovePress = useCallback(() => {
|
||||
dispatch(removeBlocklistItem({ id }));
|
||||
}, [id, dispatch]);
|
||||
removeBlocklistItem();
|
||||
}, [removeBlocklistItem]);
|
||||
|
||||
if (!series) {
|
||||
return null;
|
||||
@@ -67,7 +71,7 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
<TableSelectCell
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
onSelectedChange={onSelectedChange}
|
||||
onSelectedChange={handleSelectedChange}
|
||||
/>
|
||||
|
||||
{columns.map((column) => {
|
||||
@@ -139,6 +143,7 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
title={translate('RemoveFromBlocklist')}
|
||||
name={icons.REMOVE}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isRemoving}
|
||||
onPress={handleRemovePress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
@@ -154,6 +159,7 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
protocol={protocol}
|
||||
indexer={indexer}
|
||||
message={message}
|
||||
source={source}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
72
frontend/src/Activity/Blocklist/blocklistOptionsStore.ts
Normal file
72
frontend/src/Activity/Blocklist/blocklistOptionsStore.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
createOptionsStore,
|
||||
PageableOptions,
|
||||
} from 'Helpers/Hooks/useOptionsStore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export type BlocklistOptions = PageableOptions;
|
||||
|
||||
const { useOptions, useOption, setOptions, setOption, setSort } =
|
||||
createOptionsStore<BlocklistOptions>('blocklist_options', () => {
|
||||
return {
|
||||
pageSize: 20,
|
||||
selectedFilterKey: 'all',
|
||||
sortKey: 'time',
|
||||
sortDirection: 'descending',
|
||||
columns: [
|
||||
{
|
||||
name: 'series.sortTitle',
|
||||
label: () => translate('SeriesTitle'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'sourceTitle',
|
||||
label: () => translate('SourceTitle'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
label: () => translate('Date'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'indexer',
|
||||
label: () => translate('Indexer'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: '',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
export const useBlocklistOptions = useOptions;
|
||||
export const setBlocklistOptions = setOptions;
|
||||
export const useBlocklistOption = useOption;
|
||||
export const setBlocklistOption = setOption;
|
||||
export const setBlocklistSort = setSort;
|
||||
113
frontend/src/Activity/Blocklist/useBlocklist.ts
Normal file
113
frontend/src/Activity/Blocklist/useBlocklist.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { Filter, FilterBuilderProp } from 'Filters/Filter';
|
||||
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import usePage from 'Helpers/Hooks/usePage';
|
||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||
import Blocklist from 'typings/Blocklist';
|
||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useBlocklistOptions } from './blocklistOptionsStore';
|
||||
|
||||
interface BulkBlocklistData {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export const FILTERS: Filter[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const FILTER_BUILDER: FilterBuilderProp<Blocklist>[] = [
|
||||
{
|
||||
name: 'seriesIds',
|
||||
label: () => translate('Series'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.SERIES,
|
||||
},
|
||||
{
|
||||
name: 'protocols',
|
||||
label: () => translate('Protocol'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.PROTOCOL,
|
||||
},
|
||||
];
|
||||
|
||||
const useBlocklist = () => {
|
||||
const { page, goToPage } = usePage('blocklist');
|
||||
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||
useBlocklistOptions();
|
||||
const customFilters = useCustomFiltersList('blocklist');
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||
}, [selectedFilterKey, customFilters]);
|
||||
|
||||
const { refetch, ...query } = usePagedApiQuery<Blocklist>({
|
||||
path: '/blocklist',
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
queryOptions: {
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useBlocklist;
|
||||
|
||||
export const useFilters = () => {
|
||||
return FILTERS;
|
||||
};
|
||||
|
||||
export const useRemoveBlocklistItem = (id: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/blocklist/${id}`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeBlocklistItem: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRemoveBlocklistItems = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, BulkBlocklistData>({
|
||||
path: `/blocklist/bulk`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeBlocklistItems: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
|
||||
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
|
||||
import Link from 'Components/Link/Link';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||
import {
|
||||
DownloadFailedHistory,
|
||||
DownloadFolderImportedHistory,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
} from 'typings/History';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import formatAge from 'Utilities/Number/formatAge';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './HistoryDetails.css';
|
||||
@@ -32,9 +32,7 @@ interface HistoryDetailsProps {
|
||||
function HistoryDetails(props: HistoryDetailsProps) {
|
||||
const { eventType, sourceTitle, data, downloadId } = props;
|
||||
|
||||
const { shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
const { shortDateFormat, timeFormat } = useUiSettingsValues();
|
||||
|
||||
if (eventType === 'grabbed') {
|
||||
const {
|
||||
@@ -50,6 +48,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
ageHours,
|
||||
ageMinutes,
|
||||
publishedDate,
|
||||
size,
|
||||
} = data as GrabbedHistoryData;
|
||||
|
||||
const downloadClientNameInfo = downloadClientName ?? downloadClient;
|
||||
@@ -160,12 +159,19 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('Size')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFailed') {
|
||||
const { message } = data as DownloadFailedHistory;
|
||||
const { indexer, message, source } = data as DownloadFailedHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
@@ -179,15 +185,23 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
|
||||
) : null}
|
||||
|
||||
{indexer ? (
|
||||
<DescriptionListItem title={translate('Indexer')} data={indexer} />
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<DescriptionListItem title={translate('Message')} data={message} />
|
||||
) : null}
|
||||
|
||||
{source ? (
|
||||
<DescriptionListItem title={translate('Source')} data={source} />
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFolderImported') {
|
||||
const { customFormatScore, droppedPath, importedPath } =
|
||||
const { customFormatScore, droppedPath, importedPath, size } =
|
||||
data as DownloadFolderImportedHistory;
|
||||
|
||||
return (
|
||||
@@ -220,12 +234,20 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('FileSize')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'episodeFileDeleted') {
|
||||
const { reason, customFormatScore } = data as EpisodeFileDeletedHistory;
|
||||
const { reason, customFormatScore, size } =
|
||||
data as EpisodeFileDeletedHistory;
|
||||
|
||||
let reasonMessage = '';
|
||||
|
||||
@@ -255,6 +277,13 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('FileSize')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
@@ -9,6 +9,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useMarkAsFailed } from '../useHistory';
|
||||
import HistoryDetails from './HistoryDetails';
|
||||
import styles from './HistoryDetailsModal.css';
|
||||
|
||||
@@ -33,26 +34,32 @@ function getHeaderTitle(eventType: HistoryEventType) {
|
||||
|
||||
interface HistoryDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
id: number;
|
||||
eventType: HistoryEventType;
|
||||
sourceTitle: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
isMarkingAsFailed: boolean;
|
||||
onMarkAsFailedPress: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
eventType,
|
||||
sourceTitle,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed = false,
|
||||
onMarkAsFailedPress,
|
||||
onModalClose,
|
||||
} = props;
|
||||
const { isOpen, id, eventType, sourceTitle, data, downloadId, onModalClose } =
|
||||
props;
|
||||
|
||||
const { markAsFailed, isMarkingAsFailed, markAsFailedError } =
|
||||
useMarkAsFailed(id);
|
||||
|
||||
const wasMarkingAsFailed = useRef(isMarkingAsFailed);
|
||||
|
||||
const handleMarkAsFailedPress = useCallback(() => {
|
||||
markAsFailed();
|
||||
}, [markAsFailed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
|
||||
onModalClose();
|
||||
}
|
||||
}, [wasMarkingAsFailed, isMarkingAsFailed, markAsFailedError, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
@@ -74,7 +81,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
className={styles.markAsFailedButton}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isMarkingAsFailed}
|
||||
onPress={onMarkAsFailedPress}
|
||||
onPress={handleMarkAsFailedPress}
|
||||
>
|
||||
{translate('MarkAsFailed')}
|
||||
</SpinnerButton>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
@@ -13,21 +11,10 @@ import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import useEpisodes from 'Episode/useEpisodes';
|
||||
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
||||
import {
|
||||
clearHistory,
|
||||
fetchHistory,
|
||||
gotoHistoryPage,
|
||||
setHistoryFilter,
|
||||
setHistorySort,
|
||||
setHistoryTableOption,
|
||||
} from 'Store/Actions/historyActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import HistoryItem from 'typings/History';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
@@ -37,100 +24,86 @@ import {
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryFilterModal from './HistoryFilterModal';
|
||||
import {
|
||||
setHistoryOption,
|
||||
setHistoryOptions,
|
||||
setHistorySort,
|
||||
useHistoryOptions,
|
||||
} from './historyOptionsStore';
|
||||
import HistoryRow from './HistoryRow';
|
||||
import useHistory, { useFilters } from './useHistory';
|
||||
|
||||
function History() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
} = useSelector((state: AppState) => state.history);
|
||||
error,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useHistory();
|
||||
|
||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||
useSelector(createEpisodesFetchingSelector());
|
||||
const customFilters = useSelector(createCustomFiltersSelector('history'));
|
||||
const dispatch = useDispatch();
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useHistoryOptions();
|
||||
|
||||
const isFetchingAny = isFetching || isEpisodesFetching;
|
||||
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
|
||||
const hasError = error || episodesError;
|
||||
const episodeIds = useMemo(() => {
|
||||
return selectUniqueIds<HistoryItem, number>(records, 'episodeId');
|
||||
}, [records]);
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoHistoryPage,
|
||||
});
|
||||
isFetching: isEpisodesFetching,
|
||||
isFetched: isEpisodesFetched,
|
||||
error: episodesError,
|
||||
} = useEpisodes({ episodeIds });
|
||||
|
||||
const filters = useFilters();
|
||||
|
||||
const customFilters = useCustomFiltersList('history');
|
||||
|
||||
const isFetchingAny = isLoading || isEpisodesFetching;
|
||||
const isAllPopulated = isFetched && (isEpisodesFetched || !records.length);
|
||||
const hasError = error || episodesError;
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
dispatch(setHistoryFilter({ selectedFilterKey }));
|
||||
(selectedFilterKey: string | number) => {
|
||||
setHistoryOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setHistorySort({ sortKey }));
|
||||
(sortKey: string, sortDirection?: SortDirection) => {
|
||||
setHistorySort({
|
||||
sortKey,
|
||||
sortDirection,
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setHistoryTableOption(payload));
|
||||
setHistoryOptions(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoHistoryPage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchHistory());
|
||||
} else {
|
||||
dispatch(gotoHistoryPage({ page: 1 }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearHistory());
|
||||
dispatch(clearEpisodes());
|
||||
dispatch(clearEpisodeFiles());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeIds = selectUniqueIds<HistoryItem, number>(items, 'episodeId');
|
||||
|
||||
if (episodeIds.length) {
|
||||
dispatch(fetchEpisodes({ episodeIds }));
|
||||
} else {
|
||||
dispatch(clearEpisodes());
|
||||
}
|
||||
}, [items, dispatch]);
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
goToPage(1);
|
||||
refetch();
|
||||
}, [goToPage, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchHistory());
|
||||
refetch();
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -138,7 +111,7 @@ function History() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [dispatch]);
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('History')}>
|
||||
@@ -148,7 +121,7 @@ function History() {
|
||||
label={translate('Refresh')}
|
||||
iconName={icons.REFRESH}
|
||||
isSpinning={isFetching}
|
||||
onPress={handleFirstPagePress}
|
||||
onPress={handleRefreshPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
@@ -186,12 +159,12 @@ function History() {
|
||||
// If history isPopulated and it's empty show no history found and don't
|
||||
// wait for the episodes to populate because they are never coming.
|
||||
|
||||
isPopulated && !hasError && !items.length ? (
|
||||
isFetched && !hasError && !records.length ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
|
||||
) : null
|
||||
}
|
||||
|
||||
{isAllPopulated && !hasError && items.length ? (
|
||||
{isAllPopulated && !hasError && records.length ? (
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
@@ -202,7 +175,7 @@ function History() {
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
{records.map((item) => {
|
||||
return (
|
||||
<HistoryRow key={item.id} columns={columns} {...item} />
|
||||
);
|
||||
@@ -215,11 +188,7 @@ function History() {
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
onPageSelect={goToPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,53 +1,26 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import { setHistoryFilter } from 'Store/Actions/historyActions';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setHistoryOption } from './historyOptionsStore';
|
||||
import useHistory, { FILTER_BUILDER } from './useHistory';
|
||||
|
||||
function createHistorySelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.history.items,
|
||||
(queueItems) => {
|
||||
return queueItems;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.history.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface HistoryFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
type HistoryFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
|
||||
const sectionItems = useSelector(createHistorySelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'history';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { records } = useHistory();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: unknown) => {
|
||||
dispatch(setHistoryFilter(payload));
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
setHistoryOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
sectionItems={records}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
customFilterType="history"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
@@ -13,13 +12,11 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import useEpisode from 'Episode/useEpisode';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
|
||||
import { useSingleSeries } from 'Series/useSeries';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
@@ -61,14 +58,10 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
date,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed = false,
|
||||
markAsFailedError,
|
||||
columns,
|
||||
} = props;
|
||||
|
||||
const wasMarkingAsFailed = usePrevious(isMarkingAsFailed);
|
||||
const dispatch = useDispatch();
|
||||
const series = useSeries(seriesId);
|
||||
const series = useSingleSeries(seriesId);
|
||||
const episode = useEpisode(episodeId, 'episodes');
|
||||
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
@@ -81,23 +74,6 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
setIsDetailsModalOpen(false);
|
||||
}, [setIsDetailsModalOpen]);
|
||||
|
||||
const handleMarkAsFailedPress = useCallback(() => {
|
||||
dispatch(markAsFailed({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
|
||||
setIsDetailsModalOpen(false);
|
||||
dispatch(fetchHistory());
|
||||
}
|
||||
}, [
|
||||
wasMarkingAsFailed,
|
||||
isMarkingAsFailed,
|
||||
markAsFailedError,
|
||||
setIsDetailsModalOpen,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
if (!series || !episode) {
|
||||
return null;
|
||||
}
|
||||
@@ -254,13 +230,12 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
})}
|
||||
|
||||
<HistoryDetailsModal
|
||||
id={id}
|
||||
isOpen={isDetailsModalOpen}
|
||||
eventType={eventType}
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
isMarkingAsFailed={isMarkingAsFailed}
|
||||
onMarkAsFailedPress={handleMarkAsFailedPress}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
109
frontend/src/Activity/History/historyOptionsStore.ts
Normal file
109
frontend/src/Activity/History/historyOptionsStore.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import {
|
||||
createOptionsStore,
|
||||
PageableOptions,
|
||||
} from 'Helpers/Hooks/useOptionsStore';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export type HistoryOptions = PageableOptions;
|
||||
|
||||
const { useOptions, useOption, setOptions, setOption, setSort } =
|
||||
createOptionsStore<HistoryOptions>('history_options', () => {
|
||||
return {
|
||||
pageSize: 20,
|
||||
selectedFilterKey: 'all',
|
||||
sortKey: 'time',
|
||||
sortDirection: 'descending',
|
||||
columns: [
|
||||
{
|
||||
name: 'eventType',
|
||||
label: '',
|
||||
columnLabel: () => translate('EventType'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
{
|
||||
name: 'series.sortTitle',
|
||||
label: () => translate('Series'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episode',
|
||||
label: () => translate('Episode'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episodes.title',
|
||||
label: () => translate('EpisodeTitle'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
label: () => translate('Date'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'downloadClient',
|
||||
label: () => translate('DownloadClient'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'indexer',
|
||||
label: () => translate('Indexer'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'releaseGroup',
|
||||
label: () => translate('ReleaseGroup'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'sourceTitle',
|
||||
label: () => translate('SourceTitle'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
columnLabel: () => translate('CustomFormatScore'),
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore'),
|
||||
}),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'details',
|
||||
label: '',
|
||||
columnLabel: () => translate('Details'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
export const useHistoryOptions = useOptions;
|
||||
export const setHistoryOptions = setOptions;
|
||||
export const useHistoryOption = useOption;
|
||||
export const setHistoryOption = setOption;
|
||||
export const setHistorySort = setSort;
|
||||
20
frontend/src/Activity/History/useEpisodeHistory.ts
Normal file
20
frontend/src/Activity/History/useEpisodeHistory.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import History from 'typings/History';
|
||||
|
||||
const DEFAULT_HISTORY: History[] = [];
|
||||
|
||||
const useEpisodeHistory = (episodeId: number) => {
|
||||
const { data, ...result } = useApiQuery<History[]>({
|
||||
path: '/history/episode',
|
||||
queryParams: {
|
||||
episodeId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: data ?? DEFAULT_HISTORY,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
export default useEpisodeHistory;
|
||||
192
frontend/src/Activity/History/useHistory.ts
Normal file
192
frontend/src/Activity/History/useHistory.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Filter, FilterBuilderProp } from 'Filters/Filter';
|
||||
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import usePage from 'Helpers/Hooks/usePage';
|
||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||
import History from 'typings/History';
|
||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useHistoryOptions } from './historyOptionsStore';
|
||||
|
||||
export const FILTERS: Filter[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: [],
|
||||
},
|
||||
{
|
||||
key: 'grabbed',
|
||||
label: () => translate('Grabbed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '1',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'imported',
|
||||
label: () => translate('Imported'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '3',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'failed',
|
||||
label: () => translate('Failed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '4',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'deleted',
|
||||
label: () => translate('Deleted'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '5',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'renamed',
|
||||
label: () => translate('Renamed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '6',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ignored',
|
||||
label: () => translate('Ignored'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '7',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const FILTER_BUILDER: FilterBuilderProp<History>[] = [
|
||||
{
|
||||
name: 'eventType',
|
||||
label: () => translate('EventType'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE,
|
||||
},
|
||||
{
|
||||
name: 'seriesIds',
|
||||
label: () => translate('Series'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.SERIES,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.QUALITY,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
type: 'contains',
|
||||
valueType: filterBuilderValueTypes.LANGUAGE,
|
||||
},
|
||||
];
|
||||
|
||||
type HistoryType = 'episode' | 'series';
|
||||
|
||||
const MARK_AS_FAILED_QUERY_KEYS: Record<HistoryType, string> = {
|
||||
episode: '/history/episode',
|
||||
series: '/history/series',
|
||||
} as const;
|
||||
|
||||
const useHistory = () => {
|
||||
const { page, goToPage } = usePage('history');
|
||||
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||
useHistoryOptions();
|
||||
const customFilters = useCustomFiltersList('history');
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||
}, [selectedFilterKey, customFilters]);
|
||||
|
||||
const { refetch, ...query } = usePagedApiQuery<History>({
|
||||
path: '/history',
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
queryOptions: {
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
});
|
||||
|
||||
const handleGoToPage = useCallback(
|
||||
(page: number) => {
|
||||
goToPage(page);
|
||||
},
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage: handleGoToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useHistory;
|
||||
|
||||
export const useFilters = () => {
|
||||
return FILTERS;
|
||||
};
|
||||
|
||||
export const useMarkAsFailed = (id: number, type?: HistoryType) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/history/failed/${id}`,
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onMutate: () => {
|
||||
setError(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
const queryKey = type ? MARK_AS_FAILED_QUERY_KEYS[type] : '/history';
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: [queryKey] });
|
||||
},
|
||||
onError: () => {
|
||||
setError('Error marking history item as failed');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
markAsFailed: mutate,
|
||||
isMarkingAsFailed: isPending,
|
||||
markAsFailedError: error,
|
||||
};
|
||||
};
|
||||
24
frontend/src/Activity/History/useSeriesHistory.ts
Normal file
24
frontend/src/Activity/History/useSeriesHistory.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import History from 'typings/History';
|
||||
|
||||
const DEFAULT_HISTORY: History[] = [];
|
||||
|
||||
const useSeriesHistory = (
|
||||
seriesId: number,
|
||||
seasonNumber: number | undefined
|
||||
) => {
|
||||
const { data, ...result } = useApiQuery<History[]>({
|
||||
path: '/history/series',
|
||||
queryParams: {
|
||||
seriesId,
|
||||
seasonNumber,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: data ?? DEFAULT_HISTORY,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSeriesHistory;
|
||||
114
frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx
Normal file
114
frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, {
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import Queue from 'typings/Queue';
|
||||
|
||||
interface EpisodeDetails {
|
||||
episodeIds: number[];
|
||||
}
|
||||
|
||||
interface SeriesDetails {
|
||||
seriesId: number;
|
||||
}
|
||||
|
||||
interface AllDetails {
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
|
||||
|
||||
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
|
||||
|
||||
export default function QueueDetailsProvider({
|
||||
children,
|
||||
...filter
|
||||
}: PropsWithChildren<QueueDetailsFilter>) {
|
||||
const { data } = useApiQuery<Queue[]>({
|
||||
path: '/queue/details',
|
||||
queryParams: { ...filter },
|
||||
queryOptions: {
|
||||
enabled: Object.keys(filter).length > 0,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<QueueDetailsContext.Provider value={data}>
|
||||
{children}
|
||||
</QueueDetailsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useQueueItemForEpisode(episodeId: number) {
|
||||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo(() => {
|
||||
return queue?.find((item) => item.episodeIds.includes(episodeId));
|
||||
}, [episodeId, queue]);
|
||||
}
|
||||
|
||||
export function useIsDownloadingEpisodes(episodeIds: number[]) {
|
||||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!queue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return queue.some((item) =>
|
||||
item.episodeIds?.some((e) => episodeIds.includes(e))
|
||||
);
|
||||
}, [episodeIds, queue]);
|
||||
}
|
||||
|
||||
export interface SeriesQueueDetails {
|
||||
count: number;
|
||||
episodesWithFiles: number;
|
||||
}
|
||||
|
||||
export function useQueueDetailsForSeries(
|
||||
seriesId: number,
|
||||
seasonNumber?: number
|
||||
) {
|
||||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo<SeriesQueueDetails>(() => {
|
||||
if (!queue) {
|
||||
return { count: 0, episodesWithFiles: 0 };
|
||||
}
|
||||
|
||||
return queue.reduce<SeriesQueueDetails>(
|
||||
(acc: SeriesQueueDetails, item) => {
|
||||
if (
|
||||
item.trackedDownloadState === 'imported' ||
|
||||
item.seriesId !== seriesId
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.count++;
|
||||
|
||||
if (item.episodeHasFile) {
|
||||
acc.episodesWithFiles++;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
episodesWithFiles: 0,
|
||||
}
|
||||
);
|
||||
}, [seriesId, seasonNumber, queue]);
|
||||
}
|
||||
|
||||
export const useQueueDetails = () => {
|
||||
return useContext(QueueDetailsContext) ?? [];
|
||||
};
|
||||
76
frontend/src/Activity/Queue/EpisodeCellContent.tsx
Normal file
76
frontend/src/Activity/Queue/EpisodeCellContent.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import Episode from 'Episode/Episode';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import Series from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface EpisodeCellContentProps {
|
||||
episodes: Episode[];
|
||||
isFullSeason: boolean;
|
||||
seasonNumber?: number;
|
||||
series?: Series;
|
||||
}
|
||||
|
||||
export default function EpisodeCellContent({
|
||||
episodes,
|
||||
isFullSeason,
|
||||
seasonNumber,
|
||||
series,
|
||||
}: EpisodeCellContentProps) {
|
||||
if (episodes.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (isFullSeason && seasonNumber != null) {
|
||||
return translate('SeasonNumberToken', { seasonNumber });
|
||||
}
|
||||
|
||||
if (episodes.length === 1) {
|
||||
const episode = episodes[0];
|
||||
|
||||
return (
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={episode.seasonNumber}
|
||||
episodeNumber={episode.episodeNumber}
|
||||
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={episode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const firstEpisode = episodes[0];
|
||||
const lastEpisode = episodes[episodes.length - 1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={firstEpisode.seasonNumber}
|
||||
episodeNumber={firstEpisode.episodeNumber}
|
||||
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
|
||||
/>
|
||||
{' - '}
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={lastEpisode.seasonNumber}
|
||||
episodeNumber={lastEpisode.episodeNumber}
|
||||
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
frontend/src/Activity/Queue/EpisodeTitleCellContent.css
Normal file
13
frontend/src/Activity/Queue/EpisodeTitleCellContent.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.multiple {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.episodeNumber {
|
||||
margin-right: 8px;
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
9
frontend/src/Activity/Queue/EpisodeTitleCellContent.css.d.ts
vendored
Normal file
9
frontend/src/Activity/Queue/EpisodeTitleCellContent.css.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'episodeNumber': string;
|
||||
'multiple': string;
|
||||
'row': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
66
frontend/src/Activity/Queue/EpisodeTitleCellContent.tsx
Normal file
66
frontend/src/Activity/Queue/EpisodeTitleCellContent.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Episode from 'Episode/Episode';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import Series from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EpisodeTitleCellContent.css';
|
||||
|
||||
interface EpisodeTitleCellContentProps {
|
||||
episodes: Episode[];
|
||||
series?: Series;
|
||||
}
|
||||
|
||||
export default function EpisodeTitleCellContent({
|
||||
episodes,
|
||||
series,
|
||||
}: EpisodeTitleCellContentProps) {
|
||||
if (episodes.length === 0 || !series) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (episodes.length === 1) {
|
||||
const episode = episodes[0];
|
||||
|
||||
return (
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchor={
|
||||
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
|
||||
}
|
||||
title={translate('EpisodeTitles')}
|
||||
body={
|
||||
<>
|
||||
{episodes.map((episode) => {
|
||||
return (
|
||||
<div key={episode.id} className={styles.row}>
|
||||
<div className={styles.episodeNumber}>
|
||||
{episode.episodeNumber}
|
||||
</div>
|
||||
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
position="right"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
|
||||
import CommandNames from 'Commands/CommandNames';
|
||||
import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
@@ -22,28 +22,13 @@ import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import useEpisodes from 'Episode/useEpisodes';
|
||||
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||
import {
|
||||
clearQueue,
|
||||
fetchQueue,
|
||||
gotoQueuePage,
|
||||
grabQueueItems,
|
||||
removeQueueItems,
|
||||
setQueueFilter,
|
||||
setQueueSort,
|
||||
setQueueTableOption,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import QueueItem from 'typings/Queue';
|
||||
import QueueModel from 'typings/Queue';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import {
|
||||
@@ -51,192 +36,185 @@ import {
|
||||
unregisterPagePopulator,
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import QueueFilterModal from './QueueFilterModal';
|
||||
import QueueOptions from './QueueOptions';
|
||||
import {
|
||||
setQueueOption,
|
||||
setQueueOptions,
|
||||
setQueueSort,
|
||||
useQueueOptions,
|
||||
} from './queueOptionsStore';
|
||||
import QueueRow from './QueueRow';
|
||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||
import createQueueStatusSelector from './Status/createQueueStatusSelector';
|
||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||
import useQueueStatus from './Status/useQueueStatus';
|
||||
import useQueue, {
|
||||
useFilters,
|
||||
useGrabQueueItems,
|
||||
useRemoveQueueItems,
|
||||
} from './useQueue';
|
||||
|
||||
function Queue() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
const dispatch = useDispatch();
|
||||
function QueueContent() {
|
||||
const executeCommand = useExecuteCommand();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
isGrabbing,
|
||||
isRemoving,
|
||||
} = useSelector((state: AppState) => state.queue.paged);
|
||||
error,
|
||||
isFetching,
|
||||
isLoading,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useQueue();
|
||||
|
||||
const { count } = useSelector(createQueueStatusSelector());
|
||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||
useSelector(createEpisodesFetchingSelector());
|
||||
const customFilters = useSelector(createCustomFiltersSelector('queue'));
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useQueueOptions();
|
||||
|
||||
const isRefreshMonitoredDownloadsExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS)
|
||||
const filters = useFilters();
|
||||
|
||||
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
|
||||
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
|
||||
|
||||
const { count } = useQueueStatus();
|
||||
|
||||
const episodeIds = useMemo(() => {
|
||||
return selectUniqueIds<QueueModel, number>(records, 'episodeIds');
|
||||
}, [records]);
|
||||
|
||||
const {
|
||||
isFetching: isEpisodesFetching,
|
||||
isFetched: isEpisodesFetched,
|
||||
error: episodesError,
|
||||
} = useEpisodes({ episodeIds });
|
||||
|
||||
const customFilters = useCustomFiltersList('queue');
|
||||
|
||||
const isRefreshMonitoredDownloadsExecuting = useCommandExecuting(
|
||||
CommandNames.RefreshMonitoredDownloads
|
||||
);
|
||||
|
||||
const shouldBlockRefresh = useRef(false);
|
||||
const currentQueue = useRef<ReactElement | null>(null);
|
||||
|
||||
const [selectState, setSelectState] = useSelectState();
|
||||
const { allSelected, allUnselected, selectedState } = selectState;
|
||||
|
||||
const selectedIds = useMemo(() => {
|
||||
return getSelectedIds(selectedState);
|
||||
}, [selectedState]);
|
||||
const { allSelected, allUnselected, selectAll, unselectAll, useSelectedIds } =
|
||||
useSelect<QueueModel>();
|
||||
|
||||
const selectedIds = useSelectedIds();
|
||||
const isPendingSelected = useMemo(() => {
|
||||
return items.some((item) => {
|
||||
return records.some((item) => {
|
||||
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
||||
});
|
||||
}, [items, selectedIds]);
|
||||
}, [records, selectedIds]);
|
||||
|
||||
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const [isInteractiveImportDownloadIds, setIsInteractiveImportDownloadIds] =
|
||||
useState<string[]>(() => []);
|
||||
|
||||
const isRefreshing =
|
||||
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||
|
||||
// Use isLoading over isFetched to avoid losing the table UI when switching pages
|
||||
const isAllPopulated =
|
||||
isPopulated &&
|
||||
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
|
||||
!isLoading &&
|
||||
(isEpisodesFetched ||
|
||||
!records.length ||
|
||||
records.every((e) => !e.episodeIds?.length));
|
||||
const hasError = error || episodesError;
|
||||
const selectedCount = selectedIds.length;
|
||||
const disableSelectedActions = selectedCount === 0;
|
||||
|
||||
const handleSelectAllChange = useCallback(
|
||||
({ value }: CheckInputChanged) => {
|
||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||
if (value) {
|
||||
selectAll();
|
||||
} else {
|
||||
unselectAll();
|
||||
}
|
||||
},
|
||||
[items, setSelectState]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||
setSelectState({
|
||||
type: 'toggleSelected',
|
||||
items,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[items, setSelectState]
|
||||
[selectAll, unselectAll]
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.REFRESH_MONITORED_DOWNLOADS,
|
||||
})
|
||||
);
|
||||
}, [dispatch]);
|
||||
executeCommand({
|
||||
name: CommandNames.RefreshMonitoredDownloads,
|
||||
});
|
||||
}, [executeCommand]);
|
||||
|
||||
const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => {
|
||||
shouldBlockRefresh.current = isOpen;
|
||||
}, []);
|
||||
|
||||
const handleGrabSelectedPress = useCallback(() => {
|
||||
dispatch(grabQueueItems({ ids: selectedIds }));
|
||||
}, [selectedIds, dispatch]);
|
||||
grabQueueItems({ ids: selectedIds });
|
||||
}, [selectedIds, grabQueueItems]);
|
||||
|
||||
const handleRemoveSelectedPress = useCallback(() => {
|
||||
shouldBlockRefresh.current = true;
|
||||
setIsConfirmRemoveModalOpen(true);
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const handleRemoveSelectedConfirmed = useCallback(
|
||||
(payload: RemovePressProps) => {
|
||||
shouldBlockRefresh.current = false;
|
||||
dispatch(removeQueueItems({ ids: selectedIds, ...payload }));
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
},
|
||||
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
|
||||
);
|
||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||
shouldBlockRefresh.current = false;
|
||||
removeQueueItems({ ids: selectedIds });
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [selectedIds, removeQueueItems]);
|
||||
|
||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||
shouldBlockRefresh.current = false;
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoQueuePage,
|
||||
});
|
||||
const handleImportSelectedPress = useCallback(() => {
|
||||
shouldBlockRefresh.current = true;
|
||||
setIsInteractiveImportDownloadIds(
|
||||
selectedIds
|
||||
.map((id) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
|
||||
return item?.downloadId;
|
||||
})
|
||||
.filter((id): id is string => !!id)
|
||||
);
|
||||
}, [records, selectedIds]);
|
||||
|
||||
const handleImportSelectedModalClose = useCallback(() => {
|
||||
shouldBlockRefresh.current = false;
|
||||
setIsInteractiveImportDownloadIds([]);
|
||||
}, []);
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
dispatch(setQueueFilter({ selectedFilterKey }));
|
||||
(selectedFilterKey: string | number) => {
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setQueueSort({ sortKey }));
|
||||
(sortKey: string, sortDirection?: SortDirection) => {
|
||||
setQueueSort({
|
||||
sortKey,
|
||||
sortDirection,
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setQueueTableOption(payload));
|
||||
setQueueOptions(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchQueue());
|
||||
} else {
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearQueue());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
|
||||
items,
|
||||
'episodeId'
|
||||
);
|
||||
|
||||
if (episodeIds.length) {
|
||||
dispatch(fetchEpisodes({ episodeIds }));
|
||||
} else {
|
||||
dispatch(clearEpisodes());
|
||||
}
|
||||
}, [items, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchQueue());
|
||||
refetch();
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -244,7 +222,7 @@ function Queue() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [dispatch]);
|
||||
}, [refetch]);
|
||||
|
||||
if (!shouldBlockRefresh.current) {
|
||||
currentQueue.current = (
|
||||
@@ -255,7 +233,7 @@ function Queue() {
|
||||
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{isAllPopulated && !hasError && !items.length ? (
|
||||
{isAllPopulated && !hasError && !records.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{selectedFilterKey !== 'all' && count > 0
|
||||
? translate('QueueFilterHasNoItems')
|
||||
@@ -263,7 +241,7 @@ function Queue() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isAllPopulated && !hasError && !!items.length ? (
|
||||
{isAllPopulated && !hasError && !!records.length ? (
|
||||
<div>
|
||||
<Table
|
||||
selectAll={true}
|
||||
@@ -273,21 +251,17 @@ function Queue() {
|
||||
pageSize={pageSize}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
optionsComponent={QueueOptions}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
onSelectAllChange={handleSelectAllChange}
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
{records.map((item) => {
|
||||
return (
|
||||
<QueueRow
|
||||
key={item.id}
|
||||
episodeId={item.episodeId}
|
||||
isSelected={selectedState[item.id]}
|
||||
columns={columns}
|
||||
{...item}
|
||||
onSelectedChange={handleSelectedChange}
|
||||
onQueueRowModalOpenOrClose={
|
||||
handleQueueRowModalOpenOrClose
|
||||
}
|
||||
@@ -302,11 +276,7 @@ function Queue() {
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
onPageSelect={goToPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -342,6 +312,15 @@ function Queue() {
|
||||
isSpinning={isRemoving}
|
||||
onPress={handleRemoveSelectedPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ImportSelected')}
|
||||
iconName={icons.INTERACTIVE}
|
||||
isDisabled={disableSelectedActions}
|
||||
onPress={handleImportSelectedPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
@@ -349,7 +328,6 @@ function Queue() {
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
maxPageSize={200}
|
||||
optionsComponent={QueueOptions}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton
|
||||
@@ -376,24 +354,24 @@ function Queue() {
|
||||
selectedCount={selectedCount}
|
||||
canChangeCategory={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
selectedIds.every((id: number) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
|
||||
return !!(item && item.downloadClientHasPostImportCategory);
|
||||
})
|
||||
}
|
||||
canIgnore={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
selectedIds.every((id: number) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
|
||||
return !!(item && item.seriesId && item.episodeId);
|
||||
})
|
||||
}
|
||||
isPending={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
selectedIds.every((id: number) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
@@ -408,8 +386,25 @@ function Queue() {
|
||||
onRemovePress={handleRemoveSelectedConfirmed}
|
||||
onModalClose={handleConfirmRemoveModalClose}
|
||||
/>
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isInteractiveImportDownloadIds.length > 0}
|
||||
downloadIds={isInteractiveImportDownloadIds}
|
||||
title={translate('InteractiveImportMultipleQueueItems')}
|
||||
onModalClose={handleImportSelectedModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
function Queue() {
|
||||
const { records } = useQueue();
|
||||
|
||||
return (
|
||||
<SelectProvider<QueueModel> items={records}>
|
||||
<QueueContent />
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Queue;
|
||||
|
||||
@@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
|
||||
interface QueueDetailsProps {
|
||||
title: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
sizeLeft: number;
|
||||
estimatedCompletionTime?: string;
|
||||
status: string;
|
||||
trackedDownloadState?: QueueTrackedDownloadState;
|
||||
@@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
const {
|
||||
title,
|
||||
size,
|
||||
sizeleft,
|
||||
sizeLeft,
|
||||
status,
|
||||
trackedDownloadState = 'downloading',
|
||||
trackedDownloadStatus = 'ok',
|
||||
@@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
progressBar,
|
||||
} = props;
|
||||
|
||||
const progress = 100 - (sizeleft / size) * 100;
|
||||
const progress = 100 - (sizeLeft / size) * 100;
|
||||
const isDownloading = status === 'downloading';
|
||||
const isPaused = status === 'paused';
|
||||
const hasWarning = trackedDownloadStatus === 'warning';
|
||||
@@ -61,7 +61,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
anchor={progressBar!}
|
||||
title={`${state} - ${progress.toFixed(1)}%`}
|
||||
body={<div>{title}</div>}
|
||||
position={tooltipPositions.LEFT}
|
||||
position="bottom-start"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,24 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import { setQueueFilter } from 'Store/Actions/queueActions';
|
||||
import { SetFilter } from 'Components/Filter/Filter';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setQueueOption } from './queueOptionsStore';
|
||||
import useQueue, { FILTER_BUILDER } from './useQueue';
|
||||
|
||||
function createQueueSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.paged.items,
|
||||
(queueItems) => {
|
||||
return queueItems;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.paged.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface QueueFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
type QueueFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||
const sectionItems = useSelector(createQueueSelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'queue';
|
||||
const { records } = useQueue();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: unknown) => {
|
||||
dispatch(setQueueFilter(payload));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
const dispatchSetFilter = useCallback(({ selectedFilterKey }: SetFilter) => {
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
sectionItems={records}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
customFilterType="queue"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function QueueOptions() {
|
||||
const dispatch = useDispatch();
|
||||
const { includeUnknownSeriesItems } = useSelector(
|
||||
(state: AppState) => state.queue.options
|
||||
);
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
({ name, value }: CheckInputChanged) => {
|
||||
dispatch(
|
||||
setQueueOption({
|
||||
[name]: value,
|
||||
})
|
||||
);
|
||||
|
||||
if (name === 'includeUnknownSeriesItems') {
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeUnknownSeriesItems"
|
||||
value={includeUnknownSeriesItems}
|
||||
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
||||
onChange={handleOptionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueueOptions;
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import { useSelect } from 'App/Select/SelectContext';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
@@ -15,20 +14,17 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import useEpisode from 'Episode/useEpisode';
|
||||
import { useEpisodesWithIds } from 'Episode/useEpisode';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { useSingleSeries } from 'Series/useSeries';
|
||||
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import {
|
||||
import Queue, {
|
||||
QueueTrackedDownloadState,
|
||||
QueueTrackedDownloadStatus,
|
||||
StatusMessage,
|
||||
@@ -36,16 +32,19 @@ import {
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeCellContent from './EpisodeCellContent';
|
||||
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
|
||||
import QueueStatusCell from './QueueStatusCell';
|
||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||
import TimeleftCell from './TimeleftCell';
|
||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||
import TimeLeftCell from './TimeLeftCell';
|
||||
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
|
||||
import styles from './QueueRow.css';
|
||||
|
||||
interface QueueRowProps {
|
||||
id: number;
|
||||
seriesId?: number;
|
||||
episodeId?: number;
|
||||
downloadId?: string;
|
||||
episodeIds: number[];
|
||||
downloadId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
trackedDownloadStatus?: QueueTrackedDownloadStatus;
|
||||
@@ -58,20 +57,18 @@ interface QueueRowProps {
|
||||
customFormatScore: number;
|
||||
protocol: DownloadProtocol;
|
||||
indexer?: string;
|
||||
isFullSeason: boolean;
|
||||
seasonNumbers: number[];
|
||||
outputPath?: string;
|
||||
downloadClient?: string;
|
||||
downloadClientHasPostImportCategory?: boolean;
|
||||
estimatedCompletionTime?: string;
|
||||
added?: string;
|
||||
timeleft?: string;
|
||||
timeLeft?: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
isGrabbing?: boolean;
|
||||
grabError?: Error;
|
||||
sizeLeft: number;
|
||||
isRemoving?: boolean;
|
||||
isSelected?: boolean;
|
||||
columns: Column[];
|
||||
onSelectedChange: (options: SelectStateInputProps) => void;
|
||||
onQueueRowModalOpenOrClose: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -79,7 +76,7 @@ function QueueRow(props: QueueRowProps) {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
episodeId,
|
||||
episodeIds,
|
||||
downloadId,
|
||||
title,
|
||||
status,
|
||||
@@ -97,25 +94,24 @@ function QueueRow(props: QueueRowProps) {
|
||||
downloadClient,
|
||||
downloadClientHasPostImportCategory,
|
||||
estimatedCompletionTime,
|
||||
isFullSeason,
|
||||
seasonNumbers,
|
||||
added,
|
||||
timeleft,
|
||||
timeLeft,
|
||||
size,
|
||||
sizeleft,
|
||||
isGrabbing = false,
|
||||
grabError,
|
||||
isRemoving = false,
|
||||
isSelected,
|
||||
sizeLeft,
|
||||
columns,
|
||||
onSelectedChange,
|
||||
onQueueRowModalOpenOrClose,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const series = useSeries(seriesId);
|
||||
const episode = useEpisode(episodeId, 'episodes');
|
||||
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
const series = useSingleSeries(seriesId);
|
||||
const episodes = useEpisodesWithIds(episodeIds);
|
||||
const { showRelativeDates, shortDateFormat, timeFormat } =
|
||||
useUiSettingsValues();
|
||||
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
|
||||
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
|
||||
const { toggleSelected, useIsSelected } = useSelect<Queue>();
|
||||
const isSelected = useIsSelected(id);
|
||||
|
||||
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
||||
useState(false);
|
||||
@@ -124,8 +120,8 @@ function QueueRow(props: QueueRowProps) {
|
||||
useState(false);
|
||||
|
||||
const handleGrabPress = useCallback(() => {
|
||||
dispatch(grabQueueItem({ id }));
|
||||
}, [id, dispatch]);
|
||||
grabQueueItem();
|
||||
}, [grabQueueItem]);
|
||||
|
||||
const handleInteractiveImportPress = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(true);
|
||||
@@ -142,21 +138,33 @@ function QueueRow(props: QueueRowProps) {
|
||||
setIsRemoveQueueItemModalOpen(true);
|
||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||
|
||||
const handleRemoveQueueItemModalConfirmed = useCallback(
|
||||
(payload: RemovePressProps) => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
dispatch(removeQueueItem({ id, ...payload }));
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
},
|
||||
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
|
||||
);
|
||||
const handleRemoveQueueItemModalConfirmed = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
removeQueueItem();
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
}, [
|
||||
setIsRemoveQueueItemModalOpen,
|
||||
removeQueueItem,
|
||||
onQueueRowModalOpenOrClose,
|
||||
]);
|
||||
|
||||
const handleRemoveQueueItemModalClose = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||
|
||||
const progress = 100 - (sizeleft / size) * 100;
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||
toggleSelected({
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[toggleSelected]
|
||||
);
|
||||
|
||||
const progress = 100 - (sizeLeft / size) * 100;
|
||||
const showInteractiveImport =
|
||||
status === 'completed' && trackedDownloadStatus === 'warning';
|
||||
const isPending =
|
||||
@@ -167,7 +175,7 @@ function QueueRow(props: QueueRowProps) {
|
||||
<TableSelectCell
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
onSelectedChange={onSelectedChange}
|
||||
onSelectedChange={handleSelectedChange}
|
||||
/>
|
||||
|
||||
{columns.map((column) => {
|
||||
@@ -209,23 +217,12 @@ function QueueRow(props: QueueRowProps) {
|
||||
if (name === 'episode') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{episode ? (
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={episode.seasonNumber}
|
||||
episodeNumber={episode.episodeNumber}
|
||||
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={episode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={
|
||||
episode.sceneAbsoluteEpisodeNumber
|
||||
}
|
||||
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
<EpisodeCellContent
|
||||
episodes={episodes}
|
||||
isFullSeason={isFullSeason}
|
||||
seasonNumber={seasonNumbers[0]}
|
||||
series={series}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
@@ -233,27 +230,37 @@ function QueueRow(props: QueueRowProps) {
|
||||
if (name === 'episodes.title') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{series && episode ? (
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
<EpisodeTitleCellContent episodes={episodes} series={series} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'episodes.airDateUtc') {
|
||||
if (episode) {
|
||||
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
|
||||
if (episodes.length === 0) {
|
||||
return <TableRowCell key={name}>-</TableRowCell>;
|
||||
}
|
||||
|
||||
return <TableRowCell key={name}>-</TableRowCell>;
|
||||
if (episodes.length === 1) {
|
||||
return (
|
||||
<RelativeDateCell key={name} date={episodes[0].airDateUtc} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
component="span"
|
||||
date={episodes[0].airDateUtc}
|
||||
/>
|
||||
{' - '}
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
component="span"
|
||||
date={episodes[episodes.length - 1].airDateUtc}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
@@ -325,13 +332,13 @@ function QueueRow(props: QueueRowProps) {
|
||||
|
||||
if (name === 'estimatedCompletionTime') {
|
||||
return (
|
||||
<TimeleftCell
|
||||
<TimeLeftCell
|
||||
key={name}
|
||||
status={status}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
timeleft={timeleft}
|
||||
timeLeft={timeLeft}
|
||||
size={size}
|
||||
sizeleft={sizeleft}
|
||||
sizeLeft={sizeLeft}
|
||||
showRelativeDates={showRelativeDates}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
@@ -390,8 +397,8 @@ function QueueRow(props: QueueRowProps) {
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isInteractiveImportModalOpen}
|
||||
downloadId={downloadId}
|
||||
modalTitle={title}
|
||||
downloadIds={[downloadId]}
|
||||
title={title}
|
||||
onModalClose={handleInteractiveImportModalClose}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Icon, { IconKind } from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
|
||||
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
|
||||
// status === 'downloading'
|
||||
let iconName = icons.DOWNLOADING;
|
||||
let iconKind: IconProps['kind'] = kinds.DEFAULT;
|
||||
let iconKind: IconKind = kinds.DEFAULT;
|
||||
let title = translate('Downloading');
|
||||
|
||||
if (status === 'paused') {
|
||||
@@ -90,7 +90,7 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
|
||||
if (trackedDownloadState === 'importing') {
|
||||
title += ` - ${translate('Importing')}`;
|
||||
iconKind = kinds.PURPLE;
|
||||
iconKind = kinds.PRIMARY;
|
||||
}
|
||||
|
||||
if (trackedDownloadState === 'failedPending') {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
QueueOptions,
|
||||
setQueueOption,
|
||||
useQueueOption,
|
||||
} from './queueOptionsStore';
|
||||
import styles from './RemoveQueueItemModal.css';
|
||||
|
||||
export interface RemovePressProps {
|
||||
remove: boolean;
|
||||
changeCategory: boolean;
|
||||
blocklist: boolean;
|
||||
skipRedownload: boolean;
|
||||
}
|
||||
|
||||
interface RemoveQueueItemModalProps {
|
||||
isOpen: boolean;
|
||||
sourceTitle?: string;
|
||||
@@ -26,16 +26,10 @@ interface RemoveQueueItemModalProps {
|
||||
canIgnore: boolean;
|
||||
isPending: boolean;
|
||||
selectedCount?: number;
|
||||
onRemovePress(props: RemovePressProps): void;
|
||||
onRemovePress(): void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
|
||||
type BlocklistMethod =
|
||||
| 'doNotBlocklist'
|
||||
| 'blocklistAndSearch'
|
||||
| 'blocklistOnly';
|
||||
|
||||
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
@@ -49,11 +43,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
} = props;
|
||||
|
||||
const multipleSelected = selectedCount && selectedCount > 1;
|
||||
|
||||
const [removalMethod, setRemovalMethod] =
|
||||
useState<RemovalMethod>('removeFromClient');
|
||||
const [blocklistMethod, setBlocklistMethod] =
|
||||
useState<BlocklistMethod>('doNotBlocklist');
|
||||
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
|
||||
|
||||
const { title, message } = useMemo(() => {
|
||||
if (!selectedCount) {
|
||||
@@ -79,7 +69,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
}, [sourceTitle, selectedCount]);
|
||||
|
||||
const removalMethodOptions = useMemo(() => {
|
||||
return [
|
||||
const options: EnhancedSelectInputValue<string>[] = [
|
||||
{
|
||||
key: 'removeFromClient',
|
||||
value: translate('RemoveFromDownloadClient'),
|
||||
@@ -106,10 +96,12 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
: translate('IgnoreDownloadHint'),
|
||||
},
|
||||
];
|
||||
|
||||
return options;
|
||||
}, [canChangeCategory, canIgnore, multipleSelected]);
|
||||
|
||||
const blocklistMethodOptions = useMemo(() => {
|
||||
return [
|
||||
const options: EnhancedSelectInputValue<string>[] = [
|
||||
{
|
||||
key: 'doNotBlocklist',
|
||||
value: translate('DoNotBlocklist'),
|
||||
@@ -131,46 +123,28 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
: translate('BlocklistOnlyHint'),
|
||||
},
|
||||
];
|
||||
|
||||
return options;
|
||||
}, [isPending, multipleSelected]);
|
||||
|
||||
const handleRemovalMethodChange = useCallback(
|
||||
({ value }: { value: RemovalMethod }) => {
|
||||
setRemovalMethod(value);
|
||||
const handleRemovalOptionInputChange = useCallback(
|
||||
({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
|
||||
setQueueOption('removalOptions', {
|
||||
removalMethod,
|
||||
blocklistMethod,
|
||||
[name]: value,
|
||||
});
|
||||
},
|
||||
[setRemovalMethod]
|
||||
);
|
||||
|
||||
const handleBlocklistMethodChange = useCallback(
|
||||
({ value }: { value: BlocklistMethod }) => {
|
||||
setBlocklistMethod(value);
|
||||
},
|
||||
[setBlocklistMethod]
|
||||
[removalMethod, blocklistMethod]
|
||||
);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
onRemovePress({
|
||||
remove: removalMethod === 'removeFromClient',
|
||||
changeCategory: removalMethod === 'changeCategory',
|
||||
blocklist: blocklistMethod !== 'doNotBlocklist',
|
||||
skipRedownload: blocklistMethod === 'blocklistOnly',
|
||||
});
|
||||
|
||||
setRemovalMethod('removeFromClient');
|
||||
setBlocklistMethod('doNotBlocklist');
|
||||
}, [
|
||||
removalMethod,
|
||||
blocklistMethod,
|
||||
setRemovalMethod,
|
||||
setBlocklistMethod,
|
||||
onRemovePress,
|
||||
]);
|
||||
onRemovePress();
|
||||
}, [onRemovePress]);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
setRemovalMethod('removeFromClient');
|
||||
setBlocklistMethod('doNotBlocklist');
|
||||
|
||||
onModalClose();
|
||||
}, [setRemovalMethod, setBlocklistMethod, onModalClose]);
|
||||
}, [onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={handleModalClose}>
|
||||
@@ -193,7 +167,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
helpTextWarning={translate(
|
||||
'RemoveQueueItemRemovalMethodHelpTextWarning'
|
||||
)}
|
||||
onChange={handleRemovalMethodChange}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleRemovalOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
@@ -211,7 +186,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
value={blocklistMethod}
|
||||
values={blocklistMethodOptions}
|
||||
helpText={translate('BlocklistReleaseHelpText')}
|
||||
onChange={handleBlocklistMethodChange}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleRemovalOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import React from 'react';
|
||||
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { fetchQueueStatus } from 'Store/Actions/queueActions';
|
||||
import createQueueStatusSelector from './createQueueStatusSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import useQueueStatus from './useQueueStatus';
|
||||
|
||||
function QueueStatus() {
|
||||
const dispatch = useDispatch();
|
||||
const { isConnected, isReconnecting } = useSelector(
|
||||
(state: AppState) => state.app
|
||||
);
|
||||
const { isPopulated, count, errors, warnings } = useSelector(
|
||||
createQueueStatusSelector()
|
||||
);
|
||||
|
||||
const wasReconnecting = usePrevious(isReconnecting);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopulated) {
|
||||
dispatch(fetchQueueStatus());
|
||||
}
|
||||
}, [isPopulated, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && wasReconnecting) {
|
||||
dispatch(fetchQueueStatus());
|
||||
}
|
||||
}, [isConnected, wasReconnecting, dispatch]);
|
||||
const { errors, warnings, count } = useQueueStatus();
|
||||
|
||||
return (
|
||||
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
|
||||
<PageSidebarStatus
|
||||
aria-label={
|
||||
count === 1
|
||||
? translate('QueueItem')
|
||||
: translate('QueueItems', { count })
|
||||
}
|
||||
count={count}
|
||||
errors={errors}
|
||||
warnings={warnings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
function createQueueStatusSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.status.isPopulated,
|
||||
(state: AppState) => state.queue.status.item,
|
||||
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
|
||||
(isPopulated, status, includeUnknownSeriesItems) => {
|
||||
const {
|
||||
errors,
|
||||
warnings,
|
||||
unknownErrors,
|
||||
unknownWarnings,
|
||||
count,
|
||||
totalCount,
|
||||
} = status;
|
||||
|
||||
return {
|
||||
...status,
|
||||
isPopulated,
|
||||
count: includeUnknownSeriesItems ? totalCount : count,
|
||||
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
|
||||
warnings: includeUnknownSeriesItems
|
||||
? warnings || unknownWarnings
|
||||
: warnings,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createQueueStatusSelector;
|
||||
33
frontend/src/Activity/Queue/Status/useQueueStatus.ts
Normal file
33
frontend/src/Activity/Queue/Status/useQueueStatus.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
|
||||
export interface QueueStatus {
|
||||
totalCount: number;
|
||||
count: number;
|
||||
unknownCount: number;
|
||||
errors: boolean;
|
||||
warnings: boolean;
|
||||
unknownErrors: boolean;
|
||||
unknownWarnings: boolean;
|
||||
}
|
||||
|
||||
export default function useQueueStatus() {
|
||||
const { data } = useApiQuery<QueueStatus>({
|
||||
path: '/queue/status',
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
count: 0,
|
||||
errors: false,
|
||||
warnings: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { errors, warnings, unknownErrors, unknownWarnings, totalCount } = data;
|
||||
|
||||
return {
|
||||
count: totalCount,
|
||||
errors: errors || unknownErrors,
|
||||
warnings: warnings || unknownWarnings,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
.timeleft {
|
||||
.timeLeft {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 100px;
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'timeleft': string;
|
||||
'timeLeft': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './TimeleftCell.css';
|
||||
import styles from './TimeLeftCell.css';
|
||||
|
||||
interface TimeleftCellProps {
|
||||
interface TimeLeftCellProps {
|
||||
estimatedCompletionTime?: string;
|
||||
timeleft?: string;
|
||||
timeLeft?: string;
|
||||
status: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
sizeLeft: number;
|
||||
showRelativeDates: boolean;
|
||||
shortDateFormat: string;
|
||||
timeFormat: string;
|
||||
}
|
||||
|
||||
function TimeleftCell(props: TimeleftCellProps) {
|
||||
function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
const {
|
||||
estimatedCompletionTime,
|
||||
timeleft,
|
||||
timeLeft,
|
||||
status,
|
||||
size,
|
||||
sizeleft,
|
||||
sizeLeft,
|
||||
showRelativeDates,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
@@ -44,7 +44,7 @@ function TimeleftCell(props: TimeleftCellProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.timeleft}>
|
||||
<TableRowCell className={styles.timeLeft}>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.INFO} />}
|
||||
tooltip={translate('DelayingDownloadUntil', { date, time })}
|
||||
@@ -66,7 +66,7 @@ function TimeleftCell(props: TimeleftCellProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.timeleft}>
|
||||
<TableRowCell className={styles.timeLeft}>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.INFO} />}
|
||||
tooltip={translate('RetryingDownloadOn', { date, time })}
|
||||
@@ -77,21 +77,21 @@ function TimeleftCell(props: TimeleftCellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!timeleft || status === 'completed' || status === 'failed') {
|
||||
return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
|
||||
if (!timeLeft || status === 'completed' || status === 'failed') {
|
||||
return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
|
||||
}
|
||||
|
||||
const totalSize = formatBytes(size);
|
||||
const remainingSize = formatBytes(sizeleft);
|
||||
const remainingSize = formatBytes(sizeLeft);
|
||||
|
||||
return (
|
||||
<TableRowCell
|
||||
className={styles.timeleft}
|
||||
className={styles.timeLeft}
|
||||
title={`${remainingSize} / ${totalSize}`}
|
||||
>
|
||||
{formatTimeSpan(timeleft)}
|
||||
{formatTimeSpan(timeLeft)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeleftCell;
|
||||
export default TimeLeftCell;
|
||||
159
frontend/src/Activity/Queue/queueOptionsStore.ts
Normal file
159
frontend/src/Activity/Queue/queueOptionsStore.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import {
|
||||
createOptionsStore,
|
||||
PageableOptions,
|
||||
} from 'Helpers/Hooks/useOptionsStore';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface QueueRemovalOptions {
|
||||
removalMethod: 'changeCategory' | 'ignore' | 'removeFromClient';
|
||||
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
|
||||
}
|
||||
|
||||
export interface QueueOptions extends PageableOptions {
|
||||
removalOptions: QueueRemovalOptions;
|
||||
}
|
||||
|
||||
const { useOptions, useOption, setOptions, setOption, setSort } =
|
||||
createOptionsStore<QueueOptions>('queue_options', () => {
|
||||
return {
|
||||
pageSize: 20,
|
||||
selectedFilterKey: 'all',
|
||||
sortKey: 'time',
|
||||
sortDirection: 'descending',
|
||||
columns: [
|
||||
{
|
||||
name: 'status',
|
||||
label: '',
|
||||
columnLabel: () => translate('Status'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
{
|
||||
name: 'series.sortTitle',
|
||||
label: () => translate('Series'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episode',
|
||||
label: () => translate('EpisodeMaybePlural'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episodes.title',
|
||||
label: () => translate('EpisodeTitleMaybePlural'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episodes.airDateUtc',
|
||||
label: () => translate('EpisodeAirDate'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
columnLabel: () => translate('CustomFormatScore'),
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore'),
|
||||
}),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
label: () => translate('Protocol'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'indexer',
|
||||
label: () => translate('Indexer'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'downloadClient',
|
||||
label: () => translate('DownloadClient'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
label: () => translate('ReleaseTitle'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: () => translate('Size'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'outputPath',
|
||||
label: () => translate('OutputPath'),
|
||||
isSortable: false,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'estimatedCompletionTime',
|
||||
label: () => translate('TimeLeft'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'added',
|
||||
label: () => translate('Added'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'progress',
|
||||
label: () => translate('Progress'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: '',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
],
|
||||
removalOptions: {
|
||||
removalMethod: 'removeFromClient',
|
||||
blocklistMethod: 'doNotBlocklist',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const useQueueOptions = useOptions;
|
||||
export const setQueueOptions = setOptions;
|
||||
export const useQueueOption = useOption;
|
||||
export const setQueueOption = setOption;
|
||||
export const setQueueSort = setSort;
|
||||
209
frontend/src/Activity/Queue/useQueue.ts
Normal file
209
frontend/src/Activity/Queue/useQueue.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Filter, FilterBuilderProp } from 'Filters/Filter';
|
||||
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import usePage from 'Helpers/Hooks/usePage';
|
||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||
import Queue from 'typings/Queue';
|
||||
import getQueryString from 'Utilities/Fetch/getQueryString';
|
||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useQueueOptions } from './queueOptionsStore';
|
||||
|
||||
interface BulkQueueData {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export const FILTERS: Filter[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: [],
|
||||
},
|
||||
{
|
||||
key: 'excludeUnknownSeriesItems',
|
||||
label: () => translate('ExcludeUnknownSeriesItems'),
|
||||
filters: [
|
||||
{
|
||||
key: 'includeUnknownSeriesItems',
|
||||
value: [false],
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
|
||||
{
|
||||
name: 'seriesIds',
|
||||
label: () => translate('Series'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.SERIES,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.QUALITY,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
type: 'contains',
|
||||
valueType: filterBuilderValueTypes.LANGUAGE,
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
label: () => translate('Protocol'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.PROTOCOL,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: () => translate('Status'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.QUEUE_STATUS,
|
||||
},
|
||||
{
|
||||
name: 'includeUnknownSeriesItems',
|
||||
label: () => translate('UnknownSeriesItems'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.BOOL,
|
||||
},
|
||||
];
|
||||
|
||||
const useQueue = () => {
|
||||
const { page, goToPage } = usePage('queue');
|
||||
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||
useQueueOptions();
|
||||
const customFilters = useCustomFiltersList('queue');
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||
}, [selectedFilterKey, customFilters]);
|
||||
|
||||
const { refetch, ...query } = usePagedApiQuery<Queue>({
|
||||
path: '/queue',
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
queryOptions: {
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useQueue;
|
||||
|
||||
export const useFilters = () => {
|
||||
return FILTERS;
|
||||
};
|
||||
|
||||
const useRemovalOptions = () => {
|
||||
const { removalOptions } = useQueueOptions();
|
||||
|
||||
return {
|
||||
remove: removalOptions.removalMethod === 'removeFromClient',
|
||||
changeCategory: removalOptions.removalMethod === 'changeCategory',
|
||||
blocklist: removalOptions.blocklistMethod !== 'doNotBlocklist',
|
||||
skipRedownload: removalOptions.blocklistMethod === 'blocklistOnly',
|
||||
};
|
||||
};
|
||||
|
||||
export const useRemoveQueueItem = (id: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const removalOptions = useRemovalOptions();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/queue/${id}${getQueryString(removalOptions)}`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeQueueItem: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRemoveQueueItems = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const removalOptions = useRemovalOptions();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
||||
path: `/queue/bulk${getQueryString(removalOptions)}`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeQueueItems: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGrabQueueItem = (id: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [grabError, setGrabError] = useState<string | null>(null);
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/queue/grab/${id}`,
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onMutate: () => {
|
||||
setGrabError(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
onError: () => {
|
||||
setGrabError('Error grabbing queue item');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
grabQueueItem: mutate,
|
||||
isGrabbing: isPending,
|
||||
grabError,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGrabQueueItems = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Explicitly define the types for the mutation so we can pass in no arguments to mutate as expected.
|
||||
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
||||
path: '/queue/grab/bulk',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
grabQueueItems: mutate,
|
||||
isGrabbing: isPending,
|
||||
};
|
||||
};
|
||||
@@ -1,215 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector';
|
||||
import styles from './AddNewSeries.css';
|
||||
|
||||
class AddNewSeries extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
term: props.term || '',
|
||||
isFetching: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const term = this.state.term;
|
||||
|
||||
if (term) {
|
||||
this.props.onSeriesLookupChange(term);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
term,
|
||||
isFetching
|
||||
} = this.props;
|
||||
|
||||
if (term && term !== prevProps.term) {
|
||||
this.setState({
|
||||
term,
|
||||
isFetching: true
|
||||
});
|
||||
this.props.onSeriesLookupChange(term);
|
||||
} else if (isFetching !== prevProps.isFetching) {
|
||||
this.setState({
|
||||
isFetching
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSearchInputChange = ({ value }) => {
|
||||
const hasValue = !!value.trim();
|
||||
|
||||
this.setState({ term: value, isFetching: hasValue }, () => {
|
||||
if (hasValue) {
|
||||
this.props.onSeriesLookupChange(value);
|
||||
} else {
|
||||
this.props.onClearSeriesLookup();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onClearSeriesLookupPress = () => {
|
||||
this.setState({ term: '' });
|
||||
this.props.onClearSeriesLookup();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
error,
|
||||
items,
|
||||
hasExistingSeries
|
||||
} = this.props;
|
||||
|
||||
const term = this.state.term;
|
||||
const isFetching = this.state.isFetching;
|
||||
|
||||
return (
|
||||
<PageContent title={translate('AddNewSeries')}>
|
||||
<PageContentBody>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon
|
||||
name={icons.SEARCH}
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name="seriesLookup"
|
||||
value={term}
|
||||
placeholder="eg. Breaking Bad, tvdb:####"
|
||||
autoFocus={true}
|
||||
onChange={this.onSearchInputChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={styles.clearLookupButton}
|
||||
onPress={this.onClearSeriesLookupPress}
|
||||
>
|
||||
<Icon
|
||||
name={icons.REMOVE}
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error ?
|
||||
<div className={styles.message}>
|
||||
<div className={styles.helpText}>
|
||||
{translate('AddNewSeriesError')}
|
||||
</div>
|
||||
|
||||
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
|
||||
</div> : null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !error && !!items.length &&
|
||||
<div className={styles.searchResults}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<AddNewSeriesSearchResultConnector
|
||||
key={item.tvdbId}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !error && !items.length && !!term &&
|
||||
<div className={styles.message}>
|
||||
<div className={styles.noResults}>{translate('CouldNotFindResults', { term })}</div>
|
||||
<div>{translate('SearchByTvdbId')}</div>
|
||||
<div>
|
||||
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
|
||||
{translate('WhyCantIFindMyShow')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
term ?
|
||||
null :
|
||||
<div className={styles.message}>
|
||||
<div className={styles.helpText}>
|
||||
{translate('AddNewSeriesHelpText')}
|
||||
</div>
|
||||
<div>{translate('SearchByTvdbId')}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!term && !hasExistingSeries ?
|
||||
<div className={styles.message}>
|
||||
<div className={styles.noSeriesText}>
|
||||
{translate('NoSeriesHaveBeenAdded')}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
to="/add/import"
|
||||
kind={kinds.PRIMARY}
|
||||
>
|
||||
{translate('ImportExistingSeries')}
|
||||
</Button>
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<div />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeries.propTypes = {
|
||||
term: PropTypes.string,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
isAdding: PropTypes.bool.isRequired,
|
||||
addError: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
hasExistingSeries: PropTypes.bool.isRequired,
|
||||
onSeriesLookupChange: PropTypes.func.isRequired,
|
||||
onClearSeriesLookup: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewSeries;
|
||||
140
frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx
Normal file
140
frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import useDebounce from 'Helpers/Hooks/useDebounce';
|
||||
import useQueryParams from 'Helpers/Hooks/useQueryParams';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { useHasSeries } from 'Series/useSeries';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
|
||||
import { useLookupSeries } from './useAddSeries';
|
||||
import styles from './AddNewSeries.css';
|
||||
|
||||
function AddNewSeries() {
|
||||
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
|
||||
const hasSeries = useHasSeries();
|
||||
const [term, setTerm] = useState(initialTerm);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const query = useDebounce(term, term ? 300 : 0);
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
setTerm(value);
|
||||
setIsFetching(!!value.trim());
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClearSeriesLookupPress = useCallback(() => {
|
||||
setTerm('');
|
||||
setIsFetching(false);
|
||||
}, []);
|
||||
|
||||
const { isFetching: isFetchingApi, error, data } = useLookupSeries(query);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFetching(isFetchingApi);
|
||||
}, [isFetchingApi]);
|
||||
|
||||
useEffect(() => {
|
||||
setTerm(initialTerm);
|
||||
}, [initialTerm]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('AddNewSeries')}>
|
||||
<PageContentBody>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon name={icons.SEARCH} size={20} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name="seriesLookup"
|
||||
value={term}
|
||||
placeholder="eg. Breaking Bad, tvdb:####"
|
||||
autoFocus={true}
|
||||
onChange={handleSearchInputChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={styles.clearLookupButton}
|
||||
onPress={handleClearSeriesLookupPress}
|
||||
>
|
||||
<Icon name={icons.REMOVE} size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isFetching ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.helpText}>
|
||||
{translate('AddNewSeriesError')}
|
||||
</div>
|
||||
|
||||
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isFetching && !error && !!data.length ? (
|
||||
<div className={styles.searchResults}>
|
||||
{data.map((item) => {
|
||||
return (
|
||||
<AddNewSeriesSearchResult key={item.tvdbId} series={item} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isFetching && !error && !data.length && term ? (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.noResults}>
|
||||
{translate('CouldNotFindResults', { term })}
|
||||
</div>
|
||||
<div>{translate('SearchByTvdbId')}</div>
|
||||
<div>
|
||||
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
|
||||
{translate('WhyCantIFindMyShow')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{term ? null : (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.helpText}>
|
||||
{translate('AddNewSeriesHelpText')}
|
||||
</div>
|
||||
<div>{translate('SearchByTvdbId')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!term && !hasSeries ? (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.noSeriesText}>
|
||||
{translate('NoSeriesHaveBeenAdded')}
|
||||
</div>
|
||||
<div>
|
||||
<Button to="/add/import" kind={kinds.PRIMARY}>
|
||||
{translate('ImportExistingSeries')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewSeries;
|
||||
@@ -1,104 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearAddSeries, lookupSeries } from 'Store/Actions/addSeriesActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import parseUrl from 'Utilities/String/parseUrl';
|
||||
import AddNewSeries from './AddNewSeries';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.addSeries,
|
||||
(state) => state.series.items.length,
|
||||
(state) => state.router.location,
|
||||
(addSeries, existingSeriesCount, location) => {
|
||||
const { params } = parseUrl(location.search);
|
||||
|
||||
return {
|
||||
...addSeries,
|
||||
term: params.term,
|
||||
hasExistingSeries: existingSeriesCount > 0
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
lookupSeries,
|
||||
clearAddSeries,
|
||||
fetchRootFolders
|
||||
};
|
||||
|
||||
class AddNewSeriesConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._seriesLookupTimeout = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchRootFolders();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._seriesLookupTimeout) {
|
||||
clearTimeout(this._seriesLookupTimeout);
|
||||
}
|
||||
|
||||
this.props.clearAddSeries();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSeriesLookupChange = (term) => {
|
||||
if (this._seriesLookupTimeout) {
|
||||
clearTimeout(this._seriesLookupTimeout);
|
||||
}
|
||||
|
||||
if (term.trim() === '') {
|
||||
this.props.clearAddSeries();
|
||||
} else {
|
||||
this._seriesLookupTimeout = setTimeout(() => {
|
||||
this.props.lookupSeries({ term });
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
onClearSeriesLookup = () => {
|
||||
this.props.clearAddSeries();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
term,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<AddNewSeries
|
||||
term={term}
|
||||
{...otherProps}
|
||||
onSeriesLookupChange={this.onSeriesLookupChange}
|
||||
onClearSeriesLookup={this.onClearSeriesLookup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeriesConnector.propTypes = {
|
||||
term: PropTypes.string,
|
||||
lookupSeries: PropTypes.func.isRequired,
|
||||
clearAddSeries: PropTypes.func.isRequired,
|
||||
fetchRootFolders: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector);
|
||||
@@ -1,31 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import AddNewSeriesModalContentConnector from './AddNewSeriesModalContentConnector';
|
||||
|
||||
function AddNewSeriesModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AddNewSeriesModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddNewSeriesModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewSeriesModal;
|
||||
23
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.tsx
Normal file
23
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import AddNewSeriesModalContent, {
|
||||
AddNewSeriesModalContentProps,
|
||||
} from './AddNewSeriesModalContent';
|
||||
|
||||
interface AddNewSeriesModalProps extends AddNewSeriesModalContentProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function AddNewSeriesModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: AddNewSeriesModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<AddNewSeriesModalContent {...otherProps} onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewSeriesModal;
|
||||
@@ -1,300 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import * as seriesTypes from 'Utilities/Series/seriesTypes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AddNewSeriesModalContent.css';
|
||||
|
||||
class AddNewSeriesModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
seriesType: props.initialSeriesType === seriesTypes.STANDARD ?
|
||||
props.seriesType.value :
|
||||
props.initialSeriesType
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.seriesType.value !== prevProps.seriesType.value) {
|
||||
this.setState({ seriesType: this.props.seriesType.value });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onQualityProfileIdChange = ({ value }) => {
|
||||
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
|
||||
};
|
||||
|
||||
onAddSeriesPress = () => {
|
||||
const {
|
||||
seriesType
|
||||
} = this.state;
|
||||
|
||||
this.props.onAddSeriesPress(
|
||||
seriesType
|
||||
);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
year,
|
||||
overview,
|
||||
images,
|
||||
isAdding,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
seasonFolder,
|
||||
searchForMissingEpisodes,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
folder,
|
||||
tags,
|
||||
isSmallScreen,
|
||||
isWindows,
|
||||
onModalClose,
|
||||
onInputChange,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{title}
|
||||
|
||||
{
|
||||
!title.contains(year) && !!year &&
|
||||
<span className={styles.year}>({year})</span>
|
||||
}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.container}>
|
||||
{
|
||||
isSmallScreen ?
|
||||
null :
|
||||
<div className={styles.poster}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={styles.info}>
|
||||
{
|
||||
overview ?
|
||||
<div className={styles.overview}>
|
||||
{overview}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('RootFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.ROOT_FOLDER_SELECT}
|
||||
name="rootFolderPath"
|
||||
valueOptions={{
|
||||
seriesFolder: folder,
|
||||
isWindows
|
||||
}}
|
||||
selectedValueOptions={{
|
||||
seriesFolder: folder,
|
||||
isWindows
|
||||
}}
|
||||
helpText={translate('AddNewSeriesRootFolderHelpText', { folder })}
|
||||
onChange={onInputChange}
|
||||
{...rootFolderPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('Monitor')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.labelIcon}
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
title={translate('MonitoringOptions')}
|
||||
body={<SeriesMonitoringOptionsPopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
onChange={onInputChange}
|
||||
{...monitor}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
onChange={this.onQualityProfileIdChange}
|
||||
{...qualityProfileId}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('SeriesType')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.labelIcon}
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
title={translate('SeriesTypes')}
|
||||
body={<SeriesTypePopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
onChange={onInputChange}
|
||||
{...seriesType}
|
||||
value={this.state.seriesType}
|
||||
helpText={translate('SeriesTypesHelpText')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('SeasonFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
onChange={onInputChange}
|
||||
{...seasonFolder}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
onChange={onInputChange}
|
||||
{...tags}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div>
|
||||
<label className={styles.searchLabelContainer}>
|
||||
<span className={styles.searchLabel}>
|
||||
{translate('AddNewSeriesSearchForMissingEpisodes')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchInputContainer}
|
||||
className={styles.searchInput}
|
||||
name="searchForMissingEpisodes"
|
||||
onChange={onInputChange}
|
||||
{...searchForMissingEpisodes}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.searchLabelContainer}>
|
||||
<span className={styles.searchLabel}>
|
||||
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchInputContainer}
|
||||
className={styles.searchInput}
|
||||
name="searchForCutoffUnmetEpisodes"
|
||||
onChange={onInputChange}
|
||||
{...searchForCutoffUnmetEpisodes}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SpinnerButton
|
||||
className={styles.addButton}
|
||||
kind={kinds.SUCCESS}
|
||||
isSpinning={isAdding}
|
||||
onPress={this.onAddSeriesPress}
|
||||
>
|
||||
{translate('AddSeriesWithTitle', { title })}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeriesModalContent.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
overview: PropTypes.string,
|
||||
initialSeriesType: PropTypes.string.isRequired,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isAdding: PropTypes.bool.isRequired,
|
||||
addError: PropTypes.object,
|
||||
rootFolderPath: PropTypes.object,
|
||||
monitor: PropTypes.object.isRequired,
|
||||
qualityProfileId: PropTypes.object,
|
||||
seriesType: PropTypes.object.isRequired,
|
||||
seasonFolder: PropTypes.object.isRequired,
|
||||
searchForMissingEpisodes: PropTypes.object.isRequired,
|
||||
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
|
||||
folder: PropTypes.string.isRequired,
|
||||
tags: PropTypes.object.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
isWindows: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onAddSeriesPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewSeriesModalContent;
|
||||
302
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx
Normal file
302
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import {
|
||||
AddSeriesOptions,
|
||||
setAddSeriesOption,
|
||||
useAddSeriesOptions,
|
||||
} from 'AddSeries/addSeriesOptionsStore';
|
||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||
import { useAppDimension } from 'App/appStore';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { getValidationFailures } from 'Helpers/Hooks/useApiMutation';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { SeriesType } from 'Series/Series';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import { useIsWindows } from 'System/Status/useSystemStatus';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useAddSeries } from './useAddSeries';
|
||||
import styles from './AddNewSeriesModalContent.css';
|
||||
|
||||
export interface AddNewSeriesModalContentProps {
|
||||
series: AddSeries;
|
||||
initialSeriesType: SeriesType;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function AddNewSeriesModalContent({
|
||||
series,
|
||||
initialSeriesType,
|
||||
onModalClose,
|
||||
}: AddNewSeriesModalContentProps) {
|
||||
const { title, year, overview, images, folder } = series;
|
||||
const options = useAddSeriesOptions();
|
||||
const isSmallScreen = useAppDimension('isSmallScreen');
|
||||
const isWindows = useIsWindows();
|
||||
|
||||
const { isAdding, addError, addSeries } = useAddSeries();
|
||||
|
||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||
return {
|
||||
...selectSettings(options, {}),
|
||||
...getValidationFailures(addError),
|
||||
};
|
||||
}, [options, addError]);
|
||||
|
||||
const [seriesType, setSeriesType] = useState<SeriesType>(
|
||||
initialSeriesType === 'standard'
|
||||
? settings.seriesType.value
|
||||
: initialSeriesType
|
||||
);
|
||||
|
||||
const {
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
rootFolderPath,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
searchForMissingEpisodes,
|
||||
seasonFolder,
|
||||
seriesType: seriesTypeSetting,
|
||||
tags,
|
||||
} = settings;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleQualityProfileIdChange = useCallback(
|
||||
({ value }: InputChanged<string | number>) => {
|
||||
setAddSeriesOption('qualityProfileId', value as number);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddSeriesPress = useCallback(() => {
|
||||
addSeries({
|
||||
...series,
|
||||
rootFolderPath: rootFolderPath.value,
|
||||
addOptions: {
|
||||
monitor: monitor.value,
|
||||
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
||||
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
||||
},
|
||||
qualityProfileId: qualityProfileId.value,
|
||||
seriesType,
|
||||
seasonFolder: seasonFolder.value,
|
||||
tags: tags.value,
|
||||
});
|
||||
}, [
|
||||
series,
|
||||
seriesType,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seasonFolder,
|
||||
searchForMissingEpisodes,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
tags,
|
||||
addSeries,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setSeriesType(seriesTypeSetting.value);
|
||||
}, [seriesTypeSetting]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{title}
|
||||
|
||||
{!title.includes(String(year)) && year ? (
|
||||
<span className={styles.year}>({year})</span>
|
||||
) : null}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.container}>
|
||||
{isSmallScreen ? null : (
|
||||
<div className={styles.poster}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
title={title}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.info}>
|
||||
{overview ? (
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
) : null}
|
||||
|
||||
<Form
|
||||
validationErrors={validationErrors}
|
||||
validationWarnings={validationWarnings}
|
||||
>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('RootFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.ROOT_FOLDER_SELECT}
|
||||
name="rootFolderPath"
|
||||
valueOptions={{
|
||||
seriesFolder: folder,
|
||||
isWindows,
|
||||
}}
|
||||
selectedValueOptions={{
|
||||
seriesFolder: folder,
|
||||
isWindows,
|
||||
}}
|
||||
helpText={translate('AddNewSeriesRootFolderHelpText', {
|
||||
folder,
|
||||
})}
|
||||
onChange={handleInputChange}
|
||||
{...rootFolderPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('Monitor')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon className={styles.labelIcon} name={icons.INFO} />
|
||||
}
|
||||
title={translate('MonitoringOptions')}
|
||||
body={<SeriesMonitoringOptionsPopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
onChange={handleInputChange}
|
||||
{...monitor}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
onChange={handleQualityProfileIdChange}
|
||||
{...qualityProfileId}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('SeriesType')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon className={styles.labelIcon} name={icons.INFO} />
|
||||
}
|
||||
title={translate('SeriesTypes')}
|
||||
body={<SeriesTypePopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
onChange={handleInputChange}
|
||||
{...seriesTypeSetting}
|
||||
value={seriesType}
|
||||
helpText={translate('SeriesTypesHelpText')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('SeasonFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
onChange={handleInputChange}
|
||||
{...seasonFolder}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
onChange={handleInputChange}
|
||||
{...tags}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div>
|
||||
<label className={styles.searchLabelContainer}>
|
||||
<span className={styles.searchLabel}>
|
||||
{translate('AddNewSeriesSearchForMissingEpisodes')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchInputContainer}
|
||||
className={styles.searchInput}
|
||||
name="searchForMissingEpisodes"
|
||||
onChange={handleInputChange}
|
||||
{...searchForMissingEpisodes}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.searchLabelContainer}>
|
||||
<span className={styles.searchLabel}>
|
||||
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchInputContainer}
|
||||
className={styles.searchInput}
|
||||
name="searchForCutoffUnmetEpisodes"
|
||||
onChange={handleInputChange}
|
||||
{...searchForCutoffUnmetEpisodes}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SpinnerButton
|
||||
className={styles.addButton}
|
||||
kind={kinds.SUCCESS}
|
||||
isSpinning={isAdding}
|
||||
onPress={handleAddSeriesPress}
|
||||
>
|
||||
{translate('AddSeriesWithTitle', { title })}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewSeriesModalContent;
|
||||
@@ -1,110 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import AddNewSeriesModalContent from './AddNewSeriesModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.addSeries,
|
||||
createDimensionsSelector(),
|
||||
createSystemStatusSelector(),
|
||||
(addSeriesState, dimensions, systemStatus) => {
|
||||
const {
|
||||
isAdding,
|
||||
addError,
|
||||
defaults
|
||||
} = addSeriesState;
|
||||
|
||||
const {
|
||||
settings,
|
||||
validationErrors,
|
||||
validationWarnings
|
||||
} = selectSettings(defaults, {}, addError);
|
||||
|
||||
return {
|
||||
isAdding,
|
||||
addError,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
validationErrors,
|
||||
validationWarnings,
|
||||
isWindows: systemStatus.isWindows,
|
||||
...settings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setAddSeriesDefault,
|
||||
addSeries
|
||||
};
|
||||
|
||||
class AddNewSeriesModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setAddSeriesDefault({ [name]: value });
|
||||
};
|
||||
|
||||
onAddSeriesPress = (seriesType) => {
|
||||
const {
|
||||
tvdbId,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seasonFolder,
|
||||
searchForMissingEpisodes,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
tags
|
||||
} = this.props;
|
||||
|
||||
this.props.addSeries({
|
||||
tvdbId,
|
||||
rootFolderPath: rootFolderPath.value,
|
||||
monitor: monitor.value,
|
||||
qualityProfileId: qualityProfileId.value,
|
||||
seriesType,
|
||||
seasonFolder: seasonFolder.value,
|
||||
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
||||
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
||||
tags: tags.value
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<AddNewSeriesModalContent
|
||||
{...this.props}
|
||||
onInputChange={this.onInputChange}
|
||||
onAddSeriesPress={this.onAddSeriesPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeriesModalContentConnector.propTypes = {
|
||||
tvdbId: PropTypes.number.isRequired,
|
||||
rootFolderPath: PropTypes.object,
|
||||
monitor: PropTypes.object.isRequired,
|
||||
qualityProfileId: PropTypes.object,
|
||||
seriesType: PropTypes.object.isRequired,
|
||||
seasonFolder: PropTypes.object.isRequired,
|
||||
searchForMissingEpisodes: PropTypes.object.isRequired,
|
||||
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
|
||||
tags: PropTypes.object.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
setAddSeriesDefault: PropTypes.func.isRequired,
|
||||
addSeries: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector);
|
||||
@@ -97,6 +97,12 @@
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.excludedIcon {
|
||||
margin-left: 10px;
|
||||
color: var(--dangerColor);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.overview {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
interface CssExports {
|
||||
'alreadyExistsIcon': string;
|
||||
'content': string;
|
||||
'excludedIcon': string;
|
||||
'genres': string;
|
||||
'icons': string;
|
||||
'network': string;
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import HeartRating from 'Components/HeartRating';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import Link from 'Components/Link/Link';
|
||||
import MetadataAttribution from 'Components/MetadataAttribution';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import SeriesGenres from 'Series/SeriesGenres';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesModal from './AddNewSeriesModal';
|
||||
import styles from './AddNewSeriesSearchResult.css';
|
||||
|
||||
class AddNewSeriesSearchResult extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isNewAddSeriesModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!prevProps.isExistingSeries && this.props.isExistingSeries) {
|
||||
this.onAddSeriesModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.setState({ isNewAddSeriesModalOpen: true });
|
||||
};
|
||||
|
||||
onAddSeriesModalClose = () => {
|
||||
this.setState({ isNewAddSeriesModalOpen: false });
|
||||
};
|
||||
|
||||
onTVDBLinkPress = (event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
tvdbId,
|
||||
title,
|
||||
titleSlug,
|
||||
year,
|
||||
network,
|
||||
originalLanguage,
|
||||
genres,
|
||||
status,
|
||||
overview,
|
||||
statistics,
|
||||
ratings,
|
||||
folder,
|
||||
seriesType,
|
||||
images,
|
||||
isExistingSeries,
|
||||
isSmallScreen
|
||||
} = this.props;
|
||||
|
||||
const seasonCount = statistics.seasonCount;
|
||||
|
||||
const {
|
||||
isNewAddSeriesModalOpen
|
||||
} = this.state;
|
||||
|
||||
const linkProps = isExistingSeries ? { to: `/series/${titleSlug}` } : { onPress: this.onPress };
|
||||
let seasons = translate('OneSeason');
|
||||
|
||||
if (seasonCount > 1) {
|
||||
seasons = translate('CountSeasons', { count: seasonCount });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.searchResult}>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
{...linkProps}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay}>
|
||||
{
|
||||
isSmallScreen ?
|
||||
null :
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
overflow={true}
|
||||
lazy={false}
|
||||
/>
|
||||
}
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
|
||||
{
|
||||
!title.contains(year) && year ?
|
||||
<span className={styles.year}>
|
||||
({year})
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.icons}>
|
||||
{
|
||||
isExistingSeries ?
|
||||
<Icon
|
||||
className={styles.alreadyExistsIcon}
|
||||
name={icons.CHECK_CIRCLE}
|
||||
size={36}
|
||||
title={translate('AlreadyInYourLibrary')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
<Link
|
||||
className={styles.tvdbLink}
|
||||
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||
onPress={this.onTVDBLinkPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.tvdbLinkIcon}
|
||||
name={icons.EXTERNAL_LINK}
|
||||
size={28}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label size={sizes.LARGE}>
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={13}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
{
|
||||
originalLanguage?.name ?
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon
|
||||
name={icons.LANGUAGE}
|
||||
size={13}
|
||||
/>
|
||||
|
||||
<span className={styles.originalLanguageName}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
network ?
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon
|
||||
name={icons.NETWORK}
|
||||
size={13}
|
||||
/>
|
||||
|
||||
<span className={styles.network}>
|
||||
{network}
|
||||
</span>
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
genres.length > 0 ?
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon
|
||||
name={icons.GENRE}
|
||||
size={13}
|
||||
/>
|
||||
<SeriesGenres className={styles.genres} genres={genres} />
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
seasonCount ?
|
||||
<Label size={sizes.LARGE}>
|
||||
{seasons}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
status === 'ended' ?
|
||||
<Label
|
||||
kind={kinds.DANGER}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
{translate('Ended')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
status === 'upcoming' ?
|
||||
<Label
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
{translate('Upcoming')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>
|
||||
{overview}
|
||||
</div>
|
||||
|
||||
<MetadataAttribution />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddNewSeriesModal
|
||||
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
|
||||
tvdbId={tvdbId}
|
||||
title={title}
|
||||
year={year}
|
||||
overview={overview}
|
||||
folder={folder}
|
||||
initialSeriesType={seriesType}
|
||||
images={images}
|
||||
onModalClose={this.onAddSeriesModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewSeriesSearchResult.propTypes = {
|
||||
tvdbId: PropTypes.number.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
network: PropTypes.string,
|
||||
originalLanguage: PropTypes.object,
|
||||
genres: PropTypes.arrayOf(PropTypes.string),
|
||||
status: PropTypes.string.isRequired,
|
||||
overview: PropTypes.string,
|
||||
statistics: PropTypes.object.isRequired,
|
||||
ratings: PropTypes.object.isRequired,
|
||||
folder: PropTypes.string.isRequired,
|
||||
seriesType: PropTypes.string.isRequired,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isExistingSeries: PropTypes.bool.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
AddNewSeriesSearchResult.defaultProps = {
|
||||
genres: []
|
||||
};
|
||||
|
||||
export default AddNewSeriesSearchResult;
|
||||
192
frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx
Normal file
192
frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import { useAppDimension } from 'App/appStore';
|
||||
import HeartRating from 'Components/HeartRating';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import Link from 'Components/Link/Link';
|
||||
import MetadataAttribution from 'Components/MetadataAttribution';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import { Statistics } from 'Series/Series';
|
||||
import SeriesGenres from 'Series/SeriesGenres';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import useExistingSeries from 'Series/useExistingSeries';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesModal from './AddNewSeriesModal';
|
||||
import styles from './AddNewSeriesSearchResult.css';
|
||||
|
||||
interface AddNewSeriesSearchResultProps {
|
||||
series: AddSeries;
|
||||
}
|
||||
|
||||
function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
||||
const {
|
||||
tvdbId,
|
||||
titleSlug,
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
originalLanguage,
|
||||
genres = [],
|
||||
status,
|
||||
statistics = {} as Statistics,
|
||||
ratings,
|
||||
overview,
|
||||
seriesType,
|
||||
images,
|
||||
isExcluded,
|
||||
} = series;
|
||||
|
||||
const isExistingSeries = useExistingSeries(tvdbId);
|
||||
const isSmallScreen = useAppDimension('isSmallScreen');
|
||||
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
|
||||
|
||||
const seasonCount = statistics.seasonCount;
|
||||
const handlePress = useCallback(() => {
|
||||
setIsNewAddSeriesModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddSeriesModalClose = useCallback(() => {
|
||||
setIsNewAddSeriesModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleTvdbLinkPress = useCallback((event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const linkProps = isExistingSeries
|
||||
? { to: `/series/${titleSlug}` }
|
||||
: { onPress: handlePress };
|
||||
let seasons = translate('OneSeason');
|
||||
|
||||
if (seasonCount > 1) {
|
||||
seasons = translate('CountSeasons', { count: seasonCount });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.searchResult}>
|
||||
<Link className={styles.underlay} {...linkProps} />
|
||||
|
||||
<div className={styles.overlay}>
|
||||
{isSmallScreen ? null : (
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
overflow={true}
|
||||
lazy={false}
|
||||
title={title}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
|
||||
{!title.includes(String(year)) && year ? (
|
||||
<span className={styles.year}>({year})</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.icons}>
|
||||
{isExistingSeries ? (
|
||||
<Icon
|
||||
className={styles.alreadyExistsIcon}
|
||||
name={icons.CHECK_CIRCLE}
|
||||
size={36}
|
||||
title={translate('AlreadyInYourLibrary')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isExcluded ? (
|
||||
<Icon
|
||||
className={styles.excludedIcon}
|
||||
name={icons.DANGER}
|
||||
size={36}
|
||||
title={translate('SeriesInImportListExclusions')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Link
|
||||
className={styles.tvdbLink}
|
||||
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||
onPress={handleTvdbLinkPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.tvdbLinkIcon}
|
||||
name={icons.EXTERNAL_LINK}
|
||||
size={28}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label size={sizes.LARGE}>
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={13}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
{originalLanguage?.name ? (
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon name={icons.LANGUAGE} size={13} />
|
||||
|
||||
<span className={styles.originalLanguageName}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{network ? (
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon name={icons.NETWORK} size={13} />
|
||||
|
||||
<span className={styles.network}>{network}</span>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{genres.length > 0 ? (
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon name={icons.GENRE} size={13} />
|
||||
<SeriesGenres className={styles.genres} genres={genres} />
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{seasonCount ? <Label size={sizes.LARGE}>{seasons}</Label> : null}
|
||||
|
||||
{status === 'ended' ? (
|
||||
<Label kind={kinds.DANGER} size={sizes.LARGE}>
|
||||
{translate('Ended')}
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{status === 'upcoming' ? (
|
||||
<Label kind={kinds.INFO} size={sizes.LARGE}>
|
||||
{translate('Upcoming')}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
|
||||
<MetadataAttribution />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddNewSeriesModal
|
||||
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
|
||||
series={series}
|
||||
initialSeriesType={seriesType}
|
||||
onModalClose={handleAddSeriesModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewSeriesSearchResult;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
||||
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createExistingSeriesSelector(),
|
||||
createDimensionsSelector(),
|
||||
(isExistingSeries, dimensions) => {
|
||||
return {
|
||||
isExistingSeries,
|
||||
isSmallScreen: dimensions.isSmallScreen
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(AddNewSeriesSearchResult);
|
||||
62
frontend/src/AddSeries/AddNewSeries/useAddSeries.ts
Normal file
62
frontend/src/AddSeries/AddNewSeries/useAddSeries.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import Series from 'Series/Series';
|
||||
|
||||
interface AddSeriesPayload
|
||||
extends AddSeries,
|
||||
Omit<
|
||||
AddSeriesOptions,
|
||||
'monitor' | 'searchForMissingEpisodes' | 'searchForCutoffUnmetEpisodes'
|
||||
> {}
|
||||
|
||||
const DEFAULT_SERIES: AddSeries[] = [];
|
||||
|
||||
export const useLookupSeries = (query: string, isEnabled = true) => {
|
||||
const result = useApiQuery<AddSeries[]>({
|
||||
path: '/series/lookup',
|
||||
queryParams: {
|
||||
term: query,
|
||||
},
|
||||
queryOptions: {
|
||||
enabled: isEnabled && !!query,
|
||||
// Disable refetch on window focus to prevent refetching when the user switch tabs
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: result.data ?? DEFAULT_SERIES,
|
||||
};
|
||||
};
|
||||
|
||||
export const useAddSeries = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
|
||||
{
|
||||
path: '/series',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: (newSeries) => {
|
||||
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
|
||||
if (!oldSeries) {
|
||||
return [newSeries];
|
||||
}
|
||||
|
||||
return [...oldSeries, newSeries];
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isAdding: isPending,
|
||||
addError: error,
|
||||
addSeries: mutate,
|
||||
};
|
||||
};
|
||||
8
frontend/src/AddSeries/AddSeries.ts
Normal file
8
frontend/src/AddSeries/AddSeries.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import Series from 'Series/Series';
|
||||
|
||||
interface AddSeries extends Series {
|
||||
folder: string;
|
||||
isExcluded: boolean;
|
||||
}
|
||||
|
||||
export default AddSeries;
|
||||
@@ -1,179 +0,0 @@
|
||||
import { reduce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import ImportSeriesFooterConnector from './ImportSeriesFooterConnector';
|
||||
import ImportSeriesTableConnector from './ImportSeriesTableConnector';
|
||||
|
||||
class ImportSeries extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.scrollerRef = React.createRef();
|
||||
|
||||
this.state = {
|
||||
allSelected: false,
|
||||
allUnselected: false,
|
||||
lastToggled: null,
|
||||
selectedState: {}
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
getSelectedIds = () => {
|
||||
return reduce(
|
||||
this.state.selectedState,
|
||||
(result, value, id) => {
|
||||
if (value) {
|
||||
result.push(id);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
onSelectAllChange = ({ value }) => {
|
||||
// Only select non-dupes
|
||||
this.setState(selectAll(this.state.selectedState, value));
|
||||
};
|
||||
|
||||
onSelectedChange = ({ id, value, shiftKey = false }) => {
|
||||
this.setState((state) => {
|
||||
return toggleSelected(state, this.props.items, id, value, shiftKey);
|
||||
});
|
||||
};
|
||||
|
||||
onRemoveSelectedStateItem = (id) => {
|
||||
this.setState((state) => {
|
||||
const selectedState = Object.assign({}, state.selectedState);
|
||||
delete selectedState[id];
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedState
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.onInputChange(this.getSelectedIds(), name, value);
|
||||
};
|
||||
|
||||
onImportPress = () => {
|
||||
this.props.onImportPress(this.getSelectedIds());
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
rootFolderId,
|
||||
path,
|
||||
rootFoldersFetching,
|
||||
rootFoldersPopulated,
|
||||
rootFoldersError,
|
||||
unmappedFolders
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
allSelected,
|
||||
allUnselected,
|
||||
selectedState
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<PageContent title={translate('ImportSeries')}>
|
||||
<PageContentBody ref={this.scrollerRef} >
|
||||
{
|
||||
rootFoldersFetching ? <LoadingIndicator /> : null
|
||||
}
|
||||
|
||||
{
|
||||
!rootFoldersFetching && !!rootFoldersError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('RootFoldersLoadError')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
rootFoldersPopulated &&
|
||||
!unmappedFolders.length ?
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
rootFoldersPopulated &&
|
||||
!!unmappedFolders.length &&
|
||||
this.scrollerRef.current ?
|
||||
<ImportSeriesTableConnector
|
||||
rootFolderId={rootFolderId}
|
||||
unmappedFolders={unmappedFolders}
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
selectedState={selectedState}
|
||||
scroller={this.scrollerRef.current}
|
||||
onSelectAllChange={this.onSelectAllChange}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
onRemoveSelectedStateItem={this.onRemoveSelectedStateItem}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</PageContentBody>
|
||||
|
||||
{
|
||||
!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
!!unmappedFolders.length ?
|
||||
<ImportSeriesFooterConnector
|
||||
selectedIds={this.getSelectedIds()}
|
||||
onInputChange={this.onInputChange}
|
||||
onImportPress={this.onImportPress}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeries.propTypes = {
|
||||
rootFolderId: PropTypes.number.isRequired,
|
||||
path: PropTypes.string,
|
||||
rootFoldersFetching: PropTypes.bool.isRequired,
|
||||
rootFoldersPopulated: PropTypes.bool.isRequired,
|
||||
rootFoldersError: PropTypes.object,
|
||||
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onImportPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ImportSeries.defaultProps = {
|
||||
unmappedFolders: []
|
||||
};
|
||||
|
||||
export default ImportSeries;
|
||||
119
frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx
Normal file
119
frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import {
|
||||
setAddSeriesOption,
|
||||
useAddSeriesOption,
|
||||
} from 'AddSeries/addSeriesOptionsStore';
|
||||
import { SelectProvider } from 'App/Select/SelectContext';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import useRootFolders, { useRootFolder } from 'RootFolder/useRootFolders';
|
||||
import { useQualityProfilesData } from 'Settings/Profiles/Quality/useQualityProfiles';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import ImportSeriesFooter from './ImportSeriesFooter';
|
||||
import { clearImportSeries } from './importSeriesStore';
|
||||
import ImportSeriesTable from './ImportSeriesTable';
|
||||
|
||||
function ImportSeries() {
|
||||
const { rootFolderId: rootFolderIdString } = useParams<{
|
||||
rootFolderId: string;
|
||||
}>();
|
||||
const rootFolderId = parseInt(rootFolderIdString);
|
||||
|
||||
const {
|
||||
isFetching: rootFoldersFetching,
|
||||
isFetched: rootFoldersFetched,
|
||||
error: rootFoldersError,
|
||||
data: rootFolders,
|
||||
} = useRootFolders();
|
||||
|
||||
useRootFolder(rootFolderId, false);
|
||||
|
||||
const { path, unmappedFolders } = useMemo(() => {
|
||||
const rootFolder = rootFolders.find((r) => r.id === rootFolderId);
|
||||
|
||||
return {
|
||||
path: rootFolder?.path ?? '',
|
||||
unmappedFolders:
|
||||
rootFolder?.unmappedFolders.map((unmappedFolders) => {
|
||||
return {
|
||||
...unmappedFolders,
|
||||
id: unmappedFolders.name,
|
||||
};
|
||||
}) ?? [],
|
||||
};
|
||||
}, [rootFolders, rootFolderId]);
|
||||
|
||||
const qualityProfiles = useQualityProfilesData();
|
||||
|
||||
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const items = useMemo(() => {
|
||||
return unmappedFolders.map((unmappedFolder) => {
|
||||
return {
|
||||
...unmappedFolder,
|
||||
id: unmappedFolder.name,
|
||||
};
|
||||
});
|
||||
}, [unmappedFolders]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearImportSeries();
|
||||
};
|
||||
}, [rootFolderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!defaultQualityProfileId ||
|
||||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
|
||||
) {
|
||||
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
|
||||
}
|
||||
}, [defaultQualityProfileId, qualityProfiles]);
|
||||
|
||||
return (
|
||||
<SelectProvider items={items}>
|
||||
<PageContent title={translate('ImportSeries')}>
|
||||
<PageContentBody ref={scrollerRef}>
|
||||
{rootFoldersFetching && !rootFoldersFetched ? (
|
||||
<LoadingIndicator />
|
||||
) : null}
|
||||
|
||||
{!rootFoldersFetching && !!rootFoldersError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('RootFoldersLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!rootFoldersError &&
|
||||
!rootFoldersFetching &&
|
||||
rootFoldersFetched &&
|
||||
!unmappedFolders.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!rootFoldersError &&
|
||||
rootFoldersFetched &&
|
||||
!!unmappedFolders.length &&
|
||||
scrollerRef.current ? (
|
||||
<ImportSeriesTable items={items} scrollerRef={scrollerRef} />
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
|
||||
{!rootFoldersError && rootFoldersFetched && !!unmappedFolders.length ? (
|
||||
<ImportSeriesFooter />
|
||||
) : null}
|
||||
</PageContent>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeries;
|
||||
@@ -1,153 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
|
||||
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import { clearImportSeries, importSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import ImportSeries from './ImportSeries';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { match }) => match,
|
||||
(state) => state.rootFolders,
|
||||
(state) => state.addSeries,
|
||||
(state) => state.importSeries,
|
||||
(state) => state.settings.qualityProfiles,
|
||||
(
|
||||
match,
|
||||
rootFolders,
|
||||
addSeries,
|
||||
importSeriesState,
|
||||
qualityProfiles
|
||||
) => {
|
||||
const {
|
||||
isFetching: rootFoldersFetching,
|
||||
isPopulated: rootFoldersPopulated,
|
||||
error: rootFoldersError,
|
||||
items
|
||||
} = rootFolders;
|
||||
|
||||
const rootFolderId = parseInt(match.params.rootFolderId);
|
||||
|
||||
const result = {
|
||||
rootFolderId,
|
||||
rootFoldersFetching,
|
||||
rootFoldersPopulated,
|
||||
rootFoldersError,
|
||||
qualityProfiles: qualityProfiles.items,
|
||||
defaultQualityProfileId: addSeries.defaults.qualityProfileId
|
||||
};
|
||||
|
||||
if (items.length) {
|
||||
const rootFolder = _.find(items, { id: rootFolderId });
|
||||
|
||||
return {
|
||||
...result,
|
||||
...rootFolder,
|
||||
items: importSeriesState.items
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchSetImportSeriesValue: setImportSeriesValue,
|
||||
dispatchImportSeries: importSeries,
|
||||
dispatchClearImportSeries: clearImportSeries,
|
||||
dispatchFetchRootFolders: fetchRootFolders,
|
||||
dispatchSetAddSeriesDefault: setAddSeriesDefault
|
||||
};
|
||||
|
||||
class ImportSeriesConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
rootFolderId,
|
||||
qualityProfiles,
|
||||
defaultQualityProfileId,
|
||||
dispatchFetchRootFolders,
|
||||
dispatchSetAddSeriesDefault
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchRootFolders({ id: rootFolderId, timeout: false });
|
||||
|
||||
let setDefaults = false;
|
||||
const setDefaultPayload = {};
|
||||
|
||||
if (
|
||||
!defaultQualityProfileId ||
|
||||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
|
||||
) {
|
||||
setDefaults = true;
|
||||
setDefaultPayload.qualityProfileId = qualityProfiles[0].id;
|
||||
}
|
||||
|
||||
if (setDefaults) {
|
||||
dispatchSetAddSeriesDefault(setDefaultPayload);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.dispatchClearImportSeries();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = (ids, name, value) => {
|
||||
this.props.dispatchSetAddSeriesDefault({ [name]: value });
|
||||
|
||||
ids.forEach((id) => {
|
||||
this.props.dispatchSetImportSeriesValue({
|
||||
id,
|
||||
[name]: value
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
onImportPress = (ids) => {
|
||||
this.props.dispatchImportSeries({ ids });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ImportSeries
|
||||
{...this.props}
|
||||
onInputChange={this.onInputChange}
|
||||
onImportPress={this.onImportPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const routeMatchShape = createRouteMatchShape({
|
||||
rootFolderId: PropTypes.string.isRequired
|
||||
});
|
||||
|
||||
ImportSeriesConnector.propTypes = {
|
||||
match: routeMatchShape.isRequired,
|
||||
rootFolderId: PropTypes.number.isRequired,
|
||||
rootFoldersFetching: PropTypes.bool.isRequired,
|
||||
rootFoldersPopulated: PropTypes.bool.isRequired,
|
||||
qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
defaultQualityProfileId: PropTypes.number.isRequired,
|
||||
dispatchSetImportSeriesValue: PropTypes.func.isRequired,
|
||||
dispatchImportSeries: PropTypes.func.isRequired,
|
||||
dispatchClearImportSeries: PropTypes.func.isRequired,
|
||||
dispatchFetchRootFolders: PropTypes.func.isRequired,
|
||||
dispatchSetAddSeriesDefault: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector);
|
||||
@@ -1,300 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContentFooter from 'Components/Page/PageContentFooter';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesFooter.css';
|
||||
|
||||
const MIXED = 'mixed';
|
||||
|
||||
class ImportSeriesFooter extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const {
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeasonFolder,
|
||||
defaultSeriesType
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
monitor: defaultMonitor,
|
||||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeriesType,
|
||||
defaultSeasonFolder,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
isSeasonFolderMixed
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
seasonFolder
|
||||
} = this.state;
|
||||
|
||||
const newState = {};
|
||||
|
||||
if (isMonitorMixed && monitor !== MIXED) {
|
||||
newState.monitor = MIXED;
|
||||
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
|
||||
newState.monitor = defaultMonitor;
|
||||
}
|
||||
|
||||
if (isQualityProfileIdMixed && qualityProfileId !== MIXED) {
|
||||
newState.qualityProfileId = MIXED;
|
||||
} else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) {
|
||||
newState.qualityProfileId = defaultQualityProfileId;
|
||||
}
|
||||
|
||||
if (isSeriesTypeMixed && seriesType !== MIXED) {
|
||||
newState.seriesType = MIXED;
|
||||
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
|
||||
newState.seriesType = defaultSeriesType;
|
||||
}
|
||||
|
||||
if (isSeasonFolderMixed && seasonFolder != null) {
|
||||
newState.seasonFolder = null;
|
||||
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
|
||||
newState.seasonFolder = defaultSeasonFolder;
|
||||
}
|
||||
|
||||
if (!_.isEmpty(newState)) {
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.setState({ [name]: value });
|
||||
this.props.onInputChange({ name, value });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedCount,
|
||||
isImporting,
|
||||
isLookingUpSeries,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
hasUnsearchedItems,
|
||||
importError,
|
||||
onImportPress,
|
||||
onLookupPress,
|
||||
onCancelLookupPress
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
seasonFolder
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<PageContentFooter>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>
|
||||
{translate('Monitor')}
|
||||
</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isMonitorMixed}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>
|
||||
{translate('QualityProfile')}
|
||||
</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isQualityProfileIdMixed}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>
|
||||
{translate('SeriesType')}
|
||||
</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
value={seriesType}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isSeriesTypeMixed}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>
|
||||
{translate('SeasonFolder')}
|
||||
</div>
|
||||
|
||||
<CheckInput
|
||||
name="seasonFolder"
|
||||
value={seasonFolder}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.label}>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.importButtonContainer}>
|
||||
<SpinnerButton
|
||||
className={styles.importButton}
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isImporting}
|
||||
isDisabled={!selectedCount || isLookingUpSeries}
|
||||
onPress={onImportPress}
|
||||
>
|
||||
{translate('ImportCountSeries', { selectedCount })}
|
||||
</SpinnerButton>
|
||||
|
||||
{
|
||||
isLookingUpSeries ?
|
||||
<Button
|
||||
className={styles.loadingButton}
|
||||
kind={kinds.WARNING}
|
||||
onPress={onCancelLookupPress}
|
||||
>
|
||||
{translate('CancelProcessing')}
|
||||
</Button> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
hasUnsearchedItems ?
|
||||
<Button
|
||||
className={styles.loadingButton}
|
||||
kind={kinds.SUCCESS}
|
||||
onPress={onLookupPress}
|
||||
>
|
||||
{translate('StartProcessing')}
|
||||
</Button> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isLookingUpSeries ?
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={24}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isLookingUpSeries ?
|
||||
translate('ProcessingFolders') :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
importError ?
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.importError}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
}
|
||||
title={translate('ImportErrors')}
|
||||
body={
|
||||
<ul>
|
||||
{
|
||||
Array.isArray(importError.responseJSON) ?
|
||||
importError.responseJSON.map((error, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
{error.errorMessage}
|
||||
</li>
|
||||
);
|
||||
}) :
|
||||
<li>
|
||||
{
|
||||
JSON.stringify(importError.responseJSON)
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PageContentFooter>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesFooter.propTypes = {
|
||||
selectedCount: PropTypes.number.isRequired,
|
||||
isImporting: PropTypes.bool.isRequired,
|
||||
isLookingUpSeries: PropTypes.bool.isRequired,
|
||||
defaultMonitor: PropTypes.string.isRequired,
|
||||
defaultQualityProfileId: PropTypes.number,
|
||||
defaultSeriesType: PropTypes.string.isRequired,
|
||||
defaultSeasonFolder: PropTypes.bool.isRequired,
|
||||
isMonitorMixed: PropTypes.bool.isRequired,
|
||||
isQualityProfileIdMixed: PropTypes.bool.isRequired,
|
||||
isSeriesTypeMixed: PropTypes.bool.isRequired,
|
||||
isSeasonFolderMixed: PropTypes.bool.isRequired,
|
||||
hasUnsearchedItems: PropTypes.bool.isRequired,
|
||||
importError: PropTypes.object,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onImportPress: PropTypes.func.isRequired,
|
||||
onLookupPress: PropTypes.func.isRequired,
|
||||
onCancelLookupPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesFooter;
|
||||
@@ -0,0 +1,305 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AddSeriesOptions,
|
||||
setAddSeriesOption,
|
||||
useAddSeriesOptions,
|
||||
} from 'AddSeries/addSeriesOptionsStore';
|
||||
import { useSelect } from 'App/Select/SelectContext';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContentFooter from 'Components/Page/PageContentFooter';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
ImportSeriesItem,
|
||||
startProcessing,
|
||||
stopProcessing,
|
||||
updateImportSeriesItem,
|
||||
useImportSeriesItems,
|
||||
useLookupQueueHasItems,
|
||||
} from './importSeriesStore';
|
||||
import { useImportSeries } from './useImportSeries';
|
||||
import styles from './ImportSeriesFooter.css';
|
||||
|
||||
type MixedType = 'mixed';
|
||||
|
||||
function ImportSeriesFooter() {
|
||||
const {
|
||||
monitor: defaultMonitor,
|
||||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder,
|
||||
} = useAddSeriesOptions();
|
||||
|
||||
const items = useImportSeriesItems();
|
||||
const isLookingUpSeries = useLookupQueueHasItems();
|
||||
|
||||
const [monitor, setMonitor] = useState<SeriesMonitor | MixedType>(
|
||||
defaultMonitor
|
||||
);
|
||||
const [qualityProfileId, setQualityProfileId] = useState<number | MixedType>(
|
||||
defaultQualityProfileId
|
||||
);
|
||||
const [seriesType, setSeriesType] = useState<SeriesType | MixedType>(
|
||||
defaultSeriesType
|
||||
);
|
||||
const [seasonFolder, setSeasonFolder] = useState<boolean | MixedType>(
|
||||
defaultSeasonFolder
|
||||
);
|
||||
|
||||
const { selectedCount, getSelectedIds } = useSelect<ImportSeriesItem>();
|
||||
|
||||
const { importSeries, isImporting, importError } = useImportSeries();
|
||||
|
||||
const {
|
||||
hasUnsearchedItems,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
isSeasonFolderMixed,
|
||||
} = useMemo(() => {
|
||||
let isMonitorMixed = false;
|
||||
let isQualityProfileIdMixed = false;
|
||||
let isSeriesTypeMixed = false;
|
||||
let isSeasonFolderMixed = false;
|
||||
let hasUnsearchedItems = false;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.monitor !== defaultMonitor) {
|
||||
isMonitorMixed = true;
|
||||
}
|
||||
|
||||
if (item.qualityProfileId !== defaultQualityProfileId) {
|
||||
isQualityProfileIdMixed = true;
|
||||
}
|
||||
|
||||
if (item.seriesType !== defaultSeriesType) {
|
||||
isSeriesTypeMixed = true;
|
||||
}
|
||||
|
||||
if (item.seasonFolder !== defaultSeasonFolder) {
|
||||
isSeasonFolderMixed = true;
|
||||
}
|
||||
|
||||
if (!item.hasSearched) {
|
||||
hasUnsearchedItems = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hasUnsearchedItems: !isLookingUpSeries && hasUnsearchedItems,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
isSeasonFolderMixed,
|
||||
};
|
||||
}, [
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeasonFolder,
|
||||
defaultSeriesType,
|
||||
items,
|
||||
isLookingUpSeries,
|
||||
]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
||||
if (name === 'monitor') {
|
||||
setMonitor(value as SeriesMonitor);
|
||||
} else if (name === 'qualityProfileId') {
|
||||
setQualityProfileId(value as number);
|
||||
} else if (name === 'seriesType') {
|
||||
setSeriesType(value as SeriesType);
|
||||
} else if (name === 'seasonFolder') {
|
||||
setSeasonFolder(value as boolean);
|
||||
}
|
||||
|
||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
||||
|
||||
getSelectedIds().forEach((id) => {
|
||||
updateImportSeriesItem({
|
||||
id,
|
||||
[name]: value,
|
||||
});
|
||||
});
|
||||
},
|
||||
[getSelectedIds]
|
||||
);
|
||||
|
||||
const handleLookupPress = useCallback(() => {
|
||||
startProcessing();
|
||||
}, []);
|
||||
|
||||
const handleCancelLookupPress = useCallback(() => {
|
||||
stopProcessing();
|
||||
}, []);
|
||||
|
||||
const handleImportPress = useCallback(() => {
|
||||
importSeries(getSelectedIds());
|
||||
}, [importSeries, getSelectedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMonitorMixed && monitor !== 'mixed') {
|
||||
setMonitor('mixed');
|
||||
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
|
||||
setMonitor(defaultMonitor);
|
||||
}
|
||||
}, [defaultMonitor, isMonitorMixed, monitor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isQualityProfileIdMixed && qualityProfileId !== 'mixed') {
|
||||
setQualityProfileId('mixed');
|
||||
} else if (
|
||||
!isQualityProfileIdMixed &&
|
||||
qualityProfileId !== defaultQualityProfileId
|
||||
) {
|
||||
setQualityProfileId(defaultQualityProfileId);
|
||||
}
|
||||
}, [defaultQualityProfileId, isQualityProfileIdMixed, qualityProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSeriesTypeMixed && seriesType !== 'mixed') {
|
||||
setSeriesType('mixed');
|
||||
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
|
||||
setSeriesType(defaultSeriesType);
|
||||
}
|
||||
}, [defaultSeriesType, isSeriesTypeMixed, seriesType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSeasonFolderMixed && seasonFolder !== 'mixed') {
|
||||
setSeasonFolder('mixed');
|
||||
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
|
||||
setSeasonFolder(defaultSeasonFolder);
|
||||
}
|
||||
}, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]);
|
||||
|
||||
return (
|
||||
<PageContentFooter>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>{translate('Monitor')}</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isMonitorMixed}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>{translate('QualityProfile')}</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isQualityProfileIdMixed}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>{translate('SeriesType')}</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
value={seriesType}
|
||||
isDisabled={!selectedCount}
|
||||
includeMixed={isSeriesTypeMixed}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.label}>{translate('SeasonFolder')}</div>
|
||||
|
||||
<CheckInput
|
||||
name="seasonFolder"
|
||||
value={seasonFolder}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.label}> </div>
|
||||
|
||||
<div className={styles.importButtonContainer}>
|
||||
<SpinnerButton
|
||||
className={styles.importButton}
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isImporting}
|
||||
isDisabled={!selectedCount || isLookingUpSeries}
|
||||
onPress={handleImportPress}
|
||||
>
|
||||
{translate('ImportCountSeries', { selectedCount })}
|
||||
</SpinnerButton>
|
||||
|
||||
{isLookingUpSeries ? (
|
||||
<Button
|
||||
className={styles.loadingButton}
|
||||
kind={kinds.WARNING}
|
||||
onPress={handleCancelLookupPress}
|
||||
>
|
||||
{translate('CancelProcessing')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{hasUnsearchedItems ? (
|
||||
<Button
|
||||
className={styles.loadingButton}
|
||||
kind={kinds.SUCCESS}
|
||||
onPress={handleLookupPress}
|
||||
>
|
||||
{translate('StartProcessing')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isLookingUpSeries ? (
|
||||
<LoadingIndicator className={styles.loading} size={24} />
|
||||
) : null}
|
||||
|
||||
{isLookingUpSeries ? translate('ProcessingFolders') : null}
|
||||
|
||||
{importError ? (
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.importError}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
}
|
||||
title={translate('ImportErrors')}
|
||||
body={
|
||||
<ul>
|
||||
{Array.isArray(importError.statusBody) ? (
|
||||
importError.statusBody.map((error, index) => {
|
||||
return <li key={index}>{error.errorMessage}</li>;
|
||||
})
|
||||
) : (
|
||||
<li>{JSON.stringify(importError.statusBody)}</li>
|
||||
)}
|
||||
</ul>
|
||||
}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PageContentFooter>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesFooter;
|
||||
@@ -1,63 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { cancelLookupSeries, lookupUnsearchedSeries } from 'Store/Actions/importSeriesActions';
|
||||
import ImportSeriesFooter from './ImportSeriesFooter';
|
||||
|
||||
function isMixed(items, selectedIds, defaultValue, key) {
|
||||
return _.some(items, (series) => {
|
||||
return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue;
|
||||
});
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.addSeries,
|
||||
(state) => state.importSeries,
|
||||
(state, { selectedIds }) => selectedIds,
|
||||
(addSeries, importSeries, selectedIds) => {
|
||||
const {
|
||||
monitor: defaultMonitor,
|
||||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder
|
||||
} = addSeries.defaults;
|
||||
|
||||
const {
|
||||
isLookingUpSeries,
|
||||
isImporting,
|
||||
items,
|
||||
importError
|
||||
} = importSeries;
|
||||
|
||||
const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
|
||||
const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
|
||||
const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType');
|
||||
const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder');
|
||||
const hasUnsearchedItems = !isLookingUpSeries && items.some((item) => !item.isPopulated);
|
||||
|
||||
return {
|
||||
selectedCount: selectedIds.length,
|
||||
isLookingUpSeries,
|
||||
isImporting,
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeriesType,
|
||||
defaultSeasonFolder,
|
||||
isMonitorMixed,
|
||||
isQualityProfileIdMixed,
|
||||
isSeriesTypeMixed,
|
||||
isSeasonFolderMixed,
|
||||
importError,
|
||||
hasUnsearchedItems
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
onLookupPress: lookupUnsearchedSeries,
|
||||
onCancelLookupPress: cancelLookupSeries
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesFooter);
|
||||
@@ -1,4 +1,3 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||
@@ -8,16 +7,21 @@ import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
|
||||
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesHeader.css';
|
||||
|
||||
function ImportSeriesHeader(props) {
|
||||
const {
|
||||
allSelected,
|
||||
allUnselected,
|
||||
onSelectAllChange
|
||||
} = props;
|
||||
interface ImportSeriesHeaderProps {
|
||||
allSelected: boolean;
|
||||
allUnselected: boolean;
|
||||
onSelectAllChange: (change: CheckInputChanged) => void;
|
||||
}
|
||||
|
||||
function ImportSeriesHeader({
|
||||
allSelected,
|
||||
allUnselected,
|
||||
onSelectAllChange,
|
||||
}: ImportSeriesHeaderProps) {
|
||||
return (
|
||||
<VirtualTableHeader>
|
||||
<VirtualTableSelectAllHeaderCell
|
||||
@@ -26,26 +30,15 @@ function ImportSeriesHeader(props) {
|
||||
onSelectAllChange={onSelectAllChange}
|
||||
/>
|
||||
|
||||
<VirtualTableHeaderCell
|
||||
className={styles.folder}
|
||||
name="folder"
|
||||
>
|
||||
<VirtualTableHeaderCell className={styles.folder} name="folder">
|
||||
{translate('Folder')}
|
||||
</VirtualTableHeaderCell>
|
||||
|
||||
<VirtualTableHeaderCell
|
||||
className={styles.monitor}
|
||||
name="monitor"
|
||||
>
|
||||
<VirtualTableHeaderCell className={styles.monitor} name="monitor">
|
||||
{translate('Monitor')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.detailsIcon}
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
anchor={<Icon className={styles.detailsIcon} name={icons.INFO} />}
|
||||
title={translate('MonitoringOptions')}
|
||||
body={<SeriesMonitoringOptionsPopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
@@ -59,19 +52,11 @@ function ImportSeriesHeader(props) {
|
||||
{translate('QualityProfile')}
|
||||
</VirtualTableHeaderCell>
|
||||
|
||||
<VirtualTableHeaderCell
|
||||
className={styles.seriesType}
|
||||
name="seriesType"
|
||||
>
|
||||
<VirtualTableHeaderCell className={styles.seriesType} name="seriesType">
|
||||
{translate('SeriesType')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.detailsIcon}
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
anchor={<Icon className={styles.detailsIcon} name={icons.INFO} />}
|
||||
title={translate('SeriesType')}
|
||||
body={<SeriesTypePopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
@@ -85,20 +70,11 @@ function ImportSeriesHeader(props) {
|
||||
{translate('SeasonFolder')}
|
||||
</VirtualTableHeaderCell>
|
||||
|
||||
<VirtualTableHeaderCell
|
||||
className={styles.series}
|
||||
name="series"
|
||||
>
|
||||
<VirtualTableHeaderCell className={styles.series} name="series">
|
||||
{translate('Series')}
|
||||
</VirtualTableHeaderCell>
|
||||
</VirtualTableHeader>
|
||||
);
|
||||
}
|
||||
|
||||
ImportSeriesHeader.propTypes = {
|
||||
allSelected: PropTypes.bool.isRequired,
|
||||
allUnselected: PropTypes.bool.isRequired,
|
||||
onSelectAllChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesHeader;
|
||||
@@ -1,105 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector';
|
||||
import styles from './ImportSeriesRow.css';
|
||||
|
||||
function ImportSeriesRow(props) {
|
||||
const {
|
||||
id,
|
||||
relativePath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seasonFolder,
|
||||
seriesType,
|
||||
selectedSeries,
|
||||
isExistingSeries,
|
||||
isSelected,
|
||||
onSelectedChange,
|
||||
onInputChange
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualTableSelectCell
|
||||
inputClassName={styles.selectInput}
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
isDisabled={!selectedSeries || isExistingSeries}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>
|
||||
|
||||
<VirtualTableRowCell className={styles.folder}>
|
||||
{relativePath}
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.monitor}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.qualityProfile}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.seriesType}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
value={seriesType}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.seasonFolder}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
value={seasonFolder}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.series}>
|
||||
<ImportSeriesSelectSeriesConnector
|
||||
id={id}
|
||||
isExistingSeries={isExistingSeries}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ImportSeriesRow.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
relativePath: PropTypes.string.isRequired,
|
||||
monitor: PropTypes.string.isRequired,
|
||||
qualityProfileId: PropTypes.number.isRequired,
|
||||
seriesType: PropTypes.string.isRequired,
|
||||
seasonFolder: PropTypes.bool.isRequired,
|
||||
selectedSeries: PropTypes.object,
|
||||
isExistingSeries: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
onSelectedChange: PropTypes.func.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ImportSeriesRow.defaultsProps = {
|
||||
items: []
|
||||
};
|
||||
|
||||
export default ImportSeriesRow;
|
||||
125
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx
Normal file
125
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useSelect } from 'App/Select/SelectContext';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import useExistingSeries from 'Series/useExistingSeries';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import {
|
||||
ImportSeriesItem,
|
||||
UnamppedFolderItem,
|
||||
updateImportSeriesItem,
|
||||
useImportSeriesItem,
|
||||
} from './importSeriesStore';
|
||||
import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries';
|
||||
import styles from './ImportSeriesRow.css';
|
||||
|
||||
interface ImportSeriesRowProps {
|
||||
unmappedFolder: UnamppedFolderItem;
|
||||
}
|
||||
|
||||
function ImportSeriesRow({ unmappedFolder }: ImportSeriesRowProps) {
|
||||
const id = unmappedFolder.id;
|
||||
|
||||
const item = useImportSeriesItem(unmappedFolder.id);
|
||||
|
||||
const {
|
||||
relativePath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seasonFolder,
|
||||
seriesType,
|
||||
selectedSeries,
|
||||
} = item ?? {};
|
||||
|
||||
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId);
|
||||
|
||||
const { getIsSelected, toggleSelected, toggleDisabled } =
|
||||
useSelect<ImportSeriesItem>();
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
updateImportSeriesItem({ id, [name]: value });
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey }: SelectStateInputProps<string>) => {
|
||||
toggleSelected({
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[toggleSelected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
toggleDisabled(id, !selectedSeries || isExistingSeries);
|
||||
}, [id, selectedSeries, isExistingSeries, toggleDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
toggleSelected({ id, isSelected: !!selectedSeries, shiftKey: false });
|
||||
}, [id, selectedSeries, toggleSelected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualTableSelectCell<string>
|
||||
inputClassName={styles.selectInput}
|
||||
id={id}
|
||||
isSelected={getIsSelected(id)}
|
||||
isDisabled={!selectedSeries || isExistingSeries}
|
||||
onSelectedChange={handleSelectedChange}
|
||||
/>
|
||||
|
||||
<VirtualTableRowCell className={styles.folder}>
|
||||
{relativePath}
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.monitor}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.qualityProfile}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.seriesType}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
value={seriesType}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.seasonFolder}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
value={seasonFolder}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
|
||||
<VirtualTableRowCell className={styles.series}>
|
||||
<ImportSeriesSelectSeries id={id} onInputChange={handleInputChange} />
|
||||
</VirtualTableRowCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesRow;
|
||||
@@ -1,89 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import ImportSeriesRow from './ImportSeriesRow';
|
||||
|
||||
function createImportSeriesItemSelector() {
|
||||
return createSelector(
|
||||
(state, { id }) => id,
|
||||
(state) => state.importSeries.items,
|
||||
(id, items) => {
|
||||
return _.find(items, { id }) || {};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createImportSeriesItemSelector(),
|
||||
createAllSeriesSelector(),
|
||||
(item, series) => {
|
||||
const selectedSeries = item && item.selectedSeries;
|
||||
const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId });
|
||||
|
||||
return {
|
||||
...item,
|
||||
isExistingSeries
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setImportSeriesValue
|
||||
};
|
||||
|
||||
class ImportSeriesRowConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setImportSeriesValue({
|
||||
id: this.props.id,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
// Don't show the row until we have the information we require for it.
|
||||
|
||||
const {
|
||||
items,
|
||||
monitor,
|
||||
seriesType,
|
||||
seasonFolder
|
||||
} = this.props;
|
||||
|
||||
if (!items || !monitor || !seriesType || !seasonFolder == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImportSeriesRow
|
||||
{...this.props}
|
||||
onInputChange={this.onInputChange}
|
||||
onSeriesSelect={this.onSeriesSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesRowConnector.propTypes = {
|
||||
rootFolderId: PropTypes.number.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
monitor: PropTypes.string,
|
||||
seriesType: PropTypes.string,
|
||||
seasonFolder: PropTypes.bool,
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
setImportSeriesValue: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector);
|
||||
@@ -0,0 +1,7 @@
|
||||
.row {
|
||||
transition: background-color 500ms;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--tableRowHoverBackgroundColor);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'dragLayer': string;
|
||||
'row': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -1,188 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import VirtualTable from 'Components/Table/VirtualTable';
|
||||
import VirtualTableRow from 'Components/Table/VirtualTableRow';
|
||||
import ImportSeriesHeader from './ImportSeriesHeader';
|
||||
import ImportSeriesRowConnector from './ImportSeriesRowConnector';
|
||||
|
||||
class ImportSeriesTable extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
unmappedFolders,
|
||||
defaultMonitor,
|
||||
defaultQualityProfileId,
|
||||
defaultSeriesType,
|
||||
defaultSeasonFolder,
|
||||
onSeriesLookup,
|
||||
onSetImportSeriesValue
|
||||
} = this.props;
|
||||
|
||||
const values = {
|
||||
monitor: defaultMonitor,
|
||||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder
|
||||
};
|
||||
|
||||
unmappedFolders.forEach((unmappedFolder) => {
|
||||
const id = unmappedFolder.name;
|
||||
|
||||
onSeriesLookup(id, unmappedFolder.path, unmappedFolder.relativePath);
|
||||
|
||||
onSetImportSeriesValue({
|
||||
id,
|
||||
...values
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// This isn't great, but it's the most reliable way to ensure the items
|
||||
// are checked off even if they aren't actually visible since the cells
|
||||
// are virtualized.
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
items,
|
||||
selectedState,
|
||||
onSelectedChange,
|
||||
onRemoveSelectedStateItem
|
||||
} = this.props;
|
||||
|
||||
prevProps.items.forEach((prevItem) => {
|
||||
const {
|
||||
id
|
||||
} = prevItem;
|
||||
|
||||
const item = _.find(items, { id });
|
||||
|
||||
if (!item) {
|
||||
onRemoveSelectedStateItem(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedSeries = item.selectedSeries;
|
||||
const isSelected = selectedState[id];
|
||||
|
||||
const isExistingSeries = !!selectedSeries &&
|
||||
_.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId });
|
||||
|
||||
// Props doesn't have a selected series or
|
||||
// the selected series is an existing series.
|
||||
if ((!selectedSeries && prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) {
|
||||
onSelectedChange({ id, value: false });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// State is selected, but a series isn't selected or
|
||||
// the selected series is an existing series.
|
||||
if (isSelected && (!selectedSeries || isExistingSeries)) {
|
||||
onSelectedChange({ id, value: false });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// A series is being selected that wasn't previously selected.
|
||||
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
|
||||
onSelectedChange({ id, value: true });
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
rowRenderer = ({ key, rowIndex, style }) => {
|
||||
const {
|
||||
rootFolderId,
|
||||
items,
|
||||
selectedState,
|
||||
onSelectedChange
|
||||
} = this.props;
|
||||
|
||||
const item = items[rowIndex];
|
||||
|
||||
return (
|
||||
<VirtualTableRow
|
||||
key={key}
|
||||
style={style}
|
||||
>
|
||||
<ImportSeriesRowConnector
|
||||
key={item.id}
|
||||
rootFolderId={rootFolderId}
|
||||
isSelected={selectedState[item.id]}
|
||||
onSelectedChange={onSelectedChange}
|
||||
id={item.id}
|
||||
/>
|
||||
</VirtualTableRow>
|
||||
);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
items,
|
||||
allSelected,
|
||||
allUnselected,
|
||||
isSmallScreen,
|
||||
scroller,
|
||||
selectedState,
|
||||
onSelectAllChange
|
||||
} = this.props;
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualTable
|
||||
items={items}
|
||||
scroller={scroller}
|
||||
isSmallScreen={isSmallScreen}
|
||||
rowHeight={52}
|
||||
overscanRowCount={2}
|
||||
rowRenderer={this.rowRenderer}
|
||||
header={
|
||||
<ImportSeriesHeader
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
onSelectAllChange={onSelectAllChange}
|
||||
/>
|
||||
}
|
||||
selectedState={selectedState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesTable.propTypes = {
|
||||
rootFolderId: PropTypes.number.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
|
||||
defaultMonitor: PropTypes.string.isRequired,
|
||||
defaultQualityProfileId: PropTypes.number,
|
||||
defaultSeriesType: PropTypes.string.isRequired,
|
||||
defaultSeasonFolder: PropTypes.bool.isRequired,
|
||||
allSelected: PropTypes.bool.isRequired,
|
||||
allUnselected: PropTypes.bool.isRequired,
|
||||
selectedState: PropTypes.object.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
allSeries: PropTypes.arrayOf(PropTypes.object),
|
||||
scroller: PropTypes.instanceOf(Element).isRequired,
|
||||
onSelectAllChange: PropTypes.func.isRequired,
|
||||
onSelectedChange: PropTypes.func.isRequired,
|
||||
onRemoveSelectedStateItem: PropTypes.func.isRequired,
|
||||
onSeriesLookup: PropTypes.func.isRequired,
|
||||
onSetImportSeriesValue: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesTable;
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { RefObject, useCallback, useRef } from 'react';
|
||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
||||
import { useAppDimension } from 'App/appStore';
|
||||
import { useSelect } from 'App/Select/SelectContext';
|
||||
import VirtualTable from 'Components/Table/VirtualTable';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import ImportSeriesHeader from './ImportSeriesHeader';
|
||||
import ImportSeriesRow from './ImportSeriesRow';
|
||||
import {
|
||||
UnamppedFolderItem,
|
||||
useEnsureImportSeriesItems,
|
||||
} from './importSeriesStore';
|
||||
import styles from './ImportSeriesTable.css';
|
||||
|
||||
const ROW_HEIGHT = 52;
|
||||
|
||||
interface RowItemData {
|
||||
items: UnamppedFolderItem[];
|
||||
}
|
||||
|
||||
interface ImportSeriesTableProps {
|
||||
items: UnamppedFolderItem[];
|
||||
scrollerRef: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
|
||||
const { items } = data;
|
||||
|
||||
if (index >= items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = items[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
...style,
|
||||
}}
|
||||
className={styles.row}
|
||||
>
|
||||
<ImportSeriesRow key={item.id} unmappedFolder={item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportSeriesTable({ items, scrollerRef }: ImportSeriesTableProps) {
|
||||
const isSmallScreen = useAppDimension('isSmallScreen');
|
||||
const { allSelected, allUnselected, selectAll, unselectAll, useHasItems } =
|
||||
useSelect();
|
||||
|
||||
const listRef = useRef<FixedSizeList<RowItemData>>(null);
|
||||
|
||||
const handleSelectAllChange = useCallback(
|
||||
({ value }: CheckInputChanged) => {
|
||||
if (value) {
|
||||
selectAll();
|
||||
} else {
|
||||
unselectAll();
|
||||
}
|
||||
},
|
||||
[selectAll, unselectAll]
|
||||
);
|
||||
|
||||
const hasSelectItems = useHasItems();
|
||||
|
||||
useEnsureImportSeriesItems(items);
|
||||
|
||||
if (!items.length || !hasSelectItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualTable
|
||||
Header={
|
||||
<ImportSeriesHeader
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
onSelectAllChange={handleSelectAllChange}
|
||||
/>
|
||||
}
|
||||
itemCount={items.length}
|
||||
itemData={{
|
||||
items,
|
||||
}}
|
||||
isSmallScreen={isSmallScreen}
|
||||
listRef={listRef}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
Row={Row}
|
||||
scrollerRef={scrollerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesTable;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import ImportSeriesTable from './ImportSeriesTable';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.addSeries,
|
||||
(state) => state.importSeries,
|
||||
(state) => state.app.dimensions,
|
||||
createAllSeriesSelector(),
|
||||
(addSeries, importSeries, dimensions, allSeries) => {
|
||||
return {
|
||||
defaultMonitor: addSeries.defaults.monitor,
|
||||
defaultQualityProfileId: addSeries.defaults.qualityProfileId,
|
||||
defaultSeriesType: addSeries.defaults.seriesType,
|
||||
defaultSeasonFolder: addSeries.defaults.seasonFolder,
|
||||
items: importSeries.items,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
allSeries
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onSeriesLookup(name, path, relativePath) {
|
||||
dispatch(queueLookupSeries({
|
||||
name,
|
||||
path,
|
||||
relativePath,
|
||||
term: name
|
||||
}));
|
||||
},
|
||||
|
||||
onSetImportSeriesValue(values) {
|
||||
dispatch(setImportSeriesValue(values));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable);
|
||||
@@ -1,29 +1,35 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useCallback } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import useExistingSeries from 'Series/useExistingSeries';
|
||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||
import styles from './ImportSeriesSearchResult.css';
|
||||
|
||||
function ImportSeriesSearchResult(props) {
|
||||
const {
|
||||
tvdbId,
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
isExistingSeries,
|
||||
onPress
|
||||
} = props;
|
||||
interface ImportSeriesSearchResultProps {
|
||||
tvdbId: number;
|
||||
title: string;
|
||||
year: number;
|
||||
network?: string;
|
||||
onPress: (tvdbId: number) => void;
|
||||
}
|
||||
|
||||
const onPressCallback = useCallback(() => onPress(tvdbId), [tvdbId, onPress]);
|
||||
function ImportSeriesSearchResult({
|
||||
tvdbId,
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
onPress,
|
||||
}: ImportSeriesSearchResultProps) {
|
||||
const isExistingSeries = useExistingSeries(tvdbId);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(tvdbId);
|
||||
}, [tvdbId, onPress]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Link
|
||||
className={styles.series}
|
||||
onPress={onPressCallback}
|
||||
>
|
||||
<Link className={styles.series} onPress={handlePress}>
|
||||
<ImportSeriesTitle
|
||||
title={title}
|
||||
year={year}
|
||||
@@ -46,13 +52,4 @@ function ImportSeriesSearchResult(props) {
|
||||
);
|
||||
}
|
||||
|
||||
ImportSeriesSearchResult.propTypes = {
|
||||
tvdbId: PropTypes.number.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
network: PropTypes.string,
|
||||
isExistingSeries: PropTypes.bool.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesSearchResult;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
||||
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createExistingSeriesSelector(),
|
||||
(isExistingSeries) => {
|
||||
return {
|
||||
isExistingSeries
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(ImportSeriesSearchResult);
|
||||
@@ -1,303 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Portal from 'Components/Portal';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import getUniqueElementId from 'Utilities/getUniqueElementId';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector';
|
||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||
import styles from './ImportSeriesSelectSeries.css';
|
||||
|
||||
class ImportSeriesSelectSeries extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._seriesLookupTimeout = null;
|
||||
this._scheduleUpdate = null;
|
||||
this._buttonId = getUniqueElementId();
|
||||
this._contentId = getUniqueElementId();
|
||||
|
||||
this.state = {
|
||||
term: props.id,
|
||||
isOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this._scheduleUpdate) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_addListener() {
|
||||
window.addEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
_removeListener() {
|
||||
window.removeEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onWindowClick = (event) => {
|
||||
const button = document.getElementById(this._buttonId);
|
||||
const content = document.getElementById(this._contentId);
|
||||
|
||||
if (!button || !content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!button.contains(event.target) &&
|
||||
!content.contains(event.target) &&
|
||||
this.state.isOpen
|
||||
) {
|
||||
this.setState({ isOpen: false });
|
||||
this._removeListener();
|
||||
}
|
||||
};
|
||||
|
||||
onPress = () => {
|
||||
if (this.state.isOpen) {
|
||||
this._removeListener();
|
||||
} else {
|
||||
this._addListener();
|
||||
}
|
||||
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
};
|
||||
|
||||
onSearchInputChange = ({ value }) => {
|
||||
if (this._seriesLookupTimeout) {
|
||||
clearTimeout(this._seriesLookupTimeout);
|
||||
}
|
||||
|
||||
this.setState({ term: value }, () => {
|
||||
this._seriesLookupTimeout = setTimeout(() => {
|
||||
this.props.onSearchInputChange(value);
|
||||
}, 200);
|
||||
});
|
||||
};
|
||||
|
||||
onRefreshPress = () => {
|
||||
this.props.onSearchInputChange(this.state.term);
|
||||
};
|
||||
|
||||
onSeriesSelect = (tvdbId) => {
|
||||
this.setState({ isOpen: false });
|
||||
|
||||
this.props.onSeriesSelect(tvdbId);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedSeries,
|
||||
isExistingSeries,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
isQueued,
|
||||
isLookingUpSeries
|
||||
} = this.props;
|
||||
|
||||
const errorMessage = error &&
|
||||
error.responseJSON &&
|
||||
error.responseJSON.message;
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._buttonId}
|
||||
>
|
||||
<Link
|
||||
ref={ref}
|
||||
className={styles.button}
|
||||
component="div"
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{
|
||||
isLookingUpSeries && isQueued && !isPopulated ?
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && selectedSeries && isExistingSeries ?
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && selectedSeries ?
|
||||
<ImportSeriesTitle
|
||||
title={selectedSeries.title}
|
||||
year={selectedSeries.year}
|
||||
network={selectedSeries.network}
|
||||
isExistingSeries={isExistingSeries}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !selectedSeries ?
|
||||
<div className={styles.noMatches}>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
|
||||
{translate('NoMatchFound')}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error ?
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
title={errorMessage}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
|
||||
{translate('SearchFailedError')}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<div className={styles.dropdownArrowContainer}>
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom"
|
||||
modifiers={{
|
||||
preventOverflow: {
|
||||
boundariesElement: 'viewport'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._contentId}
|
||||
className={styles.contentContainer}
|
||||
style={style}
|
||||
>
|
||||
{
|
||||
this.state.isOpen ?
|
||||
<div className={styles.content}>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon name={icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name={`${name}_textInput`}
|
||||
value={this.state.term}
|
||||
onChange={this.onSearchInputChange}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
kind={kinds.DEFAULT}
|
||||
spinnerIcon={icons.REFRESH}
|
||||
canSpin={true}
|
||||
isSpinning={isFetching}
|
||||
onPress={this.onRefreshPress}
|
||||
>
|
||||
<Icon name={icons.REFRESH} />
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
<div className={styles.results}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<ImportSeriesSearchResultConnector
|
||||
key={item.tvdbId}
|
||||
tvdbId={item.tvdbId}
|
||||
title={item.title}
|
||||
year={item.year}
|
||||
network={item.network}
|
||||
onPress={this.onSeriesSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesSelectSeries.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
selectedSeries: PropTypes.object,
|
||||
isExistingSeries: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isQueued: PropTypes.bool.isRequired,
|
||||
isLookingUpSeries: PropTypes.bool.isRequired,
|
||||
onSearchInputChange: PropTypes.func.isRequired,
|
||||
onSeriesSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ImportSeriesSelectSeries.defaultProps = {
|
||||
isFetching: true,
|
||||
isPopulated: false,
|
||||
items: [],
|
||||
isQueued: true
|
||||
};
|
||||
|
||||
export default ImportSeriesSelectSeries;
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingPortal,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useLookupSeries } from 'AddSeries/AddNewSeries/useAddSeries';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import useDebounce from 'Helpers/Hooks/useDebounce';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useExistingSeries from 'Series/useExistingSeries';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
addToLookupQueue,
|
||||
removeFromLookupQueue,
|
||||
updateImportSeriesItem,
|
||||
useImportSeriesItem,
|
||||
useIsCurrentedItemQueued,
|
||||
useIsCurrentLookupQueueItem,
|
||||
} from '../importSeriesStore';
|
||||
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
|
||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||
import styles from './ImportSeriesSelectSeries.css';
|
||||
|
||||
interface ImportSeriesSelectSeriesProps {
|
||||
id: string;
|
||||
onInputChange: (input: InputChanged) => void;
|
||||
}
|
||||
|
||||
function ImportSeriesSelectSeries({
|
||||
id,
|
||||
onInputChange,
|
||||
}: ImportSeriesSelectSeriesProps) {
|
||||
const importSeriesItem = useImportSeriesItem(id);
|
||||
const { selectedSeries, name } = importSeriesItem ?? {};
|
||||
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId);
|
||||
|
||||
const [term, setTerm] = useState(name);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const query = useDebounce(term, term ? 300 : 0);
|
||||
const isCurrentLookupQueueItem = useIsCurrentLookupQueueItem(id);
|
||||
const isQueued = useIsCurrentedItemQueued(id);
|
||||
|
||||
const { isFetching, isFetched, error, data, refetch } = useLookupSeries(
|
||||
query,
|
||||
isCurrentLookupQueueItem
|
||||
);
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
const isLookingUpSeries = isFetching || isQueued;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
}, []);
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
setTerm(value);
|
||||
addToLookupQueue(id);
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleSeriesSelect = useCallback(
|
||||
(tvdbId: number) => {
|
||||
setIsOpen(false);
|
||||
|
||||
const selectedSeries = data.find((item) => item.tvdbId === tvdbId)!;
|
||||
|
||||
updateImportSeriesItem({
|
||||
id,
|
||||
selectedSeries,
|
||||
});
|
||||
|
||||
if (selectedSeries.seriesType !== 'standard') {
|
||||
onInputChange({
|
||||
name: 'seriesType',
|
||||
value: selectedSeries.seriesType,
|
||||
});
|
||||
}
|
||||
},
|
||||
[id, data, onInputChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetched) {
|
||||
updateImportSeriesItem({
|
||||
id,
|
||||
hasSearched: isFetched,
|
||||
selectedSeries: data[0],
|
||||
});
|
||||
|
||||
removeFromLookupQueue(id);
|
||||
}
|
||||
}, [id, isFetched, data]);
|
||||
|
||||
useEffect(() => {
|
||||
setTerm(name);
|
||||
}, [name]);
|
||||
|
||||
const { refs, context, floatingStyles } = useFloating({
|
||||
middleware: [
|
||||
flip({
|
||||
crossAxis: false,
|
||||
mainAxis: true,
|
||||
}),
|
||||
],
|
||||
open: isOpen,
|
||||
placement: 'bottom',
|
||||
whileElementsMounted: autoUpdate,
|
||||
onOpenChange: setIsOpen,
|
||||
});
|
||||
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
click,
|
||||
dismiss,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={refs.setReference} {...getReferenceProps()}>
|
||||
<Link className={styles.button} component="div" onPress={handlePress}>
|
||||
{isLookingUpSeries && isQueued && !isFetched ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
|
||||
{isFetched && selectedSeries && isExistingSeries ? (
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFetched && selectedSeries ? (
|
||||
<ImportSeriesTitle
|
||||
title={selectedSeries.title}
|
||||
year={selectedSeries.year}
|
||||
network={selectedSeries.network}
|
||||
isExistingSeries={isExistingSeries}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFetched && !selectedSeries ? (
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
|
||||
{translate('NoMatchFound')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
title={errorMessage}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
|
||||
{translate('SearchFailedError')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.dropdownArrowContainer}>
|
||||
<Icon name={icons.CARET_DOWN} />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
<FloatingPortal id="portal-root">
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
className={styles.contentContainer}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{isOpen ? (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon name={icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name={`${name}_textInput`}
|
||||
value={term}
|
||||
onChange={handleSearchInputChange}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
kind={kinds.DEFAULT}
|
||||
spinnerIcon={icons.REFRESH}
|
||||
canSpin={true}
|
||||
isSpinning={isFetching}
|
||||
onPress={handleRefreshPress}
|
||||
>
|
||||
<Icon name={icons.REFRESH} />
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
<div className={styles.results}>
|
||||
{data.map((item) => {
|
||||
return (
|
||||
<ImportSeriesSearchResult
|
||||
key={item.tvdbId}
|
||||
tvdbId={item.tvdbId}
|
||||
title={item.title}
|
||||
year={item.year}
|
||||
network={item.network}
|
||||
onPress={handleSeriesSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesSelectSeries;
|
||||
@@ -1,87 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
|
||||
import * as seriesTypes from 'Utilities/Series/seriesTypes';
|
||||
import ImportSeriesSelectSeries from './ImportSeriesSelectSeries';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.importSeries.isLookingUpSeries,
|
||||
createImportSeriesItemSelector(),
|
||||
(isLookingUpSeries, item) => {
|
||||
return {
|
||||
isLookingUpSeries,
|
||||
...item
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
queueLookupSeries,
|
||||
setImportSeriesValue
|
||||
};
|
||||
|
||||
class ImportSeriesSelectSeriesConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSearchInputChange = (term) => {
|
||||
this.props.queueLookupSeries({
|
||||
name: this.props.id,
|
||||
term,
|
||||
topOfQueue: true
|
||||
});
|
||||
};
|
||||
|
||||
onSeriesSelect = (tvdbId) => {
|
||||
const {
|
||||
id,
|
||||
items,
|
||||
onInputChange
|
||||
} = this.props;
|
||||
|
||||
const selectedSeries = items.find((item) => item.tvdbId === tvdbId);
|
||||
|
||||
this.props.setImportSeriesValue({
|
||||
id,
|
||||
selectedSeries
|
||||
});
|
||||
|
||||
if (selectedSeries.seriesType !== seriesTypes.STANDARD) {
|
||||
onInputChange({
|
||||
name: 'seriesType',
|
||||
value: selectedSeries.seriesType
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ImportSeriesSelectSeries
|
||||
{...this.props}
|
||||
onSearchInputChange={this.onSearchInputChange}
|
||||
onSeriesSelect={this.onSeriesSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesSelectSeriesConnector.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
selectedSeries: PropTypes.object,
|
||||
isSelected: PropTypes.bool,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
queueLookupSeries: PropTypes.func.isRequired,
|
||||
setImportSeriesValue: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector);
|
||||
@@ -1,57 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesTitle.css';
|
||||
|
||||
function ImportSeriesTitle(props) {
|
||||
const {
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
isExistingSeries
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{
|
||||
!title.contains(year) &&
|
||||
year > 0 ?
|
||||
<span className={styles.year}>
|
||||
({year})
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
network ?
|
||||
<Label>{network}</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isExistingSeries ?
|
||||
<Label
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
{translate('Existing')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ImportSeriesTitle.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
network: PropTypes.string,
|
||||
isExistingSeries: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesTitle;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesTitle.css';
|
||||
|
||||
interface ImportSeriesTitleProps {
|
||||
title: string;
|
||||
year: number;
|
||||
network?: string;
|
||||
isExistingSeries: boolean;
|
||||
}
|
||||
|
||||
function ImportSeriesTitle({
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
isExistingSeries,
|
||||
}: ImportSeriesTitleProps) {
|
||||
return (
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
{year > 0 && !title.includes(String(year)) ? (
|
||||
<span className={styles.year}>({year})</span>
|
||||
) : null}
|
||||
|
||||
{network ? <Label>{network}</Label> : null}
|
||||
|
||||
{isExistingSeries ? (
|
||||
<Label kind={kinds.WARNING}>{translate('Existing')}</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesTitle;
|
||||
175
frontend/src/AddSeries/ImportSeries/Import/importSeriesStore.ts
Normal file
175
frontend/src/AddSeries/ImportSeries/Import/importSeriesStore.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||
import { UnmappedFolder } from 'RootFolder/useRootFolders';
|
||||
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
|
||||
export interface UnamppedFolderItem extends UnmappedFolder {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ImportSeriesItem {
|
||||
id: string;
|
||||
monitor: SeriesMonitor;
|
||||
path: string;
|
||||
qualityProfileId: number;
|
||||
relativePath: string;
|
||||
seasonFolder: boolean;
|
||||
selectedSeries?: Series;
|
||||
seriesType: SeriesType;
|
||||
name: string;
|
||||
hasSearched: boolean;
|
||||
}
|
||||
|
||||
interface ImportSeriesState {
|
||||
items: Record<string, ImportSeriesItem>;
|
||||
lookupQueue: string[];
|
||||
isProcessing: boolean;
|
||||
}
|
||||
|
||||
const defaultState: ImportSeriesState = {
|
||||
items: {},
|
||||
lookupQueue: [],
|
||||
isProcessing: false,
|
||||
};
|
||||
|
||||
const importSeriesStore = create<ImportSeriesState>()(() => defaultState);
|
||||
|
||||
export const useEnsureImportSeriesItems = (
|
||||
unmappedFolders: UnamppedFolderItem[]
|
||||
) => {
|
||||
const { monitor, qualityProfileId, seriesType, seasonFolder } =
|
||||
useAddSeriesOptions();
|
||||
|
||||
useEffect(() => {
|
||||
unmappedFolders.forEach((unmappedFolder) => {
|
||||
const existingItem =
|
||||
importSeriesStore.getState().items[unmappedFolder.id];
|
||||
|
||||
if (existingItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: ImportSeriesItem = {
|
||||
...unmappedFolder,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
seasonFolder,
|
||||
hasSearched: false,
|
||||
};
|
||||
|
||||
importSeriesStore.setState((state) => ({
|
||||
items: {
|
||||
...state.items,
|
||||
[unmappedFolder.id]: newItem,
|
||||
},
|
||||
}));
|
||||
});
|
||||
}, [unmappedFolders, monitor, qualityProfileId, seriesType, seasonFolder]);
|
||||
};
|
||||
|
||||
export const updateImportSeriesItem = (
|
||||
itemData: Partial<ImportSeriesItem> & Pick<ImportSeriesItem, 'id'>
|
||||
) => {
|
||||
importSeriesStore.setState((state) => {
|
||||
const existingItem = state.items[itemData.id];
|
||||
|
||||
if (existingItem) {
|
||||
return {
|
||||
items: {
|
||||
...state.items,
|
||||
[itemData.id]: {
|
||||
...existingItem,
|
||||
...itemData,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
export const removeImportSeriesItemByPath = (path: string) => {
|
||||
importSeriesStore.setState((state) => {
|
||||
const item = Object.values(state.items).find((i) => i.path === path);
|
||||
|
||||
if (!item) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { [item.id]: removed, ...items } = state.items;
|
||||
|
||||
return { items };
|
||||
});
|
||||
};
|
||||
|
||||
export const clearImportSeries = () => {
|
||||
importSeriesStore.setState(defaultState);
|
||||
};
|
||||
|
||||
export const startProcessing = () => {
|
||||
importSeriesStore.setState((state) => {
|
||||
const items = Object.values(state.items).reduce<string[]>((acc, item) => {
|
||||
if (!item.hasSearched) {
|
||||
acc.push(item.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return { isProcessing: true, lookupQueue: items };
|
||||
});
|
||||
};
|
||||
|
||||
export const stopProcessing = () => {
|
||||
importSeriesStore.setState({ isProcessing: false, lookupQueue: [] });
|
||||
};
|
||||
|
||||
export const addToLookupQueue = (id: string) => {
|
||||
importSeriesStore.setState((state) => ({
|
||||
lookupQueue: [...state.lookupQueue, id],
|
||||
}));
|
||||
};
|
||||
|
||||
export const removeFromLookupQueue = (id: string) => {
|
||||
importSeriesStore.setState((state) => ({
|
||||
lookupQueue: state.lookupQueue.filter((queuedId) => queuedId !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
export const useIsCurrentLookupQueueItem = (id: string) => {
|
||||
return importSeriesStore((state) => state.lookupQueue[0] === id);
|
||||
};
|
||||
|
||||
export const useIsCurrentedItemQueued = (id: string) => {
|
||||
return importSeriesStore((state) => state.lookupQueue.includes(id));
|
||||
};
|
||||
|
||||
export const useLookupQueueHasItems = () => {
|
||||
return importSeriesStore((state) => state.lookupQueue.length > 0);
|
||||
};
|
||||
|
||||
export const useImportSeriesItem = (id: string) => {
|
||||
return importSeriesStore((state) => state.items[id]);
|
||||
};
|
||||
|
||||
export const useImportSeriesItems = () => {
|
||||
return importSeriesStore(useShallow((state) => Object.values(state.items)));
|
||||
};
|
||||
|
||||
export const getImportSeriesItems = (ids: string[]) => {
|
||||
const state = importSeriesStore.getState();
|
||||
|
||||
return ids.reduce<ImportSeriesItem[]>((acc, id) => {
|
||||
const item = state.items[id];
|
||||
|
||||
if (item != null) {
|
||||
acc.push(item);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import Series from 'Series/Series';
|
||||
import {
|
||||
getImportSeriesItems,
|
||||
removeImportSeriesItemByPath,
|
||||
} from './importSeriesStore';
|
||||
|
||||
export const useImportSeries = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isPending, error, mutate } = useApiMutation<Series[], Series[]>({
|
||||
path: '/series/import',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: (data, newSeries) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/rootFolder'] });
|
||||
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
|
||||
if (!oldSeries) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return [...oldSeries, ...data];
|
||||
});
|
||||
|
||||
newSeries.forEach((series) => {
|
||||
removeImportSeriesItemByPath(series.path);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const importSeries = useCallback(
|
||||
(ids: string[]) => {
|
||||
const items = getImportSeriesItems(ids);
|
||||
const addedIds: string[] = [];
|
||||
|
||||
const allNewSeries = ids.reduce<Series[]>((acc, id) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
const selectedSeries = item?.selectedSeries;
|
||||
|
||||
// Make sure we have a selected series and the same series hasn't been added yet.
|
||||
if (
|
||||
selectedSeries &&
|
||||
!acc.some((a) => a.tvdbId === selectedSeries.tvdbId)
|
||||
) {
|
||||
const newSeries: Series = {
|
||||
...selectedSeries,
|
||||
monitored: true,
|
||||
monitorNewItems: 'all',
|
||||
qualityProfileId: item.qualityProfileId,
|
||||
path: item.path,
|
||||
seriesType: item.seriesType,
|
||||
seasonFolder: item.seasonFolder,
|
||||
addOptions: {
|
||||
monitor: item.monitor,
|
||||
searchForMissingEpisodes: false,
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
newSeries.path = item.path;
|
||||
|
||||
addedIds.push(id);
|
||||
acc.push(newSeries);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (allNewSeries.length > 0) {
|
||||
mutate(allNewSeries);
|
||||
}
|
||||
},
|
||||
[mutate]
|
||||
);
|
||||
|
||||
return {
|
||||
isImporting: isPending,
|
||||
importError: error,
|
||||
importSeries,
|
||||
};
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ImportSeriesConnector from 'AddSeries/ImportSeries/Import/ImportSeriesConnector';
|
||||
import ImportSeriesSelectFolderConnector from 'AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
|
||||
class ImportSeries extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
exact={true}
|
||||
path="/add/import"
|
||||
component={ImportSeriesSelectFolderConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/add/import/:rootFolderId"
|
||||
component={ImportSeriesConnector}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ImportSeries;
|
||||
21
frontend/src/AddSeries/ImportSeries/ImportSeriesPage.tsx
Normal file
21
frontend/src/AddSeries/ImportSeries/ImportSeriesPage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import ImportSeries from './Import/ImportSeries';
|
||||
import ImportSeriesSelectFolder from './SelectFolder/ImportSeriesSelectFolder';
|
||||
|
||||
function ImportSeriesPage() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
exact={true}
|
||||
path="/add/import"
|
||||
component={ImportSeriesSelectFolder}
|
||||
/>
|
||||
|
||||
<Route path="/add/import/:rootFolderId" component={ImportSeries} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesPage;
|
||||
@@ -1,188 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import RootFolders from 'RootFolder/RootFolders';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesSelectFolder.css';
|
||||
|
||||
class ImportSeriesSelectFolder extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isAddNewRootFolderModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
onAddNewRootFolderPress = () => {
|
||||
this.setState({ isAddNewRootFolderModalOpen: true });
|
||||
};
|
||||
|
||||
onNewRootFolderSelect = ({ value }) => {
|
||||
this.props.onNewRootFolderSelect(value);
|
||||
};
|
||||
|
||||
onAddRootFolderModalClose = () => {
|
||||
this.setState({ isAddNewRootFolderModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isWindows,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
isSaving,
|
||||
error,
|
||||
saveError,
|
||||
items
|
||||
} = this.props;
|
||||
|
||||
const hasRootFolders = items.length > 0;
|
||||
const goodFolderExample = (isWindows) ? 'C:\\tv shows' : '/tv shows';
|
||||
const badFolderExample = (isWindows) ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons';
|
||||
|
||||
return (
|
||||
<PageContent title={translate('ImportSeries')}>
|
||||
<PageContentBody>
|
||||
{
|
||||
isFetching && !isPopulated ?
|
||||
<LoadingIndicator /> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && error ?
|
||||
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!error && isPopulated &&
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
{translate('LibraryImportSeriesHeader')}
|
||||
</div>
|
||||
|
||||
<div className={styles.tips}>
|
||||
{translate('LibraryImportTips')}
|
||||
<ul>
|
||||
<li className={styles.tip}>
|
||||
<InlineMarkdown data={translate('LibraryImportTipsQualityInEpisodeFilename')} />
|
||||
</li>
|
||||
<li className={styles.tip}>
|
||||
<InlineMarkdown data={translate('LibraryImportTipsSeriesUseRootFolder', { goodFolderExample, badFolderExample })} />
|
||||
</li>
|
||||
<li className={styles.tip}>
|
||||
{translate('LibraryImportTipsDontUseDownloadsFolder')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{
|
||||
hasRootFolders ?
|
||||
<div className={styles.recentFolders}>
|
||||
<FieldSet legend={translate('RootFolders')}>
|
||||
<RootFolders
|
||||
isFetching={isFetching}
|
||||
isPopulated={isPopulated}
|
||||
error={error}
|
||||
items={items}
|
||||
/>
|
||||
</FieldSet>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isSaving && saveError ?
|
||||
<Alert
|
||||
className={styles.addErrorAlert}
|
||||
kind={kinds.DANGER}
|
||||
>
|
||||
{translate('AddRootFolderError')}
|
||||
|
||||
<ul>
|
||||
{
|
||||
Array.isArray(saveError.responseJSON) ?
|
||||
saveError.responseJSON.map((e, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
{e.errorMessage}
|
||||
</li>
|
||||
);
|
||||
}) :
|
||||
<li>
|
||||
{
|
||||
JSON.stringify(saveError.responseJSON)
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
<div className={hasRootFolders ? undefined : styles.startImport}>
|
||||
<Button
|
||||
kind={kinds.PRIMARY}
|
||||
size={sizes.LARGE}
|
||||
onPress={this.onAddNewRootFolderPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.importButtonIcon}
|
||||
name={icons.DRIVE}
|
||||
/>
|
||||
{
|
||||
hasRootFolders ?
|
||||
translate('ChooseAnotherFolder') :
|
||||
translate('StartImport')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={this.state.isAddNewRootFolderModalOpen}
|
||||
name="rootFolderPath"
|
||||
value=""
|
||||
onChange={this.onNewRootFolderSelect}
|
||||
onModalClose={this.onAddRootFolderModalClose}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesSelectFolder.propTypes = {
|
||||
isWindows: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
saveError: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onNewRootFolderSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportSeriesSelectFolder;
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import RootFolders from 'RootFolder/RootFolders';
|
||||
import useRootFolders, { useAddRootFolder } from 'RootFolder/useRootFolders';
|
||||
import { useIsWindows } from 'System/Status/useSystemStatus';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ImportSeriesSelectFolder.css';
|
||||
|
||||
function ImportSeriesSelectFolder() {
|
||||
const { isFetching, isFetched, error, data } = useRootFolders();
|
||||
const { addRootFolder, isAdding, addError } = useAddRootFolder();
|
||||
|
||||
const isWindows = useIsWindows();
|
||||
|
||||
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const wasAdding = usePrevious(isAdding);
|
||||
|
||||
const hasRootFolders = data.length > 0;
|
||||
const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows';
|
||||
const badFolderExample = isWindows
|
||||
? 'C:\\tv shows\\the simpsons'
|
||||
: '/tv shows/the simpsons';
|
||||
|
||||
const handleAddNewRootFolderPress = useCallback(() => {
|
||||
setIsAddNewRootFolderModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddRootFolderModalClose = useCallback(() => {
|
||||
setIsAddNewRootFolderModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleNewRootFolderSelect = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
addRootFolder({ path: value });
|
||||
},
|
||||
[addRootFolder]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdding && wasAdding && !addError) {
|
||||
data.reduce((acc, item) => {
|
||||
if (item.id > acc) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
}, [isAdding, wasAdding, addError, data]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('ImportSeries')}>
|
||||
<PageContentBody>
|
||||
{isFetching && !isFetched ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!error && isFetched && (
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
{translate('LibraryImportSeriesHeader')}
|
||||
</div>
|
||||
|
||||
<div className={styles.tips}>
|
||||
{translate('LibraryImportTips')}
|
||||
<ul>
|
||||
<li className={styles.tip}>
|
||||
<InlineMarkdown
|
||||
data={translate(
|
||||
'LibraryImportTipsQualityInEpisodeFilename'
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
<li className={styles.tip}>
|
||||
<InlineMarkdown
|
||||
data={translate('LibraryImportTipsSeriesUseRootFolder', {
|
||||
goodFolderExample,
|
||||
badFolderExample,
|
||||
})}
|
||||
/>
|
||||
</li>
|
||||
<li className={styles.tip}>
|
||||
{translate('LibraryImportTipsDontUseDownloadsFolder')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{hasRootFolders ? (
|
||||
<div className={styles.recentFolders}>
|
||||
<FieldSet legend={translate('RootFolders')}>
|
||||
<RootFolders />
|
||||
</FieldSet>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isAdding && addError ? (
|
||||
<Alert className={styles.addErrorAlert} kind={kinds.DANGER}>
|
||||
{translate('AddRootFolderError')}
|
||||
|
||||
<ul>
|
||||
{Array.isArray(addError.statusBody) ? (
|
||||
addError.statusBody.map((e, index) => {
|
||||
return <li key={index}>{e.errorMessage}</li>;
|
||||
})
|
||||
) : (
|
||||
<li>{JSON.stringify(addError.statusBody)}</li>
|
||||
)}
|
||||
</ul>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className={hasRootFolders ? undefined : styles.startImport}>
|
||||
<Button
|
||||
kind={kinds.PRIMARY}
|
||||
size={sizes.LARGE}
|
||||
onPress={handleAddNewRootFolderPress}
|
||||
>
|
||||
<Icon className={styles.importButtonIcon} name={icons.DRIVE} />
|
||||
{hasRootFolders
|
||||
? translate('ChooseAnotherFolder')
|
||||
: translate('StartImport')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={isAddNewRootFolderModalOpen}
|
||||
name="rootFolderPath"
|
||||
value=""
|
||||
onChange={handleNewRootFolderSelect}
|
||||
onModalClose={handleAddRootFolderModalClose}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeriesSelectFolder;
|
||||
@@ -1,85 +0,0 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { addRootFolder, fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createRootFoldersSelector(),
|
||||
createSystemStatusSelector(),
|
||||
(rootFolders, systemStatus) => {
|
||||
return {
|
||||
...rootFolders,
|
||||
isWindows: systemStatus.isWindows
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchRootFolders,
|
||||
addRootFolder,
|
||||
push
|
||||
};
|
||||
|
||||
class ImportSeriesSelectFolderConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchRootFolders();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
items,
|
||||
isSaving,
|
||||
saveError
|
||||
} = this.props;
|
||||
|
||||
if (prevProps.isSaving && !isSaving && !saveError) {
|
||||
const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id);
|
||||
|
||||
if (newRootFolders.length === 1) {
|
||||
this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onNewRootFolderSelect = (path) => {
|
||||
this.props.addRootFolder({ path });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ImportSeriesSelectFolder
|
||||
{...this.props}
|
||||
onNewRootFolderSelect={this.onNewRootFolderSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportSeriesSelectFolderConnector.propTypes = {
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
fetchRootFolders: PropTypes.func.isRequired,
|
||||
addRootFolder: PropTypes.func.isRequired,
|
||||
push: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user