Compare commits

..

60 Commits

Author SHA1 Message Date
Fallenbagel
789bcc8eec chore(release): prepare 3.0.1 2026-02-15 03:18:54 +08:00
gauthier-th
8b0831cd9a chore: prepare 3.0.0 release 2026-02-14 19:23:03 +01:00
gauthier-th
9822565536 chore: prepare 3.0.0 release 2026-02-14 18:31:15 +01:00
semantic-release-bot
e842036faf chore(release): 2.7.3 2025-08-14 20:10:51 +00:00
Fallenbagel
2f07c38272 chore: prepare 2.7.3 release 2025-08-15 01:06:38 +05:00
semantic-release-bot
697a08167f chore(release): 2.7.2 2025-07-21 17:46:02 +00:00
Fallenbagel
017c69abb0 chore: prepare v2.7.2 release 2025-07-21 22:44:06 +05:00
semantic-release-bot
49ac9c92b3 chore(release): 2.7.1 2025-07-15 13:47:28 +00:00
Fallenbagel
dba98c0466 chore: prepare for release 2025-07-15 18:43:36 +05:00
semantic-release-bot
c187ac0965 chore(release): 2.7.0 2025-06-20 11:53:36 +00:00
Fallenbagel
01edc6c103 chore: prepare for v2.6.1 2025-06-20 16:51:12 +05:00
semantic-release-bot
51ac65a78d chore(release): 2.6.0 2025-06-09 22:56:10 +00:00
fallenbagel
28620e98d5 chore: prepare for v2.6.0 2025-06-10 06:53:52 +08:00
semantic-release-bot
fac453878e chore(release): 2.5.2 2025-04-03 17:06:57 +00:00
Fallenbagel
528db09954 chore: prepare for 2.5.2 2025-04-03 22:04:45 +05:00
semantic-release-bot
5663ac1af3 chore(release): 2.5.1 2025-03-17 02:46:28 +00:00
fallenbagel
3b23da4ed7 chore: prepare for 2.5.1 2025-03-17 10:45:04 +08:00
semantic-release-bot
4560c0f843 chore(release): 2.5.0 2025-03-11 02:42:37 +00:00
fallenbagel
2cd843535d chore: prepare for v2.4.1 2025-03-11 10:40:50 +08:00
semantic-release-bot
ceaf0b6df5 chore(release): 2.4.0 2025-03-10 18:16:42 +00:00
fallenbagel
73c7ff257f chore: prepare for 2.4.0 2025-03-11 01:40:24 +08:00
semantic-release-bot
0bb2ee0e84 chore(release): 2.3.0 2025-01-16 12:37:03 +00:00
semantic-release-bot
4eab73ae9f chore(release): 2.3.0 2025-01-16 11:59:50 +00:00
fallenbagel
7fb18d7b2c chore: prepare for 2.3.0
This version was supposed to be 2.2.4, however, due to a mistake made
this is now going to be 2.3.0
2025-01-16 19:56:22 +08:00
semantic-release-bot
a27bdb8ec6 chore(release): 2.2.3 2024-12-30 20:04:59 +00:00
fallenbagel
577288598a chore: prepare for 2.2.3 2024-12-31 04:03:23 +08:00
semantic-release-bot
d4b707e619 chore(release): 2.2.2 2024-12-30 03:26:35 +00:00
fallenbagel
8233d97f21 chore: prepare for 2.2.2 2024-12-30 11:25:01 +08:00
semantic-release-bot
d362b030f9 chore(release): 2.2.1 2024-12-30 02:28:13 +00:00
fallenbagel
cc876c8276 chore: prepare for 2.1.0 2024-12-30 10:25:57 +08:00
semantic-release-bot
f2d7a21648 chore(release): 2.2.0 2024-12-29 22:32:31 +00:00
fallenbagel
a7fe00d123 chore: prepare for 2.2.0 2024-12-30 06:30:32 +08:00
semantic-release-bot
e43fc721c8 chore(release): 2.1.0 2024-11-12 21:43:47 +00:00
fallenbagel
c53e465130 chore: prepare for v2.1.0 2024-11-13 05:41:59 +08:00
semantic-release-bot
dc2cd9f28e chore(release): 2.0.1 2024-10-17 13:51:24 +00:00
fallenbagel
dfa0229a6d chore: prepare for v2.0.1 2024-10-17 21:49:46 +08:00
semantic-release-bot
63dfe003b0 chore(release): 2.0.0 2024-10-15 17:55:56 +00:00
fallenbagel
a47db19ae7 chore(release): prepare for v1.10 release 2024-10-16 01:53:45 +08:00
semantic-release-bot
65def9d20d chore(release): 1.9.2 2024-06-13 09:32:07 +00:00
fallenbagel
a302929966 Merge remote-tracking branch 'origin/develop' 2024-06-13 14:30:15 +05:00
semantic-release-bot
f735d86064 chore(release): 1.9.1 2024-06-12 05:52:01 +00:00
fallenbagel
66c5de2bfa Merge remote-tracking branch 'origin/develop' 2024-06-12 10:49:50 +05:00
fallenbagel
6cf1ac7295 Merge remote-tracking branch 'origin/develop' 2024-06-12 10:38:58 +05:00
semantic-release-bot
25bf4b275a chore(release): 1.9.0 2024-05-29 12:53:56 +00:00
fallenbagel
103f028d99 Merge remote-tracking branch 'origin/develop' 2024-05-29 16:26:32 +05:00
semantic-release-bot
2101d0fff5 chore(release): 1.8.1 2024-04-17 19:08:15 +00:00
fallenbagel
09f50ac80f Merge branch 'develop' 2024-04-18 00:05:45 +05:00
semantic-release-bot
24fde7aec2 chore(release): 1.8.0 2024-04-15 21:49:19 +00:00
fallenbagel
d03bdf0cf9 Merge branch 'develop' 2024-04-16 02:46:59 +05:00
Fallenbagel
12986990ae Merge origin/develop into main (#716)
* fix(i18n): fixed jellyfin jobs

* feat: translations update from Hosted Weblate (#3258)

* feat(lang): translated using Weblate (Korean)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 11.2% (139 of 1233 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 11.3% (139 of 1226 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 7.8% (96 of 1226 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 7.4% (91 of 1226 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 1.7% (21 of 1226 strings)

feat(lang): added translation using Weblate (Korean)

Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: 김상구 (Studio) <spair0039@gmail.com>
Co-authored-by: 최효근 <gyrms7532@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ko/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1233 of 1233 strings)

Co-authored-by: BeardedWatermelon <periklis.karantonis@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/el/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Serbian)

Currently translated at 49.7% (608 of 1222 strings)

Co-authored-by: Dzonkins <nikoladjordjevic.ns@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1228 of 1228 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Angel <adelpozoman@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Romanian)

Currently translated at 33.0% (408 of 1234 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 29.7% (367 of 1234 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 27.9% (345 of 1234 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 27.8% (344 of 1233 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 27.6% (339 of 1226 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 27.4% (337 of 1226 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 22.8% (279 of 1223 strings)

Co-authored-by: Bunduc Dragos <bunduc.dragos@gmail.com>
Co-authored-by: DragoPrime <emperordrago@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ro/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 87.4% (1069 of 1223 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kirill Zhukov <siper13@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ru/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Emil Nymann <ens@hiper.dk>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/da/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Hungarian)

Currently translated at 86.3% (1055 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ZsiGiT <zsigit@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Dutch)

Currently translated at 99.4% (1226 of 1233 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Bas <bashankamp+weblate@gmail.com>
Co-authored-by: COTMO <moermantom1@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (1229 of 1233 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1228 of 1228 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 99.2% (1213 of 1222 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 99.1% (1212 of 1222 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 99.1% (1212 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mateus <mateusbernardo@protonmail.com>
Co-authored-by: Rafael Vieira <rafaelvieiras@pm.me>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Czech)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 99.7% (1223 of 1226 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 96.4% (1179 of 1222 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 89.1% (1090 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marek <marek@pavelka.xyz>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/cs/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1233 of 1233 strings)

Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ar/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_PT/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (German)

Currently translated at 99.9% (1233 of 1234 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.5% (1228 of 1234 strings)

feat(lang): translated using Weblate (German)

Currently translated at 99.5% (1227 of 1233 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (German)

Currently translated at 95.9% (1172 of 1222 strings)

feat(lang): translated using Weblate (German)

Currently translated at 95.9% (1172 of 1222 strings)

feat(lang): translated using Weblate (German)

Currently translated at 94.7% (1158 of 1222 strings)

Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: Furkan Çakar <cakar.55.furkan@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Juli <snowjuli@protonmail.com>
Co-authored-by: Leo Schultheiss <leoschultheiss@yahoo.de>
Co-authored-by: inkarnation <94744834+inkarnation@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 99.6% (1229 of 1233 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1228 of 1228 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 90.0% (1104 of 1226 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 90.0% (1101 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Topfield99 <timmiesonne@live.se>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Lithuanian)

Currently translated at 58.7% (725 of 1233 strings)

feat(lang): translated using Weblate (Lithuanian)

Currently translated at 58.6% (719 of 1226 strings)

feat(lang): translated using Weblate (Lithuanian)

Currently translated at 51.0% (624 of 1222 strings)

feat(lang): translated using Weblate (Lithuanian)

Currently translated at 43.9% (537 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: PovilasID <povilas.sidaravicius@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/lt/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 99.5% (1217 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 99.4% (1216 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 98.6% (1207 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 97.2% (1189 of 1223 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 94.3% (1154 of 1223 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Maite Guix <maite.guix@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ca/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Croatian)

Currently translated at 89.9% (1103 of 1226 strings)

Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Italian)

Currently translated at 92.2% (1138 of 1233 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 88.5% (1092 of 1233 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 85.8% (1058 of 1233 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 86.0% (1052 of 1223 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 83.2% (1017 of 1222 strings)

Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Gian Marco Cinalli <gm.cinalli@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mirco Cau <mircocau@gmail.com>
Co-authored-by: eggermn <egger.mn@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1226 of 1226 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.9% (1225 of 1226 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.9% (1225 of 1226 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (1224 of 1226 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.9% (1223 of 1224 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 98.0% (1198 of 1222 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 96.7% (1182 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jassy lin <linjiaxinme@gmail.com>
Co-authored-by: anpplex <anpplex@gmail.com>
Co-authored-by: kx <yoboy.rox@gmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hans/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1233 of 1233 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.9% (1225 of 1226 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1224 of 1224 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1223 of 1223 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.5% (1216 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.5% (1216 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.5% (1216 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1222 of 1222 strings)

Co-authored-by: Hordo <hordocast@mailo.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Maxent <rouaultmaxent@gmail.com>
Co-authored-by: Rémi Guerrero <remidu34070@hotmail.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Symness <simon@frayssines.fr>
Co-authored-by: Valentin <droidente@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Chinese (Traditional))

Currently translated at 99.7% (1219 of 1222 strings)

feat(lang): translated using Weblate (Chinese (Traditional))

Currently translated at 89.6% (1095 of 1222 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: 주서현 <adan.89lion@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

---------

Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: 김상구 (Studio) <spair0039@gmail.com>
Co-authored-by: 최효근 <gyrms7532@gmail.com>
Co-authored-by: BeardedWatermelon <periklis.karantonis@gmail.com>
Co-authored-by: Dzonkins <nikoladjordjevic.ns@gmail.com>
Co-authored-by: Angel <adelpozoman@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Bunduc Dragos <bunduc.dragos@gmail.com>
Co-authored-by: DragoPrime <emperordrago@gmail.com>
Co-authored-by: Kirill Zhukov <siper13@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Emil Nymann <ens@hiper.dk>
Co-authored-by: ZsiGiT <zsigit@gmail.com>
Co-authored-by: Bas <bashankamp+weblate@gmail.com>
Co-authored-by: COTMO <moermantom1@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Mateus <mateusbernardo@protonmail.com>
Co-authored-by: Rafael Vieira <rafaelvieiras@pm.me>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Co-authored-by: Marek <marek@pavelka.xyz>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: Furkan Çakar <cakar.55.furkan@gmail.com>
Co-authored-by: Juli <snowjuli@protonmail.com>
Co-authored-by: Leo Schultheiss <leoschultheiss@yahoo.de>
Co-authored-by: inkarnation <94744834+inkarnation@users.noreply.github.com>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Topfield99 <timmiesonne@live.se>
Co-authored-by: PovilasID <povilas.sidaravicius@gmail.com>
Co-authored-by: Maite Guix <maite.guix@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Gian Marco Cinalli <gm.cinalli@gmail.com>
Co-authored-by: Mirco Cau <mircocau@gmail.com>
Co-authored-by: eggermn <egger.mn@gmail.com>
Co-authored-by: Jassy lin <linjiaxinme@gmail.com>
Co-authored-by: anpplex <anpplex@gmail.com>
Co-authored-by: kx <yoboy.rox@gmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Hordo <hordocast@mailo.com>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Maxent <rouaultmaxent@gmail.com>
Co-authored-by: Rémi Guerrero <remidu34070@hotmail.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Symness <simon@frayssines.fr>
Co-authored-by: Valentin <droidente@gmail.com>
Co-authored-by: 주서현 <adan.89lion@gmail.com>

* feat: add Peacock to Network Slider (#3545)

* feat: add tooltips to tautulli avatars (#3601)

* named service inside docker-compose.yml

* Fix permissions on ManageSliderOver

Previously, would cause a 403 error when a non-admin user opened a movie/series page

* feat: add ko language (#3619)

* style: fix prettier errors

* Update de.json

Added a german translation for 
  "components.Discover.RecentlyAddedSlider.recentlyAdded": "Recently Added",

* feat: select default seriesType for anime (#3627)

* feat: select default seriesType for anime

Added flexibility to set default anime series type in service settings. Now you can choose
'standard' for anime if you prefer it, making it easier to use features like searching for season
packs on Sonarr.

fix #3626

* feat: extracted translations

* feat: standard series type selector (#3628)

* feat: added a standard series type selector

* fix: moved series type property to correct interface

* feat(notif): add Pushover sound options (#2403)

Co-authored-by: Danshil Kokil Mungur <me@danshilm.com>

* chore: specify files/directories to exclude from git archives (#2184)

Co-authored-by: Danshil Kokil Mungur <me@danshilm.com>

* feat: update SameSite policy of session cookie to Lax (#3650)

* update session cookie samesite policy to lax

* set cookie samesite policy based on csrf protection setting

* fix: resolved issue with region selector and all regions value (#3652)

* docs: add RemiRigal as a contributor for code (#3653) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* fix: request watchlist items sequentially to prevent bypassing quota (#3667)

* build: update node to 20.9 (#3668)

* build: do not link python for arm (#3670)

* docs: adds jellyseerr commit links

Adds jellyseerr commit links to Fallenbagel. TODO: add other contributors of jellyseerr into the
list

* update emoji for jellyseerr contributor

* Too many jellyfishes

* build: update docker ubuntu images to 22.04 (#3671)

* build: use node 18 (#3675)

* build: add global node-gyp for arm (#3676)

* build: correct node version in snapcraft (#3678)

* chore(translations): fixed watchlist translation so its generic for all media servers

* revert(jellyfinapi): reverts #450 as it broke library sync support for local accounts using LDAP

Reverted #450 which addressed the issue where the automatic grouping enabled libraries were not
functioning correctly. The previous fix inadvertently caused a bug for Jellyfin LDAP users,
preventing library syncing with a 401 error. Reverting this change temporarily until support for
automatic library grouping can be re-implemented

fix #489

* fix(langcode): fixes the ukranian language code

This changes the ukranian language code from ua to uk to fit to ISO 639-1 format that the tmdb api
uses.

fix #504

* fix(jellyfinlogin): use externalHostname if set for forgetpassword link

Implemented dynamic URL generation for the 'Forgot Password' feature. If jellyfin external hostname
is set, the URL is generated based on it; otherwise, jellyfin hostname is used as the base URL. The
URL includes additional parameters to handle emby support.

fix #199, fix #424, re #212

* ci(build): changes the base of the snap build to fix compatibility issues with GLIBC version

Changes base to core20 in an attempt to fix the error `node: /lib/x86_64-linux-gnu/libc.so.6:
version `GLIBC_2.28' not found (required by node)` during snap builds

* build(snap): changes node-js plugin to npm plugin for Core20

In an attempt to fix version compatibility of `GLIBC_2.28` base was upgraded from core18 to core20.
Node-js plugin was deprecated for core20 and instead npm plugin has to be used. As npm plugin cannot
specify the package manager to use, yarn is now installed globally during the override-build phase.

* build(snap): fix path for the build-environment

* build(snap): use nil package and try to setup node in override-build step

* build(snap): add yarn install before yarn build

* build(snap): add frozen-lockfile and increase network timeout for yarn install

* build(snap): remove `rm .gitbook.yaml` line to fix snap builds

* fix(watchlist): discover local watchlist item display and profile local watchlist slider visibility

Previously when you expand the `Your Watchlist` slider from the discover page to see all your
watchlist items, you only see the first 20 items. This commit fixes that so you can see all your
local watchlist items when you expand that slider. In addition, this commit also fixes the visiblity
of profile watchlist slider for local watchlists

* refactor: cleans up local watchlist logic and fixes translation extractions

* fix: fix the translations for watchlist permissions and userSettings page

* docs: [skip ci] change contributor settings to add both upstream and downstream contributors

This commit changes the contributorrc for allcontributors bot so we can add both overseerr and
jellyseerr contributors to the list

* docs: update README to accomodate both upstream and downstream contributor list seperately

* docs: [skip ci] add in current project allcontributors only

As allcontributors bot does not support having two lists of allcontributors seperately,
unfortunately had to remove upstream contributors from the .all-contributorsrc. However, they will
be added manually by @Fallenbagel to the README.md

* docs: [skip ci] removed contributor block so all-contributors can handle it

* docs: update README.md

* docs: update .all-contributorsrc

* docs: update README.md

* docs: update .all-contributorsrc

* chore: [skip ci] added skipCi to all-contributorsrc temporarily

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* Add more detailed installation instructions

* Update README.md

* ci(build): implement github repository container images

fix #370

* ci(build): hard-coded repository owner name for lowercase naming

* build: revert the hardcoded tag

* ci: github repository container lowercase tag

* update .github folder templates

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* Adding Jellyfin Setting for Custom "Forgot Password" URL

Adding Jellyfin Setting for Custom "Forgot Password" URL.  Useful in cases where you are using a custom authentication provider such as the LDAP plugin, Authelia, lldap, or any other external auth scheme with its own password reset page.

* Making the new setting optional

* Fixing code formatting, prettier

* fix(watchlist): added missing prop for watchlist item removal button in watchlist page

This fix resolves a Watchlist page bug where the isAddedToWatchlist prop was missing.
Without this prop, the removal button for watchlist items was absent. In this fix, the
isAddedToWatchlist prop is re-added and set to true, allowing users to remove items from
their local watchlist directly on the Watchlist page.

* fix: ensure watchlist updates are immediately reflected

This fix addresses an issue on the Watchlist page where changes to the watchlist were not
immediately reflected. Previously, after removing an item from the watchlist, the update
required a full page reload or revalidating upon focusing the window or tab. With this fix,
the watchlist now correctly mutates and updates in real-time, providing a seamless user
experience.

* fix: correct width issue in datepicker of filterSliderOver

This commit addresses a rendering issue with the date picker component.
The problem was traced back to a misconfiguration in the tailwindcss settings, resulting in an
incorrect width for the popup.

fix #415

* refactor: jellyfin scan jobs moved from server/jobs to server/libs/scanners

* fix: disable seasonfolder option in sonarr for jellyfin/Emby users

This disables seasonfolder option in sonarr for jellyfin/emby users as physical seasonFolders are
necessary as virtualFolders are ignored since #126

fix #575

* refactor: clean out commented code

* docs: reverted two unrelated files to its develop branch state

* fix: fix german translation for "components.Discover.FilterSlideover.tmdbuservotecount"

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* Link related projects in README.md

* Add more badges and weblate status

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* update weblate link

* move weblate details to contributing.md

* add translation percentage badge

* update discord badge

* docs: fix weblate link

* feat: added Letterboxd links for the external link blocks for movies

* ci(preview): added arm support for preview tags

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

* fix(jellyfin.ts): process virtual seasons if they have non virtual episodes (#639)

All seasons are processed now, but those without any episodes are filtered out again as unavailable.
This fixes in issue where jellyfin reports all seasons as virtual

* feat(job): media availability support for jellyfin/emby (#522)

* feat(job): media availability support for jellyfin/emby

This refactors the media availability job to support jellyfin/emby for media removal automatically.
Needs further testing on 4k items (as I have not yet tested with 4k), however, non-4k items work as
intended.

fix #406, fix #193, fix #516, fix #362, fix #84

* fix(availabilitysync): use the correct 4k jellyfinMediaId

* fix: season mapping for plex

Fixes a bug introduced with this PR where media availability sync job removed the seasons from all
series even when those seasons existed

* refactor: jellyfin authentication and add gravatar for missing avatars of jellyfin users (#664)

* refactor: jellyfin authentication

This refactor standardizes the authentication approach in Jellyfin to mirror the method employed in
Plex authentication for consistency

* feat: use gravatar for jellyfin users' with missing jellyfin avatars

* Fixed a typo (#654)

Just a simple typo fix.

* docs: add trackmastersteve as a contributor for doc (#665)

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* fix: typos on readme (#655)

* Fix typo

* Apply suggestions

* Apply suggestions

---------

Co-authored-by: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com>

* fix(embyauth): remove the accidentally added mediaServerType change code from another PR (#684)

Accidentally added the mediaServerType change code from another feature branch/PR during the auth
logic refactor that broke emby logins.

* fix(jellyfinscanner): conditionally assign the jellyfinMediaId and jellyfinMediaId4k (#686)

Previously `jellyfinMediaId4k` was being assigned even if 4k server was not setup or even if 4k
content were not present. This fixes it by conditionally assigning the jellyfinMediaId and
JellyfinMediaId4k

fix #681

* feat: check if first jellyfin user is admin (#635)

* feat: merge check if first jellyfin user is admin

re #610

* refactor(i18n): extract admin error message into en locale

---------

Co-authored-by: fallenbagel <98979876+Fallenbagel@users.noreply.github.com>

* refactor(i18n): change the user-facing identity of the application in i18n (#703)

* fix: nullable type for jellyfinMediaId(4k) (#702)

The jellyfinMediaId(4k) properties were inferred as string | undefined, causing them to be set to
undefined when assigning null. This prevented the media from being saved correctly to the SQLite
database, as it doesn't accept undefined values. This resolves the availabilitySync job issue where
the "play on" button wasn't being removed for all media server types.

fix #668

* fix(jellyfinapi): refactors jellyfin library sync to support automatic grouping and collections (#700)

* fix(jellyfinapi): refactors jellyfin library sync to support automatic grouping and collections

Previously, #450 added support for automatic library grouping. However, some users reported that
they were getting a 401 when using custom authentication such as LDAP. Therefore, that PR was
reverted (#524). This PR adds back the support for automatic library grouping for jellyfin
authentication users using the endpoint `/Library/MediaFolders` and fallsback to User views endpoint
if they're unable to sync the libraries (some, not all LDAP users had issues. Some reported that it
worked despite having custom authentication). Once it falls back to user views endpoint for syncing,
now it will detect if automatic grouping is enabled giving a warning that its not supported when
using some custom authentication methods. This PR also fixed collection syncing by expanding the
boxsets when syncing.

fix #256, fix #489, re #450, #524, fix #515, fix #474, fix #473

* refactor(i18n): adds the suffix "jellyfin" to jellyfin library sync message keys

* refactor(i18n): extract translation keys

* refactor: remove console logs

* refactor: remove more console logs

* refactor: apply review suggestions

* chore: fix prettier failing on .github file

* feat: jellyseerr makeover (#715)

---------

Co-authored-by: Daniel Fendrich <daniel.fendrich@3-s.at>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: 김상구 (Studio) <spair0039@gmail.com>
Co-authored-by: 최효근 <gyrms7532@gmail.com>
Co-authored-by: BeardedWatermelon <periklis.karantonis@gmail.com>
Co-authored-by: Dzonkins <nikoladjordjevic.ns@gmail.com>
Co-authored-by: Angel <adelpozoman@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Bunduc Dragos <bunduc.dragos@gmail.com>
Co-authored-by: DragoPrime <emperordrago@gmail.com>
Co-authored-by: Kirill Zhukov <siper13@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Emil Nymann <ens@hiper.dk>
Co-authored-by: ZsiGiT <zsigit@gmail.com>
Co-authored-by: Bas <bashankamp+weblate@gmail.com>
Co-authored-by: COTMO <moermantom1@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Mateus <mateusbernardo@protonmail.com>
Co-authored-by: Rafael Vieira <rafaelvieiras@pm.me>
Co-authored-by: Tijuco <sendtomy@protonmail.com>
Co-authored-by: Marek <marek@pavelka.xyz>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: Furkan Çakar <cakar.55.furkan@gmail.com>
Co-authored-by: Juli <snowjuli@protonmail.com>
Co-authored-by: Leo Schultheiss <leoschultheiss@yahoo.de>
Co-authored-by: inkarnation <94744834+inkarnation@users.noreply.github.com>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Topfield99 <timmiesonne@live.se>
Co-authored-by: PovilasID <povilas.sidaravicius@gmail.com>
Co-authored-by: Maite Guix <maite.guix@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Gian Marco Cinalli <gm.cinalli@gmail.com>
Co-authored-by: Mirco Cau <mircocau@gmail.com>
Co-authored-by: eggermn <egger.mn@gmail.com>
Co-authored-by: Jassy lin <linjiaxinme@gmail.com>
Co-authored-by: anpplex <anpplex@gmail.com>
Co-authored-by: kx <yoboy.rox@gmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Hordo <hordocast@mailo.com>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Maxent <rouaultmaxent@gmail.com>
Co-authored-by: Rémi Guerrero <remidu34070@hotmail.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Symness <simon@frayssines.fr>
Co-authored-by: Valentin <droidente@gmail.com>
Co-authored-by: 주서현 <adan.89lion@gmail.com>
Co-authored-by: Jean Beauchamp <jean@vwdubb.com>
Co-authored-by: Ryan Cohen <ryan@sct.dev>
Co-authored-by: Eduardo <sirmartin@gmail.com>
Co-authored-by: Rick Luiken <rick-luiken@live.nl>
Co-authored-by: Br33ce <124933490+Br33ce@users.noreply.github.com>
Co-authored-by: Brandon Cohen <brandon@z3hn.dev>
Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
Co-authored-by: Danshil Kokil Mungur <me@danshilm.com>
Co-authored-by: RemiRigal <rigal.remi@gmail.com>
Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
Co-authored-by: Athfan Khaleel <athphane@gmail.com>
Co-authored-by: Derek Paschal <dtpaschal@gmail.com>
Co-authored-by: mdll23 <m.dallinger@mailbox.org>
Co-authored-by: Janek <github@melonion.me>
Co-authored-by: Danish Humair <me@danishhumair.com>
Co-authored-by: Aleksa Siriški <31509435+aleksasiriski@users.noreply.github.com>
Co-authored-by: InvalidArgumentException <150857901+InvalidArgumentException@users.noreply.github.com>
Co-authored-by: Stephen Harris <trackmastersteve@users.noreply.github.com>
Co-authored-by: Gauvino <68083474+Gauvino@users.noreply.github.com>
2024-04-16 01:21:45 +05:00
semantic-release-bot
325e2ed6d3 chore(release): 1.7.0 2023-09-14 00:44:40 +00:00
Fallenbagel
e7c11da52b Merge pull request #477 from Fallenbagel/develop
Merge develop into main
2023-09-14 05:41:57 +05:00
semantic-release-bot
5712e19804 chore(release): 1.6.0 2023-08-04 20:43:24 +00:00
fallenbagel
4b549763e5 Merge branch 'develop' 2023-08-05 01:22:19 +05:00
semantic-release-bot
24151d27f7 chore(release): 1.5.0 2023-04-20 02:05:25 +00:00
Fallenbagel
f3cc8cba0a Merge pull request #368 from Fallenbagel/develop
Merge 'develop' into main
2023-04-20 07:02:36 +05:00
semantic-release-bot
57e7d68092 chore(release): 1.4.1 2023-01-31 00:20:50 +00:00
Fallenbagel
d3622f7bb3 Merge pull request #316 from Fallenbagel/develop
Merge develop into main
2023-01-31 05:15:41 +05:00
semantic-release-bot
20c821e2eb chore(release): 1.4.0 2023-01-29 20:33:10 +00:00
Fallenbagel
7b82ced5e6 Merge pull request #312 from Fallenbagel/develop
Merge 'origin/develop' into main
2023-01-30 01:31:00 +05:00
29 changed files with 4303 additions and 3497 deletions

4
.github/cliff.toml vendored
View File

@@ -33,9 +33,9 @@ body = """
{{ self::print_commit(commit=commit) }}
{%- endfor %}
{%- for commit in commits %}
{%- if not commit.scope %}
{%- if not commit.scope -%}
{{ self::print_commit(commit=commit) }}
{%- endif %}
{%- endif -%}
{%- endfor -%}
{%- endfor -%}

View File

@@ -16,7 +16,7 @@
description: 'Update appVersion in Chart.yaml to match Docker image',
fileMatch: ['(^|/)Chart\\.yaml$'],
matchStrings: [
"#\\s+renovate:\\s+image=(?<depName>\\S*)\nappVersion:\\s+'(?<currentValue>\\S*)'",
'#\\s+renovate:\\s+image=(?<depName>\\S*)\nappVersion:\\s+"(?<currentValue>\\S*)"',
],
datasourceTemplate: 'docker',
},

1959
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,9 @@ kubeVersion: '>=1.23.0-0'
name: seerr-chart
description: Seerr helm chart for Kubernetes
type: application
version: 3.0.2
version: 3.0.0
# renovate: image=ghcr.io/seerr-team/seerr
appVersion: 'v3.0.1'
appVersion: '3.0.0'
maintainers:
- name: Seerr Team
url: https://github.com/orgs/seerr-team/people

View File

@@ -1,6 +1,6 @@
# seerr-chart
![Version: 3.0.2](https://img.shields.io/badge/Version-3.0.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v3.0.1](https://img.shields.io/badge/AppVersion-v3.0.1-informational?style=flat-square)
![Version: 3.0.0](https://img.shields.io/badge/Version-3.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.0.0](https://img.shields.io/badge/AppVersion-3.0.0-informational?style=flat-square)
Seerr helm chart for Kubernetes

View File

@@ -5,7 +5,12 @@ sidebar_position: 3
---
# Unraid
:::danger
This method has not yet been updated for Seerr and is awaiting a community contribution.
Feel free to open a pull request on GitHub to update this installation method.
:::
<!--
:::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
@@ -14,68 +19,9 @@ Third-party installation methods are maintained by the community. The Seerr team
This method is not recommended for most users. It is intended for advanced users who are using Unraid.
:::
If an official Unraid Community Applications template for Seerr isn't available in your catalog, you can install Seerr manually using Unraid's Docker UI.
## Fresh Installation
### 1. Create the config directory
Open the Unraid terminal and run:
```bash
mkdir -p /mnt/user/appdata/seerr
chown -R 1000:1000 /mnt/user/appdata/seerr
```
### 2. Add the Docker container
Navigate to the **Docker** tab in Unraid and click **Add Container**. Fill in the following:
| Field | Value |
|---|---|
| **Name** | `seerr` |
| **Repository** | `ghcr.io/seerr-team/seerr:latest` |
| **Registry URL** (optional) | `https://ghcr.io` |
| **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` |
| **WebUI** | `http://[IP]:[PORT:5055]` |
| **Extra Parameters** | `--init` |
| **Network Type** | `bridge` |
| **Privileged** | `Off` |
Then click **Add another Path, Port, Variable** to add:
**Port:**
| Field | Value |
|---|---|
| Container Port | `5055` |
| Host Port | `5055` |
| Connection Type | `TCP` |
**Path:**
| Field | Value |
|---|---|
| Container Path | `/app/config` |
| Host Path | `/mnt/user/appdata/seerr` |
**Variable:**
| Field | Value |
|---|---|
| Key | `TZ` |
| Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) |
**Variable (optional):**
| Field | Value |
|---|---|
| Key | `LOG_LEVEL` |
| Value | `info` |
Click **Apply** to create and start the container.
### 3. Access Seerr
Open the WebUI at `http://<your-unraid-ip>:5055` and follow the setup wizard.
:::info
The `--init` flag in **Extra Parameters** is required. Seerr does not include its own init process, so `--init` ensures proper signal handling and clean container shutdowns.
:::
1. Ensure you have the **Community Applications** plugin installed.
2. Inside the **Community Applications** app store, search for **Seerr**.
3. Click the **Install Button**.
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1** \(Appdata\) as needed.
5. Click apply and access "Seerr" at your `<ServerIP:HostPort>` in a web browser.
-->

View File

@@ -23,6 +23,7 @@ Installation methods are now divided into two categories: official and third-par
The Seerr team is only responsible for official installation methods, while third-party methods are maintained by the community.
Some methods are currently not maintained, but this does not mean they are permanently discontinued. The community may restore and support them if they choose to do so.
- **Unraid app:** Not maintained
- **Snap package:** Not maintained
:::
@@ -199,82 +200,14 @@ Summary of changes :
</TabItem>
</Tabs>
## Third-party installation methods
### Nix
### Nix (Third-party installation methods)
Waiting for https://github.com/NixOS/nixpkgs/pull/450096 and https://github.com/NixOS/nixpkgs/pull/450093
### AUR
### AUR (Third-party installation methods)
See https://aur.archlinux.org/packages/seerr
### TrueNAS
### TrueNAS (Third-party installation methods)
Waiting for https://github.com/truenas/apps/issues/3374
### Unraid
Refer to [Seerr Unraid Documentation](/getting-started/third-parties/unraid), all of our examples have been updated to reflect the below change.
Seerr will automatically migrate your existing Overseerr or Jellyseerr data on first startup. No manual database migration is needed.
1. Stop and remove the old Overseerr (or Jellyseerr) container from the Unraid **Docker** tab. Click the container icon, then **Stop**, then **Remove**. **⚠️ Do not delete the appdata folder ⚠️**
2. Back up your existing appdata folder:
```bash
cp -a /mnt/user/appdata/overseerr /mnt/user/appdata/overseerr-backup
```
3. Fix config folder permissions — Seerr runs as the `node` user (UID 1000) instead of root:
```bash
chown -R 1000:1000 /mnt/user/appdata/overseerr
```
For Jellyseerr users, replace `overseerr` with `jellyseerr` in the path above.
4. Add a new container in the Unraid **Docker** tab. Click **Add Container** and fill in the following:
| Field | Value |
|---|---|
| **Name** | `seerr` |
| **Repository** | `ghcr.io/seerr-team/seerr:latest` |
| **Registry URL** (optional) | `https://ghcr.io` |
| **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` |
| **WebUI** | `http://[IP]:[PORT:5055]` |
| **Extra Parameters** | `--init` |
| **Network Type** | `bridge` |
| **Privileged** | `Off` |
Then click **Add another Path, Port, Variable** to add:
**Port:**
| Field | Value |
|---|---|
| Container Port | `5055` |
| Host Port | `5055` |
| Connection Type | `TCP` |
**Path** — point this to your existing config folder:
| Field | Value |
|---|---|
| Container Path | `/app/config` |
| Host Path | `/mnt/user/appdata/overseerr` |
For Jellyseerr users, use `/mnt/user/appdata/jellyseerr`.
**Variable:**
| Field | Value |
|---|---|
| Key | `TZ` |
| Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) |
**Variable (optional):**
| Field | Value |
|---|---|
| Key | `LOG_LEVEL` |
| Value | `info` |
5. Click **Apply** to start the container. Check the container logs to confirm the automatic migration completed successfully.
:::tip
If you are using a reverse proxy (such as SWAG or Nginx Proxy Manager), update your proxy configuration to point to the new container name `seerr`. The default port remains `5055`.
:::

View File

@@ -1,6 +1,6 @@
{
"name": "seerr",
"version": "0.1.0",
"version": "3.0.1",
"private": true,
"packageManager": "pnpm@10.24.0",
"scripts": {

View File

@@ -577,9 +577,21 @@ components:
example: false
baseUrl:
type: string
activeProfileId:
type: number
example: 1
activeProfileName:
type: string
example: 720p/1080p
activeDirectory:
type: string
example: '/movies'
is4k:
type: boolean
example: false
minimumAvailability:
type: string
example: 'In Cinema'
isDefault:
type: boolean
example: false
@@ -598,7 +610,11 @@ components:
- port
- apiKey
- useSsl
- activeProfileId
- activeProfileName
- activeDirectory
- is4k
- minimumAvailability
- isDefault
SonarrSettings:
type: object
@@ -624,6 +640,31 @@ components:
example: false
baseUrl:
type: string
activeProfileId:
type: number
example: 1
activeProfileName:
type: string
example: 720p/1080p
activeDirectory:
type: string
example: '/tv/'
activeLanguageProfileId:
type: number
example: 1
activeAnimeProfileId:
type: number
nullable: true
activeAnimeLanguageProfileId:
type: number
nullable: true
activeAnimeProfileName:
type: string
example: 720p/1080p
nullable: true
activeAnimeDirectory:
type: string
nullable: true
is4k:
type: boolean
example: false
@@ -648,6 +689,9 @@ components:
- port
- apiKey
- useSsl
- activeProfileId
- activeProfileName
- activeDirectory
- is4k
- enableSeasonFolders
- isDefault
@@ -2039,138 +2083,11 @@ components:
type: string
native_name:
type: string
RoutingRule:
OverrideRule:
type: object
properties:
id:
type: number
readOnly: true
name:
type: string
example: 'Anime Content'
serviceType:
type: string
enum:
- radarr
- sonarr
is4k:
type: boolean
priority:
type: number
users:
type: string
nullable: true
description: Comma-separated user IDs
genres:
type: string
nullable: true
description: Comma-separated genre IDs
languages:
type: string
nullable: true
description: Pipe-separated language codes (e.g. "ja|ko")
keywords:
type: string
nullable: true
description: Comma-separated keyword IDs
targetServiceId:
type: number
description: ID of the target Radarr/Sonarr instance
activeProfileId:
type: number
nullable: true
rootFolder:
type: string
nullable: true
minimumAvailability:
type: string
nullable: true
enum:
- announced
- inCinemas
- released
- null
seriesType:
type: string
nullable: true
enum:
- standard
- daily
- anime
- null
tags:
type: string
nullable: true
description: Comma-separated tag IDs
isFallback:
type: boolean
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true
RoutingRuleRequest:
type: object
required:
- name
- serviceType
- targetServiceId
properties:
name:
type: string
serviceType:
type: string
enum:
- radarr
- sonarr
is4k:
type: boolean
priority:
type: number
users:
type: string
nullable: true
genres:
type: string
nullable: true
languages:
type: string
nullable: true
keywords:
type: string
nullable: true
targetServiceId:
type: number
activeProfileId:
type: number
nullable: true
minimumAvailability:
type: string
nullable: true
enum:
- announced
- inCinemas
- released
- null
rootFolder:
type: string
nullable: true
seriesType:
type: string
nullable: true
enum:
- standard
- daily
- anime
- null
tags:
type: string
nullable: true
isFallback:
type: boolean
Certification:
type: object
properties:
@@ -7890,72 +7807,41 @@ paths:
message:
type: string
example: Unable to retrieve TV certifications.
/routingRule:
/overrideRule:
get:
summary: Get all routing rules
description: Returns all routing rules ordered by priority (highest first).
summary: Get override rules
description: Returns a list of all override rules with their conditions and settings
tags:
- settings
- overriderule
responses:
'200':
description: Routing rules returned
description: Override rules returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/RoutingRule'
$ref: '#/components/schemas/OverrideRule'
post:
summary: Create a new routing rule
description: Creates a new routing rule. Priority is auto-assigned (highest existing + 10).
summary: Create override rule
description: Creates a new Override Rule from the request body.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoutingRuleRequest'
- overriderule
responses:
'201':
description: Routing rule created
'200':
description: 'Values were successfully created'
content:
application/json:
schema:
$ref: '#/components/schemas/RoutingRule'
/routingRule/{ruleId}:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
/overrideRule/{ruleId}:
put:
summary: Update a routing rule
description: Updates an existing routing rule by ID.
summary: Update override rule
description: Updates an Override Rule from the request body.
tags:
- settings
parameters:
- in: path
name: ruleId
required: true
schema:
type: number
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoutingRuleRequest'
responses:
'200':
description: Routing rule updated
content:
application/json:
schema:
$ref: '#/components/schemas/RoutingRule'
'404':
description: Routing rule not found
delete:
summary: Delete a routing rule
description: Deletes a routing rule by ID.
tags:
- settings
- overriderule
parameters:
- in: path
name: ruleId
@@ -7964,42 +7850,31 @@ paths:
type: number
responses:
'200':
description: Routing rule deleted
content:
application/json:
schema:
$ref: '#/components/schemas/RoutingRule'
'404':
description: Routing rule not found
/routingRule/reorder:
post:
summary: Reorder routing rules
description: Bulk update priorities by providing an ordered list of rule IDs (highest priority first).
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- ruleIds
properties:
ruleIds:
type: array
items:
type: number
responses:
'200':
description: Rules reordered
description: 'Values were successfully updated'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/RoutingRule'
$ref: '#/components/schemas/OverrideRule'
delete:
summary: Delete override rule by ID
description: Deletes the override rule with the provided ruleId.
tags:
- overriderule
parameters:
- in: path
name: ruleId
required: true
schema:
type: number
responses:
'200':
description: Override rule successfully deleted
content:
application/json:
schema:
$ref: '#/components/schemas/OverrideRule'
security:
- cookieAuth: []
- apiKey: []

View File

@@ -92,7 +92,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
apiKey,
cacheName,
apiName,
timeout = 10000,
timeout = 5000,
}: {
url: string;
apiKey: string;

View File

@@ -1,4 +1,5 @@
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import {
MediaRequestStatus,
@@ -6,10 +7,10 @@ import {
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import { resolveRoute } from '@server/lib/routingResolver';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
@@ -201,41 +202,133 @@ export class MediaRequest {
}
}
// apply routing rules to determine request settings (server/profile/folder/tags)
let tmdbKeywords: number[] = [];
if ('keywords' in tmdbMedia.keywords) {
tmdbKeywords = tmdbMedia.keywords.keywords.map((k: TmdbKeyword) => k.id);
} else if ('results' in tmdbMedia.keywords) {
tmdbKeywords = tmdbMedia.keywords.results.map((k: TmdbKeyword) => k.id);
}
const isAdmin = user.hasPermission([Permission.MANAGE_REQUESTS], {
// Apply overrides if the user is not an admin or has the "advanced request" permission
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
type: 'or',
});
const route = await resolveRoute({
serviceType:
requestBody.mediaType === MediaType.MOVIE ? 'radarr' : 'sonarr',
is4k: requestBody.is4k ?? false,
userId: requestUser.id,
genres: tmdbMedia.genres.map((g) => g.id),
language: tmdbMedia.original_language,
keywords: tmdbKeywords,
});
let rootFolder = requestBody.rootFolder;
let profileId = requestBody.profileId;
let tags = requestBody.tags;
const serverId =
isAdmin && requestBody.serverId != null
? requestBody.serverId
: route.serviceId;
const profileId =
isAdmin && requestBody.profileId != null
? requestBody.profileId
: route.profileId;
const rootFolder =
isAdmin && requestBody.rootFolder
? requestBody.rootFolder
: route.rootFolder;
const tags = isAdmin && requestBody.tags ? requestBody.tags : route.tags;
if (useOverrides) {
const defaultRadarrId = requestBody.is4k
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
const defaultSonarrId = requestBody.is4k
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where:
requestBody.mediaType === MediaType.MOVIE
? { radarrServiceId: defaultRadarrId }
: { sonarrServiceId: defaultSonarrId },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
const hasAnimeKeyword =
'results' in tmdbMedia.keywords &&
tmdbMedia.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
);
// Skip override rules if the media is an anime TV show as anime TV
// is handled by default and override rules do not explicitly include
// the anime keyword
if (
requestBody.mediaType === MediaType.TV &&
hasAnimeKeyword &&
(!rule.keywords ||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
) {
return false;
}
if (
rule.users &&
!rule.users
.split(',')
.some((userId) => Number(userId) === requestUser.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
tmdbMedia.genres
.map((genre) => genre.id)
.includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === tmdbMedia.original_language)
) {
return false;
}
if (
rule.keywords &&
!rule.keywords.split(',').some((keywordId) => {
let keywordList: TmdbKeyword[] = [];
if ('keywords' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.keywords;
} else if ('results' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.results;
}
return keywordList
.map((keyword: TmdbKeyword) => keyword.id)
.includes(Number(keywordId));
})
) {
return false;
}
return true;
});
// hacky way to prioritize rules
// TODO: make this better
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
const aSpecificity = keys.filter((key) => a[key] !== null).length;
const bSpecificity = keys.filter((key) => b[key] !== null).length;
// Take the rule with the most specific condition first
return bSpecificity - aSpecificity;
})[0];
if (prioritizedRule) {
if (prioritizedRule.rootFolder) {
rootFolder = prioritizedRule.rootFolder;
}
if (prioritizedRule.profileId) {
profileId = prioritizedRule.profileId;
}
if (prioritizedRule.tags) {
tags = [
...new Set([
...(tags || []),
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
]),
];
}
logger.debug('Override rule applied.', {
label: 'Media Request',
overrides: prioritizedRule,
});
}
}
if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media);
@@ -274,7 +367,7 @@ export class MediaRequest {
? user
: undefined,
is4k: requestBody.is4k,
serverId: serverId,
serverId: requestBody.serverId,
profileId: profileId,
rootFolder: rootFolder,
tags: tags,
@@ -384,7 +477,7 @@ export class MediaRequest {
? user
: undefined,
is4k: requestBody.is4k,
serverId: serverId,
serverId: requestBody.serverId,
profileId: profileId,
rootFolder: rootFolder,
languageProfileId: requestBody.languageProfileId,

View File

@@ -1,69 +0,0 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
class RoutingRule {
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'varchar' })
public name: string;
@Column({ type: 'varchar' })
public serviceType: 'radarr' | 'sonarr';
@Column({ default: false })
public is4k: boolean;
@Column({ type: 'int', default: 0 })
public priority: number;
@Column({ nullable: true })
public users?: string;
@Column({ nullable: true })
public genres?: string;
@Column({ nullable: true })
public languages?: string;
@Column({ nullable: true })
public keywords?: string;
@Column({ type: 'int' })
public targetServiceId: number;
@Column({ type: 'int', nullable: true })
public activeProfileId?: number;
@Column({ nullable: true })
public rootFolder?: string;
@Column({ nullable: true })
public seriesType?: string;
@Column({ nullable: true })
public tags?: string;
@Column({ type: 'varchar', nullable: true })
public minimumAvailability?: 'announced' | 'inCinemas' | 'released';
@Column({ default: false })
public isFallback: boolean;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
public updatedAt: Date;
constructor(init?: Partial<RoutingRule>) {
Object.assign(this, init);
}
}
export default RoutingRule;

View File

@@ -1,136 +0,0 @@
import { getRepository } from '@server/datasource';
import RoutingRule from '@server/entity/RoutingRule';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
export interface ResolvedRoute {
serviceId: number;
profileId?: number;
rootFolder?: string;
seriesType?: string;
tags?: number[];
minimumAvailability?: string;
}
interface RouteParams {
serviceType: 'radarr' | 'sonarr';
is4k: boolean;
userId: number;
genres: number[];
language: string;
keywords: number[];
}
/**
* Evaluates routing rules top-to-bottom (by priority DESC).
* First match wins. Falls back to the default instance if no rules match.
*/
export async function resolveRoute(
params: RouteParams
): Promise<ResolvedRoute> {
const routingRuleRepository = getRepository(RoutingRule);
const settings = getSettings();
const rules = await routingRuleRepository.find({
where: {
serviceType: params.serviceType,
is4k: params.is4k,
},
order: { priority: 'DESC' },
});
for (const rule of rules) {
if (matchesAllConditions(rule, params)) {
logger.debug('Routing rule matched', {
label: 'Routing',
ruleId: rule.id,
ruleName: rule.name,
targetServiceId: rule.targetServiceId,
});
return {
serviceId: rule.targetServiceId,
profileId: rule.activeProfileId ?? undefined,
rootFolder: rule.rootFolder ?? undefined,
seriesType: rule.seriesType ?? undefined,
tags: rule.tags ? rule.tags.split(',').map(Number) : undefined,
minimumAvailability: rule.minimumAvailability ?? undefined,
};
}
}
logger.warn(
'No routing rules matched (including fallback rules). Falling back to settings default.',
{
label: 'Routing',
serviceType: params.serviceType,
is4k: params.is4k,
}
);
const services =
params.serviceType === 'radarr' ? settings.radarr : settings.sonarr;
const defaultServiceIdx = services.findIndex(
(s) => (params.is4k ? s.is4k : !s.is4k) && s.isDefault
);
if (defaultServiceIdx === -1) {
throw new Error(
`No default ${params.serviceType} instance configured for ${
params.is4k ? '4K' : 'non-4K'
} content.`
);
}
return { serviceId: services[defaultServiceIdx].id };
}
/**
* Check if a rule's conditions all match the request parameters.
*
* - No conditions (fallback) = always matches
* - AND between condition types (all populated conditions must pass)
* - OR within a condition type (any value can match)
*/
function matchesAllConditions(rule: RoutingRule, params: RouteParams): boolean {
if (rule.isFallback) {
return true;
}
const hasConditions =
rule.users || rule.genres || rule.languages || rule.keywords;
if (!hasConditions) {
return true;
}
if (rule.users) {
const ruleUserIds = rule.users.split(',').map(Number);
if (!ruleUserIds.includes(params.userId)) {
return false;
}
}
if (rule.genres) {
const ruleGenreIds = rule.genres.split(',').map(Number);
if (!ruleGenreIds.some((g) => params.genres.includes(g))) {
return false;
}
}
if (rule.languages) {
const ruleLangs = rule.languages.split('|');
if (!ruleLangs.includes(params.language)) {
return false;
}
}
if (rule.keywords) {
const ruleKeywordIds = rule.keywords.split(',').map(Number);
if (!ruleKeywordIds.some((k) => params.keywords.includes(k))) {
return false;
}
}
return true;
}

View File

@@ -1,187 +0,0 @@
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import RoutingRule from '@server/entity/RoutingRule';
import type { AllSettings } from '@server/lib/settings';
const ANIME_KEYWORD_ID = '210024';
const migrateToRoutingRules = async (settings: any): Promise<AllSettings> => {
if (
Array.isArray(settings.migrations) &&
settings.migrations.includes('0009_migrate_to_routing_rules')
) {
return settings;
}
const routingRuleRepo = getRepository(RoutingRule);
let errorOccurred = false;
for (const radarr of settings.radarr || []) {
if (!radarr.isDefault) continue;
try {
await routingRuleRepo.save(
new RoutingRule({
name: `${radarr.name} Default Route`,
serviceType: 'radarr',
targetServiceId: radarr.id,
is4k: radarr.is4k,
isFallback: true,
priority: 0,
activeProfileId: radarr.activeProfileId || undefined,
rootFolder: radarr.activeDirectory || undefined,
minimumAvailability: radarr.minimumAvailability || 'released',
tags:
radarr.tags && radarr.tags.length > 0
? radarr.tags.join(',')
: undefined,
})
);
} catch (error) {
console.error(
`Failed to create Radarr fallback routing rule for "${radarr.name}".`,
error.message
);
errorOccurred = true;
}
}
for (const sonarr of settings.sonarr || []) {
if (!sonarr.isDefault) continue;
try {
await routingRuleRepo.save(
new RoutingRule({
name: `${sonarr.name} Default Route`,
serviceType: 'sonarr',
targetServiceId: sonarr.id,
is4k: sonarr.is4k,
isFallback: true,
priority: 0,
activeProfileId: sonarr.activeProfileId || undefined,
rootFolder: sonarr.activeDirectory || undefined,
seriesType: sonarr.seriesType || 'standard',
tags:
sonarr.tags && sonarr.tags.length > 0
? sonarr.tags.join(',')
: undefined,
})
);
} catch (error) {
console.error(
`Failed to create Sonarr fallback routing rule for "${sonarr.name}".`,
error.message
);
errorOccurred = true;
}
const hasAnimeOverrides =
sonarr.activeAnimeProfileId ||
sonarr.activeAnimeDirectory ||
(sonarr.animeTags && sonarr.animeTags.length > 0);
if (hasAnimeOverrides) {
try {
await routingRuleRepo.save(
new RoutingRule({
name: 'Anime',
serviceType: 'sonarr',
targetServiceId: sonarr.id,
is4k: sonarr.is4k,
isFallback: false,
priority: 10,
keywords: ANIME_KEYWORD_ID,
activeProfileId:
sonarr.activeAnimeProfileId ||
sonarr.activeProfileId ||
undefined,
rootFolder:
sonarr.activeAnimeDirectory ||
sonarr.activeDirectory ||
undefined,
seriesType: sonarr.animeSeriesType || 'anime',
tags:
sonarr.animeTags && sonarr.animeTags.length > 0
? sonarr.animeTags.join(',')
: undefined,
})
);
} catch (error) {
console.error(
`Failed to create Sonarr anime routing rule for "${sonarr.name}".`,
error.message
);
errorOccurred = true;
}
}
}
let overrideRules: OverrideRule[] = [];
try {
const overrideRuleRepo = getRepository(OverrideRule);
overrideRules = await overrideRuleRepo.find();
} catch {
// If the OverrideRule table doesn't exist or can't be queried, we can skip this step.
}
let priority = 20;
for (const rule of overrideRules) {
const isRadarr = rule.radarrServiceId != null;
const serviceType: 'radarr' | 'sonarr' = isRadarr ? 'radarr' : 'sonarr';
const serviceIndex = isRadarr
? rule.radarrServiceId!
: rule.sonarrServiceId!;
const services =
serviceType === 'radarr' ? settings.radarr || [] : settings.sonarr || [];
const targetService = services[serviceIndex];
if (!targetService) {
console.error(
`Skipping override rule #${rule.id}: ${serviceType} instance at index ${serviceIndex} not found in settings.`
);
errorOccurred = true;
continue;
}
try {
await routingRuleRepo.save(
new RoutingRule({
name: `Migrated Rule #${rule.id}`,
serviceType,
targetServiceId: targetService.id,
is4k: targetService.is4k,
isFallback: false,
priority,
users: rule.users || undefined,
genres: rule.genre || undefined,
languages: rule.language || undefined,
keywords: rule.keywords || undefined,
activeProfileId: rule.profileId || undefined,
rootFolder: rule.rootFolder || undefined,
tags: rule.tags || undefined,
})
);
priority += 10;
} catch (error) {
console.error(
`Failed to migrate override rule #${rule.id} to routing rule.`,
error.message
);
errorOccurred = true;
}
}
if (!errorOccurred) {
if (!Array.isArray(settings.migrations)) {
settings.migrations = [];
}
settings.migrations.push('0009_migrate_to_routing_rules');
}
return settings;
};
export default migrateToRoutingRules;

View File

@@ -16,8 +16,8 @@ import deprecatedRoute from '@server/middleware/deprecation';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import { mapWatchProviderDetails } from '@server/models/common';
import overrideRuleRoutes from '@server/routes/overrideRule';
import settingsRoutes from '@server/routes/settings';
import routingRuleRoutes from '@server/routes/settings/routingRule';
import watchlistRoutes from '@server/routes/watchlist';
import {
appDataPath,
@@ -173,9 +173,9 @@ router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes);
router.use(
'/routingRule',
'/overrideRule',
isAuthenticated(Permission.ADMIN),
routingRuleRoutes
overrideRuleRoutes
);
router.get('/regions', isAuthenticated(), async (req, res, next) => {

View File

@@ -0,0 +1,136 @@
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import { Permission } from '@server/lib/permissions';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
const overrideRuleRoutes = Router();
overrideRuleRoutes.get(
'/',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rules = await overrideRuleRepository.find({});
return res.status(200).json(rules as OverrideRuleResultsResponse);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
overrideRuleRoutes.post<
Record<string, string>,
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = new OverrideRule({
users: req.body.users,
genre: req.body.genre,
language: req.body.language,
keywords: req.body.keywords,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
radarrServiceId: req.body.radarrServiceId,
sonarrServiceId: req.body.sonarrServiceId,
});
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.put<
{ ruleId: string },
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
rule.users = req.body.users;
rule.genre = req.body.genre;
rule.language = req.body.language;
rule.keywords = req.body.keywords;
rule.profileId = req.body.profileId;
rule.rootFolder = req.body.rootFolder;
rule.tags = req.body.tags;
rule.radarrServiceId = req.body.radarrServiceId;
rule.sonarrServiceId = req.body.sonarrServiceId;
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>(
'/:ruleId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
await overrideRuleRepository.remove(rule);
return res.status(200).json(rule);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
export default overrideRuleRoutes;

View File

@@ -1,6 +1,4 @@
import RadarrAPI from '@server/api/servarr/radarr';
import { getRepository } from '@server/datasource';
import RoutingRule from '@server/entity/RoutingRule';
import type { RadarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -138,7 +136,6 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
const settings = getSettings();
const routingRuleRepository = getRepository(RoutingRule);
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
@@ -148,19 +145,6 @@ radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
return next({ status: '404', message: 'Settings instance not found' });
}
const instanceId = Number(req.params.id);
const rulesToDelete = await routingRuleRepository.find({
where: {
serviceType: 'radarr',
targetServiceId: instanceId,
},
});
if (rulesToDelete.length > 0) {
await routingRuleRepository.remove(rulesToDelete);
}
const removed = settings.radarr.splice(radarrIndex, 1);
await settings.save();

View File

@@ -1,370 +0,0 @@
import { getRepository } from '@server/datasource';
import RoutingRule from '@server/entity/RoutingRule';
import { Permission } from '@server/lib/permissions';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import { In, Not } from 'typeorm';
const routingRuleRoutes = Router();
type ServiceType = 'radarr' | 'sonarr';
function resolveTargetService(
serviceType: ServiceType,
targetServiceId: number
): RadarrSettings | SonarrSettings | undefined {
const settings = getSettings();
const services = serviceType === 'radarr' ? settings.radarr : settings.sonarr;
return services.find((s) => s.id === targetServiceId);
}
function hasAnyCondition(body: Record<string, unknown>): boolean {
return !!(body.users || body.genres || body.languages || body.keywords);
}
function parseActiveProfileId(
raw: string | number | null | undefined
): number | null {
if (raw === '' || raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
routingRuleRoutes.get(
'/',
isAuthenticated(Permission.ADMIN),
async (_req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const rules = await routingRuleRepository.find({
order: { isFallback: 'ASC', priority: 'DESC' },
});
return res.status(200).json(rules);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
routingRuleRoutes.post(
'/',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const serviceType = req.body.serviceType as ServiceType;
const targetServiceId = Number(req.body.targetServiceId);
if (!serviceType || !['radarr', 'sonarr'].includes(serviceType)) {
return next({ status: 400, message: 'Invalid serviceType.' });
}
if (!Number.isFinite(targetServiceId) || targetServiceId < 0) {
return next({ status: 400, message: 'Invalid targetServiceId.' });
}
const target = resolveTargetService(serviceType, targetServiceId);
if (!target) {
return next({ status: 400, message: 'Target instance not found.' });
}
const derivedIs4k = !!target.is4k;
const isFallback = !!req.body.isFallback;
if (isFallback) {
const existing = await routingRuleRepository.findOne({
where: { serviceType, is4k: derivedIs4k, isFallback: true },
});
if (existing) {
return next({
status: 409,
message: 'Fallback already exists for this serviceType/is4k.',
});
}
if (!target.isDefault) {
return next({
status: 400,
message: 'Fallback rules must target a default instance.',
});
}
}
if (!isFallback && !hasAnyCondition(req.body)) {
return next({
status: 400,
message: 'Non-fallback rules must have at least one condition.',
});
}
const activeProfileId = parseActiveProfileId(req.body.activeProfileId);
if (isFallback) {
if (!req.body.rootFolder) {
return next({
status: 400,
message: 'Fallback requires rootFolder.',
});
}
if (activeProfileId == null) {
return next({
status: 400,
message: 'Fallback requires activeProfileId.',
});
}
if (serviceType === 'radarr' && !req.body.minimumAvailability) {
return next({
status: 400,
message: 'Fallback requires minimumAvailability for radarr.',
});
}
}
let priority = 0;
if (!isFallback) {
const highestRule = await routingRuleRepository.findOne({
where: { serviceType, is4k: derivedIs4k, isFallback: false },
order: { priority: 'DESC' },
});
priority = (highestRule?.priority ?? 0) + 10;
}
const rule = new RoutingRule({
name: req.body.name,
serviceType,
targetServiceId,
is4k: derivedIs4k,
isFallback,
priority,
users: isFallback ? null : req.body.users,
genres: isFallback ? null : req.body.genres,
languages: isFallback ? null : req.body.languages,
keywords: isFallback ? null : req.body.keywords,
activeProfileId: activeProfileId ?? undefined,
rootFolder: req.body.rootFolder,
seriesType: req.body.seriesType,
tags: req.body.tags,
minimumAvailability: req.body.minimumAvailability ?? null,
});
const newRule = await routingRuleRepository.save(rule);
return res.status(201).json(newRule);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
routingRuleRoutes.put<{ ruleId: string }>(
'/:ruleId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const rule = await routingRuleRepository.findOne({
where: { id: Number(req.params.ruleId) },
});
if (!rule) {
return next({ status: 404, message: 'Routing rule not found.' });
}
const nextServiceType = (req.body.serviceType ??
rule.serviceType) as ServiceType;
const nextTargetServiceId = Number(
req.body.targetServiceId ?? rule.targetServiceId
);
const target = resolveTargetService(nextServiceType, nextTargetServiceId);
if (!target) {
return next({ status: 400, message: 'Target instance not found.' });
}
const derivedIs4k = !!target.is4k;
const derivedIsDefault = !!target.isDefault;
const nextIsFallback = !!(req.body.isFallback ?? rule.isFallback);
if (nextIsFallback) {
const existing = await routingRuleRepository.findOne({
where: {
serviceType: nextServiceType,
is4k: derivedIs4k,
isFallback: true,
id: Not(rule.id),
},
});
if (existing) {
return next({
status: 409,
message: 'Fallback already exists for this serviceType/is4k.',
});
}
}
const mergedForConditionCheck = { ...rule, ...req.body };
if (!nextIsFallback && !hasAnyCondition(mergedForConditionCheck)) {
return next({
status: 400,
message: 'Non-fallback rules must have at least one condition.',
});
}
if (nextIsFallback && !derivedIsDefault) {
return next({
status: 400,
message: 'Fallback rules must target a default instance.',
});
}
const nextActiveProfileId = parseActiveProfileId(
req.body.activeProfileId ?? rule.activeProfileId
);
const nextRootFolder = (req.body.rootFolder ?? rule.rootFolder) as
| string
| undefined;
const nextMinimumAvailability =
nextServiceType === 'radarr'
? (req.body.minimumAvailability ?? rule.minimumAvailability)
: null;
if (nextIsFallback) {
if (!nextRootFolder) {
return next({
status: 400,
message: 'Fallback requires rootFolder.',
});
}
if (nextActiveProfileId == null) {
return next({
status: 400,
message: 'Fallback requires activeProfileId.',
});
}
if (nextServiceType === 'radarr' && !nextMinimumAvailability) {
return next({
status: 400,
message: 'Fallback requires minimumAvailability for radarr.',
});
}
}
if (nextIsFallback) {
rule.priority = 0;
} else if (typeof req.body.priority === 'number') {
rule.priority = req.body.priority;
} else {
const groupChanged =
rule.serviceType !== nextServiceType ||
rule.is4k !== derivedIs4k ||
rule.isFallback;
if (groupChanged) {
const highestRule = await routingRuleRepository.findOne({
where: {
serviceType: nextServiceType,
is4k: derivedIs4k,
isFallback: false,
},
order: { priority: 'DESC' },
});
rule.priority = (highestRule?.priority ?? 0) + 10;
}
}
rule.name = req.body.name ?? rule.name;
rule.serviceType = nextServiceType;
rule.targetServiceId = nextTargetServiceId;
rule.is4k = derivedIs4k;
rule.isFallback = nextIsFallback;
rule.users = nextIsFallback ? null : req.body.users;
rule.genres = nextIsFallback ? null : req.body.genres;
rule.languages = nextIsFallback ? null : req.body.languages;
rule.keywords = nextIsFallback ? null : req.body.keywords;
rule.activeProfileId = nextActiveProfileId ?? undefined;
rule.rootFolder = nextRootFolder;
rule.minimumAvailability = nextMinimumAvailability;
rule.tags = req.body.tags;
const updatedRule = await routingRuleRepository.save(rule);
return res.status(200).json(updatedRule);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
routingRuleRoutes.delete<{ ruleId: string }>(
'/:ruleId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const rule = await routingRuleRepository.findOne({
where: { id: Number(req.params.ruleId) },
});
if (!rule) {
return next({ status: 404, message: 'Routing rule not found.' });
}
await routingRuleRepository.remove(rule);
return res.status(200).json(rule);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
routingRuleRoutes.post(
'/reorder',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const { ruleIds } = req.body as { ruleIds: number[] };
const MAX_RULE_IDS = 1000;
if (!Array.isArray(ruleIds)) {
return next({ status: 400, message: 'ruleIds must be an array.' });
}
if (ruleIds.length > MAX_RULE_IDS) {
return next({
status: 400,
message: `Too many ruleIds provided. Maximum allowed is ${MAX_RULE_IDS}.`,
});
}
const rules = await routingRuleRepository.findBy({ id: In(ruleIds) });
const fallbackIds = new Set(
rules.filter((r) => r.isFallback).map((r) => r.id)
);
const orderedIds = ruleIds.filter((id) => !fallbackIds.has(id));
for (let i = 0; i < orderedIds.length; i++) {
await routingRuleRepository.update(orderedIds[i], {
priority: (orderedIds.length - i) * 10,
});
}
const refreshed = await routingRuleRepository.find({
order: { isFallback: 'ASC', priority: 'DESC' },
});
return res.status(200).json(refreshed);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
export default routingRuleRoutes;

View File

@@ -1,6 +1,4 @@
import SonarrAPI from '@server/api/servarr/sonarr';
import { getRepository } from '@server/datasource';
import RoutingRule from '@server/entity/RoutingRule';
import type { SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -110,7 +108,6 @@ sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
const settings = getSettings();
const routingRuleRepository = getRepository(RoutingRule);
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
@@ -122,19 +119,6 @@ sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
.json({ status: '404', message: 'Settings instance not found' });
}
const instanceId = Number(req.params.id);
const rulesToDelete = await routingRuleRepository.find({
where: {
serviceType: 'sonarr',
targetServiceId: instanceId,
},
});
if (rulesToDelete.length > 0) {
await routingRuleRepository.remove(rulesToDelete);
}
const removed = settings.sonarr.splice(sonarrIndex, 1);
await settings.save();

View File

@@ -0,0 +1,544 @@
import Modal from '@app/components/Common/Modal';
import LanguageSelector from '@app/components/LanguageSelector';
import {
GenreSelector,
KeywordSelector,
UserSelector,
} from '@app/components/Selector';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import type OverrideRule from '@server/entity/OverrideRule';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages('components.Settings.OverrideRuleModal', {
createrule: 'New Override Rule',
editrule: 'Edit Override Rule',
create: 'Create rule',
service: 'Service',
serviceDescription: 'Apply this rule to the selected service.',
selectService: 'Select service',
conditions: 'Conditions',
conditionsDescription:
'Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).',
settings: 'Settings',
settingsDescription:
'Specifies which settings will be changed when the above conditions are met.',
users: 'Users',
genres: 'Genres',
languages: 'Languages',
keywords: 'Keywords',
rootfolder: 'Root Folder',
selectRootFolder: 'Select root folder',
qualityprofile: 'Quality Profile',
selectQualityProfile: 'Select quality profile',
tags: 'Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
ruleCreated: 'Override rule created successfully!',
ruleUpdated: 'Override rule updated successfully!',
});
type OptionType = {
value: number;
label: string;
};
interface OverrideRuleModalProps {
rule: OverrideRule | null;
onClose: () => void;
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
}
const OverrideRuleModal = ({
onClose,
rule,
radarrServices,
sonarrServices,
}: OverrideRuleModalProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const { currentSettings } = useSettings();
const [isValidated, setIsValidated] = useState(rule ? true : false);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
profiles: [],
rootFolders: [],
tags: [],
});
const getServiceInfos = useCallback(
async (
{
hostname,
port,
apiKey,
baseUrl,
useSsl = false,
}: {
hostname: string;
port: number;
apiKey: string;
baseUrl?: string;
useSsl?: boolean;
},
type: 'radarr' | 'sonarr'
) => {
setIsTesting(true);
try {
const response = await axios.post<DVRTestResponse>(
`/api/v1/settings/${type}/test`,
{
hostname,
apiKey,
port: Number(port),
baseUrl,
useSsl,
}
);
setIsValidated(true);
setTestResponse(response.data);
} catch (e) {
setIsValidated(false);
} finally {
setIsTesting(false);
}
},
[]
);
useEffect(() => {
if (
rule?.radarrServiceId !== null &&
rule?.radarrServiceId !== undefined &&
radarrServices[rule?.radarrServiceId]
) {
getServiceInfos(radarrServices[rule?.radarrServiceId], 'radarr');
}
if (
rule?.sonarrServiceId !== null &&
rule?.sonarrServiceId !== undefined &&
sonarrServices[rule?.sonarrServiceId]
) {
getServiceInfos(sonarrServices[rule?.sonarrServiceId], 'sonarr');
}
}, [
getServiceInfos,
radarrServices,
rule?.radarrServiceId,
rule?.sonarrServiceId,
sonarrServices,
]);
return (
<Transition
as="div"
appear
show
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Formik
initialValues={{
radarrServiceId: rule?.radarrServiceId,
sonarrServiceId: rule?.sonarrServiceId,
users: rule?.users,
genre: rule?.genre,
language: rule?.language,
keywords: rule?.keywords,
profileId: rule?.profileId,
rootFolder: rule?.rootFolder,
tags: rule?.tags,
}}
onSubmit={async (values) => {
try {
const submission = {
users: values.users || null,
genre: values.genre || null,
language: values.language || null,
keywords: values.keywords || null,
profileId: Number(values.profileId) || null,
rootFolder: values.rootFolder || null,
tags: values.tags || null,
radarrServiceId: values.radarrServiceId,
sonarrServiceId: values.sonarrServiceId,
};
if (!rule) {
await axios.post('/api/v1/overrideRule', submission);
addToast(intl.formatMessage(messages.ruleCreated), {
appearance: 'success',
autoDismiss: true,
});
} else {
await axios.put(`/api/v1/overrideRule/${rule.id}`, submission);
addToast(intl.formatMessage(messages.ruleUpdated), {
appearance: 'success',
autoDismiss: true,
});
}
onClose();
} catch (e) {
// set error here
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
return (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(globalMessages.saving)
: rule
? intl.formatMessage(globalMessages.save)
: intl.formatMessage(messages.create)
}
okDisabled={
isSubmitting ||
!isValid ||
(!values.users &&
!values.genre &&
!values.language &&
!values.keywords) ||
(!values.rootFolder && !values.profileId && !values.tags)
}
onOk={() => handleSubmit()}
title={
!rule
? intl.formatMessage(messages.createrule)
: intl.formatMessage(messages.editrule)
}
>
<div className="mb-6">
<h3 className="text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.service)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceDescription)}
</p>
<div className="form-row">
<label htmlFor="service" className="text-label">
{intl.formatMessage(messages.service)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<select
id="service"
name="service"
defaultValue={
values.radarrServiceId !== null
? `radarr-${values.radarrServiceId}`
: `sonarr-${values.sonarrServiceId}`
}
onChange={(e) => {
const id = Number(e.target.value.split('-')[1]);
if (e.target.value.startsWith('radarr-')) {
setFieldValue('radarrServiceId', id);
setFieldValue('sonarrServiceId', null);
if (radarrServices[id]) {
getServiceInfos(radarrServices[id], 'radarr');
}
} else if (e.target.value.startsWith('sonarr-')) {
setFieldValue('radarrServiceId', null);
setFieldValue('sonarrServiceId', id);
if (sonarrServices[id]) {
getServiceInfos(sonarrServices[id], 'sonarr');
}
} else {
setFieldValue('radarrServiceId', null);
setFieldValue('sonarrServiceId', null);
setIsValidated(false);
}
}}
>
<option value="">
{intl.formatMessage(messages.selectService)}
</option>
{radarrServices.map((radarr) => (
<option
key={`radarr-${radarr.id}`}
value={`radarr-${radarr.id}`}
>
{radarr.name}
</option>
))}
{sonarrServices.map((sonarr) => (
<option
key={`sonarr-${sonarr.id}`}
value={`sonarr-${sonarr.id}`}
>
{sonarr.name}
</option>
))}
</select>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<h3 className="text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.conditions)}
</h3>
<p className="description">
{intl.formatMessage(messages.conditionsDescription)}
</p>
<div className="form-row">
<label htmlFor="users" className="text-label">
{intl.formatMessage(messages.users)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<UserSelector
defaultValue={values.users}
isDisabled={!isValidated || isTesting}
isMulti
onChange={(users) => {
setFieldValue(
'users',
users?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.users &&
touched.users &&
typeof errors.users === 'string' && (
<div className="error">{errors.users}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="genre" className="text-label">
{intl.formatMessage(messages.genres)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<GenreSelector
type={
values.radarrServiceId != null
? 'movie'
: values.sonarrServiceId != null
? 'tv'
: 'tv'
}
defaultValue={values.genre}
isMulti
isDisabled={!isValidated || isTesting}
onChange={(genres) => {
setFieldValue(
'genre',
genres?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.genre &&
touched.genre &&
typeof errors.genre === 'string' && (
<div className="error">{errors.genre}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="language" className="text-label">
{intl.formatMessage(messages.languages)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<LanguageSelector
value={values.language}
serverValue={currentSettings.originalLanguage}
setFieldValue={(_key, value) => {
setFieldValue('language', value);
}}
isDisabled={!isValidated || isTesting}
/>
</div>
{errors.language &&
touched.language &&
typeof errors.language === 'string' && (
<div className="error">{errors.language}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="keywords" className="text-label">
{intl.formatMessage(messages.keywords)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<KeywordSelector
defaultValue={values.keywords}
isMulti
isDisabled={!isValidated || isTesting}
onChange={(value) => {
setFieldValue(
'keywords',
value?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.keywords &&
touched.keywords &&
typeof errors.keywords === 'string' && (
<div className="error">{errors.keywords}</div>
)}
</div>
</div>
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.settings)}
</h3>
<p className="description">
{intl.formatMessage(messages.settingsDescription)}
</p>
<div className="form-row">
<label htmlFor="rootFolderRule" className="text-label">
{intl.formatMessage(messages.rootfolder)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolderRule"
name="rootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="profileIdRule" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="profileIdRule"
name="profileId"
disabled={!isValidated || isTesting}
>
<option value="">
{intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.profileId &&
touched.profileId &&
typeof errors.profileId === 'string' && (
<div className="error">{errors.profileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))}
isMulti
isDisabled={!isValidated || isTesting}
placeholder={intl.formatMessage(messages.selecttags)}
className="react-select-container"
classNamePrefix="react-select"
value={
(values?.tags
?.split(',')
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === Number(tagId)
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[]) || []
}
onChange={(value) => {
setFieldValue(
'tags',
value.map((option) => option.value).join(',')
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
</div>
</Modal>
);
}}
</Formik>
</Transition>
);
};
export default OverrideRuleModal;

View File

@@ -0,0 +1,309 @@
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type OverrideRule from '@server/entity/OverrideRule';
import type { User } from '@server/entity/User';
import type {
DVRSettings,
Language,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import type { Keyword } from '@server/models/common';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Settings.OverrideRuleTile', {
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
tags: 'Tags',
users: 'Users',
genre: 'Genre',
language: 'Language',
keywords: 'Keywords',
conditions: 'Conditions',
settings: 'Settings',
});
interface OverrideRuleTilesProps {
rules: OverrideRule[];
setOverrideRuleModal: ({
open,
rule,
}: {
open: boolean;
rule: OverrideRule | null;
}) => void;
revalidate: () => void;
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
}
const OverrideRuleTiles = ({
rules,
setOverrideRuleModal,
revalidate,
radarrServices,
sonarrServices,
}: OverrideRuleTilesProps) => {
const intl = useIntl();
const [users, setUsers] = useState<User[] | null>(null);
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
const [testResponses, setTestResponses] = useState<
(DVRTestResponse & { type: string; id: number })[]
>([]);
const getServiceInfos = useCallback(async () => {
const results: (DVRTestResponse & { type: string; id: number })[] = [];
const services: DVRSettings[] = [...radarrServices, ...sonarrServices];
for (const service of services) {
const { hostname, port, apiKey, baseUrl, useSsl = false } = service;
try {
const response = await axios.post<DVRTestResponse>(
`/api/v1/settings/${
radarrServices.includes(service as RadarrSettings)
? 'radarr'
: 'sonarr'
}/test`,
{
hostname,
apiKey,
port: Number(port),
baseUrl,
useSsl,
}
);
results.push({
type: radarrServices.includes(service as RadarrSettings)
? 'radarr'
: 'sonarr',
id: service.id,
...response.data,
});
} catch {
results.push({
type: radarrServices.includes(service as RadarrSettings)
? 'radarr'
: 'sonarr',
id: service.id,
profiles: [],
rootFolders: [],
tags: [],
});
}
}
setTestResponses(results);
}, [radarrServices, sonarrServices]);
useEffect(() => {
getServiceInfos();
}, [getServiceInfos]);
useEffect(() => {
(async () => {
const keywords = await Promise.all(
rules
.map((rule) => rule.keywords?.split(','))
.flat()
.filter((keywordId) => keywordId)
.map(async (keywordId) => {
const response = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return response.data;
})
);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setKeywords(validKeywords);
const allUsersFromRules = rules
.map((rule) => rule.users)
.filter((users) => users)
.join(',');
if (allUsersFromRules) {
const response = await axios.get(
`/api/v1/user?includeIds=${encodeURIComponent(allUsersFromRules)}`
);
const users: User[] = response.data.results;
setUsers(users);
}
})();
}, [rules, users]);
return (
<>
{rules.map((rule) => (
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
<div className="flex-1 truncate">
<span className="text-lg">
{intl.formatMessage(messages.conditions)}
</span>
{rule.users && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.users)}
</span>
<div className="inline-flex gap-2">
{rule.users.split(',').map((userId) => {
return (
<span>
{
users?.find((user) => user.id === Number(userId))
?.displayName
}
</span>
);
})}
</div>
</p>
)}
{rule.genre && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.genre)}
</span>
<div className="inline-flex gap-2">
{rule.genre.split(',').map((genreId) => (
<span>
{genres?.find((g) => g.id === Number(genreId))?.name}
</span>
))}
</div>
</p>
)}
{rule.language && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.language)}
</span>
<div className="inline-flex gap-2">
{rule.language
.split('|')
.filter((languageId) => languageId !== 'server')
.map((languageId) => {
const language = languages?.find(
(language) => language.iso_639_1 === languageId
);
if (!language) return null;
const languageName =
intl.formatDisplayName(language.iso_639_1, {
type: 'language',
fallback: 'none',
}) ?? language.english_name;
return <span>{languageName}</span>;
})}
</div>
</p>
)}
{rule.keywords && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.keywords)}
</span>
<div className="inline-flex gap-2">
{rule.keywords.split(',').map((keywordId) => {
return (
<span>
{
keywords?.find(
(keyword) => keyword.id === Number(keywordId)
)?.name
}
</span>
);
})}
</div>
</p>
)}
<span className="text-lg">
{intl.formatMessage(messages.settings)}
</span>
{rule.profileId && (
<p className="runcate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.qualityprofile)}
</span>
{testResponses
.find(
(r) =>
(r.id === rule.radarrServiceId &&
r.type === 'radarr') ||
(r.id === rule.sonarrServiceId && r.type === 'sonarr')
)
?.profiles.find((profile) => rule.profileId === profile.id)
?.name || rule.profileId}
</p>
)}
{rule.rootFolder && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.rootfolder)}
</span>
{rule.rootFolder}
</p>
)}
{rule.tags && rule.tags.length > 0 && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.tags)}
</span>
<div className="inline-flex gap-2">
{rule.tags.split(',').map((tag) => (
<span>
{testResponses
.find(
(r) =>
(r.id === rule.radarrServiceId &&
r.type === 'radarr') ||
(r.id === rule.sonarrServiceId &&
r.type === 'sonarr')
)
?.tags?.find((t) => t.id === Number(tag))?.label ||
tag}
</span>
))}
</div>
</p>
)}
</div>
</div>
<div className="border-t border-gray-500">
<div className="-mt-px flex">
<div className="flex w-0 flex-1 border-r border-gray-500">
<button
onClick={() => setOverrideRuleModal({ open: true, rule })}
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<PencilIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</button>
</div>
<div className="-ml-px flex w-0 flex-1">
<button
onClick={async () => {
await axios.delete(`/api/v1/overrideRule/${rule.id}`);
revalidate();
}}
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<TrashIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.delete)}</span>
</button>
</div>
</div>
</div>
</li>
))}
</>
);
};
export default OverrideRuleTiles;

View File

@@ -10,9 +10,15 @@ import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
type OptionType = {
value: number;
label: string;
};
const messages = defineMessages('components.Settings.RadarrModal', {
createradarr: 'Add New Radarr Server',
create4kradarr: 'Add New 4K Radarr Server',
@@ -22,6 +28,10 @@ const messages = defineMessages('components.Settings.RadarrModal', {
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationApiKeyRequired: 'You must provide an API key',
validationRootFolderRequired: 'You must select a root folder',
validationProfileRequired: 'You must select a quality profile',
validationMinimumAvailabilityRequired:
'You must select a minimum availability',
toastRadarrTestSuccess: 'Radarr connection established successfully!',
toastRadarrTestFailure: 'Failed to connect to Radarr.',
add: 'Add Server',
@@ -33,9 +43,22 @@ const messages = defineMessages('components.Settings.RadarrModal', {
ssl: 'Use SSL',
apiKey: 'API Key',
baseUrl: 'URL Base',
server4k: '4K Server',
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
minimumAvailability: 'Minimum Availability',
server4k: '4K Server',
selectQualityProfile: 'Select quality profile',
selectRootFolder: 'Select root folder',
selectMinimumAvailability: 'Select minimum availability',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
loadingTags: 'Loading tags…',
testFirstTags: 'Test connection to load tags',
tags: 'Tags',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
@@ -44,12 +67,17 @@ const messages = defineMessages('components.Settings.RadarrModal', {
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
validationBaseUrlTrailingSlash: 'URL base must not end in a trailing slash',
notagoptions: 'No tags.',
selecttags: 'Select tags',
announced: 'Announced',
inCinemas: 'In Cinemas',
released: 'Released',
});
interface RadarrModalProps {
radarr: RadarrSettings | null;
onClose: () => void;
onSave: (savedInstance: RadarrSettings) => Promise<void>;
onSave: () => void;
}
const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
@@ -77,6 +105,15 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
apiKey: Yup.string().required(
intl.formatMessage(messages.validationApiKeyRequired)
),
rootFolder: Yup.string().required(
intl.formatMessage(messages.validationRootFolderRequired)
),
activeProfileId: Yup.string().required(
intl.formatMessage(messages.validationProfileRequired)
),
minimumAvailability: Yup.string().required(
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
),
externalUrl: Yup.string()
.test(
'valid-url',
@@ -184,6 +221,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
ssl: radarr?.useSsl ?? false,
apiKey: radarr?.apiKey,
baseUrl: radarr?.baseUrl,
activeProfileId: radarr?.activeProfileId,
rootFolder: radarr?.activeDirectory,
minimumAvailability: radarr?.minimumAvailability ?? 'released',
tags: radarr?.tags ?? [],
isDefault: radarr?.isDefault ?? false,
is4k: radarr?.is4k ?? false,
externalUrl: radarr?.externalUrl,
@@ -194,6 +235,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
validationSchema={RadarrSettingsSchema}
onSubmit={async (values) => {
try {
const profileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeProfileId)
)?.name;
const submission = {
name: values.name,
hostname: values.hostname,
@@ -201,32 +246,30 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
apiKey: values.apiKey,
useSsl: values.ssl,
baseUrl: values.baseUrl,
isDefault: values.isDefault,
activeProfileId: Number(values.activeProfileId),
activeProfileName: profileName,
activeDirectory: values.rootFolder,
is4k: values.is4k,
minimumAvailability: values.minimumAvailability,
tags: values.tags,
isDefault: values.isDefault,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
let savedInstance: RadarrSettings;
if (!radarr) {
const response = await axios.post<RadarrSettings>(
'/api/v1/settings/radarr',
submission
);
savedInstance = response.data;
await axios.post('/api/v1/settings/radarr', submission);
} else {
const response = await axios.put<RadarrSettings>(
await axios.put(
`/api/v1/settings/radarr/${radarr.id}`,
submission
);
savedInstance = response.data;
}
await onSave(savedInstance);
onSave();
} catch (e) {
// TODO: handle error
// set error here
}
}}
>
@@ -458,6 +501,176 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="activeProfileId" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeProfileId"
name="activeProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(
messages.selectQualityProfile
)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeProfileId &&
touched.activeProfileId &&
typeof errors.activeProfileId === 'string' && (
<div className="error">{errors.activeProfileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="rootFolder" className="text-label">
{intl.formatMessage(messages.rootfolder)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolder"
name="rootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(
messages.testFirstRootFolders
)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="minimumAvailability" className="text-label">
{intl.formatMessage(messages.minimumAvailability)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="minimumAvailability"
name="minimumAvailability"
>
<option value="announced">
{intl.formatMessage(messages.announced)}
</option>
<option value="inCinemas">
{intl.formatMessage(messages.inCinemas)}
</option>
<option value="released">
{intl.formatMessage(messages.released)}
</option>
</Field>
</div>
{errors.minimumAvailability &&
touched.minimumAvailability && (
<div className="error">
{errors.minimumAvailability}
</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={!isValidated || isTesting}
placeholder={
!isValidated
? intl.formatMessage(messages.testFirstTags)
: isTesting
? intl.formatMessage(messages.loadingTags)
: intl.formatMessage(messages.selecttags)
}
className="react-select-container"
classNamePrefix="react-select"
value={
values.tags
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[]
}
onChange={(value) => {
setFieldValue(
'tags',
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}

View File

@@ -1,599 +0,0 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import RoutingRuleRow from '@app/components/Settings/RoutingRule/RoutingRuleRow';
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import {
ExclamationTriangleIcon,
InformationCircleIcon,
PlusIcon,
} from '@heroicons/react/24/solid';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type { User } from '@server/entity/User';
import type {
Language,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import type { Keyword } from '@server/models/common';
import axios from 'axios';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
type FilterType = 'all' | 'sonarr' | 'radarr';
type ServiceType = 'radarr' | 'sonarr';
const messages = defineMessages('components.Settings.RoutingRuleList', {
routingRules: 'Routing Rules',
routingRulesDescription:
'Rules are evaluated top-to-bottom. The first matching rule determines where the request is sent. Drag to reorder priority.',
routingRulesConditionLogic:
'Conditions use AND logic between fields (all must match) and OR logic within a field (any value can match).',
addRule: 'Add Rule',
all: 'All',
sonarr: 'Sonarr',
radarr: 'Radarr',
howRoutingWorks: 'How routing works:',
routingExplainer:
'When a request comes in, rules are checked from top to bottom. The first rule whose conditions all match will determine which instance and settings are used. Fallback rules (no conditions) catch everything that did not match above.',
noFallbackWarning:
'No fallback rule configured for {serviceType}. Requests that do not match any rule will fail.',
deleteConfirm: 'Are you sure you want to delete this routing rule?',
deleteRule: 'Delete Routing Rule',
animeRuleSuggestion:
'Want anime to use different settings? Add an anime routing rule.',
addAnimeRule: 'Add Anime Rule',
});
interface RoutingRuleListProps {
rules: RoutingRule[];
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
onAddRule: (prefillData?: Partial<RoutingRule>) => void;
onEditRule: (rule: RoutingRule) => void;
revalidate: () => void;
}
const isFallbackRule = (r: RoutingRule) => !!r.isFallback;
const hasFallback = (
rules: RoutingRule[],
serviceType: ServiceType,
is4k: boolean
) =>
rules.some(
(r) => r.serviceType === serviceType && !!r.isFallback && !!r.is4k === is4k
);
function getDefaultInstance(
serviceType: ServiceType,
is4k: boolean,
radarrServices: RadarrSettings[],
sonarrServices: SonarrSettings[]
) {
const services = serviceType === 'radarr' ? radarrServices : sonarrServices;
return services.find((s) => !!s.isDefault && !!s.is4k === is4k);
}
const RoutingRuleList = ({
rules,
radarrServices,
sonarrServices,
onAddRule,
onEditRule,
revalidate,
}: RoutingRuleListProps) => {
const intl = useIntl();
const [filter, setFilter] = useState<FilterType>('all');
const [expandedId, setExpandedId] = useState<number | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [deleteModal, setDeleteModal] = useState<{
open: boolean;
rule: RoutingRule | null;
}>({ open: false, rule: null });
const [users, setUsers] = useState<User[]>([]);
const [keywordsData, setKeywordsData] = useState<Keyword[]>([]);
const [testResponses, setTestResponses] = useState<
(DVRTestResponse & { type: string; id: number })[]
>([]);
const [localOrder, setLocalOrder] = useState<RoutingRule[] | null>(null);
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
const radarrDefaultNon4k = useMemo(
() => getDefaultInstance('radarr', false, radarrServices, sonarrServices),
[radarrServices, sonarrServices]
);
const radarrDefault4k = useMemo(
() => getDefaultInstance('radarr', true, radarrServices, sonarrServices),
[radarrServices, sonarrServices]
);
const sonarrDefaultNon4k = useMemo(
() => getDefaultInstance('sonarr', false, radarrServices, sonarrServices),
[radarrServices, sonarrServices]
);
const sonarrDefault4k = useMemo(
() => getDefaultInstance('sonarr', true, radarrServices, sonarrServices),
[radarrServices, sonarrServices]
);
const missingFallbacks = useMemo(
() => ({
radarrNon4k: !!radarrDefaultNon4k && !hasFallback(rules, 'radarr', false),
radarr4k: !!radarrDefault4k && !hasFallback(rules, 'radarr', true),
sonarrNon4k: !!sonarrDefaultNon4k && !hasFallback(rules, 'sonarr', false),
sonarr4k: !!sonarrDefault4k && !hasFallback(rules, 'sonarr', true),
}),
[
rules,
radarrDefaultNon4k,
radarrDefault4k,
sonarrDefaultNon4k,
sonarrDefault4k,
]
);
const getServiceInfos = useCallback(async () => {
const results: (DVRTestResponse & { type: string; id: number })[] = [];
const allServices = [
...radarrServices.map((s) => ({ ...s, _type: 'radarr' as const })),
...sonarrServices.map((s) => ({ ...s, _type: 'sonarr' as const })),
];
for (const service of allServices) {
try {
const response = await axios.post<DVRTestResponse>(
`/api/v1/settings/${service._type}/test`,
{
hostname: service.hostname,
apiKey: service.apiKey,
port: Number(service.port),
baseUrl: service.baseUrl,
useSsl: service.useSsl,
}
);
results.push({
type: service._type,
id: service.id,
...response.data,
});
} catch {
results.push({
type: service._type,
id: service.id,
profiles: [],
rootFolders: [],
tags: [],
});
}
}
setTestResponses(results);
}, [radarrServices, sonarrServices]);
useEffect(() => {
getServiceInfos();
}, [getServiceInfos]);
useEffect(() => {
(async () => {
const allKeywordIds = rules
.map((rule) => rule.keywords?.split(','))
.flat()
.filter((id): id is string => !!id);
if (allKeywordIds.length > 0) {
const keywordResults = await Promise.all(
[...new Set(allKeywordIds)].map(async (id) => {
try {
const response = await axios.get<Keyword | null>(
`/api/v1/keyword/${id}`
);
return response.data;
} catch {
return null;
}
})
);
setKeywordsData(keywordResults.filter((k): k is Keyword => k !== null));
}
const allUserIds = rules
.map((rule) => rule.users)
.filter((u): u is string => !!u)
.join(',');
if (allUserIds) {
try {
const response = await axios.get(
`/api/v1/user?includeIds=${encodeURIComponent(allUserIds)}`
);
setUsers(response.data.results);
} catch {
// ignore
}
}
})();
}, [rules]);
const sortedRules = useMemo(() => {
return [...rules].sort((a, b) => {
const aFallback = isFallbackRule(a);
const bFallback = isFallbackRule(b);
if (aFallback && !bFallback) return 1;
if (!aFallback && bFallback) return -1;
if (aFallback && bFallback) {
const a4k = !!a.is4k;
const b4k = !!b.is4k;
if (a4k !== b4k) return a4k ? 1 : -1;
return 0;
}
return b.priority - a.priority;
});
}, [rules]);
const filteredRules = useMemo(
() =>
sortedRules.filter((r) => filter === 'all' || r.serviceType === filter),
[sortedRules, filter]
);
const displayRules = localOrder ?? filteredRules;
const counts = {
all: rules.length,
sonarr: rules.filter((r) => r.serviceType === 'sonarr').length,
radarr: rules.filter((r) => r.serviceType === 'radarr').length,
};
const openMissingFallbackIfAny = () => {
const pickForService = (svc: ServiceType) => {
if (svc === 'radarr') {
if (missingFallbacks.radarrNon4k && radarrDefaultNon4k) {
return {
serviceType: 'radarr' as const,
is4k: false,
instance: radarrDefaultNon4k,
};
}
if (missingFallbacks.radarr4k && radarrDefault4k) {
return {
serviceType: 'radarr' as const,
is4k: true,
instance: radarrDefault4k,
};
}
} else {
if (missingFallbacks.sonarrNon4k && sonarrDefaultNon4k) {
return {
serviceType: 'sonarr' as const,
is4k: false,
instance: sonarrDefaultNon4k,
};
}
if (missingFallbacks.sonarr4k && sonarrDefault4k) {
return {
serviceType: 'sonarr' as const,
is4k: true,
instance: sonarrDefault4k,
};
}
}
return null;
};
let target: {
serviceType: ServiceType;
is4k: boolean;
instance: RadarrSettings | SonarrSettings;
} | null = null;
if (filter === 'radarr') target = pickForService('radarr');
else if (filter === 'sonarr') target = pickForService('sonarr');
else {
target = pickForService('radarr') ?? pickForService('sonarr');
}
if (!target) return false;
onAddRule({
name: `${target.instance.name} Default Route`,
serviceType: target.serviceType,
is4k: target.is4k,
targetServiceId: target.instance.id,
isFallback: true,
});
return true;
};
const missingAnimeRule = useMemo(() => {
const hasSonarrFallback = (is4k: boolean) =>
rules.some(
(r) => r.serviceType === 'sonarr' && r.isFallback && !!r.is4k === is4k
);
const hasAnimeRule = (is4k: boolean) =>
rules.some(
(r) =>
r.serviceType === 'sonarr' &&
!!r.is4k === is4k &&
r.keywords?.includes('210024')
);
return {
non4k: hasSonarrFallback(false) && !hasAnimeRule(false),
is4k: hasSonarrFallback(true) && !hasAnimeRule(true),
};
}, [rules]);
const handleAddRuleClick = () => {
if (openMissingFallbackIfAny()) return;
onAddRule();
};
const handleDragStart = (index: number) => {
setDragIndex(index);
setLocalOrder([...filteredRules]);
};
const handleDragOver = (
e: React.DragEvent<HTMLDivElement>,
index: number
) => {
e.preventDefault();
if (dragIndex === null || dragIndex === index || !localOrder) return;
if (
isFallbackRule(localOrder[index]) ||
isFallbackRule(localOrder[dragIndex])
) {
return;
}
const reordered = [...localOrder];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(index, 0, moved);
setLocalOrder(reordered);
setDragIndex(index);
};
const handleDragEnd = async () => {
if (localOrder) {
const nonFallbackIds = localOrder
.filter((r) => !isFallbackRule(r))
.map((r) => r.id);
try {
await axios.post('/api/v1/routingRule/reorder', {
ruleIds: nonFallbackIds,
});
revalidate();
} catch {
revalidate();
}
}
setDragIndex(null);
setLocalOrder(null);
};
const handleDelete = async (rule: RoutingRule) => {
setDeleteModal({ open: true, rule });
};
const confirmDelete = async () => {
if (!deleteModal.rule) return;
try {
await axios.delete(`/api/v1/routingRule/${deleteModal.rule.id}`);
revalidate();
if (expandedId === deleteModal.rule.id) {
setExpandedId(null);
}
} catch {
// ignore
} finally {
setDeleteModal({ open: false, rule: null });
}
};
return (
<div>
<Transition
as={Fragment}
show={deleteModal.open}
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Modal
title={intl.formatMessage(messages.deleteRule)}
okText={intl.formatMessage(globalMessages.delete)}
okButtonType="danger"
onOk={() => confirmDelete()}
onCancel={() => setDeleteModal({ open: false, rule: null })}
>
{intl.formatMessage(messages.deleteConfirm)}
</Modal>
</Transition>
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h3 className="heading">
{intl.formatMessage(messages.routingRules)}
</h3>
</div>
<Button
buttonType="ghost"
disabled={
radarrServices.length === 0 && sonarrServices.length === 0
}
onClick={handleAddRuleClick}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addRule)}</span>
</Button>
</div>
<p className="description">
{intl.formatMessage(messages.routingRulesDescription)}
</p>
</div>
<div className="mb-4 flex gap-1 rounded-lg bg-gray-800 p-1 ring-1 ring-gray-700">
{(
[
{ key: 'all', label: messages.all },
{ key: 'sonarr', label: messages.sonarr },
{ key: 'radarr', label: messages.radarr },
] as const
).map((tab) => (
<button
key={tab.key}
onClick={() => setFilter(tab.key)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
filter === tab.key
? 'bg-gray-700 text-white shadow-sm'
: 'text-gray-400 hover:text-gray-300'
}`}
>
{intl.formatMessage(tab.label)}
<span
className={`ml-1.5 ${
filter === tab.key ? 'text-gray-400' : 'text-gray-600'
}`}
>
{counts[tab.key]}
</span>
</button>
))}
</div>
{sonarrDefaultNon4k && missingFallbacks.sonarrNon4k && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-200">
{intl.formatMessage(messages.noFallbackWarning, {
serviceType: 'Sonarr',
})}
</span>
</div>
)}
{sonarrDefault4k && missingFallbacks.sonarr4k && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-200">
{intl.formatMessage(messages.noFallbackWarning, {
serviceType: 'Sonarr (4K)',
})}
</span>
</div>
)}
{missingAnimeRule.non4k && (
<div className="mb-3 flex items-center justify-between rounded-lg bg-blue-900/20 px-4 py-2 ring-1 ring-blue-700/50">
<div className="flex items-center gap-2">
<InformationCircleIcon className="h-4 w-4 text-blue-400" />
<span className="text-sm text-blue-200">
{intl.formatMessage(messages.animeRuleSuggestion)}
</span>
</div>
<Button
buttonType="ghost"
className="text-xs"
onClick={() => {
const sonarrFallback = rules.find(
(r) => r.serviceType === 'sonarr' && r.isFallback && !r.is4k
);
onAddRule({
name: 'Anime',
serviceType: 'sonarr',
is4k: false,
targetServiceId: sonarrFallback?.targetServiceId,
keywords: '210024',
seriesType: 'anime',
});
}}
>
{intl.formatMessage(messages.addAnimeRule)}
</Button>
</div>
)}
{radarrDefaultNon4k && missingFallbacks.radarrNon4k && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-200">
{intl.formatMessage(messages.noFallbackWarning, {
serviceType: 'Radarr',
})}
</span>
</div>
)}
{radarrDefault4k && missingFallbacks.radarr4k && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-200">
{intl.formatMessage(messages.noFallbackWarning, {
serviceType: 'Radarr (4K)',
})}
</span>
</div>
)}
<div className="space-y-2">
{displayRules.map((rule, index) => (
<div
key={rule.id}
draggable={!isFallbackRule(rule)}
onDragStart={() => handleDragStart(index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, index)}
>
<RoutingRuleRow
rule={rule}
index={index}
expanded={expandedId === rule.id}
isDragging={dragIndex === index}
onToggle={() =>
setExpandedId(expandedId === rule.id ? null : rule.id)
}
onEdit={() => onEditRule(rule)}
onDelete={() => handleDelete(rule)}
dragHandleProps={{}}
users={users}
genres={genres}
languages={languages}
keywords={keywordsData}
radarrServices={radarrServices}
sonarrServices={sonarrServices}
testResponses={testResponses}
/>
</div>
))}
</div>
{filteredRules.length > 0 && (
<div className="mt-4 rounded-lg bg-gray-800 p-4 ring-1 ring-gray-700">
<div className="flex gap-3">
<InformationCircleIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-500" />
<div className="text-xs leading-relaxed text-gray-500">
<span className="font-medium text-gray-400">
{intl.formatMessage(messages.howRoutingWorks)}
</span>{' '}
{intl.formatMessage(messages.routingExplainer)}
</div>
</div>
</div>
)}
</div>
);
};
export default RoutingRuleList;

View File

@@ -1,797 +0,0 @@
import Badge from '@app/components/Common/Badge';
import Modal from '@app/components/Common/Modal';
import LanguageSelector from '@app/components/LanguageSelector';
import {
GenreSelector,
KeywordSelector,
UserSelector,
} from '@app/components/Selector';
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { InformationCircleIcon } from '@heroicons/react/24/solid';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
type OptionType = { value: number; label: string };
type ServiceType = 'radarr' | 'sonarr';
const messages = defineMessages('components.Settings.RoutingRuleModal', {
createRule: 'New Routing Rule',
editRule: 'Edit Routing Rule',
create: 'Create Rule',
ruleName: 'Rule Name',
ruleNamePlaceholder: 'e.g. Anime Content, Japanese Dramas',
serviceType: 'Service Type',
targetInstance: 'Target Instance',
selectInstance: 'Select instance',
firstInstanceSetup: 'First instance setup!',
firstInstanceSetup4k: 'First 4K instance setup!',
firstInstanceSetupBody:
'Were creating a fallback rule that catches all {mediaType} requests. You can customize defaults below or save to use instance defaults.',
fallbackMustBeDefault: 'Fallback rules must target a default instance.',
fallbackMustBe4k:
'This fallback is for 4K requests, so it must target a 4K instance.',
nonFallbackNeedsCondition:
'Non-fallback rules must have at least one condition.',
conditions: 'Conditions',
conditionsDescription:
'All condition types must match (AND). Within each type, any value can match (OR). Leave all empty for a fallback rule.',
target: 'Target Settings',
targetDescription:
'Override settings for the target instance. Leave empty to use instance defaults.',
users: 'Users',
genres: 'Genres',
languages: 'Languages',
keywords: 'Keywords',
rootFolder: 'Root Folder',
selectRootFolder: 'Select root folder',
qualityProfile: 'Quality Profile',
selectQualityProfile: 'Select quality profile',
minimumAvailability: 'Minimum Availability',
announced: 'Announced',
inCinemas: 'In Cinemas',
released: 'Released',
seriesType: 'Series Type',
tags: 'Tags',
selectTags: 'Select tags',
noTagOptions: 'No tags.',
badgeDefault: 'Default',
badge4k: '4K',
conditionalShouldNotBeDefault:
'Conditional rules should target a non-default instance.',
ruleCreated: 'Routing rule created successfully!',
ruleUpdated: 'Routing rule updated successfully!',
validationNameRequired: 'You must provide a rule name',
validationTargetRequired: 'You must select a target instance',
validationRootFolderRequired: 'You must select a root folder',
validationProfileRequired: 'You must select a quality profile',
validationMinimumAvailabilityRequired:
'You must select a minimum availability',
});
interface RoutingRuleModalProps {
rule: RoutingRule | null;
onClose: () => void;
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
prefillData?: Partial<RoutingRule>;
}
const RoutingRuleModal = ({
onClose,
rule,
radarrServices,
sonarrServices,
prefillData,
}: RoutingRuleModalProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const { currentSettings } = useSettings();
const [isValidated, setIsValidated] = useState(!!rule);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
profiles: [],
rootFolders: [],
tags: [],
});
const isFallbackMode = !!(rule?.isFallback || prefillData?.isFallback);
const isPrefillFallback = !rule && !!prefillData?.isFallback;
const requires4kFallback = !!(
isFallbackMode &&
(rule?.is4k || prefillData?.is4k)
);
const getServiceInfos = useCallback(
async (service: RadarrSettings | SonarrSettings, type: ServiceType) => {
setIsTesting(true);
try {
const response = await axios.post<DVRTestResponse>(
`/api/v1/settings/${type}/test`,
{
hostname: service.hostname,
apiKey: service.apiKey,
port: Number(service.port),
baseUrl: service.baseUrl,
useSsl: service.useSsl,
}
);
setIsValidated(true);
setTestResponse(response.data);
} catch {
setIsValidated(false);
setTestResponse({ profiles: [], rootFolders: [], tags: [] });
} finally {
setIsTesting(false);
}
},
[]
);
useEffect(() => {
const data = rule ?? prefillData;
if (!data?.serviceType || data.targetServiceId == null) return;
const services =
data.serviceType === 'radarr' ? radarrServices : sonarrServices;
const svc = services.find((s) => s.id === data.targetServiceId);
if (!svc) return;
getServiceInfos(svc, data.serviceType);
}, [rule, prefillData, radarrServices, sonarrServices, getServiceInfos]);
const RoutingRuleSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
),
targetServiceId: Yup.number()
.required(intl.formatMessage(messages.validationTargetRequired))
.min(0, intl.formatMessage(messages.validationTargetRequired)),
isFallback: Yup.boolean().default(isFallbackMode),
rootFolder: Yup.string().when('isFallback', {
is: true,
then: (s) =>
s.required(intl.formatMessage(messages.validationRootFolderRequired)),
otherwise: (s) => s.nullable(),
}),
activeProfileId: Yup.number()
.transform((val, orig) =>
orig === '' || orig == null ? null : Number(orig)
)
.nullable()
.when('isFallback', {
is: true,
then: (s) =>
s.required(intl.formatMessage(messages.validationProfileRequired)),
otherwise: (s) => s.nullable(),
}),
minimumAvailability: Yup.string().when(['isFallback', 'serviceType'], {
is: (isFallback: boolean, serviceType: ServiceType) =>
isFallback && serviceType === 'radarr',
then: (s) =>
s.required(
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
),
otherwise: (s) => s.nullable(),
}),
});
const getDerivedFlags = (svc?: RadarrSettings | SonarrSettings) => {
const isDefault = !!(svc && svc.isDefault);
const is4k = !!(svc && svc.is4k);
return { isDefault, is4k };
};
return (
<Transition
as="div"
appear
show
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Formik
initialValues={{
name: rule?.name ?? prefillData?.name ?? '',
serviceType: (rule?.serviceType ??
prefillData?.serviceType ??
'sonarr') as ServiceType,
targetServiceId:
rule?.targetServiceId ?? prefillData?.targetServiceId ?? -1,
isFallback: isFallbackMode,
users: rule?.users ?? prefillData?.users ?? undefined,
genres: rule?.genres ?? prefillData?.genres ?? undefined,
languages: rule?.languages ?? prefillData?.languages ?? undefined,
keywords: rule?.keywords ?? prefillData?.keywords ?? undefined,
activeProfileId:
rule?.activeProfileId ?? prefillData?.activeProfileId ?? undefined,
rootFolder: rule?.rootFolder ?? prefillData?.rootFolder ?? undefined,
minimumAvailability:
rule?.minimumAvailability ??
prefillData?.minimumAvailability ??
'released',
seriesType: rule?.seriesType ?? prefillData?.seriesType ?? undefined,
tags: rule?.tags ?? prefillData?.tags ?? undefined,
}}
validationSchema={RoutingRuleSchema}
onSubmit={async (values) => {
try {
const services =
values.serviceType === 'radarr' ? radarrServices : sonarrServices;
const selectedService = services.find(
(s) => s.id === values.targetServiceId
);
const derived = getDerivedFlags(selectedService);
const activeProfileId =
values.activeProfileId == null
? null
: Number(values.activeProfileId);
const profileName =
testResponse.profiles.find(
(p) => p.id === Number(values.activeProfileId)
)?.name ?? null;
const submission = {
name: values.name,
serviceType: values.serviceType,
targetServiceId: values.targetServiceId,
isFallback: values.isFallback,
is4k: derived.is4k,
users: values.isFallback ? null : values.users || null,
genres: values.isFallback ? null : values.genres || null,
languages: values.isFallback ? null : values.languages || null,
keywords: values.isFallback ? null : values.keywords || null,
activeProfileId,
activeProfileName: profileName,
rootFolder: values.rootFolder || null,
minimumAvailability:
values.serviceType === 'radarr'
? values.minimumAvailability || null
: null,
seriesType:
values.serviceType === 'sonarr'
? values.seriesType || null
: null,
tags: values.tags || null,
};
if (!rule) {
await axios.post('/api/v1/routingRule', submission);
addToast(intl.formatMessage(messages.ruleCreated), {
appearance: 'success',
autoDismiss: true,
});
} else {
await axios.put(`/api/v1/routingRule/${rule.id}`, submission);
addToast(intl.formatMessage(messages.ruleUpdated), {
appearance: 'success',
autoDismiss: true,
});
}
onClose();
} catch {
// TODO: handle error
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
const services =
values.serviceType === 'radarr' ? radarrServices : sonarrServices;
const selectedService = services.find(
(s) => s.id === values.targetServiceId
);
const derived = getDerivedFlags(selectedService);
const hasAnyCondition = !!(
values.users ||
values.genres ||
values.languages ||
values.keywords
);
const fallbackTargetOk =
derived.isDefault && (!requires4kFallback || derived.is4k);
const canSave =
isValid &&
isValidated &&
(!values.isFallback ? hasAnyCondition : fallbackTargetOk);
const optionsDisabled = !isValidated || isTesting;
return (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(globalMessages.saving)
: rule
? intl.formatMessage(globalMessages.save)
: intl.formatMessage(messages.create)
}
okDisabled={isSubmitting || !isValid || !canSave}
onOk={() => handleSubmit()}
title={
!rule
? intl.formatMessage(messages.createRule)
: intl.formatMessage(messages.editRule)
}
>
<div className="mb-6">
{isPrefillFallback && (
<div className="mb-4 rounded-lg border border-blue-500/30 bg-blue-900/10 p-3">
<div className="flex items-start gap-2">
<InformationCircleIcon className="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-400" />
<div className="text-sm text-blue-200">
<strong>
{intl.formatMessage(
requires4kFallback
? messages.firstInstanceSetup4k
: messages.firstInstanceSetup
)}
</strong>{' '}
{intl.formatMessage(messages.firstInstanceSetupBody, {
mediaType:
values.serviceType === 'radarr' ? 'movie' : 'TV',
})}
</div>
</div>
</div>
)}
<div className="form-row">
<label htmlFor="name" className="text-label">
{intl.formatMessage(messages.ruleName)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="name"
name="name"
type="text"
placeholder={intl.formatMessage(
messages.ruleNamePlaceholder
)}
/>
</div>
{errors.name &&
touched.name &&
typeof errors.name === 'string' && (
<div className="error">{errors.name}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="serviceType" className="text-label">
{intl.formatMessage(messages.serviceType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="serviceType"
name="serviceType"
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
setFieldValue('serviceType', e.target.value);
setFieldValue('targetServiceId', -1);
setIsValidated(false);
setTestResponse({
profiles: [],
rootFolders: [],
tags: [],
});
setFieldValue('activeProfileId', undefined);
setFieldValue('rootFolder', undefined);
setFieldValue('tags', undefined);
setFieldValue('seriesType', undefined);
setFieldValue('minimumAvailability', 'released');
}}
>
<option value="sonarr">Sonarr</option>
<option value="radarr">Radarr</option>
</Field>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="targetServiceId" className="text-label">
{intl.formatMessage(messages.targetInstance)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="targetServiceId"
name="targetServiceId"
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const id = Number(e.target.value);
setFieldValue('targetServiceId', id);
const svc = services.find((s) => s.id === id);
if (svc) {
getServiceInfos(svc, values.serviceType);
} else {
setIsValidated(false);
setTestResponse({
profiles: [],
rootFolders: [],
tags: [],
});
}
}}
>
<option value={-1}>
{intl.formatMessage(messages.selectInstance)}
</option>
{services.map((service) => (
<option key={service.id} value={service.id}>
{service.name}
</option>
))}
</Field>
</div>
{selectedService && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{derived.isDefault && (
<Badge badgeType="primary">
{intl.formatMessage(messages.badgeDefault)}
</Badge>
)}
{derived.is4k && (
<Badge badgeType="warning">
{intl.formatMessage(messages.badge4k)}
</Badge>
)}
</div>
)}
{values.isFallback &&
values.targetServiceId >= 0 &&
!derived.isDefault && (
<div className="mt-2 rounded-md border border-red-500/30 bg-red-900/10 p-2 text-sm text-red-200">
{intl.formatMessage(messages.fallbackMustBeDefault)}
</div>
)}
{values.isFallback &&
requires4kFallback &&
values.targetServiceId >= 0 &&
derived.isDefault &&
!derived.is4k && (
<div className="mt-2 rounded-md border border-red-500/30 bg-red-900/10 p-2 text-sm text-red-200">
{intl.formatMessage(messages.fallbackMustBe4k)}
</div>
)}
{!values.isFallback && !hasAnyCondition && (
<div className="mt-2 text-xs text-gray-400">
{intl.formatMessage(messages.nonFallbackNeedsCondition)}
</div>
)}
{errors.targetServiceId &&
touched.targetServiceId &&
typeof errors.targetServiceId === 'string' && (
<div className="error">{errors.targetServiceId}</div>
)}
</div>
</div>
{!values.isFallback && (
<>
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.conditions)}
</h3>
<p className="description">
{intl.formatMessage(messages.conditionsDescription)}
</p>
<div className="form-row">
<label htmlFor="users" className="text-label">
{intl.formatMessage(messages.users)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<UserSelector
defaultValue={values.users}
isMulti
onChange={(selectedUsers) => {
setFieldValue(
'users',
selectedUsers?.map((v) => v.value).join(',') ||
undefined
);
}}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="genres" className="text-label">
{intl.formatMessage(messages.genres)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<GenreSelector
type={
values.serviceType === 'radarr' ? 'movie' : 'tv'
}
defaultValue={values.genres}
isMulti
onChange={(selectedGenres) => {
setFieldValue(
'genres',
selectedGenres?.map((v) => v.value).join(',') ||
undefined
);
}}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="languages" className="text-label">
{intl.formatMessage(messages.languages)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<LanguageSelector
value={values.languages}
serverValue={currentSettings.originalLanguage}
setFieldValue={(_key, value) => {
setFieldValue('languages', value);
}}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="keywords" className="text-label">
{intl.formatMessage(messages.keywords)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<KeywordSelector
defaultValue={values.keywords}
isMulti
onChange={(value) => {
setFieldValue(
'keywords',
value?.map((v) => v.value).join(',') ||
undefined
);
}}
/>
</div>
</div>
</div>
</>
)}
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.target)}
</h3>
<p className="description">
{intl.formatMessage(messages.targetDescription)}
</p>
<div className="form-row">
<label htmlFor="rootFolder" className="text-label">
{intl.formatMessage(messages.rootFolder)}
{values.isFallback && (
<span className="label-required">*</span>
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolder"
name="rootFolder"
disabled={optionsDisabled}
>
<option value="">
{intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.map((folder) => (
<option key={folder.id} value={folder.path}>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="activeProfileId" className="text-label">
{intl.formatMessage(messages.qualityProfile)}
{values.isFallback && (
<span className="label-required">*</span>
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeProfileId"
name="activeProfileId"
disabled={optionsDisabled}
>
<option value="">
{intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeProfileId &&
touched.activeProfileId &&
typeof errors.activeProfileId === 'string' && (
<div className="error">{errors.activeProfileId}</div>
)}
</div>
</div>
{values.serviceType === 'radarr' && (
<div className="form-row">
<label htmlFor="minimumAvailability" className="text-label">
{intl.formatMessage(messages.minimumAvailability)}
{values.isFallback && (
<span className="label-required">*</span>
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="minimumAvailability"
name="minimumAvailability"
disabled={optionsDisabled}
>
<option value="announced">
{intl.formatMessage(messages.announced)}
</option>
<option value="inCinemas">
{intl.formatMessage(messages.inCinemas)}
</option>
<option value="released">
{intl.formatMessage(messages.released)}
</option>
</Field>
</div>
{errors.minimumAvailability &&
touched.minimumAvailability &&
typeof errors.minimumAvailability === 'string' && (
<div className="error">
{errors.minimumAvailability}
</div>
)}
</div>
</div>
)}
{values.serviceType === 'sonarr' && (
<div className="form-row">
<label htmlFor="seriesType" className="text-label">
{intl.formatMessage(messages.seriesType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="seriesType"
name="seriesType"
disabled={optionsDisabled}
>
<option value=""></option>
<option value="standard">Standard</option>
<option value="daily">Daily</option>
<option value="anime">Anime</option>
</Field>
</div>
</div>
</div>
)}
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={optionsDisabled}
placeholder={intl.formatMessage(messages.selectTags)}
className="react-select-container"
classNamePrefix="react-select"
value={
(values.tags
?.split(',')
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === Number(tagId)
);
if (!foundTag) return undefined;
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[]) ?? []
}
onChange={(value) => {
setFieldValue(
'tags',
value.map((option) => option.value).join(',') ||
undefined
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.noTagOptions)
}
/>
</div>
</div>
</div>
</Modal>
);
}}
</Formik>
</Transition>
);
};
export default RoutingRuleModal;

View File

@@ -1,555 +0,0 @@
import Badge from '@app/components/Common/Badge';
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import {
ChevronDownIcon,
PencilIcon,
TrashIcon,
} from '@heroicons/react/24/solid';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type { User } from '@server/entity/User';
import type {
Language,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import type { Keyword } from '@server/models/common';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Settings.RoutingRuleRow', {
fallback: 'Fallback',
conditions: 'Conditions',
routeTo: 'Route To',
matchesAll: 'Matches all requests',
instanceDefaults: 'Uses instance defaults',
instance: 'Instance',
rootFolder: 'Root Folder',
qualityProfile: 'Quality Profile',
minimumAvailability: 'Minimum Availability',
seriesType: 'Series Type',
tags: 'Tags',
users: 'Users',
genres: 'Genres',
languages: 'Languages',
keywords: 'Keywords',
sonarr: 'Sonarr',
radarr: 'Radarr',
});
interface RoutingRuleRowProps {
rule: RoutingRule;
index: number;
expanded: boolean;
isDragging: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
dragHandleProps: Record<string, unknown>;
users?: User[];
genres?: TmdbGenre[];
languages?: Language[];
keywords?: Keyword[];
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
testResponses: (DVRTestResponse & { type: string; id: number })[];
}
const ConditionBadges = ({
rule,
users,
genres,
languages,
keywords,
}: {
rule: RoutingRule;
users?: User[];
genres?: TmdbGenre[];
languages?: Language[];
keywords?: Keyword[];
}) => {
const intl = useIntl();
const hasConditions =
!!rule.users || !!rule.genres || !!rule.languages || !!rule.keywords;
if (!hasConditions) {
return (
<span className="text-sm italic text-gray-500">
{intl.formatMessage(messages.matchesAll)}
</span>
);
}
return (
<div className="flex flex-wrap gap-1.5">
{rule.keywords
?.split(',')
.filter(Boolean)
.map((keywordId) => {
const keyword = keywords?.find((k) => k.id === Number(keywordId));
return (
<Badge key={`kw-${keywordId}`} badgeType="warning">
{keyword?.name ?? keywordId}
</Badge>
);
})}
{rule.genres
?.split(',')
.filter(Boolean)
.map((genreId) => {
const genre = genres?.find((g) => g.id === Number(genreId));
return (
<Badge key={`g-${genreId}`} badgeType="warning">
{genre?.name ?? genreId}
</Badge>
);
})}
{rule.languages
?.split('|')
.filter((l) => l && l !== 'server')
.map((langCode) => {
const lang = languages?.find((l) => l.iso_639_1 === langCode);
const name =
intl.formatDisplayName(langCode, {
type: 'language',
fallback: 'none',
}) ??
lang?.english_name ??
langCode;
return (
<Badge key={`l-${langCode}`} badgeType="success">
{name}
</Badge>
);
})}
{rule.users
?.split(',')
.filter(Boolean)
.map((userId) => {
const user = users?.find((u) => u.id === Number(userId));
return (
<Badge key={`u-${userId}`}>{user?.displayName ?? userId}</Badge>
);
})}
</div>
);
};
const TargetBadges = ({
rule,
radarrServices,
sonarrServices,
testResponses,
}: {
rule: RoutingRule;
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
testResponses: (DVRTestResponse & { type: string; id: number })[];
}) => {
const intl = useIntl();
const services =
rule.serviceType === 'sonarr' ? sonarrServices : radarrServices;
const targetService = services.find((s) => s.id === rule.targetServiceId);
const testResponse = testResponses.find(
(r) => r.id === rule.targetServiceId && r.type === rule.serviceType
);
const profileName =
(rule.activeProfileId != null
? testResponse?.profiles.find(
(p) => p.id === Number(rule.activeProfileId)
)?.name
: null) ??
rule.activeProfileName ??
null;
const hasOverrides = Boolean(
rule.rootFolder ||
rule.activeProfileId != null ||
rule.seriesType ||
rule.tags ||
(rule.serviceType === 'radarr' && rule.minimumAvailability)
);
return (
<div className="flex flex-wrap items-center gap-1.5">
<Badge badgeType="primary">{targetService?.name ?? 'Unknown'}</Badge>
{rule.rootFolder && <Badge>{rule.rootFolder}</Badge>}
{rule.activeProfileId != null && (
<Badge>{profileName ?? String(rule.activeProfileId)}</Badge>
)}
{rule.seriesType && <Badge badgeType="warning">{rule.seriesType}</Badge>}
{rule.tags?.split(',').map((tagId) => {
const tag = testResponse?.tags.find((t) => t.id === Number(tagId));
return <Badge key={`t-${tagId}`}>{tag?.label ?? tagId}</Badge>;
})}
{!hasOverrides && (
<span className="text-xs text-gray-500">
{intl.formatMessage(messages.instanceDefaults)}
</span>
)}
</div>
);
};
const DragHandle = (props: Record<string, unknown>) => (
<div
{...props}
className="flex cursor-grab flex-col items-center justify-center gap-[3px] px-3 py-3 text-gray-600 transition-colors hover:text-gray-400 active:cursor-grabbing"
>
{[0, 1, 2].map((i) => (
<div key={i} className="flex gap-[3px]">
<div className="h-[3px] w-[3px] rounded-full bg-current" />
<div className="h-[3px] w-[3px] rounded-full bg-current" />
</div>
))}
</div>
);
const RoutingRuleRow = ({
rule,
index,
expanded,
isDragging,
onToggle,
onEdit,
onDelete,
dragHandleProps,
users,
genres,
languages,
keywords,
radarrServices,
sonarrServices,
testResponses,
}: RoutingRuleRowProps) => {
const intl = useIntl();
const isFallback = !!rule.isFallback;
const services =
rule.serviceType === 'sonarr' ? sonarrServices : radarrServices;
const targetService = services.find((s) => s.id === rule.targetServiceId);
const testResponse = testResponses.find(
(r) => r.id === rule.targetServiceId && r.type === rule.serviceType
);
const profileName =
(rule.activeProfileId != null
? testResponse?.profiles.find(
(p) => p.id === Number(rule.activeProfileId)
)?.name
: null) ??
rule.activeProfileName ??
null;
return (
<div
className={`rounded-lg transition-all duration-200 ${
isDragging
? 'scale-[1.01] bg-gray-700 shadow-lg ring-2 ring-indigo-500'
: expanded
? 'bg-gray-800 ring-1 ring-gray-500'
: 'bg-gray-800 ring-1 ring-gray-700 hover:ring-gray-500'
}`}
>
<div className="flex items-center">
{!isFallback && <DragHandle {...dragHandleProps} />}
{isFallback && <div className="w-9" />}
<button
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-3 py-3 pr-4 text-left"
>
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-gray-700 font-mono text-xs text-gray-400">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="truncate text-sm font-medium text-white">
{rule.name}
</span>
{isFallback && (
<Badge badgeType="success">
{intl.formatMessage(messages.fallback)}
</Badge>
)}
<Badge
badgeType={rule.serviceType === 'sonarr' ? 'primary' : 'danger'}
>
{rule.serviceType === 'sonarr'
? intl.formatMessage(messages.sonarr)
: intl.formatMessage(messages.radarr)}
</Badge>
{rule.is4k && <Badge badgeType="warning">4K</Badge>}
</div>
{!expanded && (
<div className="flex items-center gap-2 text-xs">
<ConditionBadges
rule={rule}
users={users}
genres={genres}
languages={languages}
keywords={keywords}
/>
<span className="text-gray-600"></span>
<TargetBadges
rule={rule}
radarrServices={radarrServices}
sonarrServices={sonarrServices}
testResponses={testResponses}
/>
</div>
)}
</div>
<ChevronDownIcon
className={`h-4 w-4 flex-shrink-0 text-gray-500 transition-transform ${
expanded ? 'rotate-180' : ''
}`}
/>
</button>
</div>
{expanded && (
<div className="border-t border-gray-700 px-4 pb-4 pl-12">
<div className="grid grid-cols-2 gap-6 pt-4">
{/* Conditions */}
<div>
<h4 className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-400">
{intl.formatMessage(messages.conditions)}
</h4>
{!rule.users &&
!rule.genres &&
!rule.languages &&
!rule.keywords ? (
<p className="text-sm italic text-gray-500">
{intl.formatMessage(messages.matchesAll)}
</p>
) : (
<div className="space-y-2">
{rule.keywords && (
<div className="flex items-start gap-2">
<span className="w-20 pt-0.5 text-xs text-gray-500">
{intl.formatMessage(messages.keywords)}
</span>
<div className="flex flex-wrap gap-1">
{rule.keywords
.split(',')
.filter(Boolean)
.map((keywordId) => {
const keyword = keywords?.find(
(k) => k.id === Number(keywordId)
);
return (
<Badge key={keywordId} badgeType="warning">
{keyword?.name ?? keywordId}
</Badge>
);
})}
</div>
</div>
)}
{rule.genres && (
<div className="flex items-start gap-2">
<span className="w-20 pt-0.5 text-xs text-gray-500">
{intl.formatMessage(messages.genres)}
</span>
<div className="flex flex-wrap gap-1">
{rule.genres
.split(',')
.filter(Boolean)
.map((genreId) => {
const genre = genres?.find(
(g) => g.id === Number(genreId)
);
return (
<Badge key={genreId} badgeType="warning">
{genre?.name ?? genreId}
</Badge>
);
})}
</div>
</div>
)}
{rule.languages && (
<div className="flex items-start gap-2">
<span className="w-20 pt-0.5 text-xs text-gray-500">
{intl.formatMessage(messages.languages)}
</span>
<div className="flex flex-wrap gap-1">
{rule.languages
.split('|')
.filter((l) => l && l !== 'server')
.map((langCode) => {
const name =
intl.formatDisplayName(langCode, {
type: 'language',
fallback: 'none',
}) ?? langCode;
return (
<Badge key={langCode} badgeType="success">
{name}
</Badge>
);
})}
</div>
</div>
)}
{rule.users && (
<div className="flex items-start gap-2">
<span className="w-20 pt-0.5 text-xs text-gray-500">
{intl.formatMessage(messages.users)}
</span>
<div className="flex flex-wrap gap-1">
{rule.users
.split(',')
.filter(Boolean)
.map((userId) => {
const user = users?.find(
(u) => u.id === Number(userId)
);
return (
<Badge key={userId}>
{user?.displayName ?? userId}
</Badge>
);
})}
</div>
</div>
)}
</div>
)}
</div>
<div>
<h4 className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-400">
{intl.formatMessage(messages.routeTo)}
</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.instance)}
</span>
<Badge badgeType="primary">
{targetService?.name ?? 'Unknown'}
</Badge>
</div>
{rule.rootFolder && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.rootFolder)}
</span>
<span className="font-mono text-xs text-gray-300">
{rule.rootFolder}
</span>
</div>
)}
{rule.activeProfileId != null && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.qualityProfile)}
</span>
<span className="text-xs text-gray-300">
{profileName ?? String(rule.activeProfileId)}
</span>
</div>
)}
{rule.serviceType === 'radarr' && rule.minimumAvailability && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.minimumAvailability)}
</span>
<Badge badgeType="warning">
{rule.minimumAvailability}
</Badge>
</div>
)}
{rule.seriesType && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.seriesType)}
</span>
<Badge badgeType="warning">{rule.seriesType}</Badge>
</div>
)}
{rule.tags && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.tags)}
</span>
<div className="flex gap-1">
{rule.tags
.split(',')
.filter(Boolean)
.map((tagId) => {
const tag = testResponse?.tags.find(
(t) => t.id === Number(tagId)
);
return (
<Badge key={tagId}>{tag?.label ?? tagId}</Badge>
);
})}
</div>
</div>
)}
{!rule.rootFolder &&
rule.activeProfileId == null &&
!rule.minimumAvailability &&
!rule.seriesType &&
!rule.tags && (
<p className="text-xs italic text-gray-500">
{intl.formatMessage(messages.instanceDefaults)}
</p>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="mt-4 flex justify-end gap-2 border-t border-gray-700 pt-3">
<button
onClick={onEdit}
className="inline-flex items-center gap-1.5 rounded-lg border border-transparent bg-gray-700 px-3 py-1.5 text-xs font-medium text-gray-200 transition duration-150 hover:text-white"
>
<PencilIcon className="h-3 w-3" />
{intl.formatMessage(globalMessages.edit)}
</button>
{!isFallback && (
<button
onClick={onDelete}
className="inline-flex items-center gap-1.5 rounded-lg border border-transparent px-3 py-1.5 text-xs font-medium text-gray-400 transition duration-150 hover:bg-red-900/20 hover:text-red-400"
>
<TrashIcon className="h-3 w-3" />
{intl.formatMessage(globalMessages.delete)}
</button>
)}
</div>
</div>
)}
</div>
);
};
export default RoutingRuleRow;

View File

@@ -1,23 +0,0 @@
export interface RoutingRule {
id: number;
serviceType: 'radarr' | 'sonarr';
isFallback: boolean;
is4k: boolean;
priority: number;
name: string;
users?: string | null;
genres?: string | null;
languages?: string | null;
keywords?: string | null;
targetServiceId: number;
activeProfileId?: number | null;
activeProfileName: string | null;
rootFolder?: string | null;
seriesType?: string | null;
tags?: string | null;
minimumAvailability?: 'announced' | 'inCinemas' | 'released' | null;
createdAt: string;
updatedAt: string;
}
export type RoutingRuleResultsResponse = RoutingRule[];

View File

@@ -1,19 +1,21 @@
import RadarrLogo from '@app/assets/services/radarr.svg';
import SonarrLogo from '@app/assets/services/sonarr.svg';
import Alert from '@app/components/Common/Alert';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
import OverrideRuleTiles from '@app/components/Settings/OverrideRule/OverrideRuleTiles';
import RadarrModal from '@app/components/Settings/RadarrModal';
import RoutingRuleList from '@app/components/Settings/RoutingRule/RoutingRuleList';
import RoutingRuleModal from '@app/components/Settings/RoutingRule/RoutingRuleModal';
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Fragment, useState } from 'react';
@@ -22,22 +24,32 @@ import useSWR, { mutate } from 'swr';
const messages = defineMessages('components.Settings', {
services: 'Services',
instances: 'Instances',
instancesDescription:
'Configure your Sonarr and Radarr server connections below. Routing rules determine which instance handles each request.',
radarrsettings: 'Radarr Settings',
sonarrsettings: 'Sonarr Settings',
serviceSettingsDescription:
'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.',
deleteserverconfirm: 'Are you sure you want to delete this server?',
ssl: 'SSL',
default: 'Default',
default4k: 'Default 4K',
is4k: '4K',
address: 'Address',
routingRules: 'Routing Rules',
noRules: 'No routing rules configured',
ruleCount: '{count} routing {count, plural, one {rule} other {rules}}',
addInstance: 'Add Instance',
addRadarr: 'Add Radarr',
addSonarr: 'Add Sonarr',
activeProfile: 'Active Profile',
addradarr: 'Add Radarr Server',
addsonarr: 'Add Sonarr Server',
noDefaultServer:
'At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.',
noDefaultNon4kServer:
'If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.',
noDefault4kServer:
'A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.',
mediaTypeMovie: 'movie',
mediaTypeSeries: 'series',
deleteServer: 'Delete {serverType} Server',
overrideRules: 'Override Rules',
overrideRulesDescription:
'Override rules allow you to specify properties that will be replaced if a request matches the rule.',
addrule: 'New Override Rule',
});
interface ServerInstanceProps {
@@ -48,7 +60,7 @@ interface ServerInstanceProps {
port: number;
isSSL?: boolean;
externalUrl?: string;
ruleCount: number;
profileName: string;
isSonarr?: boolean;
onEdit: () => void;
onDelete: () => void;
@@ -85,11 +97,11 @@ const ServerInstance = ({
name,
hostname,
port,
isDefault = false,
profileName,
is4k = false,
isDefault = false,
isSSL = false,
isSonarr = false,
ruleCount,
externalUrl,
onEdit,
onDelete,
@@ -147,11 +159,9 @@ const ServerInstance = ({
</p>
<p className="mt-1 truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.routingRules)}
{intl.formatMessage(messages.activeProfile)}
</span>
{ruleCount === 0
? intl.formatMessage(messages.noRules)
: intl.formatMessage(messages.ruleCount, { count: ruleCount })}
{profileName}
</p>
</div>
<a
@@ -205,10 +215,8 @@ const SettingsServices = () => {
error: sonarrError,
mutate: revalidateSonarr,
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
const { data: routingRules, mutate: revalidateRules } = useSWR<RoutingRule[]>(
'/api/v1/routingRule'
);
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const [editRadarrModal, setEditRadarrModal] = useState<{
open: boolean;
radarr: RadarrSettings | null;
@@ -232,10 +240,9 @@ const SettingsServices = () => {
type: 'radarr',
serverId: null,
});
const [routingRuleModal, setRoutingRuleModal] = useState<{
const [overrideRuleModal, setOverrideRuleModal] = useState<{
open: boolean;
rule: RoutingRule | null;
prefillData?: Partial<RoutingRule>;
rule: OverrideRule | null;
}>({
open: false,
rule: null,
@@ -249,73 +256,6 @@ const SettingsServices = () => {
revalidateRadarr();
revalidateSonarr();
mutate('/api/v1/settings/public');
revalidateRules();
};
const handleRadarrSave = async (savedInstance: RadarrSettings) => {
setEditRadarrModal({ open: false, radarr: null });
revalidateRadarr();
mutate('/api/v1/settings/public');
if (!savedInstance.isDefault) return;
const rules = (await revalidateRules()) ?? [];
const existingDefault = rules.find(
(r) =>
r.serviceType === 'radarr' &&
r.is4k === savedInstance.is4k &&
r.isFallback
);
setRoutingRuleModal({
open: true,
rule: existingDefault
? { ...existingDefault, targetServiceId: savedInstance.id }
: null,
prefillData: existingDefault
? undefined
: {
name: `${savedInstance.name} Default Route`,
serviceType: 'radarr',
is4k: savedInstance.is4k,
targetServiceId: savedInstance.id,
isFallback: true,
},
});
};
const handleSonarrSave = async (savedInstance: SonarrSettings) => {
setEditSonarrModal({ open: false, sonarr: null });
revalidateSonarr();
mutate('/api/v1/settings/public');
if (!savedInstance.isDefault) return;
const rules = (await revalidateRules()) ?? [];
const existingDefault = rules.find(
(r) =>
r.serviceType === 'sonarr' &&
r.is4k === savedInstance.is4k &&
r.isFallback
);
setRoutingRuleModal({
open: true,
rule: existingDefault
? { ...existingDefault, targetServiceId: savedInstance.id }
: null,
prefillData: existingDefault
? undefined
: {
name: `${savedInstance.name} Default Route`,
serviceType: 'sonarr',
is4k: savedInstance.is4k,
targetServiceId: savedInstance.id,
isFallback: true,
},
});
};
return (
@@ -326,22 +266,44 @@ const SettingsServices = () => {
intl.formatMessage(globalMessages.settings),
]}
/>
<div className="mb-6">
<h3 className="heading">
{intl.formatMessage(messages.radarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
serverType: 'Radarr',
})}
</p>
</div>
{editRadarrModal.open && (
<RadarrModal
radarr={editRadarrModal.radarr}
onClose={() => setEditRadarrModal({ open: false, radarr: null })}
onSave={handleRadarrSave}
onClose={() => {
if (!overrideRuleModal.open)
setEditRadarrModal({ open: false, radarr: null });
}}
onSave={() => {
revalidateRadarr();
mutate('/api/v1/settings/public');
setEditRadarrModal({ open: false, radarr: null });
}}
/>
)}
{editSonarrModal.open && (
<SonarrModal
sonarr={editSonarrModal.sonarr}
onClose={() => setEditSonarrModal({ open: false, sonarr: null })}
onSave={handleSonarrSave}
onClose={() => {
if (!overrideRuleModal.open)
setEditSonarrModal({ open: false, sonarr: null });
}}
onSave={() => {
revalidateSonarr();
mutate('/api/v1/settings/public');
setEditSonarrModal({ open: false, sonarr: null });
}}
/>
)}
<Transition
as={Fragment}
show={deleteServerModal.open}
@@ -371,128 +333,226 @@ const SettingsServices = () => {
{intl.formatMessage(messages.deleteserverconfirm)}
</Modal>
</Transition>
{routingRuleModal.open && radarrData && sonarrData && (
<RoutingRuleModal
rule={routingRuleModal.rule}
<div className="section">
{!radarrData && !radarrError && <LoadingSpinner />}
{radarrData && !radarrError && (
<>
{radarrData.length > 0 &&
(!radarrData.some((radarr) => radarr.isDefault) ? (
<Alert
title={intl.formatMessage(messages.noDefaultServer, {
serverType: 'Radarr',
mediaType: intl.formatMessage(messages.mediaTypeMovie),
})}
/>
) : !radarrData.some(
(radarr) => radarr.isDefault && !radarr.is4k
) ? (
<Alert
title={intl.formatMessage(messages.noDefaultNon4kServer, {
serverType: 'Radarr',
strong: (msg: React.ReactNode) => (
<strong className="font-semibold text-white">
{msg}
</strong>
),
})}
/>
) : (
radarrData.some((radarr) => radarr.is4k) &&
!radarrData.some(
(radarr) => radarr.isDefault && radarr.is4k
) && (
<Alert
title={intl.formatMessage(messages.noDefault4kServer, {
serverType: 'Radarr',
mediaType: intl.formatMessage(messages.mediaTypeMovie),
})}
/>
)
))}
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{radarrData.map((radarr) => (
<ServerInstance
key={`radarr-config-${radarr.id}`}
name={radarr.name}
hostname={radarr.hostname}
port={radarr.port}
profileName={radarr.activeProfileName}
isSSL={radarr.useSsl}
isDefault={radarr.isDefault}
is4k={radarr.is4k}
externalUrl={radarr.externalUrl}
onEdit={() => setEditRadarrModal({ open: true, radarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: radarr.id,
type: 'radarr',
})
}
/>
))}
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
className="mb-3 mt-3"
onClick={() =>
setEditRadarrModal({ open: true, radarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addradarr)}</span>
</Button>
</div>
</li>
</ul>
</>
)}
</div>
<div className="mb-6 mt-10">
<h3 className="heading">
{intl.formatMessage(messages.sonarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
serverType: 'Sonarr',
})}
</p>
</div>
<div className="section">
{!sonarrData && !sonarrError && <LoadingSpinner />}
{sonarrData && !sonarrError && (
<>
{sonarrData.length > 0 &&
(!sonarrData.some((sonarr) => sonarr.isDefault) ? (
<Alert
title={intl.formatMessage(messages.noDefaultServer, {
serverType: 'Sonarr',
mediaType: intl.formatMessage(messages.mediaTypeSeries),
})}
/>
) : !sonarrData.some(
(sonarr) => sonarr.isDefault && !sonarr.is4k
) ? (
<Alert
title={intl.formatMessage(messages.noDefaultNon4kServer, {
serverType: 'Sonarr',
strong: (msg: React.ReactNode) => (
<strong className="font-semibold text-white">
{msg}
</strong>
),
})}
/>
) : (
sonarrData.some((sonarr) => sonarr.is4k) &&
!sonarrData.some(
(sonarr) => sonarr.isDefault && sonarr.is4k
) && (
<Alert
title={intl.formatMessage(messages.noDefault4kServer, {
serverType: 'Sonarr',
mediaType: intl.formatMessage(messages.mediaTypeSeries),
})}
/>
)
))}
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{sonarrData.map((sonarr) => (
<ServerInstance
key={`sonarr-config-${sonarr.id}`}
name={sonarr.name}
hostname={sonarr.hostname}
port={sonarr.port}
profileName={sonarr.activeProfileName}
isSSL={sonarr.useSsl}
isSonarr
isDefault={sonarr.isDefault}
is4k={sonarr.is4k}
externalUrl={sonarr.externalUrl}
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: sonarr.id,
type: 'sonarr',
})
}
/>
))}
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
onClick={() =>
setEditSonarrModal({ open: true, sonarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addsonarr)}</span>
</Button>
</div>
</li>
</ul>
</>
)}
</div>
<div className="mb-6 mt-10">
<h3 className="heading">
{intl.formatMessage(messages.overrideRules)}
</h3>
<p className="description">
{intl.formatMessage(messages.overrideRulesDescription, {
serverType: 'Sonarr',
})}
</p>
</div>
<div className="section">
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{rules && radarrData && sonarrData && (
<OverrideRuleTiles
rules={rules}
radarrServices={radarrData}
sonarrServices={sonarrData}
setOverrideRuleModal={setOverrideRuleModal}
revalidate={revalidate}
/>
)}
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
disabled={!radarrData?.length && !sonarrData?.length}
onClick={() =>
setOverrideRuleModal({
open: true,
rule: null,
})
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addrule)}</span>
</Button>
</div>
</li>
</ul>
</div>
{overrideRuleModal.open && radarrData && sonarrData && (
<OverrideRuleModal
rule={overrideRuleModal.rule}
onClose={() => {
setRoutingRuleModal({ open: false, rule: null });
revalidateRules();
setOverrideRuleModal({
open: false,
rule: null,
});
revalidate();
}}
radarrServices={radarrData}
sonarrServices={sonarrData}
prefillData={routingRuleModal.prefillData}
/>
)}
<div className="mb-6">
<h3 className="heading">{intl.formatMessage(messages.instances)}</h3>
<p className="description">
{intl.formatMessage(messages.instancesDescription)}
</p>
</div>
<div className="section">
{(!radarrData && !radarrError) || (!sonarrData && !sonarrError) ? (
<LoadingSpinner />
) : (
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{radarrData?.map((radarr) => (
<ServerInstance
key={`radarr-config-${radarr.id}`}
name={radarr.name}
isDefault={radarr.isDefault}
hostname={radarr.hostname}
port={radarr.port}
ruleCount={
routingRules?.filter(
(r) =>
r.serviceType === 'radarr' &&
r.targetServiceId === radarr.id
).length ?? 0
}
isSSL={radarr.useSsl}
is4k={radarr.is4k}
externalUrl={radarr.externalUrl}
onEdit={() => setEditRadarrModal({ open: true, radarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: radarr.id,
type: 'radarr',
})
}
/>
))}
{sonarrData?.map((sonarr) => (
<ServerInstance
key={`sonarr-config-${sonarr.id}`}
name={sonarr.name}
isDefault={sonarr.isDefault}
hostname={sonarr.hostname}
port={sonarr.port}
ruleCount={
routingRules?.filter(
(r) =>
r.serviceType === 'sonarr' &&
r.targetServiceId === sonarr.id
).length ?? 0
}
isSSL={sonarr.useSsl}
isSonarr
is4k={sonarr.is4k}
externalUrl={sonarr.externalUrl}
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: sonarr.id,
type: 'sonarr',
})
}
/>
))}
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<Button
buttonType="ghost"
onClick={() =>
setEditRadarrModal({ open: true, radarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addRadarr)}</span>
</Button>
<Button
buttonType="ghost"
onClick={() =>
setEditSonarrModal({ open: true, sonarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addSonarr)}</span>
</Button>
</div>
</li>
</ul>
)}
</div>
<div className="mt-10">
{radarrData && sonarrData && routingRules && (
<RoutingRuleList
rules={routingRules}
radarrServices={radarrData}
sonarrServices={sonarrData}
onAddRule={(prefillData) =>
setRoutingRuleModal({ open: true, rule: null, prefillData })
}
onEditRule={(rule) => setRoutingRuleModal({ open: true, rule })}
revalidate={revalidateRules}
/>
)}
</div>
</>
);
};

View File

@@ -10,9 +10,16 @@ import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import type { OnChangeValue } from 'react-select';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
type OptionType = {
value: number;
label: string;
};
const messages = defineMessages('components.Settings.SonarrModal', {
createsonarr: 'Add New Sonarr Server',
create4ksonarr: 'Add New 4K Sonarr Server',
@@ -22,6 +29,9 @@ const messages = defineMessages('components.Settings.SonarrModal', {
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationApiKeyRequired: 'You must provide an API key',
validationRootFolderRequired: 'You must select a root folder',
validationProfileRequired: 'You must select a quality profile',
validationLanguageProfileRequired: 'You must select a language profile',
toastSonarrTestSuccess: 'Sonarr connection established successfully!',
toastSonarrTestFailure: 'Failed to connect to Sonarr.',
add: 'Add Server',
@@ -33,8 +43,27 @@ const messages = defineMessages('components.Settings.SonarrModal', {
ssl: 'Use SSL',
apiKey: 'API Key',
baseUrl: 'URL Base',
server4k: '4K Server',
qualityprofile: 'Quality Profile',
languageprofile: 'Language Profile',
rootfolder: 'Root Folder',
seriesType: 'Series Type',
animeSeriesType: 'Anime Series Type',
animequalityprofile: 'Anime Quality Profile',
animelanguageprofile: 'Anime Language Profile',
animerootfolder: 'Anime Root Folder',
seasonfolders: 'Season Folders',
server4k: '4K Server',
selectQualityProfile: 'Select quality profile',
selectRootFolder: 'Select root folder',
selectLanguageProfile: 'Select language profile',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
loadinglanguageprofiles: 'Loading language profiles…',
testFirstLanguageProfiles: 'Test connection to load language profiles',
loadingTags: 'Loading tags…',
testFirstTags: 'Test connection to load tags',
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
enableSearch: 'Enable Automatic Search',
@@ -45,12 +74,16 @@ const messages = defineMessages('components.Settings.SonarrModal', {
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
tags: 'Tags',
animeTags: 'Anime Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
});
interface SonarrModalProps {
sonarr: SonarrSettings | null;
onClose: () => void;
onSave: (savedInstance: SonarrSettings) => Promise<void>;
onSave: () => void;
}
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
@@ -79,6 +112,17 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
apiKey: Yup.string().required(
intl.formatMessage(messages.validationApiKeyRequired)
),
rootFolder: Yup.string().required(
intl.formatMessage(messages.validationRootFolderRequired)
),
activeProfileId: Yup.string().required(
intl.formatMessage(messages.validationProfileRequired)
),
activeLanguageProfileId: testResponse.languageProfiles
? Yup.number().required(
intl.formatMessage(messages.validationLanguageProfileRequired)
)
: Yup.number(),
externalUrl: Yup.string()
.test(
'valid-url',
@@ -186,6 +230,16 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
ssl: sonarr?.useSsl ?? false,
apiKey: sonarr?.apiKey,
baseUrl: sonarr?.baseUrl,
activeProfileId: sonarr?.activeProfileId,
activeLanguageProfileId: sonarr?.activeLanguageProfileId,
rootFolder: sonarr?.activeDirectory,
seriesType: sonarr?.seriesType,
animeSeriesType: sonarr?.animeSeriesType,
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
activeAnimeLanguageProfileId: sonarr?.activeAnimeLanguageProfileId,
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
tags: sonarr?.tags ?? [],
animeTags: sonarr?.animeTags ?? [],
isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false,
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
@@ -197,6 +251,13 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
validationSchema={SonarrSettingsSchema}
onSubmit={async (values) => {
try {
const profileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeProfileId)
)?.name;
const animeProfileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeAnimeProfileId)
)?.name;
const submission = {
name: values.name,
hostname: values.hostname,
@@ -204,32 +265,44 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
apiKey: values.apiKey,
useSsl: values.ssl,
baseUrl: values.baseUrl,
isDefault: values.isDefault,
activeProfileId: Number(values.activeProfileId),
activeLanguageProfileId: values.activeLanguageProfileId
? Number(values.activeLanguageProfileId)
: undefined,
activeProfileName: profileName,
activeDirectory: values.rootFolder,
seriesType: values.seriesType,
animeSeriesType: values.animeSeriesType,
activeAnimeProfileId: values.activeAnimeProfileId
? Number(values.activeAnimeProfileId)
: undefined,
activeAnimeLanguageProfileId: values.activeAnimeLanguageProfileId
? Number(values.activeAnimeLanguageProfileId)
: undefined,
activeAnimeProfileName: animeProfileName ?? undefined,
activeAnimeDirectory: values.activeAnimeRootFolder,
tags: values.tags,
animeTags: values.animeTags,
is4k: values.is4k,
isDefault: values.isDefault,
enableSeasonFolders: values.enableSeasonFolders,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
let savedInstance: SonarrSettings;
if (!sonarr) {
const response = await axios.post<SonarrSettings>(
'/api/v1/settings/sonarr',
submission
);
savedInstance = response.data;
await axios.post('/api/v1/settings/sonarr', submission);
} else {
const response = await axios.put<SonarrSettings>(
await axios.put(
`/api/v1/settings/sonarr/${sonarr.id}`,
submission
);
savedInstance = response.data;
}
await onSave(savedInstance);
onSave();
} catch (e) {
// TODO: handle error
// set error here
}
}}
>
@@ -461,6 +534,444 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="seriesType" className="text-label">
{intl.formatMessage(messages.seriesType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="seriesType"
name="seriesType"
disabled={!isValidated || isTesting}
>
<option value="standard">Standard</option>
<option value="daily">Daily</option>
</Field>
</div>
</div>
{errors.seriesType && touched.seriesType && (
<div className="error">{errors.seriesType}</div>
)}
</div>
<div className="form-row">
<label htmlFor="activeProfileId" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeProfileId"
name="activeProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(
messages.selectQualityProfile
)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeProfileId &&
touched.activeProfileId &&
typeof errors.activeProfileId === 'string' && (
<div className="error">{errors.activeProfileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="rootFolder" className="text-label">
{intl.formatMessage(messages.rootfolder)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolder"
name="rootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(
messages.testFirstRootFolders
)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
{testResponse.languageProfiles && (
<div className="form-row">
<label
htmlFor="activeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.languageprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeLanguageProfileId"
name="activeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeLanguageProfileId &&
touched.activeLanguageProfileId && (
<div className="error">
{errors.activeLanguageProfileId}
</div>
)}
</div>
</div>
)}
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={!isValidated || isTesting}
placeholder={
!isValidated
? intl.formatMessage(messages.testFirstTags)
: isTesting
? intl.formatMessage(messages.loadingTags)
: intl.formatMessage(messages.selecttags)
}
isLoading={isTesting}
className="react-select-container"
classNamePrefix="react-select"
value={
isTesting
? []
: (values.tags
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[])
}
onChange={(value: OnChangeValue<OptionType, true>) => {
setFieldValue(
'tags',
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="animeSeriesType" className="text-label">
{intl.formatMessage(messages.animeSeriesType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="animeSeriesType"
name="animeSeriesType"
disabled={!isValidated || isTesting}
>
<option value="standard">Standard</option>
<option value="anime">Anime</option>
</Field>
</div>
</div>
{errors.animeSeriesType && touched.animeSeriesType && (
<div className="error">{errors.animeSeriesType}</div>
)}
</div>
<div className="form-row">
<label htmlFor="activeAnimeProfileId" className="text-label">
{intl.formatMessage(messages.animequalityprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeProfileId"
name="activeAnimeProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(
messages.selectQualityProfile
)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeProfileId &&
touched.activeAnimeProfileId && (
<div className="error">
{errors.activeAnimeProfileId}
</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="activeAnimeRootFolder" className="text-label">
{intl.formatMessage(messages.animerootfolder)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeRootFolder"
name="activeAnimeRootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(
messages.testFirstRootFolders
)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.activeAnimeRootFolder &&
touched.activeAnimeRootFolder && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
{testResponse.languageProfiles && (
<div className="form-row">
<label
htmlFor="activeAnimeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.animelanguageprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeLanguageProfileId"
name="activeAnimeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeLanguageProfileId &&
touched.activeAnimeLanguageProfileId && (
<div className="error">
{errors.activeAnimeLanguageProfileId}
</div>
)}
</div>
</div>
)}
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.animeTags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={!isValidated}
placeholder={
!isValidated
? intl.formatMessage(messages.testFirstTags)
: isTesting
? intl.formatMessage(messages.loadingTags)
: intl.formatMessage(messages.selecttags)
}
isLoading={isTesting}
className="react-select-container"
classNamePrefix="react-select"
value={
isTesting
? []
: (values.animeTags
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[])
}
onChange={(value) => {
setFieldValue(
'animeTags',
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label
htmlFor="enableSeasonFolders"
className="checkbox-label"
>
{intl.formatMessage(messages.seasonfolders)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enableSeasonFolders"
name="enableSeasonFolders"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
@@ -481,21 +992,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="enableSeasonFolders"
className="checkbox-label"
>
{intl.formatMessage(messages.seasonfolders)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enableSeasonFolders"
name="enableSeasonFolders"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="syncEnabled" className="checkbox-label">
{intl.formatMessage(messages.syncEnabled)}

View File

@@ -769,7 +769,40 @@
"components.Settings.Notifications.webhookRoleIdTip": "The role ID to mention in the webhook message. Leave empty to disable mentions",
"components.Settings.Notifications.webhookUrl": "Webhook URL",
"components.Settings.Notifications.webhookUrlTip": "Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server",
"components.Settings.OverrideRuleModal.conditions": "Conditions",
"components.Settings.OverrideRuleModal.conditionsDescription": "Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).",
"components.Settings.OverrideRuleModal.create": "Create rule",
"components.Settings.OverrideRuleModal.createrule": "New Override Rule",
"components.Settings.OverrideRuleModal.editrule": "Edit Override Rule",
"components.Settings.OverrideRuleModal.genres": "Genres",
"components.Settings.OverrideRuleModal.keywords": "Keywords",
"components.Settings.OverrideRuleModal.languages": "Languages",
"components.Settings.OverrideRuleModal.notagoptions": "No tags.",
"components.Settings.OverrideRuleModal.qualityprofile": "Quality Profile",
"components.Settings.OverrideRuleModal.rootfolder": "Root Folder",
"components.Settings.OverrideRuleModal.ruleCreated": "Override rule created successfully!",
"components.Settings.OverrideRuleModal.ruleUpdated": "Override rule updated successfully!",
"components.Settings.OverrideRuleModal.selectQualityProfile": "Select quality profile",
"components.Settings.OverrideRuleModal.selectRootFolder": "Select root folder",
"components.Settings.OverrideRuleModal.selectService": "Select service",
"components.Settings.OverrideRuleModal.selecttags": "Select tags",
"components.Settings.OverrideRuleModal.service": "Service",
"components.Settings.OverrideRuleModal.serviceDescription": "Apply this rule to the selected service.",
"components.Settings.OverrideRuleModal.settings": "Settings",
"components.Settings.OverrideRuleModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.",
"components.Settings.OverrideRuleModal.tags": "Tags",
"components.Settings.OverrideRuleModal.users": "Users",
"components.Settings.OverrideRuleTile.conditions": "Conditions",
"components.Settings.OverrideRuleTile.genre": "Genre",
"components.Settings.OverrideRuleTile.keywords": "Keywords",
"components.Settings.OverrideRuleTile.language": "Language",
"components.Settings.OverrideRuleTile.qualityprofile": "Quality Profile",
"components.Settings.OverrideRuleTile.rootfolder": "Root Folder",
"components.Settings.OverrideRuleTile.settings": "Settings",
"components.Settings.OverrideRuleTile.tags": "Tags",
"components.Settings.OverrideRuleTile.users": "Users",
"components.Settings.RadarrModal.add": "Add Server",
"components.Settings.RadarrModal.announced": "Announced",
"components.Settings.RadarrModal.apiKey": "API Key",
"components.Settings.RadarrModal.baseUrl": "URL Base",
"components.Settings.RadarrModal.create4kradarr": "Add New 4K Radarr Server",
@@ -781,13 +814,30 @@
"components.Settings.RadarrModal.enableSearch": "Enable Automatic Search",
"components.Settings.RadarrModal.externalUrl": "External URL",
"components.Settings.RadarrModal.hostname": "Hostname or IP Address",
"components.Settings.RadarrModal.inCinemas": "In Cinemas",
"components.Settings.RadarrModal.loadingTags": "Loading tags…",
"components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
"components.Settings.RadarrModal.notagoptions": "No tags.",
"components.Settings.RadarrModal.port": "Port",
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
"components.Settings.RadarrModal.released": "Released",
"components.Settings.RadarrModal.rootfolder": "Root Folder",
"components.Settings.RadarrModal.selectMinimumAvailability": "Select minimum availability",
"components.Settings.RadarrModal.selectQualityProfile": "Select quality profile",
"components.Settings.RadarrModal.selectRootFolder": "Select root folder",
"components.Settings.RadarrModal.selecttags": "Select tags",
"components.Settings.RadarrModal.server4k": "4K Server",
"components.Settings.RadarrModal.servername": "Server Name",
"components.Settings.RadarrModal.ssl": "Use SSL",
"components.Settings.RadarrModal.syncEnabled": "Enable Scan",
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
"components.Settings.RadarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
"components.Settings.RadarrModal.tags": "Tags",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders",
"components.Settings.RadarrModal.testFirstTags": "Test connection to load tags",
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.",
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established successfully!",
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
@@ -796,83 +846,11 @@
"components.Settings.RadarrModal.validationBaseUrlLeadingSlash": "URL base must have a leading slash",
"components.Settings.RadarrModal.validationBaseUrlTrailingSlash": "URL base must not end in a trailing slash",
"components.Settings.RadarrModal.validationHostnameRequired": "You must provide a valid hostname or IP address",
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "You must select a minimum availability",
"components.Settings.RadarrModal.validationNameRequired": "You must provide a server name",
"components.Settings.RadarrModal.validationPortRequired": "You must provide a valid port number",
"components.Settings.RoutingRuleList.addAnimeRule": "Add Anime Rule",
"components.Settings.RoutingRuleList.addRule": "Add Rule",
"components.Settings.RoutingRuleList.all": "All",
"components.Settings.RoutingRuleList.animeRuleSuggestion": "Want anime to use different settings? Add an anime routing rule.",
"components.Settings.RoutingRuleList.deleteConfirm": "Are you sure you want to delete this routing rule?",
"components.Settings.RoutingRuleList.deleteRule": "Delete Routing Rule",
"components.Settings.RoutingRuleList.howRoutingWorks": "How routing works:",
"components.Settings.RoutingRuleList.noFallbackWarning": "No fallback rule configured for {serviceType}. Requests that do not match any rule will fail.",
"components.Settings.RoutingRuleList.radarr": "Radarr",
"components.Settings.RoutingRuleList.routingExplainer": "When a request comes in, rules are checked from top to bottom. The first rule whose conditions all match will determine which instance and settings are used. Fallback rules (no conditions) catch everything that did not match above.",
"components.Settings.RoutingRuleList.routingRules": "Routing Rules",
"components.Settings.RoutingRuleList.routingRulesConditionLogic": "Conditions use AND logic between fields (all must match) and OR logic within a field (any value can match).",
"components.Settings.RoutingRuleList.routingRulesDescription": "Rules are evaluated top-to-bottom. The first matching rule determines where the request is sent. Drag to reorder priority.",
"components.Settings.RoutingRuleList.sonarr": "Sonarr",
"components.Settings.RoutingRuleModal.announced": "Announced",
"components.Settings.RoutingRuleModal.badge4k": "4K",
"components.Settings.RoutingRuleModal.badgeDefault": "Default",
"components.Settings.RoutingRuleModal.conditionalShouldNotBeDefault": "Conditional rules should target a non-default instance.",
"components.Settings.RoutingRuleModal.conditions": "Conditions",
"components.Settings.RoutingRuleModal.conditionsDescription": "All condition types must match (AND). Within each type, any value can match (OR). Leave all empty for a fallback rule.",
"components.Settings.RoutingRuleModal.create": "Create Rule",
"components.Settings.RoutingRuleModal.createRule": "New Routing Rule",
"components.Settings.RoutingRuleModal.editRule": "Edit Routing Rule",
"components.Settings.RoutingRuleModal.fallbackMustBe4k": "This fallback is for 4K requests, so it must target a 4K instance.",
"components.Settings.RoutingRuleModal.fallbackMustBeDefault": "Fallback rules must target a default instance.",
"components.Settings.RoutingRuleModal.firstInstanceSetup": "First instance setup!",
"components.Settings.RoutingRuleModal.firstInstanceSetup4k": "First 4K instance setup!",
"components.Settings.RoutingRuleModal.firstInstanceSetupBody": "Were creating a fallback rule that catches all {mediaType} requests. You can customize defaults below or save to use instance defaults.",
"components.Settings.RoutingRuleModal.genres": "Genres",
"components.Settings.RoutingRuleModal.inCinemas": "In Cinemas",
"components.Settings.RoutingRuleModal.keywords": "Keywords",
"components.Settings.RoutingRuleModal.languages": "Languages",
"components.Settings.RoutingRuleModal.minimumAvailability": "Minimum Availability",
"components.Settings.RoutingRuleModal.noTagOptions": "No tags.",
"components.Settings.RoutingRuleModal.nonFallbackNeedsCondition": "Non-fallback rules must have at least one condition.",
"components.Settings.RoutingRuleModal.qualityProfile": "Quality Profile",
"components.Settings.RoutingRuleModal.released": "Released",
"components.Settings.RoutingRuleModal.rootFolder": "Root Folder",
"components.Settings.RoutingRuleModal.ruleCreated": "Routing rule created successfully!",
"components.Settings.RoutingRuleModal.ruleName": "Rule Name",
"components.Settings.RoutingRuleModal.ruleNamePlaceholder": "e.g. Anime Content, Japanese Dramas",
"components.Settings.RoutingRuleModal.ruleUpdated": "Routing rule updated successfully!",
"components.Settings.RoutingRuleModal.selectInstance": "Select instance",
"components.Settings.RoutingRuleModal.selectQualityProfile": "Select quality profile",
"components.Settings.RoutingRuleModal.selectRootFolder": "Select root folder",
"components.Settings.RoutingRuleModal.selectTags": "Select tags",
"components.Settings.RoutingRuleModal.seriesType": "Series Type",
"components.Settings.RoutingRuleModal.serviceType": "Service Type",
"components.Settings.RoutingRuleModal.tags": "Tags",
"components.Settings.RoutingRuleModal.target": "Target Settings",
"components.Settings.RoutingRuleModal.targetDescription": "Override settings for the target instance. Leave empty to use instance defaults.",
"components.Settings.RoutingRuleModal.targetInstance": "Target Instance",
"components.Settings.RoutingRuleModal.users": "Users",
"components.Settings.RoutingRuleModal.validationMinimumAvailabilityRequired": "You must select a minimum availability",
"components.Settings.RoutingRuleModal.validationNameRequired": "You must provide a rule name",
"components.Settings.RoutingRuleModal.validationProfileRequired": "You must select a quality profile",
"components.Settings.RoutingRuleModal.validationRootFolderRequired": "You must select a root folder",
"components.Settings.RoutingRuleModal.validationTargetRequired": "You must select a target instance",
"components.Settings.RoutingRuleRow.conditions": "Conditions",
"components.Settings.RoutingRuleRow.fallback": "Fallback",
"components.Settings.RoutingRuleRow.genres": "Genres",
"components.Settings.RoutingRuleRow.instance": "Instance",
"components.Settings.RoutingRuleRow.instanceDefaults": "Uses instance defaults",
"components.Settings.RoutingRuleRow.keywords": "Keywords",
"components.Settings.RoutingRuleRow.languages": "Languages",
"components.Settings.RoutingRuleRow.matchesAll": "Matches all requests",
"components.Settings.RoutingRuleRow.minimumAvailability": "Minimum Availability",
"components.Settings.RoutingRuleRow.qualityProfile": "Quality Profile",
"components.Settings.RoutingRuleRow.radarr": "Radarr",
"components.Settings.RoutingRuleRow.rootFolder": "Root Folder",
"components.Settings.RoutingRuleRow.routeTo": "Route To",
"components.Settings.RoutingRuleRow.seriesType": "Series Type",
"components.Settings.RoutingRuleRow.sonarr": "Sonarr",
"components.Settings.RoutingRuleRow.tags": "Tags",
"components.Settings.RoutingRuleRow.users": "Users",
"components.Settings.RadarrModal.validationProfileRequired": "You must select a quality profile",
"components.Settings.RadarrModal.validationRootFolderRequired": "You must select a root folder",
"components.Settings.SettingsAbout.Releases.currentversion": "Current",
"components.Settings.SettingsAbout.Releases.latestversion": "Latest",
"components.Settings.SettingsAbout.Releases.releasedataMissing": "Release data is currently unavailable.",
@@ -1070,6 +1048,11 @@
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
"components.Settings.SettingsUsers.users": "Users",
"components.Settings.SonarrModal.add": "Add Server",
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
"components.Settings.SonarrModal.animeTags": "Anime Tags",
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
"components.Settings.SonarrModal.animequalityprofile": "Anime Quality Profile",
"components.Settings.SonarrModal.animerootfolder": "Anime Root Folder",
"components.Settings.SonarrModal.apiKey": "API Key",
"components.Settings.SonarrModal.baseUrl": "URL Base",
"components.Settings.SonarrModal.create4ksonarr": "Add New 4K Sonarr Server",
@@ -1081,14 +1064,32 @@
"components.Settings.SonarrModal.enableSearch": "Enable Automatic Search",
"components.Settings.SonarrModal.externalUrl": "External URL",
"components.Settings.SonarrModal.hostname": "Hostname or IP Address",
"components.Settings.SonarrModal.languageprofile": "Language Profile",
"components.Settings.SonarrModal.loadingTags": "Loading tags…",
"components.Settings.SonarrModal.loadinglanguageprofiles": "Loading language profiles…",
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.SonarrModal.notagoptions": "No tags.",
"components.Settings.SonarrModal.port": "Port",
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
"components.Settings.SonarrModal.rootfolder": "Root Folder",
"components.Settings.SonarrModal.seasonfolders": "Season Folders",
"components.Settings.SonarrModal.selectLanguageProfile": "Select language profile",
"components.Settings.SonarrModal.selectQualityProfile": "Select quality profile",
"components.Settings.SonarrModal.selectRootFolder": "Select root folder",
"components.Settings.SonarrModal.selecttags": "Select tags",
"components.Settings.SonarrModal.seriesType": "Series Type",
"components.Settings.SonarrModal.server4k": "4K Server",
"components.Settings.SonarrModal.servername": "Server Name",
"components.Settings.SonarrModal.ssl": "Use SSL",
"components.Settings.SonarrModal.syncEnabled": "Enable Scan",
"components.Settings.SonarrModal.tagRequests": "Tag Requests",
"components.Settings.SonarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
"components.Settings.SonarrModal.tags": "Tags",
"components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.SonarrModal.testFirstRootFolders": "Test connection to load root folders",
"components.Settings.SonarrModal.testFirstTags": "Test connection to load tags",
"components.Settings.SonarrModal.toastSonarrTestFailure": "Failed to connect to Sonarr.",
"components.Settings.SonarrModal.toastSonarrTestSuccess": "Sonarr connection established successfully!",
"components.Settings.SonarrModal.validationApiKeyRequired": "You must provide an API key",
@@ -1097,12 +1098,16 @@
"components.Settings.SonarrModal.validationBaseUrlLeadingSlash": "Base URL must have a leading slash",
"components.Settings.SonarrModal.validationBaseUrlTrailingSlash": "Base URL must not end in a trailing slash",
"components.Settings.SonarrModal.validationHostnameRequired": "You must provide a valid hostname or IP address",
"components.Settings.SonarrModal.validationLanguageProfileRequired": "You must select a language profile",
"components.Settings.SonarrModal.validationNameRequired": "You must provide a server name",
"components.Settings.SonarrModal.validationPortRequired": "You must provide a valid port number",
"components.Settings.addInstance": "Add Instance",
"components.Settings.addRadarr": "Add Radarr",
"components.Settings.addSonarr": "Add Sonarr",
"components.Settings.SonarrModal.validationProfileRequired": "You must select a quality profile",
"components.Settings.SonarrModal.validationRootFolderRequired": "You must select a root folder",
"components.Settings.activeProfile": "Active Profile",
"components.Settings.addradarr": "Add Radarr Server",
"components.Settings.address": "Address",
"components.Settings.addrule": "New Override Rule",
"components.Settings.addsonarr": "Add Sonarr Server",
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
"components.Settings.allChosenProvidersAreOperational": "All chosen metadata providers are operational",
"components.Settings.animeMetadataProvider": "Anime metadata provider",
@@ -1132,8 +1137,6 @@
"components.Settings.general": "General",
"components.Settings.hostname": "Hostname or IP Address",
"components.Settings.importBlocklistedTagsTip": "Import blocklisted tag configuration",
"components.Settings.instances": "Instances",
"components.Settings.instancesDescription": "Configure your Sonarr and Radarr server connections below. Routing rules determine which instance handles each request.",
"components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.",
"components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Settings.is4k": "4K",
@@ -1154,6 +1157,8 @@
"components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Seerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
"components.Settings.manualscanDescriptionJellyfin": "Normally, this will only be run once every 24 hours. Seerr will check your {mediaServerName} server's recently added more aggressively. If this is your first time configuring Seerr, a one-time full manual library scan is recommended!",
"components.Settings.manualscanJellyfin": "Manual Library Scan",
"components.Settings.mediaTypeMovie": "movie",
"components.Settings.mediaTypeSeries": "series",
"components.Settings.menuAbout": "About",
"components.Settings.menuGeneralSettings": "General",
"components.Settings.menuJellyfinSettings": "{mediaServerName}",
@@ -1170,7 +1175,9 @@
"components.Settings.metadataSettings": "Settings for metadata provider",
"components.Settings.metadataSettingsSaved": "Metadata provider settings saved",
"components.Settings.no": "No",
"components.Settings.noRules": "No routing rules configured",
"components.Settings.noDefault4kServer": "A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.",
"components.Settings.noDefaultNon4kServer": "If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.",
"components.Settings.noDefaultServer": "At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.",
"components.Settings.noSpecialCharacters": "Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.",
"components.Settings.nooptions": "No results.",
"components.Settings.notTested": "Not Tested",
@@ -1179,6 +1186,8 @@
"components.Settings.notificationsettings": "Notification Settings",
"components.Settings.notrunning": "Not Running",
"components.Settings.operational": "Operational",
"components.Settings.overrideRules": "Override Rules",
"components.Settings.overrideRulesDescription": "Override rules allow you to specify properties that will be replaced if a request matches the rule.",
"components.Settings.plex": "Plex",
"components.Settings.plexlibraries": "Plex Libraries",
"components.Settings.plexlibrariesDescription": "The libraries Seerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.",
@@ -1186,9 +1195,8 @@
"components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Seerr scans your Plex libraries to determine content availability.",
"components.Settings.port": "Port",
"components.Settings.providerStatus": "Metadata Provider Status",
"components.Settings.radarrsettings": "Radarr Settings",
"components.Settings.restartrequiredTooltip": "Seerr must be restarted for changes to this setting to take effect",
"components.Settings.routingRules": "Routing Rules",
"components.Settings.ruleCount": "{count} routing {count, plural, one {rule} other {rules}}",
"components.Settings.save": "Save Changes",
"components.Settings.saving": "Saving…",
"components.Settings.scan": "Sync Libraries",
@@ -1203,9 +1211,11 @@
"components.Settings.serverpresetLoad": "Press the button to load available servers",
"components.Settings.serverpresetManualMessage": "Manual configuration",
"components.Settings.serverpresetRefreshing": "Retrieving servers…",
"components.Settings.serviceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.",
"components.Settings.services": "Services",
"components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter the details manually or select a server retrieved from <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>. Press the button to the right of the dropdown to fetch the list of available servers.",
"components.Settings.settings": "Settings",
"components.Settings.sonarrsettings": "Sonarr Settings",
"components.Settings.ssl": "SSL",
"components.Settings.startscan": "Start Scan",
"components.Settings.starttyping": "Starting typing to search.",